fix: add Workbench projection read model

This commit is contained in:
lyon
2026-06-18 23:17:58 +08:00
parent 5b05981959
commit d0eb94ae6a
10 changed files with 486 additions and 183 deletions
+31 -97
View File
@@ -1,5 +1,5 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Code Agent HTTP turn admission, lifecycle compatibility wrappers, and session evidence projection.
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Code Agent HTTP turn admission, lifecycle compatibility wrappers, and Workbench projection writer/finalizer integration.
import { createHash, randomUUID } from "node:crypto";
@@ -18,6 +18,8 @@ import {
import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-chat.ts";
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 {
firstHeaderValue,
getHeader,
@@ -794,76 +796,20 @@ function scheduleAgentRunProjectionSync({ traceId, params = {}, options = {}, tr
const noResponseTimeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_NO_RESPONSE_TIMEOUT_MS, null);
const pollIntervalMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_SYNC_POLL_INTERVAL_MS, 1_000);
const refreshOptions = codeAgentTurnStatusRefreshOptions(options);
let lastActivityAt = Date.now();
let lastActivitySignature = agentRunProjectionActivitySignature(currentResult, traceStore.snapshot(traceId));
let idleDegraded = false;
setImmediate(() => {
void (async () => {
let result = currentResult;
let lastError = null;
while (true) {
const cached = options.codeAgentChatResults?.get?.(traceId);
if (isCodeAgentResultCanceled(cached)) return;
if (isTraceCommandTerminalStatus(cached?.status)) {
scheduleCodeAgentTerminalTurnStatusEffects({ payload: cached, params, options, preserveLastTraceId: true });
return;
}
try {
const synced = await syncAgentRunChatResult({ traceId, currentResult: result, options: refreshOptions, traceStore });
result = synced.result ?? result;
if (result && isTraceCommandTerminalStatus(result.status)) {
scheduleCodeAgentTerminalTurnStatusEffects({ payload: result, params, options, preserveLastTraceId: true });
return;
}
const signature = agentRunProjectionActivitySignature(result, synced.runnerTrace ?? traceStore.snapshot(traceId));
if (signature && signature !== lastActivitySignature) {
lastActivitySignature = signature;
lastActivityAt = Date.now();
idleDegraded = false;
}
lastError = null;
} catch (error) {
lastError = error;
}
const idleMs = Date.now() - lastActivityAt;
if (noResponseTimeoutMs && idleMs >= noResponseTimeoutMs && !idleDegraded) {
traceStore.append(traceId, {
type: "turn-status",
status: "degraded",
label: "agentrun:projection:no-response-idle-timeout",
errorCode: lastError?.code ?? "no_response_idle_timeout",
degradedReason: lastError ? "projection_activity_stale" : "no_response_idle_timeout",
message: lastError?.message ?? `AgentRun projection has no new activity for ${idleMs}ms; idle threshold ${noResponseTimeoutMs}ms.`,
runId: result?.agentRun?.runId ?? currentResult.agentRun.runId,
commandId: result?.agentRun?.commandId ?? currentResult.agentRun.commandId,
timeoutMs: noResponseTimeoutMs,
idleMs,
lastActivityAt: new Date(lastActivityAt).toISOString(),
waitingFor: "agentrun-result-activity",
terminal: false,
valuesPrinted: false
});
idleDegraded = true;
}
await sleepAgentRunProjectionSync(pollIntervalMs);
}
})().catch((error) => {
traceStore.append(traceId, {
type: "turn-status",
status: "degraded",
label: "agentrun:projection:sync-crashed",
errorCode: error?.code ?? "agentrun_projection_sync_crashed",
message: error?.message ?? "AgentRun projection sync crashed.",
runId: currentResult.agentRun.runId,
commandId: currentResult.agentRun.commandId,
waitingFor: "agentrun-result",
terminal: false,
valuesPrinted: false
});
});
return scheduleWorkbenchProjectionFinalizer({
traceId,
currentResult,
traceStore,
noResponseTimeoutMs,
pollIntervalMs,
getCachedResult: () => options.codeAgentChatResults?.get?.(traceId),
isCanceled: isCodeAgentResultCanceled,
isTerminal: isTraceCommandTerminalStatus,
activitySignature: agentRunProjectionActivitySignature,
sleep: sleepAgentRunProjectionSync,
syncResult: ({ result }) => syncAgentRunChatResult({ traceId, currentResult: result, options: refreshOptions, traceStore }),
onTerminal: (payload, finalizerOptions = {}) => scheduleCodeAgentTerminalTurnStatusEffects({ payload, params, options, preserveLastTraceId: finalizerOptions.preserveLastTraceId === true })
});
return { scheduled: true, traceId, noResponseTimeoutMs, pollIntervalMs, valuesPrinted: false };
}
function agentRunProjectionActivitySignature(result, runnerTrace) {
@@ -2096,32 +2042,20 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options
const sessionId = safeSessionId(payload.sessionId ?? session?.sessionId ?? sessionReuse?.sessionId ?? params.sessionId) || null;
const conversationId = safeConversationId(payload.conversationId ?? session?.conversationId ?? sessionReuse?.conversationId ?? params.conversationId) || null;
const threadId = safeOpaqueId(session?.threadId ?? sessionReuse?.threadId ?? payload.threadId ?? params.threadId) || null;
try {
return await options.accessController.recordAgentSessionOwner({
ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole ?? null,
sessionId,
projectId: params.projectId ?? payload.projectId ?? null,
conversationId,
threadId,
traceId: preserveLastTraceId ? undefined : traceId,
status,
session: codeAgentSessionOwnerEvidence(payload, params)
});
} catch (error) {
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
if (traceId) {
traceStore.append(traceId, {
type: "session-owner",
status: "degraded",
label: "session-owner:persist-failed",
errorCode: "agent_session_owner_persist_failed",
message: error?.message ?? "Code Agent session owner binding persistence failed.",
valuesPrinted: false
});
}
return null;
}
return writeWorkbenchProjectionSession({
accessController: options.accessController,
traceStore: options.traceStore ?? defaultCodeAgentTraceStore,
traceId,
ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole ?? null,
sessionId,
projectId: params.projectId ?? payload.projectId ?? null,
conversationId,
threadId,
status,
preserveLastTraceId,
session: codeAgentSessionOwnerEvidence(payload, params)
});
}
function codeAgentOwnerStatusForResult(result = {}) {
@@ -1,3 +1,6 @@
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Workbench read model, durable projection diagnostics, and pure-GET regression tests.
import assert from "node:assert/strict";
import { test } from "bun:test";
@@ -438,6 +441,16 @@ test("workbench read model projects completed current turn for idle session summ
assert.equal(turn.body.turn.running, false);
assert.equal(turn.body.turn.terminal, true);
assert.equal(turn.body.turn.assistantText, finalText);
assert.equal(turn.body.projectionStatus, "caught-up");
assert.equal(turn.body.sourceRunId, "run_workbench_idle_completed");
assert.equal(turn.body.sourceCommandId, "cmd_workbench_idle_completed");
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
assert.equal(trace.status, 200);
assert.equal(trace.body.projectionStatus, "caught-up");
assert.equal(trace.body.sourceRunId, "run_workbench_idle_completed");
assert.equal(trace.body.sourceCommandId, "cmd_workbench_idle_completed");
assert.equal(trace.body.lastProjectedSeq, 2);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
+39 -80
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; 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 syncbilling finalize workspace repair
*/
import { createHash } from "node:crypto";
@@ -13,6 +13,7 @@ import {
safeTraceId,
sendJson
} from "./server-http-utils.ts";
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 100;
@@ -77,6 +78,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
const requestedTraceId = safeTraceId(url.searchParams.get("traceId"));
const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS);
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const readModel = createWorkbenchReadModel(options, auth.actor);
let closed = false;
const cleanup = [];
@@ -108,7 +110,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
}
});
const initialSession = requestedSessionId ? await visibleSessionById(options.accessController?.store, requestedSessionId, auth.actor) : null;
const initialSession = requestedSessionId ? await readModel.getSessionById(requestedSessionId) : null;
const initialSessionSnapshot = objectValue(initialSession?.session);
const activeTraceId = requestedTraceId
?? safeTraceId(initialSession?.lastTraceId ?? initialSessionSnapshot.lastTraceId ?? initialSessionSnapshot.currentTraceId)
@@ -202,19 +204,8 @@ async function handleWorkbenchSessionList(response, url, options, actor) {
const limit = boundedLimit(url.searchParams.get("limit"));
const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId"));
const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId"));
const store = options.accessController?.store;
const listInput = {
ownerUserId: actor.id,
actorRole: actor.role,
ownerScoped: true,
limit,
includeArchived: false
};
let sessions = await store?.listAgentSessionsForUser?.(listInput) ?? [];
if (includeRouteId && !sessions.some((session) => session.id === includeSessionId)) {
const included = await visibleSessionByRouteId(store, includeRouteId, actor);
if (included) sessions = [included, ...sessions];
}
const readModel = createWorkbenchReadModel(options, actor);
const sessions = await readModel.listSessions({ limit, includeRouteId });
const summaries = sessions.map((session) => sessionSummary(session, options)).filter(Boolean);
sendJson(response, 200, {
ok: true,
@@ -229,7 +220,8 @@ async function handleWorkbenchSessionList(response, url, options, actor) {
}
async function handleWorkbenchSessionDetail(response, options, actor, sessionId) {
const session = await visibleSessionByRouteId(options.accessController?.store, sessionId, actor);
const readModel = createWorkbenchReadModel(options, actor);
const session = await readModel.getSessionByRouteId(sessionId);
if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }));
sendJson(response, 200, {
ok: true,
@@ -242,7 +234,8 @@ async function handleWorkbenchSessionDetail(response, options, actor, sessionId)
}
async function handleWorkbenchMessagePage(response, url, options, actor, sessionId) {
const session = await visibleSessionByRouteId(options.accessController?.store, sessionId, actor);
const readModel = createWorkbenchReadModel(options, actor);
const session = await readModel.getSessionByRouteId(sessionId);
if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }));
const messages = sessionMessages(session, options);
const limit = boundedLimit(url.searchParams.get("limit"));
@@ -270,21 +263,29 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur
const traceId = safeTraceId(rawTurnId) ?? queryTraceId;
const turnId = safeTurnId(rawTurnId) || traceId || rawTurnId;
if (!traceId) return sendJson(response, 400, workbenchError("invalid_turn_id", "turnId must currently be a trace id or include traceId query.", { turnId }));
const result = options.codeAgentChatResults?.get?.(traceId) ?? null;
const session = await visibleSessionByTrace(options.accessController?.store, traceId, actor);
if (result?.ownerUserId && !canActorReadOwner(result.ownerUserId, actor)) {
const readModel = createWorkbenchReadModel(options, actor);
const result = readModel.resultForTrace(traceId);
const session = await readModel.getSessionByTraceId(traceId);
if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) {
return sendJson(response, 403, workbenchError("agent_session_owner_required", "Only the session owner or admin can read this Workbench turn.", { traceId }));
}
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
const trace = await traceSnapshot(options, traceId);
const trace = await readModel.traceSnapshot(traceId);
const status = workbenchTurnProjectionStatus(trace);
const found = Boolean(resultVisible || session);
if (!found) return sendJson(response, 404, workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId }));
const projection = readModel.projectionDiagnostics({ traceId, result, trace });
sendJson(response, 200, {
ok: true,
status,
contractVersion: "workbench-turn-snapshot-v1",
turn: turnSnapshot({ turnId, traceId, status, result, session, trace }),
projection,
projectionStatus: projection.projectionStatus,
lastProjectedSeq: projection.lastProjectedSeq,
sourceRunId: projection.sourceRunId,
sourceCommandId: projection.sourceCommandId,
blocker: projection.blocker,
valuesRedacted: true,
secretMaterialStored: false
});
@@ -293,23 +294,31 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur
async function handleWorkbenchTraceEventPage(response, url, options, actor, rawTraceId) {
const traceId = safeTraceId(rawTraceId);
if (!traceId) return sendJson(response, 400, workbenchError("invalid_trace_id", "traceId must start with trc_.", { traceId: rawTraceId }));
const result = options.codeAgentChatResults?.get?.(traceId) ?? null;
const session = await visibleSessionByTrace(options.accessController?.store, traceId, actor);
if (result?.ownerUserId && !canActorReadOwner(result.ownerUserId, actor)) {
const readModel = createWorkbenchReadModel(options, actor);
const result = readModel.resultForTrace(traceId);
const session = await readModel.getSessionByTraceId(traceId);
if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) {
return sendJson(response, 403, workbenchError("agent_session_owner_required", "Only the session owner or admin can read this Workbench trace.", { traceId }));
}
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
if (!resultVisible && !session) {
return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId }));
}
const trace = await traceSnapshot(options, traceId);
const trace = await readModel.traceSnapshot(traceId);
const page = traceEventPage(trace, tracePageOptions(url));
const projection = readModel.projectionDiagnostics({ traceId, result, trace });
sendJson(response, 200, {
ok: true,
status: "succeeded",
contractVersion: "workbench-trace-events-v1",
traceId,
...page,
projection,
projectionStatus: projection.projectionStatus,
lastProjectedSeq: projection.lastProjectedSeq,
sourceRunId: projection.sourceRunId,
sourceCommandId: projection.sourceCommandId,
blocker: projection.blocker,
valuesRedacted: true,
secretMaterialStored: false
});
@@ -567,61 +576,10 @@ function tracePageOptions(url) {
return { afterSeq, limit: boundedLimit(url.searchParams.get("limit")) };
}
async function traceSnapshot(options, traceId) {
const memory = traceSnapshotSync(options, traceId);
if (memory?.status !== "missing" && (memory.eventCount > 0 || memory.lastEvent)) return memory;
const durable = await durableTraceSnapshot(options, traceId);
return durable ?? memory;
}
function traceSnapshotSync(options, traceId) {
return (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId);
}
async function durableTraceSnapshot(options, traceId) {
const store = options.runtimeStore;
if (!store || typeof store.queryAgentTraceEvents !== "function") return null;
let result;
try {
result = await store.queryAgentTraceEvents({ traceId });
} catch {
return null;
}
const events = Array.isArray(result?.events) ? result.events : [];
if (events.length === 0) return null;
const normalizedEvents = events.map((event, index) => ({ ...event, traceId, seq: eventSeq(event, index) }));
const firstEvent = normalizedEvents[0] ?? null;
const lastEvent = normalizedEvents.at(-1) ?? null;
const status = durableTraceStatus(normalizedEvents);
return {
traceId,
status,
createdAt: firstEvent?.createdAt ?? null,
updatedAt: lastEvent?.createdAt ?? firstEvent?.createdAt ?? null,
startedAt: firstEvent?.createdAt ?? null,
finishedAt: TERMINAL_STATUSES.has(status) ? lastEvent?.createdAt ?? null : null,
eventCount: normalizedEvents.length,
events: normalizedEvents,
assistantStreams: [],
eventLabels: normalizedEvents.map((event) => event.label).filter(Boolean),
lastEvent,
outputTruncated: normalizedEvents.some((event) => event.outputTruncated === true),
valuesPrinted: false
};
}
function durableTraceStatus(events) {
const lastEvent = events.at(-1) ?? null;
const normalized = normalizeStatus(lastEvent?.status ?? lastEvent?.type);
if (lastEvent?.terminal === true) {
if (normalized === "observed" || normalized === "event" || normalized === "unknown") return "completed";
return normalized;
}
if (TERMINAL_STATUSES.has(normalized) || RUNNING_STATUSES.has(normalized)) return normalized;
if (["failed", "error", "timeout"].includes(normalized)) return normalized;
return "running";
}
async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId, reason }) {
const context = await visibleTraceContext(options, actor, traceId);
if (!context.visible) {
@@ -650,12 +608,13 @@ async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId,
}
async function visibleTraceContext(options, actor, traceId) {
const result = options.codeAgentChatResults?.get?.(traceId) ?? null;
const session = await visibleSessionByTrace(options.accessController?.store, traceId, actor);
if (result?.ownerUserId && !canActorReadOwner(result.ownerUserId, actor)) return { visible: false };
const readModel = createWorkbenchReadModel(options, actor);
const result = readModel.resultForTrace(traceId);
const session = await readModel.getSessionByTraceId(traceId);
if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) return { visible: false };
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
if (!resultVisible && !session) return { visible: false };
const trace = await traceSnapshot(options, traceId);
const trace = await readModel.traceSnapshot(traceId);
const status = workbenchTurnProjectionStatus(trace);
return { visible: true, turnId: traceId, traceId, status, result, session, trace };
}
+165
View File
@@ -0,0 +1,165 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: Workbench durable facts store adapter session/message/turn/trace projection facts GET AgentRun facts
*/
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { safeConversationId, safeSessionId, safeTraceId } from "./server-http-utils.ts";
export function createWorkbenchFactsStore(options = {}, actor = null) {
const accessStore = options.accessController?.store ?? null;
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const runtimeStore = options.runtimeStore ?? null;
const results = options.codeAgentChatResults ?? null;
async function getSessionById(sessionId) {
const safeId = safeSessionId(sessionId);
if (!safeId) return null;
const session = await accessStore?.getAgentSession?.(safeId) ?? null;
return canActorReadSession(session, actor) ? session : null;
}
async function getSessionByRouteId(routeId) {
const sessionId = safeSessionId(routeId);
if (sessionId) return getSessionById(sessionId);
const conversationId = safeConversationId(routeId);
if (!conversationId) return null;
const listed = await accessStore?.listAgentSessionsForUser?.({
ownerUserId: actor?.id,
actorRole: actor?.role,
ownerScoped: true,
conversationId,
limit: 1,
includeArchived: false
}) ?? [];
const matched = listed.find((session) => canActorReadSession(session, actor)) ?? null;
if (matched) return matched;
const suffix = conversationId.slice("cnv_".length);
const deterministicSessionId = safeSessionId(`ses_${suffix}`);
return deterministicSessionId ? getSessionById(deterministicSessionId) : null;
}
async function getSessionByTraceId(traceId) {
const safeId = safeTraceId(traceId);
if (!safeId) return null;
const session = await accessStore?.getAgentSessionByTraceId?.(safeId) ?? null;
return canActorReadSession(session, actor) ? session : null;
}
async function listSessions({ limit, includeRouteId } = {}) {
let sessions = await accessStore?.listAgentSessionsForUser?.({
ownerUserId: actor?.id,
actorRole: actor?.role,
ownerScoped: true,
limit,
includeArchived: false
}) ?? [];
const includeSessionId = safeSessionId(includeRouteId);
if (includeRouteId && !sessions.some((session) => session.id === includeSessionId)) {
const included = await getSessionByRouteId(includeRouteId);
if (included) sessions = [included, ...sessions];
}
return sessions.filter((session) => canActorReadSession(session, actor));
}
function resultForTrace(traceId) {
const safeId = safeTraceId(traceId);
return safeId ? results?.get?.(safeId) ?? null : null;
}
function traceSnapshotSync(traceId) {
return traceStore.snapshot(traceId);
}
async function traceSnapshot(traceId) {
const memory = traceSnapshotSync(traceId);
if (memory?.status !== "missing" && (memory.eventCount > 0 || memory.lastEvent)) return memory;
const durable = await durableTraceSnapshot(runtimeStore, traceId);
return durable ?? memory;
}
function subscribeTrace(traceId, listener) {
return traceStore.subscribe(traceId, listener);
}
return {
getSessionById,
getSessionByRouteId,
getSessionByTraceId,
listSessions,
resultForTrace,
traceSnapshot,
traceSnapshotSync,
subscribeTrace,
canReadOwner: (ownerUserId) => canActorReadOwner(ownerUserId, actor)
};
}
async function durableTraceSnapshot(runtimeStore, traceId) {
if (!runtimeStore || typeof runtimeStore.queryAgentTraceEvents !== "function") return null;
let result;
try {
result = await runtimeStore.queryAgentTraceEvents({ traceId });
} catch {
return null;
}
const events = Array.isArray(result?.events) ? result.events : [];
if (events.length === 0) return null;
const normalizedEvents = events.map((event, index) => ({ ...event, traceId, seq: eventSeq(event, index) }));
const firstEvent = normalizedEvents[0] ?? null;
const lastEvent = normalizedEvents.at(-1) ?? null;
const status = durableTraceStatus(normalizedEvents);
return {
traceId,
status,
createdAt: firstEvent?.createdAt ?? null,
updatedAt: lastEvent?.createdAt ?? firstEvent?.createdAt ?? null,
startedAt: firstEvent?.createdAt ?? null,
finishedAt: TERMINAL_STATUSES.has(status) ? lastEvent?.createdAt ?? null : null,
eventCount: normalizedEvents.length,
events: normalizedEvents,
assistantStreams: [],
eventLabels: normalizedEvents.map((event) => event.label).filter(Boolean),
lastEvent,
outputTruncated: normalizedEvents.some((event) => event.outputTruncated === true),
valuesPrinted: false
};
}
const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"]);
const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active"]);
function durableTraceStatus(events) {
const lastEvent = events.at(-1) ?? null;
const normalized = normalizeStatus(lastEvent?.status ?? lastEvent?.type);
if (lastEvent?.terminal === true) {
if (normalized === "observed" || normalized === "event" || normalized === "unknown") return "completed";
return normalized;
}
if (TERMINAL_STATUSES.has(normalized) || RUNNING_STATUSES.has(normalized)) return normalized;
if (["failed", "error", "timeout"].includes(normalized)) return normalized;
return "running";
}
function canActorReadSession(session, actor) {
if (!session || !actor) return false;
if (normalizeStatus(session.status) === "archived") return false;
if (actor.role === "admin") return true;
return session.ownerUserId === actor.id;
}
function canActorReadOwner(ownerUserId, actor) {
if (!ownerUserId || !actor) return true;
if (actor.role === "admin") return true;
return ownerUserId === actor.id;
}
function eventSeq(event, index) {
const seq = Number(event?.seq);
return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1;
}
function normalizeStatus(value) {
const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
if (text === "cancelled") return "canceled";
return text || "unknown";
}
@@ -0,0 +1,81 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0.
* 职责: WorkbenchProjectionFinalizer checkpoint/ AgentRun facts GET path finalize/repair
*/
import { appendProjectionDiagnostic } from "./workbench-projection-writer.ts";
export function scheduleWorkbenchProjectionFinalizer({ traceId, currentResult = null, traceStore, getCachedResult, isCanceled, isTerminal, syncResult, onTerminal, activitySignature, noResponseTimeoutMs = null, pollIntervalMs = 1000, sleep = defaultSleep } = {}) {
if (!traceId || !currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId || typeof syncResult !== "function") return null;
let lastActivityAt = Date.now();
let lastActivitySignature = activitySignature?.(currentResult, traceStore?.snapshot?.(traceId)) ?? null;
let idleDegraded = false;
setImmediate(() => {
void (async () => {
let result = currentResult;
let lastError = null;
while (true) {
const cached = getCachedResult?.(traceId);
if (isCanceled?.(cached)) return;
if (isTerminal?.(cached?.status)) {
onTerminal?.(cached, { preserveLastTraceId: true });
return;
}
try {
const synced = await syncResult({ result });
result = synced?.result ?? result;
if (result && isTerminal?.(result.status)) {
onTerminal?.(result, { preserveLastTraceId: true });
return;
}
const signature = activitySignature?.(result, synced?.runnerTrace ?? traceStore?.snapshot?.(traceId));
if (signature && signature !== lastActivitySignature) {
lastActivitySignature = signature;
lastActivityAt = Date.now();
idleDegraded = false;
}
lastError = null;
} catch (error) {
lastError = error;
}
const idleMs = Date.now() - lastActivityAt;
if (noResponseTimeoutMs && idleMs >= noResponseTimeoutMs && !idleDegraded) {
appendProjectionDiagnostic(traceStore, traceId, {
type: "turn-status",
status: "degraded",
label: "projection-finalizer:no-response-idle-timeout",
errorCode: lastError?.code ?? "no_response_idle_timeout",
degradedReason: lastError ? "projection_activity_stale" : "no_response_idle_timeout",
message: lastError?.message ?? `AgentRun projection has no new activity for ${idleMs}ms; idle threshold ${noResponseTimeoutMs}ms.`,
runId: result?.agentRun?.runId ?? currentResult.agentRun.runId,
commandId: result?.agentRun?.commandId ?? currentResult.agentRun.commandId,
timeoutMs: noResponseTimeoutMs,
idleMs,
lastActivityAt: new Date(lastActivityAt).toISOString(),
waitingFor: "agentrun-result-activity",
terminal: false
});
idleDegraded = true;
}
await sleep(pollIntervalMs);
}
})().catch((error) => {
appendProjectionDiagnostic(traceStore, traceId, {
type: "turn-status",
status: "degraded",
label: "projection-finalizer:sync-crashed",
errorCode: error?.code ?? "workbench_projection_finalizer_crashed",
message: error?.message ?? "Workbench projection finalizer crashed.",
runId: currentResult.agentRun.runId,
commandId: currentResult.agentRun.commandId,
waitingFor: "agentrun-result",
terminal: false
});
});
});
return { scheduled: true, traceId, noResponseTimeoutMs, pollIntervalMs, valuesPrinted: false };
}
function defaultSleep(ms) {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
@@ -0,0 +1,46 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0.
* 职责: WorkbenchProjectionWriter Code Agent/AgentRun facts Workbench session projection facts
*/
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { safeTraceId } from "./server-http-utils.ts";
export async function writeWorkbenchProjectionSession({ accessController, traceStore = defaultCodeAgentTraceStore, traceId, ownerUserId, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, preserveLastTraceId = false } = {}) {
if (!ownerUserId || typeof accessController?.recordAgentSessionOwner !== "function") return null;
const safeId = safeTraceId(traceId);
try {
return await accessController.recordAgentSessionOwner({
ownerUserId,
ownerRole,
sessionId,
projectId,
conversationId,
threadId,
traceId: preserveLastTraceId ? undefined : safeId,
status,
session
});
} catch (error) {
if (safeId) appendProjectionDiagnostic(traceStore, safeId, {
type: "session-owner",
status: "degraded",
label: "projection-writer:session-owner:persist-failed",
errorCode: "workbench_projection_session_persist_failed",
message: error?.message ?? "Workbench projection session persistence failed.",
valuesPrinted: false
});
return null;
}
}
export function appendProjectionDiagnostic(traceStore = defaultCodeAgentTraceStore, traceId, event = {}) {
const safeId = safeTraceId(traceId);
if (!safeId || typeof traceStore?.append !== "function") return null;
return traceStore.append(safeId, {
type: "projection-diagnostic",
status: "degraded",
label: "projection:diagnostic",
...event,
valuesPrinted: false
});
}
+62
View File
@@ -0,0 +1,62 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: WorkbenchReadModel WorkbenchFactsStore durable projection facts AgentRun sync/finalize/repair
*/
import { createWorkbenchFactsStore } from "./workbench-facts-store.ts";
export function createWorkbenchReadModel(options = {}, actor = null) {
const facts = createWorkbenchFactsStore(options, actor);
return {
facts,
listSessions: (input = {}) => facts.listSessions(input),
getSessionById: (sessionId) => facts.getSessionById(sessionId),
getSessionByRouteId: (routeId) => facts.getSessionByRouteId(routeId),
getSessionByTraceId: (traceId) => facts.getSessionByTraceId(traceId),
resultForTrace: (traceId) => facts.resultForTrace(traceId),
traceSnapshot: (traceId) => facts.traceSnapshot(traceId),
traceSnapshotSync: (traceId) => facts.traceSnapshotSync(traceId),
subscribeTrace: (traceId, listener) => facts.subscribeTrace(traceId, listener),
canReadOwner: (ownerUserId) => facts.canReadOwner(ownerUserId),
projectionDiagnostics: ({ traceId, result = null, trace = null } = {}) => projectionDiagnostics({ traceId, result, trace })
};
}
export function projectionDiagnostics({ traceId, result = null, trace = null } = {}) {
const agentRun = result?.agentRun && typeof result.agentRun === "object" ? result.agentRun : null;
const events = Array.isArray(trace?.events) ? trace.events : [];
const lastEvent = trace?.lastEvent ?? events.at(-1) ?? null;
const lastProjectedSeq = Number(lastEvent?.seq ?? trace?.lastProjectedSeq ?? 0);
const sourceRunId = agentRun?.runId ?? lastEvent?.runId ?? null;
const sourceCommandId = agentRun?.commandId ?? lastEvent?.commandId ?? null;
const blocker = result?.blocker ?? result?.error ?? null;
const status = blocker
? "blocked"
: isTerminalStatus(result?.status ?? trace?.status)
? "caught-up"
: result || trace?.status !== "missing"
? "projecting"
: "unknown";
return {
projectionStatus: status,
lastProjectedSeq: Number.isFinite(lastProjectedSeq) && lastProjectedSeq > 0 ? Math.trunc(lastProjectedSeq) : null,
sourceRunId,
sourceCommandId,
blocker: blocker ? diagnosticBlocker(blocker) : null,
updatedAt: trace?.updatedAt ?? result?.updatedAt ?? null,
valuesRedacted: true
};
}
function diagnosticBlocker(value = {}) {
if (!value || typeof value !== "object") return { code: "projection_blocked", summary: String(value), valuesRedacted: true };
return {
code: String(value.code ?? value.errorCode ?? "projection_blocked"),
summary: String(value.summary ?? value.userMessage ?? value.message ?? value.code ?? "Projection is blocked."),
retryable: typeof value.retryable === "boolean" ? value.retryable : null,
valuesRedacted: true
};
}
function isTerminalStatus(value) {
return ["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"].includes(String(value ?? "").trim().toLowerCase());
}
@@ -1,9 +1,12 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Regression tests for Workbench route parsing, server-state projection, and session tab status authority.
import assert from "node:assert/strict";
import test from "node:test";
import type { WorkbenchSessionRecord } from "../src/types/index.ts";
import { initialWorkbenchSessionIdFromLocation } from "../src/stores/workbench-projection.ts";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionStatusAuthority, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts";
import { sessionToSessionTab, stableSessionList } from "../src/stores/workbench-session.ts";
test("Workbench route parsing accepts only session routes", () => {
@@ -75,3 +78,25 @@ test("Workbench session tab status is read from server projection", () => {
assert.equal(tab.status, "running");
assert.equal(tab.running, true);
});
test("Workbench server-state session detail updates session status authority", () => {
let state = createWorkbenchServerState();
state = reduceWorkbenchServerState(state, {
type: "session.status",
session: { sessionId: "ses_backfill", status: "running" }
});
state = reduceWorkbenchServerState(state, {
type: "session.detail",
session: {
sessionId: "ses_backfill",
threadId: "thr_backfill",
status: "completed",
lastTraceId: "trc_backfill",
updatedAt: "2026-06-18T00:00:03.000Z"
}
});
const authority = selectSessionStatusAuthority(state).ses_backfill;
assert.equal(authority?.status, "completed");
assert.equal(authority?.lastTraceId, "trc_backfill");
});
@@ -1,4 +1,4 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1.
// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection.
// Responsibility: Normalized Workbench server-state reducer for session, turn, and trace caches.
import type { ChatMessage, WorkbenchSessionRecord } from "@/types";
@@ -73,9 +73,13 @@ export function selectActiveMessages(state: WorkbenchServerState, sessionId: str
function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSessionRecord | null | undefined): WorkbenchServerState {
const sessionId = session?.sessionId;
if (!sessionId || !session) return state;
const sessionStatus = sessionStatusAuthorityFromDetail(session);
return {
...state,
sessionsById: { ...state.sessionsById, [sessionId]: session },
sessionStatusById: sessionStatus
? { ...state.sessionStatusById, [sessionId]: sessionStatus }
: state.sessionStatusById,
messagesBySessionId: {
...state.messagesBySessionId,
[sessionId]: Array.isArray(session.messages) ? session.messages : state.messagesBySessionId[sessionId] ?? []
@@ -83,6 +87,18 @@ function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSess
};
}
function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): SessionStatusAuthority | null {
const sessionId = session.sessionId;
if (!sessionId || typeof session.status !== "string") return null;
return {
sessionId,
status: session.status,
updatedAt: typeof session.updatedAt === "string" ? session.updatedAt : null,
lastTraceId: typeof session.lastTraceId === "string" ? session.lastTraceId : null,
loadedAt: new Date().toISOString()
};
}
function withoutKey<T>(source: Record<string, T>, key: string): Record<string, T> {
if (!(key in source)) return source;
const next = { ...source };
+6 -4
View File
@@ -95,9 +95,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
clearSessionDetailLoading(targetSessionId);
if (selected && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value);
if (selected && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value);
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "invalid session URL", { restorePrevious: false });
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value);
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value);
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false });
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false });
const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected);
sessions.value = nextSessions;
rememberSessionList(nextSessions);
@@ -935,7 +936,8 @@ async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRec
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
if (!requestId) return null;
const detail = await api.workbench.session(requestId);
const detailSession = detail.ok ? sessionFromWorkbenchSession(detail.data?.session) : null;
if (!detail.ok) return null;
const detailSession = sessionFromWorkbenchSession(detail.data?.session);
const id = detailSession?.sessionId ?? normalizeWorkbenchSessionId(requestId) ?? seed?.sessionId;
if (!id) return null;
const messages = await api.workbench.sessionMessages(id, { limit: 100 });