Merge pull request #1739 from pikasTech/fix/1737-workbench-projection
fix: persist manual Workbench sessions
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user