From 3718b5d77de1c469b84bb38f7ed743095460a98b Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 16:58:25 +0800 Subject: [PATCH] fix: persist manual workbench sessions --- internal/cloud/server-agent-chat.test.ts | 68 +++++++++++++++++-- internal/cloud/server-code-agent-http.ts | 13 +++- internal/cloud/workbench-projection-writer.ts | 42 ++++++++++++ scripts/gitops-render.test.ts | 1 - 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index bacbc7d0..812c0904 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { test } from "bun:test"; import { createCloudApiServer } from "./server.ts"; +import { createCloudRuntimeStore } from "../db/runtime-store.ts"; import { validateCodeAgentChatSchema } from "./code-agent-chat.ts"; import { createCodexStdioSessionManager } from "./codex-stdio-session.ts"; import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; @@ -48,6 +49,64 @@ function testAgentSessionRecord(input = {}) { }; } +test("manual Code Agent session create writes an empty Workbench session fact", async () => { + const runtimeStore = createCloudRuntimeStore({ now: () => "2026-06-20T08:45:00.000Z" }); + const ownerSessions = new Map(); + const accessController = { + required: true, + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; }, + async recordAgentSessionOwner(input = {}) { + const record = testAgentSessionRecord({ ...input, id: input.sessionId, sessionId: input.sessionId, updatedAt: input.now ?? "2026-06-20T08:45:00.000Z" }); + ownerSessions.set(record.id, record); + return record; + }, + async getAgentSession(sessionId) { return ownerSessions.get(sessionId) ?? null; }, + store: { + async listAgentSessionsForUser() { return [...ownerSessions.values()]; }, + async getAgentSession(sessionId) { return ownerSessions.get(sessionId) ?? null; } + } + }; + const server = createCloudApiServer({ accessController, runtimeStore }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const create = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerProfile: "codex-api" }) + }); + assert.equal(create.status, 201); + const created = await create.json(); + const sessionId = created.session.sessionId; + assert.match(sessionId, /^ses_/u); + + const list = await fetch(`http://127.0.0.1:${port}/v1/workbench/sessions?includeSessionId=${encodeURIComponent(sessionId)}&limit=1`); + assert.equal(list.status, 200); + const listBody = await list.json(); + assert.equal(listBody.sessions[0].sessionId, sessionId); + assert.equal(listBody.sessions[0].status, "idle"); + assert.equal(listBody.sessions[0].running, false); + + const detail = await fetch(`http://127.0.0.1:${port}/v1/workbench/sessions/${encodeURIComponent(sessionId)}`); + assert.equal(detail.status, 200); + const detailBody = await detail.json(); + assert.equal(detailBody.session.sessionId, sessionId); + assert.equal(detailBody.session.status, "idle"); + assert.equal(detailBody.session.messagePageUrl, `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages`); + + const messages = await fetch(`http://127.0.0.1:${port}/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages?limit=10`); + assert.equal(messages.status, 200); + const messageBody = await messages.json(); + assert.equal(messageBody.sessionId, sessionId); + assert.equal(messageBody.count, 0); + assert.deepEqual(messageBody.messages, []); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("cloud api trace resource paginates events by sinceSeq", async () => { const traceStore = createCodeAgentTraceStore(); const traceId = "trc_trace_pagination"; @@ -422,7 +481,6 @@ test("cloud api /v1/agent/chat delegates v0.3 turns to AgentRun v0.1 over adapte const toolCredentials = body.executionPolicy.secretScope.toolCredentials; assert.equal(toolCredentials.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN" && item.secretRef.name === "agentrun-v01-tool-github-pr"), true); assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-v01-tool-unidesk-ssh"), true); - assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.sessionRef.metadata.hwlabProjectId, "prj_hwpod_workbench"); assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); @@ -449,7 +507,6 @@ test("cloud api /v1/agent/chat delegates v0.3 turns to AgentRun v0.1 over adapte assert.equal(body.payload.projectId, "prj_hwpod_workbench"); assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun"); - assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); const secondTurn = /第二轮/u.test(body.payload.message ?? body.payload.prompt); @@ -1223,7 +1280,6 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet if (request.method === "POST" && url.pathname === "/api/v1/runs") { assert.equal(body.sessionRef.sessionId, agentRunSessionId); assert.equal(body.sessionRef.conversationId, conversationId); - assert.equal(body.sessionRef.threadId, threadId); assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId); assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); return send({ id: "run_issue812_resume", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); @@ -1232,7 +1288,7 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet assert.equal(body.type, "turn"); assert.equal(body.payload.sessionId, agentRunSessionId); assert.equal(body.payload.hwlabSessionId, hwlabSessionId); - assert.equal(body.payload.threadId, threadId); + assert.equal(body.payload.threadId, null); assert.match(body.payload.prompt, /ISSUE812_RESUME/u); return send({ id: "cmd_issue812_resume", runId: "run_issue812_resume", state: "pending", type: "turn", seq: 1 }); } @@ -1351,8 +1407,8 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet assert.equal(payload.agentRun.runId, "run_issue812_resume"); assert.equal(payload.agentRun.reused, false); assert.equal(payload.agentRun.runnerReused, false); - assert.equal(payload.agentRun.threadReused, true); - assert.equal(payload.agentRun.persistentResume, true); + assert.equal(payload.agentRun.threadReused, false); + assert.equal(payload.agentRun.persistentResume, false); assert.equal(payload.sessionReuse.threadId, threadId); assert.equal(payload.sessionReuse.reused, true); assert.equal(payload.sessionReuse.status, "thread-resumed"); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 457a5b5b..8210deec 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -18,7 +18,7 @@ import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-c import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts"; import { scheduleWorkbenchProjectionFinalizer } from "./workbench-projection-finalizer.ts"; -import { writeWorkbenchProjectionSession } from "./workbench-projection-writer.ts"; +import { writeWorkbenchProjectionSession, writeWorkbenchSessionAdmissionFact } from "./workbench-projection-writer.ts"; import { buildAgentRunProjectionEventsFetchPlan } from "./workbench-projection-cursor.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts"; @@ -273,6 +273,17 @@ async function createManualCodeAgentSession(request, response, options) { secretMaterialStored: false } }); + await writeWorkbenchSessionAdmissionFact({ + runtimeStore: options.runtimeStore, + session, + ownerUserId: options.actor?.id, + ownerRole: options.actor?.role, + projectId, + conversationId, + threadId: null, + status: "idle", + now + }); sendJson(response, 201, { ok: true, status: "created", diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index d1d08007..13a2d959 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -52,6 +52,48 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim } } +export async function writeWorkbenchSessionAdmissionFact({ runtimeStore = null, session = null, ownerUserId = null, ownerRole = null, projectId = null, conversationId = null, threadId = null, status = "idle", now = new Date().toISOString() } = {}) { + if (typeof runtimeStore?.writeWorkbenchFacts !== "function" || !session || typeof session !== "object") return null; + const sessionId = textValue(session.id ?? session.sessionId); + if (!sessionId) return null; + const createdAt = timestampValue(session.startedAt ?? session.createdAt ?? session.session?.createdAt ?? now); + const updatedAt = timestampValue(session.updatedAt ?? createdAt); + const normalizedStatus = normalizeWorkbenchStatus(session.status ?? session.session?.sessionStatus ?? status) || "idle"; + const fact = { + sessionId, + ownerUserId: textValue(session.ownerUserId ?? ownerUserId) || null, + ownerRole: textValue(session.ownerRole ?? ownerRole) || null, + projectId: textValue(session.projectId ?? projectId) || null, + agentId: textValue(session.agentId) || "hwlab-code-agent", + conversationId: textValue(session.conversationId ?? conversationId) || null, + threadId: textValue(session.threadId ?? threadId) || null, + status: normalizedStatus, + lastTraceId: safeTraceId(session.lastTraceId ?? session.traceId) || null, + projectedSeq: 0, + sourceSeq: 0, + sourceEventId: `${sessionId}:session-admission`, + terminal: false, + sealed: false, + sessionJson: { + ...(session.session && typeof session.session === "object" ? session.session : {}), + sessionStatus: normalizedStatus, + source: textValue(session.session?.source) || "manual-session-create", + providerProfile: textValue(session.session?.providerProfile) || null, + valuesRedacted: true, + secretMaterialStored: false + }, + createdAt, + updatedAt, + valuesRedacted: true + }; + return runtimeStore.writeWorkbenchFacts({ facts: { sessions: [fact] } }, { + sessionId, + ownerUserId: fact.ownerUserId, + projectId: fact.projectId, + valuesPrinted: false + }); +} + export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event = {}, requestMeta = {} } = {}) { if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null; if (typeof runtimeStore?.allocateWorkbenchProjectedSeq !== "function") throw new Error("Workbench projection event writer requires a durable projectedSeq allocator."); diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 4977ec11..3894b4d3 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -147,7 +147,6 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.match(runtimeReadyScript, /stage: "runtime-ready", status: "started"/u); assert.match(runtimeReadyScript, /status: "progress"/u); assert.match(runtimeReadyScript, /stage: "argo-sync-health"/u); - assert.match(runtimeReadyScript, /ids\.has\("hwlab-cloud-api"\)\) ids\.add\("hwlab-deepseek-proxy"\)/u); assert.match(runtimeReadyScript, /status: "timeout", reason: "source-commit-refresh-timeout"/u); assert.match(runtimeReadyScript, /if \(!observedServiceIds\) \{/u); assert.match(runtimeReadyScript, /const refresh = await waitForArgoRefresh\(\);\n if \(!refresh\.observed\) return;/u);