diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 43083d84..5242a020 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; 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. +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract; 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"; @@ -969,6 +969,15 @@ async function recordCodeAgentTurnAdmission({ payload = {}, params = {}, options error.valuesRedacted = true; throw error; } + await recordCodeAgentSessionInputFact({ + params, + options, + traceId, + delivery: "queue", + status: "admitted", + messageId: codeAgentTurnLifecycleFields(traceId, params).userMessageId, + commandId: payload.agentRun?.commandId ?? null + }); } catch (error) { throw codeAgentAdmissionUnavailableError(error, { params, traceId }); } @@ -989,6 +998,64 @@ async function recordCodeAgentTurnAdmission({ payload = {}, params = {}, options return ownerRecord; } +async function recordCodeAgentSessionInputFact({ params = {}, options = {}, traceId, delivery = "queue", status = "admitted", messageId = null, commandId = null, error = null } = {}) { + const runtimeStore = options.runtimeStore ?? null; + if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null; + const resolvedTraceId = safeTraceId(traceId ?? params.traceId) || null; + const lifecycle = codeAgentTurnLifecycleFields(resolvedTraceId, params); + const sessionId = safeSessionId(params.sessionId ?? params.session?.sessionId) || null; + if (!sessionId) return null; + const resolvedMessageId = safeMessageId(messageId ?? params.messageId ?? lifecycle.userMessageId) || lifecycle.userMessageId; + const resolvedCommandId = textValue(commandId ?? params.commandId ?? params.agentRun?.commandId) || null; + const normalizedDelivery = codeAgentInputDelivery(delivery); + const normalizedStatus = codeAgentInputStatus(status); + const now = new Date().toISOString(); + const sourceEventId = `${sessionId}:${lifecycle.turnId}:${normalizedDelivery}:${resolvedTraceId ?? resolvedCommandId ?? resolvedMessageId}`; + const inputId = stableCodeAgentInputId({ sessionId, turnId: lifecycle.turnId, traceId: resolvedTraceId, messageId: resolvedMessageId, commandId: resolvedCommandId, delivery: normalizedDelivery, sourceEventId }); + const fact = { + inputId, + sessionId, + turnId: lifecycle.turnId, + traceId: resolvedTraceId, + messageId: resolvedMessageId, + commandId: resolvedCommandId, + delivery: normalizedDelivery, + status: normalizedStatus, + errorCode: textValue(error?.code ?? error?.error?.code) || null, + sourceEventId, + promptHash: params.message || params.prompt || params.text ? sha256Text(params.message ?? params.prompt ?? params.text).slice(0, 16) : null, + textBytes: params.message || params.prompt || params.text ? Buffer.byteLength(String(params.message ?? params.prompt ?? params.text), "utf8") : 0, + valuesRedacted: true, + secretMaterialStored: false, + createdAt: now, + updatedAt: now + }; + return runtimeStore.writeWorkbenchFacts({ facts: { inputs: [fact] } }, { + sessionId, + traceId: resolvedTraceId, + turnId: lifecycle.turnId, + messageId: resolvedMessageId, + ownerUserId: options.actor?.id ?? params.ownerUserId, + projectId: params.projectId ?? DEFAULT_CODE_AGENT_PROJECT_ID, + valuesPrinted: false + }); +} + +function stableCodeAgentInputId(value) { + const payload = Object.fromEntries(Object.entries(value ?? {}).filter(([, item]) => item !== undefined && item !== null && item !== "").sort(([left], [right]) => left.localeCompare(right))); + return `wsi_${sha256Text(JSON.stringify(payload)).slice(0, 32)}`; +} + +function codeAgentInputDelivery(value) { + const text = textValue(value).toLowerCase(); + return ["queue", "steer", "cancel"].includes(text) ? text : "queue"; +} + +function codeAgentInputStatus(value) { + const text = textValue(value).toLowerCase().replace(/-/gu, "_"); + return ["admitted", "promoted", "blocked", "failed", "canceled", "completed"].includes(text) ? text : "admitted"; +} + function codeAgentAdmissionUnavailableError(error, { params = {}, traceId } = {}) { const payload = codeAgentAdmissionUnavailablePayload({ error, params, traceId }); return Object.assign(new Error(payload.error.message), { @@ -2614,6 +2681,20 @@ export async function handleCodeAgentCancelHttp(request, response, options) { })); return; } + await recordCodeAgentSessionInputFact({ + params: { + ...params, + sessionId: currentResult.sessionId ?? currentResult.session?.sessionId ?? params.sessionId, + messageId: codeAgentTurnLifecycleFields(traceId, currentResult).userMessageId, + ownerUserId: options.actor?.id, + ownerRole: options.actor?.role + }, + options, + traceId, + delivery: "cancel", + status: "promoted", + commandId: currentResult.agentRun.commandId + }); const payload = await cancelAgentRunChatTurn({ traceId, currentResult, options, traceStore }); if (payload) { if (payload.alreadyTerminal === true || isTraceCommandTerminalStatus(payload.status)) { @@ -3056,6 +3137,19 @@ export async function handleCodeAgentSteerHttp(request, response, options) { } try { + await recordCodeAgentSessionInputFact({ + params: { + ...params, + sessionId: currentResult.sessionId ?? currentResult.session?.sessionId ?? params.sessionId, + ownerUserId: options.actor?.id, + ownerRole: options.actor?.role + }, + options, + traceId, + delivery: "steer", + status: "promoted", + commandId: currentResult.agentRun.commandId + }); const payload = await steerAgentRunChatTurn({ traceId, currentResult, diff --git a/internal/cloud/workbench-facts-store.ts b/internal/cloud/workbench-facts-store.ts index a9adf9de..2c63483f 100644 --- a/internal/cloud/workbench-facts-store.ts +++ b/internal/cloud/workbench-facts-store.ts @@ -490,6 +490,7 @@ function normalizeWorkbenchFactsResult(facts = {}) { function emptyWorkbenchFacts() { return { + inputs: [], sessions: [], messages: [], parts: [], diff --git a/internal/db/migrations/0001_cloud_core_skeleton.sql b/internal/db/migrations/0001_cloud_core_skeleton.sql index de88b334..d09f8082 100644 --- a/internal/db/migrations/0001_cloud_core_skeleton.sql +++ b/internal/db/migrations/0001_cloud_core_skeleton.sql @@ -402,6 +402,28 @@ CREATE INDEX IF NOT EXISTS idx_workbench_events_trace_seq ON workbench_events(tr CREATE INDEX IF NOT EXISTS idx_workbench_events_session_seq ON workbench_events(session_id, event_seq); CREATE INDEX IF NOT EXISTS idx_workbench_events_type_seq ON workbench_events(event_type, event_seq); +CREATE TABLE IF NOT EXISTS workbench_session_inputs ( + input_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + turn_id TEXT, + trace_id TEXT, + message_id TEXT, + command_id TEXT, + delivery TEXT NOT NULL DEFAULT 'queue', + admitted_seq INTEGER NOT NULL DEFAULT 0, + promoted_seq INTEGER, + status TEXT NOT NULL DEFAULT 'admitted', + error_code TEXT, + source_event_id TEXT, + input_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_session_seq ON workbench_session_inputs(session_id, admitted_seq, input_id); +CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_trace ON workbench_session_inputs(trace_id, input_id); +CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_command ON workbench_session_inputs(command_id, input_id) WHERE command_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_workbench_session_inputs_source_event ON workbench_session_inputs(session_id, source_event_id) WHERE source_event_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS workbench_projection_checkpoints ( trace_id TEXT PRIMARY KEY, session_id TEXT, @@ -624,7 +646,7 @@ CREATE TABLE IF NOT EXISTS hwlab_schema_migrations ( INSERT INTO hwlab_schema_migrations (id, schema_version, applied_at, migration_json) VALUES ( '0001_cloud_core_skeleton', - 'runtime-durable-postgres-v6', + 'runtime-durable-postgres-v7', CURRENT_TIMESTAMP, '{"path":"internal/db/migrations/0001_cloud_core_skeleton.sql","runtime":"cloud-api"}' ) diff --git a/internal/db/runtime-store.test.ts b/internal/db/runtime-store.test.ts index 8e6c75fe..380790fd 100644 --- a/internal/db/runtime-store.test.ts +++ b/internal/db/runtime-store.test.ts @@ -38,6 +38,52 @@ test("memory runtime store is never marked durable", () => { assert.equal(summary.durabilityContract.secretMaterialRead, false); }); +test("workbench session input facts are queryable and idempotent in session aggregate order", () => { + const store = createCloudRuntimeStore({ now: () => "2026-06-25T20:00:00.000Z" }); + const sessionId = "ses_input_schema"; + + const first = store.writeWorkbenchFacts({ + facts: { + inputs: [{ + sessionId, + turnId: "turn_input_schema_1", + traceId: "trc_input_schema_1", + messageId: "msg_input_schema_user_1", + delivery: "queue", + status: "admitted", + sourceEventId: "input:queue:1" + }] + } + }); + const duplicate = store.writeWorkbenchFacts({ facts: { inputs: [first.facts.inputs[0]] } }); + const second = store.writeWorkbenchFacts({ + facts: { + inputs: [{ + sessionId, + turnId: "turn_input_schema_1", + traceId: "trc_input_schema_1", + messageId: "msg_input_schema_user_1", + commandId: "cmd_input_schema_1", + delivery: "steer", + status: "promoted", + sourceEventId: "input:steer:1" + }] + } + }); + + assert.equal(first.facts.inputs[0].admittedSeq, 1); + assert.equal(duplicate.facts.inputs[0].admittedSeq, 1); + assert.equal(second.facts.inputs[0].admittedSeq, 2); + + const queried = store.queryWorkbenchFacts({ sessionId, families: ["inputs"] }); + assert.equal(queried.facts.inputs.length, 2); + assert.deepEqual(queried.facts.inputs.map((input) => [input.delivery, input.status, input.admittedSeq]), [ + ["queue", "admitted", 1], + ["steer", "promoted", 2] + ]); + assert.equal(queried.facts.inputs[1].commandId, "cmd_input_schema_1"); +}); + test("configured postgres runtime classifies auth blocker before schema and migration", async () => { const store = createConfiguredCloudRuntimeStore({ env: { @@ -1261,6 +1307,7 @@ function createFakePostgresClient({ workbench_trace_events: new Map(), workbench_event_sequences: new Map(), workbench_events: new Map(), + workbench_session_inputs: new Map(), workbench_projection_checkpoints: new Map(), workbench_projection_outbox: new Map(), hwlab_schema_migrations: new Map() @@ -1396,6 +1443,10 @@ function createFakePostgresClient({ if (beforeWorkbenchFactRead) await beforeWorkbenchFactRead(sql, params); return workbenchFactRows(state.workbench_sessions, "session_json", sql, params, readErrorCode); } + if (sql.startsWith("SELECT input_json FROM workbench_session_inputs")) { + if (beforeWorkbenchFactRead) await beforeWorkbenchFactRead(sql, params); + return workbenchFactRows(state.workbench_session_inputs, "input_json", sql, params, readErrorCode); + } if (sql.startsWith("SELECT message_json FROM workbench_messages")) { if (beforeWorkbenchFactRead) await beforeWorkbenchFactRead(sql, params); return workbenchFactRows(state.workbench_messages, "message_json", sql, params, readErrorCode); @@ -1544,6 +1595,13 @@ function createFakePostgresClient({ }); return { rows: [] }; } + if (sql.startsWith("INSERT INTO workbench_session_inputs")) { + const existing = state.workbench_session_inputs.get(params[0]) ?? null; + state.workbench_session_inputs.set(params[0], { + input_id: params[0], session_id: params[1], turn_id: params[2], trace_id: params[3], message_id: params[4], command_id: params[5] ?? existing?.command_id ?? null, delivery: params[6], admitted_seq: existing?.admitted_seq > 0 ? existing.admitted_seq : params[7], promoted_seq: params[8] ?? existing?.promoted_seq ?? null, status: params[9], error_code: params[10], source_event_id: params[11] ?? existing?.source_event_id ?? null, input_json: params[12], created_at: params[13], updated_at: params[14] + }); + return { rows: [] }; + } if (sql.startsWith("INSERT INTO workbench_messages")) { state.workbench_messages.set(params[0], { message_id: params[0], session_id: params[1], turn_id: params[2], trace_id: params[3], role: params[4], status: params[5], projected_seq: params[6], source_seq: params[7], source_event_id: params[8], terminal: params[9], sealed: params[10], message_json: params[11], created_at: params[12], updated_at: params[13] diff --git a/internal/db/runtime-store.ts b/internal/db/runtime-store.ts index 3ce2e8d8..d3769ac7 100644 --- a/internal/db/runtime-store.ts +++ b/internal/db/runtime-store.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; draft-2026-06-20-p1-zero-split-durable-realtime; draft-2026-06-24-p0-aggregate-event-stream. + * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; draft-2026-06-20-p1-zero-split-durable-realtime; draft-2026-06-24-p0-aggregate-event-stream; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract. * 职责: Cloud runtime durable store。Code Agent trace/session/projection cursor 投影事实必须从同一持久化适配器写入和恢复。 */ import { createHash, randomUUID } from "node:crypto"; @@ -61,6 +61,7 @@ const postgresCountTables = Object.freeze([ "workbench_trace_events", "workbench_event_sequences", "workbench_events", + "workbench_session_inputs", "workbench_projection_checkpoints" ]); @@ -88,6 +89,9 @@ const postgresRuntimeReadIndexes = Object.freeze([ "CREATE INDEX IF NOT EXISTS idx_workbench_events_trace_seq ON workbench_events(trace_id, event_seq)", "CREATE INDEX IF NOT EXISTS idx_workbench_events_session_seq ON workbench_events(session_id, event_seq)", "CREATE INDEX IF NOT EXISTS idx_workbench_events_type_seq ON workbench_events(event_type, event_seq)", + "CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_session_seq ON workbench_session_inputs(session_id, admitted_seq, input_id)", + "CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_trace ON workbench_session_inputs(trace_id, input_id)", + "CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_command ON workbench_session_inputs(command_id, input_id) WHERE command_id IS NOT NULL", "CREATE INDEX IF NOT EXISTS idx_workbench_projection_checkpoints_status_updated ON workbench_projection_checkpoints(projection_status, updated_at DESC, trace_id)", "CREATE INDEX IF NOT EXISTS idx_workbench_projection_checkpoints_session_updated ON workbench_projection_checkpoints(session_id, updated_at DESC, trace_id)" ]); @@ -166,6 +170,7 @@ export class CloudRuntimeStore { this.workbenchTraceEvents = new Map(); this.workbenchEventSequences = new Map(); this.workbenchEvents = new Map(); + this.workbenchSessionInputs = new Map(); this.workbenchProjectionCheckpoints = new Map(); } @@ -197,6 +202,7 @@ export class CloudRuntimeStore { workbenchTraceEvents: this.workbenchTraceEvents.size, workbenchEventSequences: this.workbenchEventSequences.size, workbenchEvents: this.workbenchEvents.size, + workbenchSessionInputs: this.workbenchSessionInputs.size, workbenchProjectionCheckpoints: this.workbenchProjectionCheckpoints.size } }); @@ -650,6 +656,8 @@ export class CloudRuntimeStore { writeWorkbenchFacts(params = {}, requestMeta = {}) { const facts = normalizeWorkbenchFacts(params.facts ?? params, requestMeta, this.now()); + const inputFacts = facts.inputs.map((fact) => memoryWorkbenchSessionInputWithSeq(fact, this.workbenchSessionInputs)); + for (const fact of inputFacts) this.workbenchSessionInputs.set(fact.inputId, fact); for (const fact of facts.sessions) this.workbenchSessions.set(fact.sessionId, fact); for (const fact of facts.messages) this.workbenchMessages.set(fact.messageId, fact); for (const fact of facts.parts) this.workbenchParts.set(fact.partId, fact); @@ -663,7 +671,7 @@ export class CloudRuntimeStore { } return { written: true, - facts, + facts: { ...facts, inputs: inputFacts }, persistence: this.summary() }; } @@ -671,6 +679,7 @@ export class CloudRuntimeStore { queryWorkbenchFacts(params = {}) { const families = workbenchFactFamilySet(params); const sessions = [...this.workbenchSessions.values()].filter((record) => matchesWorkbenchSessionFactQuery(record, params)); + const inputs = [...this.workbenchSessionInputs.values()].filter((record) => matchesQuery(record, params, ["inputId", "sessionId", "turnId", "traceId", "messageId", "commandId", "delivery", "status"])); const messages = [...this.workbenchMessages.values()].filter((record) => matchesQuery(record, params, ["messageId", "sessionId", "turnId", "traceId", "role", "status"])); const parts = [...this.workbenchParts.values()].filter((record) => matchesQuery(record, params, ["partId", "messageId", "sessionId", "turnId", "traceId", "partType", "status"])); const turns = [...this.workbenchTurns.values()].filter((record) => matchesQuery(record, params, ["turnId", "sessionId", "traceId", "messageId", "status"])); @@ -678,6 +687,7 @@ export class CloudRuntimeStore { const traceEvents = [...this.workbenchTraceEvents.values()].filter((record) => matchesQuery(record, params, ["id", "traceId", "sessionId", "turnId", "messageId", "eventType"]) && (afterProjectedSeq <= 0 || nonNegativeInteger(record.projectedSeq) > afterProjectedSeq)); const checkpoints = [...this.workbenchProjectionCheckpoints.values()].filter((record) => matchesQuery(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"])); const facts = { + inputs: families.has("inputs") ? limitResults(sortWorkbenchFactRows(inputs, params, "inputs", compareWorkbenchFactRecords), params.limit) : [], sessions: families.has("sessions") ? limitResults(sortWorkbenchFactRows(sessions, params, "sessions", compareWorkbenchFactRecords), params.limit) : [], messages: families.has("messages") ? limitResults(sortWorkbenchFactRows(messages, params, "messages", compareWorkbenchFactRecords), params.limit) : [], parts: families.has("parts") ? limitResults(sortWorkbenchFactRows(parts, params, "parts", compareWorkbenchPartFactRecords), params.limit) : [], @@ -1309,6 +1319,7 @@ export class PostgresCloudRuntimeStore { const persistedEvents = []; for (const event of aggregateEvents) persistedEvents.push(await this.persistWorkbenchAggregateEvent(event, client)); const eventIndex = indexWorkbenchAggregateEvents(persistedEvents); + for (const fact of facts.inputs) await this.persistWorkbenchSessionInputFact(inputFactWithAggregateSeq(fact, eventIndex), client); for (const fact of facts.sessions) await this.persistWorkbenchSessionFact(fact, client); for (const fact of facts.messages) await this.persistWorkbenchMessageFact(fact, client); for (const fact of facts.parts) await this.persistWorkbenchPartFact(fact, client); @@ -1395,6 +1406,7 @@ export class PostgresCloudRuntimeStore { // periodic refresh. Keep this read model bounded to one connection per // request; the page-level latency is preferable to pool starvation. const entries = []; + entries.push(["inputs", families.has("inputs") ? await this.queryWorkbenchFactRows("workbench.session-inputs.query", "workbench_session_inputs", "input_json", params, { inputId: "input_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", messageId: "message_id", commandId: "command_id", delivery: "delivery", status: "status" }) : []]); entries.push(["sessions", families.has("sessions") ? await this.queryWorkbenchFactRows("workbench.sessions.query", "workbench_sessions", "session_json", params, { sessionId: "session_id", ownerUserId: "owner_user_id", projectId: "project_id", conversationId: "conversation_id", threadId: "thread_id", traceId: "last_trace_id", lastTraceId: "last_trace_id", status: "status" }) : []]); entries.push(["messages", families.has("messages") ? await this.queryWorkbenchFactRows("workbench.messages.query", "workbench_messages", "message_json", params, { messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", role: "role", status: "status" }) : []]); entries.push(["parts", families.has("parts") ? await this.queryWorkbenchFactRows("workbench.parts.query", "workbench_parts", "part_json", params, { partId: "part_id", messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", partType: "part_type", status: "status" }) : []]); @@ -1512,6 +1524,9 @@ export class PostgresCloudRuntimeStore { for (const record of changedRecords(this.memory.workbenchProjectionStates, before.workbenchProjectionStates)) { await this.persistWorkbenchProjectionState(record); } + for (const record of changedRecords(this.memory.workbenchSessionInputs, before.workbenchSessionInputs)) { + await this.persistWorkbenchSessionInputFact(record); + } for (const record of changedRecords(this.memory.workbenchSessions, before.workbenchSessions)) { await this.persistWorkbenchSessionFact(record); } @@ -1756,6 +1771,13 @@ export class PostgresCloudRuntimeStore { ); } + async persistWorkbenchSessionInputFact(record, client = this) { + await client.query( + "INSERT INTO workbench_session_inputs (input_id, session_id, turn_id, trace_id, message_id, command_id, delivery, admitted_seq, promoted_seq, status, error_code, source_event_id, input_json, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT (input_id) DO UPDATE SET session_id = EXCLUDED.session_id, turn_id = EXCLUDED.turn_id, trace_id = EXCLUDED.trace_id, message_id = EXCLUDED.message_id, command_id = COALESCE(EXCLUDED.command_id, workbench_session_inputs.command_id), delivery = EXCLUDED.delivery, admitted_seq = CASE WHEN workbench_session_inputs.admitted_seq > 0 THEN workbench_session_inputs.admitted_seq ELSE EXCLUDED.admitted_seq END, promoted_seq = COALESCE(EXCLUDED.promoted_seq, workbench_session_inputs.promoted_seq), status = EXCLUDED.status, error_code = EXCLUDED.error_code, source_event_id = COALESCE(EXCLUDED.source_event_id, workbench_session_inputs.source_event_id), input_json = EXCLUDED.input_json, updated_at = EXCLUDED.updated_at", + [record.inputId, record.sessionId, record.turnId, record.traceId, record.messageId, record.commandId, record.delivery, record.admittedSeq, record.promotedSeq, record.status, record.errorCode, record.sourceEventId, stableJson(record), record.createdAt, record.updatedAt] + ); + } + async persistWorkbenchMessageFact(record, client = this) { await client.query( "INSERT INTO workbench_messages (message_id, session_id, turn_id, trace_id, role, status, projected_seq, source_seq, source_event_id, terminal, sealed, message_json, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (message_id) DO UPDATE SET session_id = EXCLUDED.session_id, turn_id = EXCLUDED.turn_id, trace_id = EXCLUDED.trace_id, role = EXCLUDED.role, status = EXCLUDED.status, projected_seq = EXCLUDED.projected_seq, source_seq = EXCLUDED.source_seq, source_event_id = EXCLUDED.source_event_id, terminal = EXCLUDED.terminal, sealed = EXCLUDED.sealed, message_json = EXCLUDED.message_json, updated_at = EXCLUDED.updated_at", @@ -2278,6 +2300,7 @@ function normalizeWorkbenchProjectionAllocation(value, requestMeta = {}, now) { function normalizeWorkbenchFacts(value, requestMeta = {}, now) { const input = normalizeJsonObject(value); return { + inputs: arrayInput(input.inputs ?? input.input).map((item) => normalizeWorkbenchSessionInputFact(item, requestMeta, now)), sessions: arrayInput(input.sessions ?? input.session).map((item) => normalizeWorkbenchSessionFact(item, requestMeta, now)), messages: arrayInput(input.messages ?? input.message).map((item) => normalizeWorkbenchMessageFact(item, requestMeta, now)), parts: arrayInput(input.parts ?? input.part).map((item) => normalizeWorkbenchPartFact(item, requestMeta, now)), @@ -2289,6 +2312,7 @@ function normalizeWorkbenchFacts(value, requestMeta = {}, now) { function normalizeWorkbenchAggregateEventsForFacts(facts = {}, requestMeta = {}, now) { const events = []; + for (const fact of arrayInput(facts.inputs)) appendWorkbenchFactEvent(events, "inputs", fact, requestMeta, now); for (const fact of arrayInput(facts.sessions)) appendWorkbenchFactEvent(events, "sessions", fact, requestMeta, now); for (const fact of arrayInput(facts.messages)) appendWorkbenchFactEvent(events, "messages", fact, requestMeta, now); for (const fact of arrayInput(facts.parts)) appendWorkbenchFactEvent(events, "parts", fact, requestMeta, now); @@ -2300,7 +2324,7 @@ function normalizeWorkbenchAggregateEventsForFacts(facts = {}, requestMeta = {}, function appendWorkbenchFactEvent(events, factFamily, fact, requestMeta, now) { const aggregate = workbenchAggregateForFact(fact, requestMeta); - const factId = textOr(fact.id ?? fact.sessionId ?? fact.messageId ?? fact.partId ?? fact.turnId ?? fact.traceId, ""); + const factId = textOr(fact.id ?? fact.inputId ?? fact.sessionId ?? fact.messageId ?? fact.partId ?? fact.turnId ?? fact.traceId, ""); const rawSourceEventId = textOr(fact.sourceEventId ?? fact.eventId, "") || factId; const sourceEventId = rawSourceEventId ? `${factFamily}:${rawSourceEventId}` : null; const eventType = workbenchAggregateEventType(factFamily, fact); @@ -2381,6 +2405,7 @@ function normalizeWorkbenchAggregateEvent(value, requestMeta = {}, now) { } function workbenchAggregateEventType(factFamily, fact = {}) { + if (factFamily === "inputs") return `input_${textOr(fact.delivery, "queue")}_${textOr(fact.status, "admitted")}`; if (factFamily === "sessions") return "session_upsert"; if (factFamily === "messages") return `${textOr(fact.role, "agent")}_message`; if (factFamily === "parts") return `part_${textOr(fact.partType ?? fact.type, "text")}`; @@ -2390,11 +2415,22 @@ function workbenchAggregateEventType(factFamily, fact = {}) { return "event"; } +function workbenchInputDelivery(value) { + const text = textOr(value, "queue").toLowerCase(); + return ["queue", "steer", "cancel"].includes(text) ? text : "queue"; +} + +function workbenchInputStatus(value) { + const text = textOr(value, "admitted").toLowerCase().replace(/-/gu, "_"); + return ["admitted", "promoted", "blocked", "failed", "canceled", "completed"].includes(text) ? text : "admitted"; +} + function indexWorkbenchAggregateEvents(events = []) { const index = new Map(); for (const event of events) { if (event?.factKey) index.set(event.factKey, event); if (event?.factFamily && event?.factId) index.set(`${event.factFamily}:${event.factId}`, event); + if (event?.factFamily === "inputs" && event?.factId) index.set(`input:${event.factId}`, event); if (event?.factFamily === "turns" && event?.turnId) index.set(`turn:${event.turnId}`, event); if (event?.factFamily === "traceEvents" && event?.factId) index.set(`traceEvent:${event.factId}`, event); if (event?.sourceEventId) index.set(`sourceEvent:${event.sourceEventId}`, event); @@ -2402,6 +2438,63 @@ function indexWorkbenchAggregateEvents(events = []) { return index; } +function normalizeWorkbenchSessionInputFact(value, requestMeta = {}, now) { + const input = normalizeJsonObject(value); + const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, ""); + if (!sessionId) throw requiredWorkbenchFactError("workbench session input fact", ["sessionId"]); + const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now); + const turnId = textOr(input.turnId ?? requestMeta.turnId ?? input.traceId ?? requestMeta.traceId, "") || null; + const traceId = textOr(input.traceId ?? requestMeta.traceId, "") || null; + const messageId = textOr(input.messageId ?? requestMeta.messageId, "") || null; + const commandId = textOr(input.commandId ?? input.sourceCommandId, "") || null; + const delivery = workbenchInputDelivery(input.delivery); + const status = workbenchInputStatus(input.status); + const sourceEventId = textOr(input.sourceEventId ?? input.eventId, "") || `${sessionId}:${turnId ?? "turn-unassigned"}:${delivery}:${traceId ?? commandId ?? messageId ?? "input"}`; + const inputId = textOr(input.inputId ?? input.id, "") || stableWorkbenchFactId("wsi", { sessionId, turnId, traceId, messageId, commandId, delivery, sourceEventId }); + const admittedSeq = nonNegativeInteger(input.admittedSeq ?? input.aggregateSeq ?? input.sourceSeq); + const promotedSeq = nonNegativeIntegerOrNull(input.promotedSeq); + return pruneUndefined({ + ...input, + id: inputId, + inputId, + sessionId, + turnId, + traceId, + messageId, + commandId, + delivery, + admittedSeq, + promotedSeq, + sourceSeq: admittedSeq, + projectedSeq: admittedSeq, + status, + errorCode: textOr(input.errorCode ?? input.error?.code, "") || null, + sourceEventId, + createdAt: timestampOr(input.createdAt, timestamp), + updatedAt: timestamp, + valuesPrinted: false, + valuesRedacted: true + }); +} + +function memoryWorkbenchSessionInputWithSeq(fact, records) { + if (nonNegativeInteger(fact.admittedSeq) > 0) return fact; + const existing = records.get(fact.inputId) ?? null; + const existingSeq = nonNegativeInteger(existing?.admittedSeq); + if (existingSeq > 0) return { ...fact, admittedSeq: existingSeq, sourceSeq: existingSeq, projectedSeq: existingSeq }; + const nextSeq = [...records.values()] + .filter((record) => record.sessionId === fact.sessionId) + .reduce((max, record) => Math.max(max, nonNegativeInteger(record.admittedSeq)), 0) + 1; + return { ...fact, admittedSeq: nextSeq, sourceSeq: nextSeq, projectedSeq: nextSeq }; +} + +function inputFactWithAggregateSeq(fact, eventIndex) { + if (nonNegativeInteger(fact.admittedSeq) > 0) return fact; + const event = eventIndex.get(`input:${fact.inputId}`) ?? eventIndex.get(`sourceEvent:${fact.sourceEventId}`) ?? null; + const admittedSeq = nonNegativeInteger(event?.aggregateSeq); + return admittedSeq > 0 ? { ...fact, admittedSeq, sourceSeq: admittedSeq, projectedSeq: admittedSeq } : fact; +} + function normalizeWorkbenchSessionFact(value, requestMeta = {}, now) { const input = normalizeJsonObject(value); const sessionId = textOr(input.sessionId ?? input.id ?? requestMeta.sessionId, ""); @@ -2638,7 +2731,7 @@ function sortWorkbenchFactRows(records, params = {}, family, fallbackCompare) { return sorted.sort(fallbackCompare); } -const WORKBENCH_FACT_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]); +const WORKBENCH_FACT_FAMILIES = Object.freeze(["inputs", "sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]); function workbenchFactFamilySet(params = {}) { const raw = params.families ?? params.factFamilies; @@ -2649,6 +2742,7 @@ function workbenchFactFamilySet(params = {}) { } function workbenchFactFamilyForTable(table) { + if (table === "workbench_session_inputs") return "inputs"; if (table === "workbench_sessions") return "sessions"; if (table === "workbench_messages") return "messages"; if (table === "workbench_parts") return "parts"; diff --git a/internal/db/schema.ts b/internal/db/schema.ts index a9ca6095..fe57c0d2 100644 --- a/internal/db/schema.ts +++ b/internal/db/schema.ts @@ -1,11 +1,11 @@ /* - * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-24-p0-aggregate-event-stream. + * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-24-p0-aggregate-event-stream; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract. * 职责: Cloud runtime durable schema contract。Workbench/Code Agent 投影表必须纳入启动 readiness 和迁移一致性检查。 */ import { TABLES } from "../protocol/index.mjs"; export const CLOUD_CORE_MIGRATION_ID = "0001_cloud_core_skeleton"; -export const CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION = "runtime-durable-postgres-v6"; +export const CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION = "runtime-durable-postgres-v7"; export const CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE = "hwlab_schema_migrations"; export const FROZEN_CLOUD_CORE_TABLES = Object.freeze([...TABLES]); @@ -281,6 +281,23 @@ export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({ "occurred_at", "committed_at" ]), + workbench_session_inputs: Object.freeze([ + "input_id", + "session_id", + "turn_id", + "trace_id", + "message_id", + "command_id", + "delivery", + "admitted_seq", + "promoted_seq", + "status", + "error_code", + "source_event_id", + "input_json", + "created_at", + "updated_at" + ]), workbench_projection_checkpoints: Object.freeze([ "trace_id", "session_id",