diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index dc8417ed..494a957e 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -14,7 +14,7 @@ test("workbench read model exposes session, messages, turn, and trace without wr const traceId = "trc_workbench_read_model"; const session = { id: "ses_workbench_read_model", - projectId: "prj_hwpod_workbench", + projectId: "prj_workbench_authority_session", agentId: "hwlab-code-agent", status: "completed", startedAt: "2026-06-17T00:00:00.000Z", @@ -36,6 +36,7 @@ test("workbench read model exposes session, messages, turn, and trace without wr secretMaterialStored: false } }; + const listInputs = []; traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" }); traceStore.append(traceId, { type: "result", status: "completed", label: "result:completed", terminal: true }); results.set(traceId, { @@ -51,7 +52,10 @@ test("workbench read model exposes session, messages, turn, and trace without wr const accessController = { store: { - async listAgentSessionsForUser() { return [session]; }, + async listAgentSessionsForUser(input = {}) { + listInputs.push(input); + return [session].filter((item) => !input.projectId || item.projectId === input.projectId); + }, async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }, async recordAgentSessionOwner() { writes.recordAgentSessionOwner += 1; throw new Error("read model must not write session owner"); }, @@ -65,11 +69,19 @@ test("workbench read model exposes session, messages, turn, and trace without wr try { const { port } = server.address(); - const sessions = await getJson(port, "/v1/workbench/sessions?projectId=prj_hwpod_workbench"); + const sessions = await getJson(port, "/v1/workbench/sessions"); assert.equal(sessions.status, 200); assert.equal(sessions.body.contractVersion, "workbench-sessions-v1"); + assert.equal(sessions.body.projectId, null); assert.equal(sessions.body.sessions[0].sessionId, session.id); assert.equal(sessions.body.sessions[0].turnSummary.traceId, traceId); + assert.equal(listInputs[0].projectId, undefined); + + const staleProject = await getJson(port, `/v1/workbench/sessions?projectId=prj_stale_filter&includeSessionId=${encodeURIComponent(session.id)}`); + assert.equal(staleProject.status, 200); + assert.equal(staleProject.body.projectId, "prj_stale_filter"); + assert.equal(staleProject.body.sessions[0].sessionId, session.id); + assert.equal(staleProject.body.sessions[0].projectId, session.projectId); const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}`); assert.equal(detail.status, 200); @@ -227,7 +239,7 @@ test("workbench read model treats active session state as running", async () => try { const { port } = server.address(); - const sessions = await getJson(port, "/v1/workbench/sessions?projectId=prj_hwpod_workbench"); + const sessions = await getJson(port, "/v1/workbench/sessions"); assert.equal(sessions.status, 200); assert.equal(sessions.body.sessions[0].status, "active"); assert.equal(sessions.body.sessions[0].running, true); @@ -237,7 +249,7 @@ test("workbench read model treats active session state as running", async () => } }); -test("workbench realtime stream emits workspace, turn, trace, and live trace events", async () => { +test("workbench realtime stream accepts session authority without project or workspace", async () => { const traceStore = createCodeAgentTraceStore(); const results = createCodeAgentChatResultStore(); const traceId = "trc_workbench_realtime"; @@ -270,6 +282,7 @@ test("workbench realtime stream emits workspace, turn, trace, and live trace eve store: { async getOrCreateDefaultWorkspace() { return workspace; }, async getWorkspaceForUser() { return workspace; }, + async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; } }, async ensureBootstrap() {}, @@ -281,20 +294,20 @@ test("workbench realtime stream emits workspace, turn, trace, and live trace eve try { const { port } = server.address(); - const eventsPromise = getSseEvents(port, `/v1/workbench/events?projectId=prj_hwpod_workbench&workspaceId=${encodeURIComponent(workspace.workspaceId)}&traceId=${encodeURIComponent(traceId)}`, 5); + const eventsPromise = getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(session.id)}`, 4); setTimeout(() => traceStore.append(traceId, { type: "backend", status: "running", label: "backend:live" }), 50); const events = await eventsPromise; - assert.deepEqual(events.slice(0, 4).map((event) => event.event), [ + assert.deepEqual(events.slice(0, 3).map((event) => event.event), [ "workbench.connected", - "workbench.workspace.snapshot", "workbench.trace.snapshot", "workbench.turn.snapshot" ]); - assert.equal(events[1].data.workspace.workspaceId, workspace.workspaceId); - assert.equal(events[2].data.traceId, traceId); - assert.equal(events[3].data.turn.traceId, traceId); - assert.equal(events[4].event, "workbench.trace.event"); - assert.equal(events[4].data.event.label, "backend:live"); + assert.equal(events[0].data.projectId, null); + assert.equal(events[0].data.filters.sessionId, session.id); + assert.equal(events[1].data.traceId, traceId); + assert.equal(events[2].data.turn.traceId, traceId); + assert.equal(events[3].event, "workbench.trace.event"); + assert.equal(events[3].data.event.label, "backend:live"); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 265e442c..c4d546cb 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 + * SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 * 职责: Workbench REST read model。只读投影 session/message/turn/trace facts,不执行 AgentRun sync、billing finalize 或 workspace repair。 */ import { createHash } from "node:crypto"; @@ -14,7 +14,6 @@ import { sendJson } from "./server-http-utils.ts"; -const DEFAULT_PROJECT_ID = "prj_hwpod_workbench"; const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 100; const DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS = 15000; @@ -69,7 +68,8 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options); if (!auth) return; - const projectId = textValue(url.searchParams.get("projectId")) || DEFAULT_PROJECT_ID; + const projectId = textValue(url.searchParams.get("projectId")) || null; + const requestedSessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId")); const requestedWorkspaceId = safeWorkspaceId(url.searchParams.get("workspaceId")); const requestedTraceId = safeTraceId(url.searchParams.get("traceId")); const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS); @@ -102,11 +102,18 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option heartbeatMs, filters: { workspaceId: requestedWorkspaceId, + sessionId: requestedSessionId, traceId: requestedTraceId } }); - const initialWorkspace = perf ? await perf.measure("workbench_initial_workspace", () => visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId })) : await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId }); + const initialSession = requestedSessionId ? await visibleSessionById(options.accessController?.store, requestedSessionId, auth.actor) : null; + let initialWorkspace = null; + if (requestedWorkspaceId || projectId) { + initialWorkspace = perf + ? await perf.measure("workbench_initial_workspace", () => visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId })) + : await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId }); + } if (initialWorkspace) { latestWorkspaceRevision = Number(initialWorkspace.revision ?? 0) || 0; writeEvent("workbench.workspace.snapshot", { @@ -117,7 +124,10 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option }); } - const activeTraceId = requestedTraceId ?? safeTraceId(initialWorkspace?.activeTraceId ?? initialWorkspace?.workspace?.activeTraceId ?? initialWorkspace?.workspace?.lastTraceId); + const initialSessionSnapshot = objectValue(initialSession?.session); + const activeTraceId = requestedTraceId + ?? safeTraceId(initialSession?.lastTraceId ?? initialSessionSnapshot.lastTraceId ?? initialSessionSnapshot.currentTraceId) + ?? safeTraceId(initialWorkspace?.activeTraceId ?? initialWorkspace?.workspace?.activeTraceId ?? initialWorkspace?.workspace?.lastTraceId); if (activeTraceId) { await (perf ? perf.measure("workbench_initial_trace", () => writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })) : writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })); const unsubscribe = traceStore.subscribe(activeTraceId, (event, snapshot) => { @@ -147,7 +157,10 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option const heartbeat = setInterval(async () => { try { - const workspace = await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId ?? initialWorkspace?.workspaceId, projectId }); + const heartbeatWorkspaceId = requestedWorkspaceId ?? initialWorkspace?.workspaceId; + const workspace = heartbeatWorkspaceId || projectId + ? await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: heartbeatWorkspaceId, projectId }) + : null; const revision = Number(workspace?.revision ?? 0) || 0; if (workspace && revision > latestWorkspaceRevision) { latestWorkspaceRevision = revision; @@ -215,21 +228,22 @@ async function authenticateWorkbenchRead(request, response, options) { } async function handleWorkbenchSessionList(response, url, options, actor) { - const projectId = textValue(url.searchParams.get("projectId")) || DEFAULT_PROJECT_ID; + const projectId = textValue(url.searchParams.get("projectId")) || null; const limit = boundedLimit(url.searchParams.get("limit")); const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); const store = options.accessController?.store; - let sessions = await store?.listAgentSessionsForUser?.({ + const listInput = { ownerUserId: actor.id, actorRole: actor.role, ownerScoped: true, - projectId, limit, includeArchived: false - }) ?? []; + }; + if (projectId) listInput.projectId = projectId; + let sessions = await store?.listAgentSessionsForUser?.(listInput) ?? []; if (includeSessionId && !sessions.some((session) => session.id === includeSessionId)) { const included = await visibleSessionById(store, includeSessionId, actor); - if (included && (!projectId || included.projectId === projectId)) sessions = [included, ...sessions]; + if (included) sessions = [included, ...sessions]; } const summaries = sessions.map((session) => sessionSummary(session, options)).filter(Boolean); sendJson(response, 200, { @@ -594,9 +608,11 @@ async function visibleWorkbenchWorkspace(options, actor, input = {}) { if (workspaceId) { return await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: actor.id, actorRole: actor.role }) ?? null; } + const projectId = textValue(input.projectId); + if (!projectId) return null; return await store.getOrCreateDefaultWorkspace?.({ ownerUserId: actor.id, - projectId: textValue(input.projectId) || DEFAULT_PROJECT_ID, + projectId, now: new Date().toISOString() }) ?? null; }