/* global module */ /** * 小程序终端续接状态纯函数: * 1. 负责“后台保活分钟数”的收敛; * 2. 负责终端续接快照的规范化与过期判断; * 3. 不依赖 wx,便于单元测试覆盖。 */ const TERMINAL_SESSION_SNAPSHOT_VERSION = 1; const DEFAULT_TERMINAL_RESUME_MINUTES = 15; const MIN_TERMINAL_RESUME_MINUTES = 1; const MAX_TERMINAL_RESUME_MINUTES = 60; const MIN_TERMINAL_RESUME_GRACE_MS = MIN_TERMINAL_RESUME_MINUTES * 60 * 1000; const MAX_TERMINAL_RESUME_GRACE_MS = MAX_TERMINAL_RESUME_MINUTES * 60 * 1000; const VALID_TERMINAL_SESSION_STATUS = new Set([ "connecting", "auth_pending", "connected", "resumable", "disconnected", "error" ]); const VALID_ACTIVE_AI_PROVIDER = new Set(["", "codex", "copilot"]); const VALID_CODEX_SANDBOX_MODE = new Set(["read-only", "workspace-write", "danger-full-access"]); function normalizeCodexSandboxMode(value) { const normalized = String(value || "").trim(); if (!normalized) return ""; return VALID_CODEX_SANDBOX_MODE.has(normalized) ? normalized : "workspace-write"; } function normalizeTerminalResumeMinutes(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return DEFAULT_TERMINAL_RESUME_MINUTES; const normalized = Math.round(parsed); if (normalized < MIN_TERMINAL_RESUME_MINUTES) return MIN_TERMINAL_RESUME_MINUTES; if (normalized > MAX_TERMINAL_RESUME_MINUTES) return MAX_TERMINAL_RESUME_MINUTES; return normalized; } function normalizeTerminalResumeGraceMs(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return DEFAULT_TERMINAL_RESUME_MINUTES * 60 * 1000; } const normalized = Math.round(parsed); if (normalized < MIN_TERMINAL_RESUME_GRACE_MS) return MIN_TERMINAL_RESUME_GRACE_MS; if (normalized > MAX_TERMINAL_RESUME_GRACE_MS) return MAX_TERMINAL_RESUME_GRACE_MS; return normalized; } function resolveTerminalResumeGraceMs(settings) { const minutes = normalizeTerminalResumeMinutes( settings && typeof settings === "object" ? settings.backgroundSessionKeepAliveMinutes : undefined ); return minutes * 60 * 1000; } function normalizeTerminalSessionStatus(value) { const normalized = String(value || "").trim(); if (!VALID_TERMINAL_SESSION_STATUS.has(normalized)) { return "disconnected"; } return normalized; } function isTerminalSessionSnapshotExpired(snapshot, now = Date.now()) { if (!snapshot || typeof snapshot !== "object") return true; if (String(snapshot.status || "") !== "resumable") return false; const expiresAt = Number(snapshot.resumeExpiresAt); if (!Number.isFinite(expiresAt)) return true; return expiresAt <= now; } function normalizeTerminalSessionSnapshot(input, now = Date.now()) { const source = input && typeof input === "object" ? input : null; if (!source) return null; const version = Number(source.version); if (version !== TERMINAL_SESSION_SNAPSHOT_VERSION) { return null; } const serverId = String(source.serverId || "").trim(); const sessionId = String(source.sessionId || "").trim(); const sessionKey = String(source.sessionKey || "").trim(); if (!serverId || !sessionId || !sessionKey) { return null; } const status = normalizeTerminalSessionStatus(source.status); const activeAiProvider = VALID_ACTIVE_AI_PROVIDER.has(String(source.activeAiProvider || "").trim()) ? String(source.activeAiProvider || "").trim() : ""; const codexSandboxMode = activeAiProvider === "codex" ? normalizeCodexSandboxMode(source.codexSandboxMode) : ""; const savedAt = Number(source.savedAt); const resumeGraceMs = normalizeTerminalResumeGraceMs(source.resumeGraceMs); const resumeExpiresAt = status === "resumable" ? Number(source.resumeExpiresAt) : 0; const snapshot = { version: TERMINAL_SESSION_SNAPSHOT_VERSION, serverId, serverLabel: String(source.serverLabel || "").trim(), sessionId, sessionKey, status, activeAiProvider, codexSandboxMode, resumeGraceMs, resumeExpiresAt: Number.isFinite(resumeExpiresAt) ? Math.round(resumeExpiresAt) : 0, savedAt: Number.isFinite(savedAt) ? Math.round(savedAt) : now }; if (isTerminalSessionSnapshotExpired(snapshot, now)) { return null; } return snapshot; } function buildTerminalSessionSnapshot(input, now = Date.now()) { const source = input && typeof input === "object" ? input : {}; const status = normalizeTerminalSessionStatus(source.status); const resumeGraceMs = normalizeTerminalResumeGraceMs(source.resumeGraceMs); const resumeExpiresAt = status === "resumable" ? now + resumeGraceMs : Math.max(0, Math.round(Number(source.resumeExpiresAt) || 0)); return normalizeTerminalSessionSnapshot( { version: TERMINAL_SESSION_SNAPSHOT_VERSION, serverId: String(source.serverId || "").trim(), serverLabel: String(source.serverLabel || "").trim(), sessionId: String(source.sessionId || "").trim(), sessionKey: String(source.sessionKey || "").trim(), status, activeAiProvider: VALID_ACTIVE_AI_PROVIDER.has(String(source.activeAiProvider || "").trim()) ? String(source.activeAiProvider || "").trim() : "", codexSandboxMode: String(source.activeAiProvider || "").trim() === "codex" ? normalizeCodexSandboxMode(source.codexSandboxMode) : "", resumeGraceMs, resumeExpiresAt, savedAt: now }, now ); } function isTerminalSessionHighlighted(snapshot, serverId, now = Date.now()) { const normalized = normalizeTerminalSessionSnapshot(snapshot, now); if (!normalized || normalized.serverId !== String(serverId || "").trim()) { return false; } return normalized.status === "connected" || normalized.status === "resumable"; } /** * AI 高亮态比普通连接态更严格: * 1. 目标服务器必须仍处于“已连接 / 可续接”窗口; * 2. 同一快照里还要明确记录了 AI 前台 provider; * 3. 这样列表页和终端页才能只在“真的有 AI 会话”时点亮 AI 按钮。 */ function isTerminalSessionAiHighlighted(snapshot, serverId, now = Date.now()) { const normalized = normalizeTerminalSessionSnapshot(snapshot, now); if (!normalized || normalized.serverId !== String(serverId || "").trim()) { return false; } if (!normalized.activeAiProvider) { return false; } return normalized.status === "connected" || normalized.status === "resumable"; } function isTerminalSessionConnecting(snapshot, serverId, now = Date.now()) { const normalized = normalizeTerminalSessionSnapshot(snapshot, now); if (!normalized || normalized.serverId !== String(serverId || "").trim()) { return false; } return normalized.status === "connecting" || normalized.status === "auth_pending"; } module.exports = { TERMINAL_SESSION_SNAPSHOT_VERSION, DEFAULT_TERMINAL_RESUME_MINUTES, MIN_TERMINAL_RESUME_MINUTES, MAX_TERMINAL_RESUME_MINUTES, normalizeTerminalResumeMinutes, normalizeTerminalResumeGraceMs, resolveTerminalResumeGraceMs, normalizeCodexSandboxMode, normalizeTerminalSessionStatus, normalizeTerminalSessionSnapshot, buildTerminalSessionSnapshot, isTerminalSessionSnapshotExpired, isTerminalSessionAiHighlighted, isTerminalSessionHighlighted, isTerminalSessionConnecting };