Merge pull request #1469 from pikasTech/fix/issue-1465-workbench-session-api
fix: 收敛 Workbench session API authority
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user