fix(workbench): gc stale empty sessions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<string, number> = {};
|
||||
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<string, any>) {
|
||||
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<string, any>) {
|
||||
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<string, any>) {
|
||||
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<string, any> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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"] }
|
||||
])
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, number> = {};
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, number> };
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user