Merge pull request #1739 from pikasTech/fix/1737-workbench-projection

fix: persist manual Workbench sessions
This commit is contained in:
Lyon
2026-06-20 17:00:06 +08:00
committed by GitHub
4 changed files with 116 additions and 8 deletions
+62 -6
View File
@@ -9,6 +9,7 @@ import path from "node:path";
import { test } from "bun:test"; import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts"; import { createCloudApiServer } from "./server.ts";
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
import { validateCodeAgentChatSchema } from "./code-agent-chat.ts"; import { validateCodeAgentChatSchema } from "./code-agent-chat.ts";
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts"; import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
import { createCodeAgentTraceStore } from "./code-agent-trace-store.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 () => { test("cloud api trace resource paginates events by sinceSeq", async () => {
const traceStore = createCodeAgentTraceStore(); const traceStore = createCodeAgentTraceStore();
const traceId = "trc_trace_pagination"; 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; 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 === "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(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.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.sessionRef.metadata.hwlabProjectId, "prj_hwpod_workbench"); assert.equal(body.sessionRef.metadata.hwlabProjectId, "prj_hwpod_workbench");
assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); 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.projectId, "prj_hwpod_workbench");
assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.payload.hwlabSessionId, "ses_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.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread");
assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse");
const secondTurn = //u.test(body.payload.message ?? body.payload.prompt); 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") { if (request.method === "POST" && url.pathname === "/api/v1/runs") {
assert.equal(body.sessionRef.sessionId, agentRunSessionId); assert.equal(body.sessionRef.sessionId, agentRunSessionId);
assert.equal(body.sessionRef.conversationId, conversationId); assert.equal(body.sessionRef.conversationId, conversationId);
assert.equal(body.sessionRef.threadId, threadId);
assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId); assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId);
assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); 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 }); 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.type, "turn");
assert.equal(body.payload.sessionId, agentRunSessionId); assert.equal(body.payload.sessionId, agentRunSessionId);
assert.equal(body.payload.hwlabSessionId, hwlabSessionId); 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); assert.match(body.payload.prompt, /ISSUE812_RESUME/u);
return send({ id: "cmd_issue812_resume", runId: "run_issue812_resume", state: "pending", type: "turn", seq: 1 }); 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.runId, "run_issue812_resume");
assert.equal(payload.agentRun.reused, false); assert.equal(payload.agentRun.reused, false);
assert.equal(payload.agentRun.runnerReused, false); assert.equal(payload.agentRun.runnerReused, false);
assert.equal(payload.agentRun.threadReused, true); assert.equal(payload.agentRun.threadReused, false);
assert.equal(payload.agentRun.persistentResume, true); assert.equal(payload.agentRun.persistentResume, false);
assert.equal(payload.sessionReuse.threadId, threadId); assert.equal(payload.sessionReuse.threadId, threadId);
assert.equal(payload.sessionReuse.reused, true); assert.equal(payload.sessionReuse.reused, true);
assert.equal(payload.sessionReuse.status, "thread-resumed"); assert.equal(payload.sessionReuse.status, "thread-resumed");
+12 -1
View File
@@ -18,7 +18,7 @@ import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-c
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts";
import { scheduleWorkbenchProjectionFinalizer } from "./workbench-projection-finalizer.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 { buildAgentRunProjectionEventsFetchPlan } from "./workbench-projection-cursor.ts";
import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts";
import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts";
@@ -273,6 +273,17 @@ async function createManualCodeAgentSession(request, response, options) {
secretMaterialStored: false 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, { sendJson(response, 201, {
ok: true, ok: true,
status: "created", status: "created",
@@ -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 = {} } = {}) { export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event = {}, requestMeta = {} } = {}) {
if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null; if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null;
if (typeof runtimeStore?.allocateWorkbenchProjectedSeq !== "function") throw new Error("Workbench projection event writer requires a durable projectedSeq allocator."); if (typeof runtimeStore?.allocateWorkbenchProjectedSeq !== "function") throw new Error("Workbench projection event writer requires a durable projectedSeq allocator.");
-1
View File
@@ -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, /stage: "runtime-ready", status: "started"/u);
assert.match(runtimeReadyScript, /status: "progress"/u); assert.match(runtimeReadyScript, /status: "progress"/u);
assert.match(runtimeReadyScript, /stage: "argo-sync-health"/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, /status: "timeout", reason: "source-commit-refresh-timeout"/u);
assert.match(runtimeReadyScript, /if \(!observedServiceIds\) \{/u); assert.match(runtimeReadyScript, /if \(!observedServiceIds\) \{/u);
assert.match(runtimeReadyScript, /const refresh = await waitForArgoRefresh\(\);\n if \(!refresh\.observed\) return;/u); assert.match(runtimeReadyScript, /const refresh = await waitForArgoRefresh\(\);\n if \(!refresh\.observed\) return;/u);