Merge pull request #2435 from pikasTech/issue-1585-1587-realtime-authority-p3p5
Pipelines as Code CI / hwlab-jd01-v03-ci-poll- Failed
Pipelines as Code CI / hwlab-jd01-v03-ci-poll- Failed
fix: route workbench recovery through sync authority
This commit is contained in:
@@ -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<string, unknown>, { 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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<ApiResult<{ session?: Record<string, unknown> }>, Record<string, unknown> | 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<ApiResult<AgentChatResponse>, WorkbenchAgentMutationInput>({
|
||||
key: workbenchColadaKeys.mutationSubmit(),
|
||||
@@ -69,10 +69,9 @@ export function useWorkbenchColadaMutations(): WorkbenchColadaMutations {
|
||||
|
||||
async function invalidateMutationScope(queryCache: ReturnType<typeof useQueryCache>, sessionId: string | null | undefined, traceId: string | null | undefined): Promise<void> {
|
||||
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()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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<ApiResult<WorkbenchSessionListResponse>>;
|
||||
fetchSession: (sessionId: string, options?: WorkbenchSessionDetailQueryOptions) => Promise<ApiResult<WorkbenchSessionDetailResponse>>;
|
||||
fetchSyncReplay: (input: WorkbenchSyncReplayRequest, options?: ApiRequestOptions & QueryRunOptions) => Promise<ApiResult<WorkbenchSyncReplayResponse>>;
|
||||
fetchSessionMessages: (sessionId: string, options?: SessionMessageOptions & QueryRunOptions) => Promise<ApiResult<WorkbenchMessagePageResponse>>;
|
||||
fetchTurn: (traceId: string, options?: WorkbenchTurnQueryOptions) => Promise<ApiResult<AgentChatResultResponse>>;
|
||||
fetchTraceEvents: (traceId: string, options?: WorkbenchTraceEventsQueryOptions) => Promise<ApiResult<AgentChatResultResponse>>;
|
||||
@@ -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()
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof createApp> {
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(recordValue(item))) : [];
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown> | null, ...keys: strin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function diagnosticBoolean(record: Record<string, unknown> | 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;
|
||||
|
||||
Reference in New Issue
Block a user