diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 49cacf52..f31f4c0a 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -386,6 +386,17 @@ test("Workbench UI OTel spans include timeout activity diagnostics without raw t activityWaitingFor: "code-agent", activityLastEventLabel: "realtime:heartbeat", activityIdleMs: 13042, + eventCount: 1, + contractVersion: "workbench-sync-v1", + realtimeAuthority: "workbench-realtime-authority-v2", + sinceOutboxSeq: 7, + syncCursorOutboxSeq: 12, + entityFamilyCount: 1, + entityFamilies: "turn", + maxEntityVersion: 3, + projectionRevision: "rev_sync", + terminalSeal: true, + detailProjection: false, traceId: "trc_secret" }] } as Record, { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel.example/v1/traces" }); @@ -393,12 +404,23 @@ test("Workbench UI OTel spans include timeout activity diagnostics without raw t assert.equal(result.submitted, 1); assert.equal(calls.length, 1); const span = calls[0].resourceSpans[0].scopeSpans[0].spans[0]; - const attrs = Object.fromEntries(span.attributes.map((item) => [item.key, item.value.stringValue ?? Number(item.value.intValue) ?? item.value.boolValue])); + const attrs = Object.fromEntries(span.attributes.map((item) => [item.key, item.value.stringValue ?? (item.value.intValue !== undefined ? Number(item.value.intValue) : item.value.boolValue)])); assert.equal(attrs["http.route"], "/v1/workbench/turns/:id"); assert.equal(attrs["workbench.ui.timeout_name"], "workbench turn"); assert.equal(attrs["workbench.ui.activity_waiting_for"], "code-agent"); assert.equal(attrs["workbench.ui.activity_last_event_label"], "realtime:heartbeat"); assert.equal(attrs["workbench.ui.activity_idle_ms"], 13042); + assert.equal(attrs["workbench.sync.event_count"], 1); + assert.equal(attrs["workbench.sync.contract_version"], "workbench-sync-v1"); + assert.equal(attrs["workbench.realtime.authority"], "workbench-realtime-authority-v2"); + assert.equal(attrs["workbench.sync.since_outbox_seq"], 7); + assert.equal(attrs["workbench.sync.cursor_outbox_seq"], 12); + assert.equal(attrs["workbench.sync.entity_family_count"], 1); + assert.equal(attrs["workbench.sync.entity_families"], "turn"); + assert.equal(attrs["workbench.sync.max_entity_version"], 3); + assert.equal(attrs["workbench.projection.revision"], "rev_sync"); + assert.equal(attrs["workbench.terminal.seal"], true); + assert.equal(attrs["workbench.detail.projection"], false); assert.doesNotMatch(JSON.stringify(calls[0]), /trc_secret|ses_secret|prompt|api key/iu); } finally { globalThis.fetch = originalFetch; diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 63871aa1..3a4ae606 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -126,6 +126,17 @@ interface WebPerformanceEvent { resourceDecodedBodySize?: unknown; resourceNextHopProtocol?: unknown; resourceServerTiming?: unknown; + eventCount?: unknown; + contractVersion?: unknown; + realtimeAuthority?: unknown; + sinceOutboxSeq?: unknown; + syncCursorOutboxSeq?: unknown; + entityFamilyCount?: unknown; + entityFamilies?: unknown; + maxEntityVersion?: unknown; + projectionRevision?: unknown; + terminalSeal?: unknown; + detailProjection?: unknown; } interface NormalizedPerformanceEvent { @@ -839,6 +850,15 @@ function normalizeWorkbenchUiOtelSpan(rawEvent: unknown, payload: WebPerformance const resourceDecodedBodySize = Math.min(Math.max(finiteNumber(input.resourceDecodedBodySize, NaN), 0), 100 * 1024 * 1024); const resourceNextHopProtocol = sanitizeOtelAttribute(input.resourceNextHopProtocol, 40); const resourceServerTiming = sanitizeOtelAttribute(input.resourceServerTiming, 240); + const eventCount = Math.min(Math.max(finiteNumber(input.eventCount, NaN), 0), 100_000); + const contractVersion = sanitizeOtelAttribute(input.contractVersion, 80); + const realtimeAuthority = sanitizeOtelAttribute(input.realtimeAuthority, 120); + const entityFamilies = sanitizeOtelAttribute(input.entityFamilies, 180); + const projectionRevision = sanitizeOtelAttribute(input.projectionRevision, 120); + const sinceOutboxSeq = Math.min(Math.max(finiteNumber(input.sinceOutboxSeq, NaN), 0), Number.MAX_SAFE_INTEGER); + const syncCursorOutboxSeq = Math.min(Math.max(finiteNumber(input.syncCursorOutboxSeq, NaN), 0), Number.MAX_SAFE_INTEGER); + const entityFamilyCount = Math.min(Math.max(finiteNumber(input.entityFamilyCount, NaN), 0), 1000); + const maxEntityVersion = Math.min(Math.max(finiteNumber(input.maxEntityVersion, NaN), 0), Number.MAX_SAFE_INTEGER); return { name: ["workbench", eventType, scope, state].filter((part) => part && part !== "unknown").join("."), uiTraceId, @@ -874,6 +894,17 @@ function normalizeWorkbenchUiOtelSpan(rawEvent: unknown, payload: WebPerformance ...(Number.isFinite(resourceDecodedBodySize) ? { "workbench.ui.resource_decoded_body_size": Math.trunc(resourceDecodedBodySize) } : {}), ...(resourceNextHopProtocol ? { "workbench.ui.resource_next_hop_protocol": resourceNextHopProtocol } : {}), ...(resourceServerTiming ? { "workbench.ui.resource_server_timing": resourceServerTiming } : {}), + ...(Number.isFinite(eventCount) ? { "workbench.sync.event_count": Math.trunc(eventCount) } : {}), + ...(contractVersion ? { "workbench.sync.contract_version": contractVersion } : {}), + ...(realtimeAuthority ? { "workbench.realtime.authority": realtimeAuthority } : {}), + ...(Number.isFinite(sinceOutboxSeq) ? { "workbench.sync.since_outbox_seq": Math.trunc(sinceOutboxSeq) } : {}), + ...(Number.isFinite(syncCursorOutboxSeq) ? { "workbench.sync.cursor_outbox_seq": Math.trunc(syncCursorOutboxSeq) } : {}), + ...(Number.isFinite(entityFamilyCount) ? { "workbench.sync.entity_family_count": Math.trunc(entityFamilyCount) } : {}), + ...(entityFamilies ? { "workbench.sync.entity_families": entityFamilies } : {}), + ...(Number.isFinite(maxEntityVersion) ? { "workbench.sync.max_entity_version": Math.trunc(maxEntityVersion) } : {}), + ...(projectionRevision ? { "workbench.projection.revision": projectionRevision } : {}), + ...(typeof input.terminalSeal === "boolean" ? { "workbench.terminal.seal": input.terminalSeal } : {}), + ...(typeof input.detailProjection === "boolean" ? { "workbench.detail.projection": input.detailProjection } : {}), "workbench.ui.values_printed": false, "http.route": route, "http.request.method": normalizeMethod(input.method), diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts index a24cbf15..a6f75444 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts @@ -1,5 +1,5 @@ -// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration. -// Responsibility: Stable Workbench query and mutation keys for Pinia Colada cache authority. +// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2. +// Responsibility: Stable Workbench semantic query and mutation keys for Pinia Colada cache authority. import type { EntryKey } from "@pinia/colada"; @@ -19,19 +19,40 @@ export interface WorkbenchTraceEventsKeyInput { limit?: number | null; } +export interface WorkbenchSyncReplayKeyInput { + sessionId?: string | null; + traceId?: string | null; + since?: number | null; +} + export const workbenchColadaKeys = { root: (): EntryKey => ["workbench"], - serverState: (): EntryKey => ["workbench", "server-state"], - sessionsRoot: (): EntryKey => ["workbench", "sessions"], - sessions: (input: WorkbenchSessionsKeyInput = {}): EntryKey => ["workbench", "sessions", normalizeKeyObject({ includeSessionId: input.includeSessionId ?? null, cursor: input.cursor ?? null, limit: finiteNumber(input.limit) })], - sessionRoot: (): EntryKey => ["workbench", "session"], - session: (sessionId: string): EntryKey => ["workbench", "session", sessionId], - sessionMessagesRoot: (): EntryKey => ["workbench", "session-messages"], - sessionMessages: (sessionId: string, input: WorkbenchSessionMessagesKeyInput = {}): EntryKey => ["workbench", "session-messages", sessionId, normalizeKeyObject({ cursor: input.cursor ?? null, limit: finiteNumber(input.limit) })], - turnRoot: (): EntryKey => ["workbench", "turn"], - turn: (traceId: string): EntryKey => ["workbench", "turn", traceId], - traceEventsRoot: (): EntryKey => ["workbench", "trace-events"], - traceEvents: (traceId: string, input: WorkbenchTraceEventsKeyInput = {}): EntryKey => ["workbench", "trace-events", traceId, normalizeKeyObject({ afterProjectedSeq: finiteNumber(input.afterProjectedSeq), limit: finiteNumber(input.limit) })], + serverState: (): EntryKey => ["workbench", "projection", "server-state"], + snapshotRoot: (): EntryKey => ["workbench", "snapshot"], + snapshotSessionsRoot: (): EntryKey => ["workbench", "snapshot", "sessions"], + snapshotSessions: (input: WorkbenchSessionsKeyInput = {}): EntryKey => ["workbench", "snapshot", "sessions", normalizeKeyObject({ includeSessionId: input.includeSessionId ?? null, cursor: input.cursor ?? null, limit: finiteNumber(input.limit) })], + snapshotSessionRoot: (): EntryKey => ["workbench", "snapshot", "session"], + snapshotSession: (sessionId: string): EntryKey => ["workbench", "snapshot", "session", sessionId], + syncRoot: (): EntryKey => ["workbench", "sync"], + syncReplay: (input: WorkbenchSyncReplayKeyInput = {}): EntryKey => ["workbench", "sync", "replay", normalizeKeyObject({ sessionId: input.sessionId ?? null, traceId: input.traceId ?? null, since: finiteNumber(input.since) })], + historyRoot: (): EntryKey => ["workbench", "history"], + historySessionMessagesRoot: (): EntryKey => ["workbench", "history", "session-messages"], + historySessionMessages: (sessionId: string, input: WorkbenchSessionMessagesKeyInput = {}): EntryKey => ["workbench", "history", "session-messages", sessionId, normalizeKeyObject({ cursor: input.cursor ?? null, limit: finiteNumber(input.limit) })], + detailRoot: (): EntryKey => ["workbench", "detail"], + detailTurnRoot: (): EntryKey => ["workbench", "detail", "turn"], + detailTurn: (traceId: string): EntryKey => ["workbench", "detail", "turn", traceId], + detailTraceEventsRoot: (): EntryKey => ["workbench", "detail", "trace-events"], + detailTraceEvents: (traceId: string, input: WorkbenchTraceEventsKeyInput = {}): EntryKey => ["workbench", "detail", "trace-events", traceId, normalizeKeyObject({ afterProjectedSeq: finiteNumber(input.afterProjectedSeq), limit: finiteNumber(input.limit) })], + sessionsRoot: (): EntryKey => workbenchColadaKeys.snapshotSessionsRoot(), + sessions: (input: WorkbenchSessionsKeyInput = {}): EntryKey => workbenchColadaKeys.snapshotSessions(input), + sessionRoot: (): EntryKey => workbenchColadaKeys.snapshotSessionRoot(), + session: (sessionId: string): EntryKey => workbenchColadaKeys.snapshotSession(sessionId), + sessionMessagesRoot: (): EntryKey => workbenchColadaKeys.historySessionMessagesRoot(), + sessionMessages: (sessionId: string, input: WorkbenchSessionMessagesKeyInput = {}): EntryKey => workbenchColadaKeys.historySessionMessages(sessionId, input), + turnRoot: (): EntryKey => workbenchColadaKeys.detailTurnRoot(), + turn: (traceId: string): EntryKey => workbenchColadaKeys.detailTurn(traceId), + traceEventsRoot: (): EntryKey => workbenchColadaKeys.detailTraceEventsRoot(), + traceEvents: (traceId: string, input: WorkbenchTraceEventsKeyInput = {}): EntryKey => workbenchColadaKeys.detailTraceEvents(traceId, input), providerProfiles: (): EntryKey => ["workbench", "provider-profiles"], mutationSubmit: (): EntryKey => ["workbench", "submit"], mutationSteer: (): EntryKey => ["workbench", "steer"], diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada-mutations.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-mutations.ts index 356185a4..5ee2ce9d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-mutations.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-mutations.ts @@ -1,5 +1,5 @@ -// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration. -// Responsibility: Colada-governed Workbench mutation execution and query invalidation. +// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2. +// Responsibility: Colada-governed Workbench mutation execution and semantic query invalidation. import { useMutation, useQueryCache } from "@pinia/colada"; import { api } from "@/api"; @@ -35,7 +35,7 @@ export function useWorkbenchColadaMutations(): WorkbenchColadaMutations { const createSessionMutation = useMutation }>, Record | undefined>({ key: workbenchColadaKeys.mutationCreateSession(), mutation: (payload) => api.agent.createAgentSession(payload ?? {}), - onSettled: () => { void queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionsRoot() }); } + onSettled: () => { void queryCache.invalidateQueries({ key: workbenchColadaKeys.snapshotRoot() }); } }); const submitMutation = useMutation, WorkbenchAgentMutationInput>({ key: workbenchColadaKeys.mutationSubmit(), @@ -69,10 +69,9 @@ export function useWorkbenchColadaMutations(): WorkbenchColadaMutations { async function invalidateMutationScope(queryCache: ReturnType, sessionId: string | null | undefined, traceId: string | null | undefined): Promise { await Promise.all([ - queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionsRoot() }), - sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.session(sessionId) }) : Promise.resolve(), - sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionMessagesRoot().concat(sessionId) }) : Promise.resolve(), - traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.turn(traceId) }) : Promise.resolve(), - traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.traceEventsRoot().concat(traceId) }) : Promise.resolve() + queryCache.invalidateQueries({ key: workbenchColadaKeys.syncRoot() }), + queryCache.invalidateQueries({ key: workbenchColadaKeys.snapshotRoot() }), + sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.historySessionMessagesRoot().concat(sessionId) }) : Promise.resolve(), + traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.detailRoot() }) : Promise.resolve() ]); } diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts index 4aec2256..a00e56b2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts @@ -1,9 +1,10 @@ -// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration. -// Responsibility: Colada-governed Workbench read-model query execution, cache keys, invalidation and refetch. +// SPEC: pikasTech/HWLAB#2345 Workbench Pinia Colada migration; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2. +// Responsibility: Colada-governed Workbench semantic query execution, cache keys, invalidation and refetch. import { useQueryCache, type EntryKey, type QueryCache } from "@pinia/colada"; import { api } from "@/api"; import type { ApiRequestOptions } from "@/api/client"; +import { fetchWorkbenchSyncReplay, type WorkbenchSyncReplayRequest, type WorkbenchSyncReplayResponse } from "@/api/workbench-events"; import type { AgentChatResultResponse, ApiResult } from "@/types"; import type { SessionListOptions, SessionMessageOptions, WorkbenchMessagePageResponse, WorkbenchSessionDetailResponse, WorkbenchSessionListResponse, WorkbenchTraceRequestOptions } from "@/api/workbench"; import { workbenchColadaKeys } from "./workbench-colada-keys"; @@ -32,6 +33,7 @@ export interface WorkbenchColadaQueries { queryCache: QueryCache; fetchSessions: (options?: SessionListOptions & QueryRunOptions) => Promise>; fetchSession: (sessionId: string, options?: WorkbenchSessionDetailQueryOptions) => Promise>; + fetchSyncReplay: (input: WorkbenchSyncReplayRequest, options?: ApiRequestOptions & QueryRunOptions) => Promise>; fetchSessionMessages: (sessionId: string, options?: SessionMessageOptions & QueryRunOptions) => Promise>; fetchTurn: (traceId: string, options?: WorkbenchTurnQueryOptions) => Promise>; fetchTraceEvents: (traceId: string, options?: WorkbenchTraceEventsQueryOptions) => Promise>; @@ -51,6 +53,7 @@ export function useWorkbenchColadaQueries(): WorkbenchColadaQueries { queryCache, fetchSessions: (options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessions(options), () => api.workbench.sessions(options), options), fetchSession: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.session(sessionId), () => api.workbench.session(sessionId, { timeoutMs: options.timeoutMs ?? null, includeMessages: false }), options), + fetchSyncReplay: (input, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.syncReplay(input), () => fetchWorkbenchSyncReplay(input, options), options), fetchSessionMessages: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessionMessages(sessionId, options), () => api.workbench.sessionMessages(sessionId, options), options), fetchTurn: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.turn(traceId), () => api.workbench.turn(traceId, options.timeoutMs ?? 8000, options.activityRef), options), fetchTraceEvents: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.traceEvents(traceId, options), () => api.workbench.traceEvents(traceId, options.timeoutMs ?? 8000, options.activityRef, { afterProjectedSeq: options.afterProjectedSeq, limit: options.limit }), options), @@ -61,11 +64,10 @@ export function useWorkbenchColadaQueries(): WorkbenchColadaQueries { invalidateTurn: (traceId) => traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.turn(traceId) }) : Promise.resolve(), invalidateTraceEvents: (traceId) => traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.traceEventsRoot().concat(traceId) }) : Promise.resolve(), invalidateTraceScope: async (input) => Promise.all([ - queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionsRoot() }), - input.sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.session(input.sessionId) }) : Promise.resolve(), - input.sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionMessagesRoot().concat(input.sessionId) }) : Promise.resolve(), - input.traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.turn(input.traceId) }) : Promise.resolve(), - input.traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.traceEventsRoot().concat(input.traceId) }) : Promise.resolve() + queryCache.invalidateQueries({ key: workbenchColadaKeys.syncRoot() }), + queryCache.invalidateQueries({ key: workbenchColadaKeys.snapshotRoot() }), + input.sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.historySessionMessagesRoot().concat(input.sessionId) }) : Promise.resolve(), + input.traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.detailRoot() }) : Promise.resolve() ]) }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada.test.ts b/web/hwlab-cloud-web/src/stores/workbench-colada.test.ts index 8cf5f384..b886da60 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada.test.ts @@ -10,6 +10,7 @@ import { PiniaColada, useQueryCache } from "@pinia/colada"; import { createWorkbenchServerState, type WorkbenchServerState } from "./workbench-server-state"; import { workbenchColadaKeys } from "./workbench-colada-keys"; import { useWorkbenchColadaReducer } from "./workbench-colada-reducer"; +import { workbenchSyncReplayDiagnostic, workbenchSyncReplayEvents } from "./workbench-realtime-authority"; const storeDir = path.dirname(fileURLToPath(import.meta.url)); @@ -23,9 +24,10 @@ function installColadaContext(): ReturnType { } test("workbench colada keys keep serializable hierarchical cache scopes", () => { - assert.deepEqual(workbenchColadaKeys.sessions({ limit: 50, includeSessionId: "ses_colada", cursor: "cur_2" }), ["workbench", "sessions", { cursor: "cur_2", includeSessionId: "ses_colada", limit: 50 }]); - assert.deepEqual(workbenchColadaKeys.sessionMessages("ses_colada", { limit: 20 }), ["workbench", "session-messages", "ses_colada", { cursor: null, limit: 20 }]); - assert.deepEqual(workbenchColadaKeys.traceEvents("trc_colada", { afterProjectedSeq: 4, limit: 25 }), ["workbench", "trace-events", "trc_colada", { afterProjectedSeq: 4, limit: 25 }]); + assert.deepEqual(workbenchColadaKeys.sessions({ limit: 50, includeSessionId: "ses_colada", cursor: "cur_2" }), ["workbench", "snapshot", "sessions", { cursor: "cur_2", includeSessionId: "ses_colada", limit: 50 }]); + assert.deepEqual(workbenchColadaKeys.syncReplay({ sessionId: "ses_colada", traceId: "trc_colada", since: 42 }), ["workbench", "sync", "replay", { sessionId: "ses_colada", since: 42, traceId: "trc_colada" }]); + assert.deepEqual(workbenchColadaKeys.sessionMessages("ses_colada", { limit: 20 }), ["workbench", "history", "session-messages", "ses_colada", { cursor: null, limit: 20 }]); + assert.deepEqual(workbenchColadaKeys.traceEvents("trc_colada", { afterProjectedSeq: 4, limit: 25 }), ["workbench", "detail", "trace-events", "trc_colada", { afterProjectedSeq: 4, limit: 25 }]); }); test("workbench colada reducer stores projection state in the query cache", () => { @@ -50,3 +52,53 @@ test("workbench colada read path preserves freshness governance", () => { assert.match(source, /const state = await queryCache\.refresh\(entry\);/u); assert.doesNotMatch(source, /queryCache\.fetch\(entry/u); }); + +test("workbench colada sync replay is the only live repair query scope", () => { + const querySource = fs.readFileSync(path.join(storeDir, "workbench-colada-queries.ts"), "utf8"); + const mutationSource = fs.readFileSync(path.join(storeDir, "workbench-colada-mutations.ts"), "utf8"); + const storeSource = fs.readFileSync(path.join(storeDir, "workbench.ts"), "utf8"); + + assert.match(querySource, /fetchSyncReplay: \(input, options = \{\}\) => runWorkbenchQuery\(queryCache, workbenchColadaKeys\.syncReplay\(input\),/u); + assert.match(storeSource, /workbenchColadaQueries\.fetchSyncReplay\(\{ sessionId, traceId, since: sinceOutboxSeq \}/u); + assert.doesNotMatch(storeSource, /import \{ fetchWorkbenchSyncReplay \} from "@\/api\/workbench-events"/u); + + assert.match(mutationSource, /workbenchColadaKeys\.syncRoot\(\)/u); + assert.match(mutationSource, /workbenchColadaKeys\.snapshotRoot\(\)/u); + assert.match(mutationSource, /workbenchColadaKeys\.historySessionMessagesRoot\(\)/u); + assert.match(mutationSource, /workbenchColadaKeys\.detailRoot\(\)/u); + assert.doesNotMatch(mutationSource, /workbenchColadaKeys\.traceEventsRoot\(\)\.concat/u); +}); + +test("workbench sync replay diagnostic exposes authority and terminal seal metadata", () => { + const payload = { + contractVersion: "workbench-sync-v1", + realtimeAuthority: "workbench-realtime-authority-v2", + cursor: { outboxSeq: 12 }, + events: [{ + type: "workbench.turn.snapshot", + realtimeAuthority: "workbench-realtime-authority-v2", + entity: { family: "turn", id: "trc_sync", version: 3, outboxSeq: 12, projectionRevision: "rev_sync" }, + turn: { traceId: "trc_sync", status: "completed", terminal: true } + }] + }; + + const events = workbenchSyncReplayEvents(payload); + const diagnostic = workbenchSyncReplayDiagnostic(payload, events, { reason: "sse-gap", sinceOutboxSeq: 7 }); + + assert.deepEqual(diagnostic, { + code: "workbench_sync_replay_applied", + reason: "sse-gap", + eventCount: 1, + sinceOutboxSeq: 7, + syncCursorOutboxSeq: 12, + contractVersion: "workbench-sync-v1", + realtimeAuthority: "workbench-realtime-authority-v2", + entityFamilyCount: 1, + entityFamilies: "turn", + maxEntityVersion: 3, + projectionRevision: "rev_sync", + terminalSeal: true, + detailProjection: false, + valuesRedacted: true + }); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-realtime-authority.ts b/web/hwlab-cloud-web/src/stores/workbench-realtime-authority.ts index e68507f6..aaf0781a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-realtime-authority.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-realtime-authority.ts @@ -96,6 +96,34 @@ export function workbenchSyncReplayEvents(payload: WorkbenchSyncReplayResponse | return output; } +export function workbenchSyncReplayDiagnostic(payload: WorkbenchSyncReplayResponse | null | undefined, events: WorkbenchRealtimeEvent[], input: { reason: string; sinceOutboxSeq?: number | null }): Record { + const eventRows = Array.isArray(events) ? events : []; + const cursor = recordValue(payload?.cursor); + const entities = eventRows.map(workbenchRealtimeEntityAuthority).filter((item): item is WorkbenchRealtimeEntityAuthority => item !== null); + const families = Array.from(new Set(entities.map((item) => item.family).filter(Boolean))).sort(); + const versions = entities.map((item) => item.version).filter((value) => Number.isFinite(value)); + const projectionRevision = firstString( + ...entities.map((item) => item.projectionRevision), + ...eventRows.map((event) => stringValue(event.projectionRevision ?? event.entity?.projectionRevision)) + ); + return { + code: "workbench_sync_replay_applied", + reason: input.reason, + eventCount: eventRows.length, + sinceOutboxSeq: finiteNumber(input.sinceOutboxSeq), + syncCursorOutboxSeq: finiteNumber(cursor?.outboxSeq ?? payload?.cursor?.outboxSeq), + contractVersion: stringValue(payload?.contractVersion), + realtimeAuthority: stringValue(payload?.realtimeAuthority), + entityFamilyCount: families.length, + entityFamilies: families.slice(0, 8).join(","), + maxEntityVersion: versions.length > 0 ? Math.max(...versions) : null, + projectionRevision, + terminalSeal: eventRows.some(workbenchRealtimeEventHasTerminalSeal), + detailProjection: eventRows.some((event) => workbenchRealtimeDetailOnly(event, workbenchRealtimeEntityAuthority(event))), + valuesRedacted: true + }; +} + function authorityFailureCode(event: WorkbenchRealtimeEvent, entity: WorkbenchRealtimeEntityAuthority | null, detailProjection: boolean): string | null { if (detailProjection) return "workbench_realtime_detail_only_rejected"; if (stringValue(event.realtimeAuthority) !== WORKBENCH_REALTIME_AUTHORITY_VERSION) return "workbench_realtime_authority_missing"; @@ -114,6 +142,35 @@ function syncEventKey(event: WorkbenchRealtimeEvent): string { return [event.type ?? "~", event.traceId ?? "~", event.sessionId ?? "~", event.cursor?.outboxSeq ?? event.outboxSeq ?? "~", event.cursor?.traceSeq ?? event.traceSeq ?? "~"].join("|"); } +function workbenchRealtimeEventHasTerminalSeal(event: WorkbenchRealtimeEvent): boolean { + const turn = recordValue(event.turn); + if (turn?.terminal === true) return true; + const message = recordValue(event.message); + const snapshot = recordValue(event.snapshot); + const traceEvent = recordValue(event.event); + return [ + event.status, + turn?.status, + message?.status, + snapshot?.status, + traceEvent?.status, + traceEvent?.type, + ].some(isTerminalStatusText); +} + +function isTerminalStatusText(value: unknown): boolean { + const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"); + return /^(completed|complete|succeeded|success|failed|failure|error|canceled|cancelled|done|terminal|sealed|thread-resume-failed)$/u.test(text); +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + const text = stringValue(value); + if (text) return text; + } + return null; +} + function arrayOfRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => Boolean(recordValue(item))) : []; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 1b9dfe41..d70f3ae9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,6 @@ import { computed, nextTick, ref } from "vue"; import { defineStore } from "pinia"; import { api } from "@/api"; -import { fetchWorkbenchSyncReplay } from "@/api/workbench-events"; import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy"; import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight"; import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health"; @@ -68,7 +67,7 @@ import { traceSnapshotError } from "./workbench-message-projection-runtime"; import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan"; -import { workbenchSyncReplayEvents } from "./workbench-realtime-authority"; +import { workbenchSyncReplayDiagnostic, workbenchSyncReplayEvents } from "./workbench-realtime-authority"; import { useWorkbenchColadaMutations } from "./workbench-colada-mutations"; import { useWorkbenchColadaQueries } from "./workbench-colada-queries"; import { useWorkbenchColadaReducer } from "./workbench-colada-reducer"; @@ -1151,14 +1150,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function refreshWorkbenchSyncReplay(sessionId: string | null, traceId: string | null, sinceOutboxSeq: number | null, reason: string): Promise { - const result = await fetchWorkbenchSyncReplay({ sessionId, traceId, since: sinceOutboxSeq }, { timeoutMs: 8000, activityRef: () => activityRef.value }); + const result = await workbenchColadaQueries.fetchSyncReplay({ sessionId, traceId, since: sinceOutboxSeq }, { timeoutMs: 8000, activityRef: () => activityRef.value }); if (!result.ok || !result.data) { recordWorkbenchRuntimeDiagnostic({ module: "workbench-sync-replay", sessionId, traceId, outcome: "network", diagnostic: { code: "workbench_sync_replay_failed", reason, status: result.status, apiError: result.apiError, valuesRedacted: true } }); return; } const events = workbenchSyncReplayEvents(result.data); for (const event of events) applyRealtimeEvent(event, realtimeEventName(event)); - recordWorkbenchRuntimeDiagnostic({ module: "workbench-sync-replay", sessionId, traceId, outcome: "ok", diagnostic: { code: "workbench_sync_replay_applied", reason, eventCount: events.length, sinceOutboxSeq, valuesRedacted: true } }); + recordWorkbenchRuntimeDiagnostic({ module: "workbench-sync-replay", sessionId, traceId, outcome: "ok", diagnostic: workbenchSyncReplayDiagnostic(result.data, events, { reason, sinceOutboxSeq }) }); } function scheduleActiveTraceRestGapFill(traceId: string | null | undefined, reason: string, delayMs = runtimePolicy.workbenchActiveTraceRestGapFillInitialMs): void { diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 69d957ac..93ec3ce7 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -58,6 +58,16 @@ interface WorkbenchPerformanceEvent { diagnosticCode?: string; rootCause?: string; recoveryAction?: string; + contractVersion?: string; + realtimeAuthority?: string; + sinceOutboxSeq?: number; + syncCursorOutboxSeq?: number; + entityFamilyCount?: number; + entityFamilies?: string; + maxEntityVersion?: number; + projectionRevision?: string; + terminalSeal?: boolean; + detailProjection?: boolean; transportState?: string; scopedKey?: string; eventCount?: number; @@ -379,6 +389,16 @@ export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null diagnosticCode: diagnosticLabel(code), rootCause: diagnosticText(diagnostic, "rootCause", "reason")?.slice(0, 160), recoveryAction: diagnosticText(diagnostic, "recoveryAction")?.slice(0, 160), + contractVersion: diagnosticLabel(diagnosticText(diagnostic, "contractVersion")), + realtimeAuthority: diagnosticLabel(diagnosticText(diagnostic, "realtimeAuthority", "authority")), + sinceOutboxSeq: diagnosticNumber(diagnostic, "sinceOutboxSeq"), + syncCursorOutboxSeq: diagnosticNumber(diagnostic, "syncCursorOutboxSeq", "cursorOutboxSeq"), + entityFamilyCount: diagnosticNumber(diagnostic, "entityFamilyCount"), + entityFamilies: diagnosticText(diagnostic, "entityFamilies")?.slice(0, 160), + maxEntityVersion: diagnosticNumber(diagnostic, "maxEntityVersion", "entityVersion"), + projectionRevision: diagnosticText(diagnostic, "projectionRevision")?.slice(0, 120), + terminalSeal: diagnosticBoolean(diagnostic, "terminalSeal"), + detailProjection: diagnosticBoolean(diagnostic, "detailProjection"), transportState: diagnosticLabel(diagnosticText(diagnostic, "transportState")), scopedKey: hashIdentifier(diagnosticText(diagnostic, "scopedKey"), "scope"), eventCount: diagnosticNumber(diagnostic, "eventCount"), @@ -872,6 +892,20 @@ function diagnosticNumber(record: Record | null, ...keys: strin return undefined; } +function diagnosticBoolean(record: Record | null, ...keys: string[]): boolean | undefined { + if (!record) return undefined; + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const text = value.trim().toLowerCase(); + if (text === "true") return true; + if (text === "false") return false; + } + } + return undefined; +} + function diagnosticLabel(value: unknown): string | undefined { const text = safeText(value).replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80); return text || undefined;