diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index f0657d52..7a0d4592 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -448,6 +448,11 @@ lanes: HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v02 HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v02 HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git + HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED: "1" + HWLAB_WORKBENCH_EMPTY_SESSION_TTL_MS: "600000" + HWLAB_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS: "60000" + HWLAB_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS: "5000" + HWLAB_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE: "100" - serviceId: hwlab-user-billing env: HWLAB_USER_BILLING_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index cfa44c12..f5abc693 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -68,6 +68,7 @@ import { handleCodeAgentTraceHttp } from "./server-code-agent-http.ts"; import { handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts"; +import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts"; @@ -115,7 +116,8 @@ export function createCloudApiServer(options = {}) { if (typeof accessController.configureCodeAgentWorkspaceContext === "function") { accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults }); } - return createServer(async (request, response) => { + const workbenchEmptySessionGc = startWorkbenchEmptySessionGc({ env, accessController, logger: console }); + const server = createServer(async (request, response) => { const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response); await withBackendPerformanceContext(backendPerformance, async () => { try { @@ -133,6 +135,8 @@ export function createCloudApiServer(options = {}) { } }); }); + server.on("close", () => workbenchEmptySessionGc.stop()); + return server; } export function ensureCodeAgentRuntimeBase(env = process.env) { diff --git a/internal/cloud/workbench-empty-session-gc.test.ts b/internal/cloud/workbench-empty-session-gc.test.ts new file mode 100644 index 00000000..4f8cb55f --- /dev/null +++ b/internal/cloud/workbench-empty-session-gc.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { + classifyWorkbenchEmptySessionGcCandidate, + runWorkbenchEmptySessionGc +} from "./workbench-empty-session-gc.ts"; + +test("workbench empty session GC archives only stale empty idle sessions", async () => { + const sessions = new Map([ + ["ses_empty_old", session({ id: "ses_empty_old", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z" })], + ["ses_empty_fresh", session({ id: "ses_empty_fresh", status: "idle", updatedAt: "2026-06-18T00:15:30.000Z" })], + ["ses_with_user", session({ id: "ses_with_user", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z", messages: [{ role: "user", text: "keep" }] })], + ["ses_running", session({ id: "ses_running", status: "running", updatedAt: "2026-06-18T00:00:00.000Z" })], + ["ses_trace", session({ id: "ses_trace", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z", lastTraceId: "trc_keep" })], + ["ses_completed", session({ id: "ses_completed", status: "completed", updatedAt: "2026-06-18T00:00:00.000Z" })] + ]); + const store = { + async listAgentSessionsForUser(input = {}) { + assert.equal(input.ownerScoped, false); + assert.equal(input.actorRole, "admin"); + return [...sessions.values()].filter((item) => item.status !== "archived"); + }, + async archiveAgentConversation(input = {}) { + assert.equal(input.ownerScoped, false); + assert.equal(input.actorRole, "admin"); + const current = sessions.get(input.sessionId); + if (!current || current.status === "archived") return { count: 0 }; + sessions.set(input.sessionId, { ...current, status: "archived", endedAt: input.now, updatedAt: input.now }); + return { count: 1 }; + } + }; + + const result = await runWorkbenchEmptySessionGc({ + store, + ttlMs: 10 * 60 * 1000, + now: "2026-06-18T00:20:00.000Z" + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.archivedSessionIds, ["ses_empty_old"]); + assert.equal(sessions.get("ses_empty_old")?.status, "archived"); + assert.equal(sessions.get("ses_empty_fresh")?.status, "idle"); + assert.equal(sessions.get("ses_with_user")?.status, "idle"); + assert.equal(sessions.get("ses_running")?.status, "running"); + assert.equal(sessions.get("ses_trace")?.status, "idle"); + assert.equal(sessions.get("ses_completed")?.status, "completed"); + assert.equal(result.keptByReason.ttl_not_elapsed, 1); + assert.equal(result.keptByReason.has_messages, 1); + assert.equal(result.keptByReason.running_or_admitting, 1); + assert.equal(result.keptByReason.has_trace_or_terminal_evidence, 1); + assert.equal(result.keptByReason.terminal_result_status, 1); +}); + +test("workbench empty session GC classifier uses canonical activity timestamp", () => { + const candidate = session({ + id: "ses_touched", + status: "idle", + updatedAt: "2026-06-18T00:00:00.000Z", + session: { createdAt: "2026-06-18T00:00:00.000Z", lastTouchedAt: "2026-06-18T00:19:00.000Z", messageCount: 0 } + }); + + assert.deepEqual( + classifyWorkbenchEmptySessionGcCandidate(candidate, { nowMs: Date.parse("2026-06-18T00:20:00.000Z"), ttlMs: 10 * 60 * 1000 }), + { eligible: false, reason: "ttl_not_elapsed" } + ); +}); + +function session(input: any) { + const messages = input.messages ?? []; + return { + id: input.id, + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: input.status, + startedAt: input.startedAt ?? input.updatedAt, + endedAt: null, + ownerUserId: "usr_gc", + conversationId: `cnv_${input.id.slice("ses_".length)}`, + threadId: null, + lastTraceId: input.lastTraceId ?? null, + updatedAt: input.updatedAt, + session: { + source: "test", + createdAt: input.startedAt ?? input.updatedAt, + updatedAt: input.updatedAt, + messageCount: messages.length, + messages, + ...(input.session ?? {}), + valuesRedacted: true, + secretMaterialStored: false + } + }; +} diff --git a/internal/cloud/workbench-empty-session-gc.ts b/internal/cloud/workbench-empty-session-gc.ts new file mode 100644 index 00000000..6bc2e782 --- /dev/null +++ b/internal/cloud/workbench-empty-session-gc.ts @@ -0,0 +1,203 @@ +/* + * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-zero-repair; PJ2026-010403 API契约 draft-2026-06-18-r1. + * 职责: Workbench 空消息 session 后台生命周期 GC。只调用服务端 session lifecycle mutation,不在 GET/read model/Web 中做 cleanup 或 repair。 + */ + +export const DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS = 10 * 60 * 1000; +export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS = 60 * 1000; +export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS = 5 * 1000; +export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE = 100; + +const RUNNING_STATUSES = new Set(["active", "accepted", "busy", "creating", "dispatching", "pending", "queued", "running", "streaming"]); +const TERMINAL_RESULT_STATUSES = new Set(["blocked", "canceled", "cancelled", "completed", "failed", "timeout"]); + +export function startWorkbenchEmptySessionGc(options: any = {}) { + const env = options.env ?? process.env; + const store = options.store ?? options.accessController?.store ?? null; + const enabled = envFlag(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED, true); + const ttlMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_TTL_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, { min: 60_000, max: 86_400_000 }); + const intervalMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS, { min: 1_000, max: 3_600_000 }); + const initialDelayMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS, { min: 0, max: 3_600_000 }); + const batchSize = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, { min: 1, max: 100 }); + const canMutate = typeof store?.listAgentSessionsForUser === "function" && typeof store?.archiveAgentConversation === "function"; + if (!enabled || !canMutate) { + return disabledGcHandle({ enabled, reason: enabled ? "session_store_missing_lifecycle_mutation" : "disabled", ttlMs, intervalMs, initialDelayMs, batchSize }); + } + + let stopped = false; + let running = false; + const tick = async (reason = "manual") => { + if (stopped) return { ok: false, status: "stopped", reason }; + if (running) return { ok: true, status: "skipped", reason: "already_running" }; + running = true; + try { + const result = await runWorkbenchEmptySessionGc({ store, ttlMs, batchSize, now: options.now, reason }); + logGcResult(options.logger, result); + return result; + } catch (error) { + const result = { ok: false, status: "failed", reason, error: error instanceof Error ? error.message : String(error), valuesRedacted: true }; + logGcResult(options.logger, result); + return result; + } finally { + running = false; + } + }; + + const startupTimer = setTimeout(() => { void tick("startup"); }, initialDelayMs); + startupTimer.unref?.(); + const interval = setInterval(() => { void tick("interval"); }, intervalMs); + interval.unref?.(); + + return { + enabled: true, + status: "running", + ttlMs, + intervalMs, + initialDelayMs, + batchSize, + tick, + stop() { + stopped = true; + clearTimeout(startupTimer); + clearInterval(interval); + } + }; +} + +export async function runWorkbenchEmptySessionGc({ store, ttlMs = DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, batchSize = DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, now, reason = "manual" }: any = {}) { + if (typeof store?.listAgentSessionsForUser !== "function" || typeof store?.archiveAgentConversation !== "function") { + return { ok: false, status: "blocked", reason: "session_store_missing_lifecycle_mutation", ttlMs, batchSize, valuesRedacted: true }; + } + const observedAt = timestampFor(now); + const nowMs = Date.parse(observedAt); + const sessions = await store.listAgentSessionsForUser({ + ownerScoped: false, + actorRole: "admin", + includeArchived: false, + limit: batchSize + }) ?? []; + const keptByReason: Record = {}; + const archivedSessionIds: string[] = []; + for (const session of sessions) { + const decision = classifyWorkbenchEmptySessionGcCandidate(session, { nowMs, ttlMs }); + if (!decision.eligible) { + keptByReason[decision.reason] = (keptByReason[decision.reason] ?? 0) + 1; + continue; + } + const archived = await store.archiveAgentConversation({ + actorRole: "admin", + ownerScoped: false, + sessionId: session.id, + now: observedAt + }) ?? { count: 0 }; + if (Number(archived.count ?? 0) > 0) archivedSessionIds.push(session.id); + } + return { + ok: true, + status: "completed", + reason, + observedAt, + cutoffAt: new Date(nowMs - ttlMs).toISOString(), + ttlMs, + batchSize, + scanned: sessions.length, + archived: archivedSessionIds.length, + archivedSessionIds, + keptByReason, + valuesRedacted: true + }; +} + +export function classifyWorkbenchEmptySessionGcCandidate(session: any, { nowMs = Date.now(), ttlMs = DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS } = {}) { + if (!session?.id || typeof session.id !== "string") return { eligible: false, reason: "invalid_session" }; + const snapshot = objectValue(session.session); + const status = normalizeStatus(session.status ?? snapshot.sessionStatus ?? snapshot.status); + if (status === "archived") return { eligible: false, reason: "already_archived" }; + if (RUNNING_STATUSES.has(status)) return { eligible: false, reason: "running_or_admitting" }; + if (TERMINAL_RESULT_STATUSES.has(status)) return { eligible: false, reason: "terminal_result_status" }; + if (sessionMessageCount(session, snapshot) > 0) return { eligible: false, reason: "has_messages" }; + if (hasTraceOrTerminalEvidence(session, snapshot)) return { eligible: false, reason: "has_trace_or_terminal_evidence" }; + const activityMs = lastActivityMs(session, snapshot); + if (!Number.isFinite(activityMs)) return { eligible: false, reason: "missing_activity_timestamp" }; + if (nowMs - activityMs < ttlMs) return { eligible: false, reason: "ttl_not_elapsed" }; + return { eligible: true, reason: "stale_empty_session", activityAt: new Date(activityMs).toISOString() }; +} + +function sessionMessageCount(session: any, snapshot: Record) { + for (const messages of [snapshot.messages, snapshot.chatMessages, session.messages, session.chatMessages]) { + if (Array.isArray(messages) && messages.length > 0) return messages.length; + } + for (const value of [snapshot.messageCount, session.messageCount]) { + const count = Number(value); + if (Number.isFinite(count) && count > 0) return Math.trunc(count); + } + return 0; +} + +function hasTraceOrTerminalEvidence(session: any, snapshot: Record) { + if (text(session.lastTraceId) || text(snapshot.lastTraceId) || text(snapshot.currentTraceId) || text(snapshot.traceId)) return true; + if (objectHasKeys(snapshot.traceResults) || objectHasKeys(snapshot.traceResult)) return true; + return Boolean(snapshot.finalResponse || snapshot.traceSummary || snapshot.agentRun || snapshot.terminalEvidence); +} + +function lastActivityMs(session: any, snapshot: Record) { + const values = [ + session.updatedAt, + snapshot.updatedAt, + snapshot.lastTouchedAt, + snapshot.lastActivityAt, + session.startedAt, + snapshot.startedAt, + snapshot.createdAt + ].map((value) => Date.parse(String(value ?? ""))).filter(Number.isFinite); + return values.length > 0 ? Math.max(...values) : NaN; +} + +function envFlag(value: unknown, fallback: boolean) { + const textValue = text(value).toLowerCase(); + if (!textValue) return fallback; + if (["0", "false", "no", "off", "disabled"].includes(textValue)) return false; + if (["1", "true", "yes", "on", "enabled"].includes(textValue)) return true; + return fallback; +} + +function boundedInteger(value: unknown, fallback: number, { min, max }: { min: number; max: number }) { + const parsed = Number.parseInt(String(value ?? ""), 10); + const candidate = Number.isInteger(parsed) ? parsed : fallback; + return Math.min(Math.max(candidate, min), max); +} + +function timestampFor(now: unknown) { + const value = typeof now === "function" ? (now as () => unknown)() : now; + if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString(); + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value; + return new Date().toISOString(); +} + +function normalizeStatus(value: unknown) { + const status = text(value).toLowerCase().replace(/_/gu, "-"); + return status === "cancelled" ? "canceled" : status; +} + +function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function objectHasKeys(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0; +} + +function text(value: unknown) { + return String(value ?? "").trim(); +} + +function disabledGcHandle(meta: any) { + return { ...meta, status: "disabled", tick: async () => ({ ok: false, status: "disabled", ...meta }), stop() {} }; +} + +function logGcResult(logger: any, result: any) { + if (!logger || result?.archived <= 0 && result?.status !== "failed") return; + const message = JSON.stringify({ event: "workbench_empty_session_gc", ...result }); + if (result?.status === "failed" && typeof logger.error === "function") logger.error(message); + else if (typeof logger.info === "function") logger.info(message); +} diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 66042163..ff5640df 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -49,6 +49,7 @@ export const checkProfiles = Object.freeze({ { id: "check-029-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-chat.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-030-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/m3-io-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-030a-cloud-api-access-control", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, + { id: "check-030b-cloud-api-workbench-empty-session-gc", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/workbench-empty-session-gc.ts","internal/cloud/workbench-empty-session-gc.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-031-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-032-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server-test-helpers.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-033-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-trace-store.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, @@ -143,7 +144,7 @@ export const checkProfiles = Object.freeze({ { id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] }, { id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] }, { id: "check-145-repo-bootstrap-skills-sh", group: "repo", command: ["sh","-n","scripts/bootstrap-skills.sh","scripts/worker-entrypoint.sh"] }, - { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } + { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/workbench-empty-session-gc.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } ]) }); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 322fe149..d79d3c03 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -6,6 +6,11 @@ import { readFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { extname, join, resolve, sep } from "node:path"; +import { + DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, + classifyWorkbenchEmptySessionGcCandidate +} from "../../../internal/cloud/workbench-empty-session-gc.ts"; + type JsonRecord = Record; interface SessionRecord extends JsonRecord { @@ -78,6 +83,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) state = createScenarioState(String(body.scenarioId ?? "baseline")); return json(response, 200, stateSummary()); } + if (path === "/__e2e/gc-empty-sessions" && method === "POST") { + const body = await readJson(request); + return json(response, 200, gcEmptySessions(body)); + } if (path === "/auth/session" || path === "/auth/bootstrap") { const payload = authPayload(); @@ -268,6 +277,7 @@ function createScenarioState(scenarioId: string): ScenarioState { } if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession()); if (id === "session-switch-empty-reload") sessions.push(emptySession()); + if (id === "empty-session-gc") sessions.unshift(staleEmptyTtlSession(), freshEmptyTtlSession(), staleMessagedTtlSession()); const selectedSessionId = id === "deep-link" || id === "stale-nested-trace" ? "ses_failed" : id === "legacy-cnv-deeplink-canonical" @@ -394,6 +404,33 @@ function emptySession(): SessionRecord { return { sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", messages: [] }; } +function staleEmptyTtlSession(): SessionRecord { + const at = "2026-06-18T16:00:00.000Z"; + return { sessionId: "ses_empty_ttl", threadId: "thr_empty_ttl", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] }; +} + +function freshEmptyTtlSession(): SessionRecord { + const at = "2026-06-18T16:15:30.000Z"; + return { sessionId: "ses_empty_fresh", threadId: "thr_empty_fresh", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] }; +} + +function staleMessagedTtlSession(): SessionRecord { + const at = "2026-06-18T15:50:00.000Z"; + return { + sessionId: "ses_empty_ttl_has_message", + threadId: "thr_empty_ttl_has_message", + status: "completed", + startedAt: at, + updatedAt: at, + messageCount: 2, + firstUserMessagePreview: "超过 TTL 但已有消息,不能被 GC", + messages: [ + { id: "msg_empty_ttl_user", role: "user", title: "用户", text: "超过 TTL 但已有消息,不能被 GC", status: "sent", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" }, + { id: "msg_empty_ttl_agent", role: "agent", title: "Code Agent", text: "保留已有消息 session。", status: "completed", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" } + ] + }; +} + function archivedDeletedSession(): SessionRecord { const now = new Date().toISOString(); return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] }; @@ -687,6 +724,44 @@ function stateSummary(): JsonRecord { return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) }; } +function gcEmptySessions(body: JsonRecord): JsonRecord { + const ttlMs = positiveInteger(body.ttlMs, DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS); + const nowMs = Date.parse(typeof body.now === "string" ? body.now : "2026-06-18T16:20:30.000Z"); + const archivedSessionIds: string[] = []; + const keptByReason: Record = {}; + for (const session of state.sessions) { + if (session.status === "archived") continue; + const decision = classifyWorkbenchEmptySessionGcCandidate(toBackendSessionRecord(session), { nowMs, ttlMs }); + if (!decision.eligible) { + keptByReason[decision.reason] = (keptByReason[decision.reason] ?? 0) + 1; + continue; + } + session.status = "archived"; + session.updatedAt = new Date(nowMs).toISOString(); + archivedSessionIds.push(session.sessionId); + } + return { ok: true, status: "completed", ttlMs, archived: archivedSessionIds.length, archivedSessionIds, keptByReason, valuesRedacted: true }; +} + +function toBackendSessionRecord(session: SessionRecord): JsonRecord { + return { + id: session.sessionId, + status: session.status, + startedAt: session.startedAt, + updatedAt: session.updatedAt, + lastTraceId: session.lastTraceId, + session: { + createdAt: session.startedAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + messages: session.messages ?? [], + lastTraceId: session.lastTraceId, + valuesRedacted: true, + secretMaterialStored: false + } + }; +} + function recordLedger(method: string, url: URL): void { if (!url.pathname.startsWith("/v1/")) return; const entry = { method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()) }; @@ -763,3 +838,8 @@ function contentType(filePath: string): string { function delay(ms: number): Promise { return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve(); } + +function positiveInteger(value: unknown, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/empty-session-gc.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/empty-session-gc.spec.ts new file mode 100644 index 00000000..f6747572 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/empty-session-gc.spec.ts @@ -0,0 +1,37 @@ +import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; + +test.use({ scenarioId: "empty-session-gc" }); + +test("server-side empty session GC archives only stale empty sessions", async ({ page }, testInfo) => { + await gotoWorkbench(page); + await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(1); + await expect(page.locator(sessionTab("ses_empty_fresh"))).toHaveCount(1); + await expect(page.locator(sessionTab("ses_empty_ttl_has_message"))).toHaveCount(1); + await saveScreenshot(page, testInfo, "empty-session-gc-before"); + + const gc = await page.request.post("/__e2e/gc-empty-sessions", { + data: { ttlMs: 600_000, now: "2026-06-18T16:20:30.000Z" } + }); + expect(gc.ok()).toBeTruthy(); + const gcBody = await gc.json() as { archivedSessionIds?: string[]; keptByReason?: Record }; + expect(gcBody.archivedSessionIds).toEqual(["ses_empty_ttl"]); + expect(gcBody.keptByReason?.ttl_not_elapsed).toBeGreaterThanOrEqual(1); + expect(gcBody.keptByReason?.terminal_result_status).toBeGreaterThanOrEqual(1); + + await page.reload(); + await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(0); + await expect(page.locator(sessionTab("ses_empty_fresh"))).toHaveCount(1); + await expect(page.locator(sessionTab("ses_empty_ttl_has_message"))).toHaveCount(1); + await saveScreenshot(page, testInfo, "empty-session-gc-after-rail"); + + const state = await fakeServerState(page); + const archived = (state.sessions as Array<{ sessionId?: string; status?: string }>).find((item) => item.sessionId === "ses_empty_ttl"); + expect(archived?.status).toBe("archived"); + + await gotoWorkbench(page, "/workbench/sessions/ses_empty_ttl"); + await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(0); + await expect(page.locator(".conversation-error-hint")).toContainText(/archived|归档|session/u); + await expect(page.locator(selectors.commandSend)).toBeDisabled(); + await saveScreenshot(page, testInfo, "empty-session-gc-deeplink"); +});