@sema-agent/server 7.93.7 → 7.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/boot/stores.js +6 -3
  2. package/dist/boot/workflow-orchestration.js +5 -1
  3. package/dist/http/resume-ctx-read.d.ts +40 -0
  4. package/dist/http/resume-ctx-read.js +25 -0
  5. package/dist/http/resume-legs.js +24 -14
  6. package/dist/http/routes/images.js +12 -0
  7. package/dist/http/routes/runs.js +18 -4
  8. package/dist/http/routes/workflows.js +3 -2
  9. package/dist/observability/corrupt-read-seat.d.ts +56 -0
  10. package/dist/observability/corrupt-read-seat.js +16 -0
  11. package/dist/observability/fail-open.d.ts +4 -0
  12. package/dist/observability/fail-open.js +4 -0
  13. package/dist/orchestration/workflow-journal-entry.d.ts +24 -6
  14. package/dist/orchestration/workflow-journal-entry.js +17 -10
  15. package/dist/plugins/approval-ask-store-sql.js +2 -2
  16. package/dist/plugins/checkpoint-store-sql.d.ts +16 -3
  17. package/dist/plugins/checkpoint-store-sql.js +7 -9
  18. package/dist/plugins/image-bake-store-sql.js +85 -13
  19. package/dist/plugins/image-index-sql.js +75 -12
  20. package/dist/plugins/local-checkpoint-store.d.ts +23 -2
  21. package/dist/plugins/local-checkpoint-store.js +40 -33
  22. package/dist/plugins/local-session-store.d.ts +14 -0
  23. package/dist/plugins/local-session-store.js +12 -7
  24. package/dist/plugins/local-sidecar-quarantine.d.ts +10 -0
  25. package/dist/plugins/local-sidecar-quarantine.js +15 -0
  26. package/dist/plugins/local-task-attachment-store.d.ts +1 -1
  27. package/dist/plugins/local-task-attachment-store.js +5 -2
  28. package/dist/plugins/mailbox-store-sql.d.ts +6 -3
  29. package/dist/plugins/mailbox-store-sql.js +45 -43
  30. package/dist/plugins/memory-embedder-fingerprint.js +8 -12
  31. package/dist/plugins/memory-engine-pg.js +34 -16
  32. package/dist/plugins/memory-engine-tidb.js +19 -12
  33. package/dist/plugins/memory-engine-vector-util.d.ts +26 -0
  34. package/dist/plugins/memory-engine-vector-util.js +3 -0
  35. package/dist/plugins/memory-sync-store-pg.d.ts +6 -0
  36. package/dist/plugins/memory-sync-store-pg.js +14 -10
  37. package/dist/plugins/memory-sync-store-tidb.js +7 -11
  38. package/dist/plugins/pg-session-storage.js +26 -28
  39. package/dist/plugins/session-event-payload.d.ts +37 -0
  40. package/dist/plugins/session-event-payload.js +20 -0
  41. package/dist/plugins/sql-json-column.d.ts +124 -22
  42. package/dist/plugins/sql-json-column.js +22 -1
  43. package/dist/plugins/sql-row-helpers.d.ts +10 -16
  44. package/dist/plugins/sql-row-helpers.js +0 -29
  45. package/dist/plugins/store-backend.js +10 -12
  46. package/dist/plugins/store-contracts.d.ts +4 -6
  47. package/dist/plugins/store-contracts.js +0 -60
  48. package/dist/plugins/task-attachment-store.d.ts +21 -12
  49. package/dist/plugins/task-attachment-store.js +12 -0
  50. package/dist/plugins/tidb-session-storage.d.ts +2 -2
  51. package/dist/plugins/tidb-session-storage.js +12 -11
  52. package/dist/plugins/tidb-session-store.d.ts +2 -2
  53. package/dist/plugins/tidb-session-store.js +20 -22
  54. package/dist/plugins/usage-window-store-sql.d.ts +9 -1
  55. package/dist/plugins/usage-window-store-sql.js +23 -9
  56. package/dist/plugins/workflow-journal-store-sql.js +16 -4
  57. package/dist/run-local.js +2 -10
  58. package/dist/usage-analytics.d.ts +14 -1
  59. package/dist/usage-analytics.js +11 -17
  60. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { FileBackgroundAgentStore, FileMailboxStore, FileRosterStore, FileUsageWindowStore, InMemoryUsageWindowStore, mailboxCrossProcessMountVerdict } from "@sema-agent/core";
3
+ import { createCorruptReadSeat } from "../observability/corrupt-read-seat.js";
3
4
  import { createMemorySyncRunner, createMemorySyncTransport } from "../memory-sync-client.js";
4
5
  import { memoryEmbedderFor } from "../plugins/memory-embedder.js";
5
6
  import { ensurePgMemoryEmbedderMetaSchema, reconcileEmbedderFingerprint } from "../plugins/memory-embedder-fingerprint.js";
@@ -231,7 +232,7 @@ export async function openStores(ctx) {
231
232
  }
232
233
  else if (backend?.kind === "local") {
233
234
  rosterStore = new FileRosterStore(join(config.localDataRoot ?? localRoot, "roster.json"), {
234
- onCorruptRead: (info) => logger.warn("roster_corrupt_read", { path: info.path, reason: info.reason }),
235
+ onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "roster_corrupt_read"),
235
236
  });
236
237
  }
237
238
  if (rosterStore)
@@ -251,7 +252,9 @@ export async function openStores(ctx) {
251
252
  backgroundAgentStore = new TiDBBackgroundAgentStore(mysqlPool);
252
253
  }
253
254
  else if (backend?.kind === "local") {
254
- backgroundAgentStore = new FileBackgroundAgentStore(config.localDataRoot ?? localRoot);
255
+ backgroundAgentStore = new FileBackgroundAgentStore(config.localDataRoot ?? localRoot, {
256
+ onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "background_agent_corrupt_read"),
257
+ });
255
258
  }
256
259
  if (backgroundAgentStore)
257
260
  logger.info("background_agent_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
@@ -305,7 +308,7 @@ export async function openStores(ctx) {
305
308
  }
306
309
  else if (backend?.kind === "local") {
307
310
  mailboxStore = new FileMailboxStore(config.localDataRoot ?? localRoot, {
308
- onCorruptRead: (info) => logger.warn("mailbox_corrupt_read", { path: info.path, reason: info.reason }),
311
+ onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "mailbox_corrupt_read"),
309
312
  });
310
313
  mailboxRecipientLifecycle = createDropOnlyRecipientLifecycle(mailboxStore);
311
314
  }
@@ -2,6 +2,7 @@ import { FileWorkflowRunStore, InMemoryWorkflowRunStore } from "@sema-agent/core
2
2
  import { join } from "node:path";
3
3
  import { FleetEventBus } from "../fleet/fleet-bus.js";
4
4
  import { LocalWorkflowAgentSessionIndex } from "../orchestration/workflow-agent-session-index.js";
5
+ import { createCorruptReadSeat } from "../observability/corrupt-read-seat.js";
5
6
  import { FileWorkflowCompletionInbox, InMemoryWorkflowCompletionInbox, resolveCompletionRoute } from "../orchestration/workflow-completion-inbox.js";
6
7
  import { FileWorkflowNotifyJournalStore, JournalingWorkflowRunStore, WorkflowNotifyGate } from "../orchestration/workflow-notify-journal.js";
7
8
  import { SubagentSteerRegistry } from "../orchestration/subagent-steer.js";
@@ -13,7 +14,10 @@ export function createWorkflowOrchestration(ctx) {
13
14
  const baseWorkflowRunStore = config.selfOrchestrationEnabled
14
15
  ? config.workflowRunStoreBackend === "memory"
15
16
  ? new InMemoryWorkflowRunStore()
16
- : (sqlWorkflowRunStore ?? new FileWorkflowRunStore(join(config.localDataRoot ?? localRoot, "workflows")))
17
+ : (sqlWorkflowRunStore ??
18
+ new FileWorkflowRunStore(join(config.localDataRoot ?? localRoot, "workflows"), {
19
+ onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "workflow_run_corrupt_read"),
20
+ }))
17
21
  : undefined;
18
22
  const workflowNotifyJournal = config.selfOrchestrationEnabled && config.workflowRunStoreBackend !== "memory"
19
23
  ? (sqlWorkflowRunStore ? backend.notifyJournal() : new FileWorkflowNotifyJournalStore(join(config.localDataRoot ?? localRoot, "workflows")))
@@ -0,0 +1,40 @@
1
+ /**
2
+ * **续跑上下文这一次读的结局**(S-577 第二半,7.94.0)—— 属主模块,所有消费腿共用这一只读口。
3
+ *
4
+ * 🔴 **为什么必须有这只铸点**:此前各消费腿各写一句 `.catch(() => null)`(还有一处把异常文案拼上 wire),
5
+ * 于是店层在 S-577 里把坏体从「答缺席」改成「响亮拒」之后,那些 `catch` 会把那次拒**原样吞回缺席** ——
6
+ * 机制上「坏体不再与缺席同答」成立,**用户面上不成立**(用户拿到的还是同一个 409)。判据要钉用户实际
7
+ * 拿到的码,这只铸点就是让那句判据有地方落(test [ref] 的判据订正,当拍采纳)。
8
+ *
9
+ * 三态与各自的理由:
10
+ * · `ok` —— 读出来了。
11
+ * · `absent` —— 行不在(`reapCtx` 按设计在 run 终态后删掉它),**或**读腿抛了一个**不是坏体**的错
12
+ * (连接 / 超时 / 权限 / 店抖动):证不出它在就当它不在,**这是刻意保守**([ref] 方向锚:
13
+ * 拒绝可重试,搁浅不可)。
14
+ * · `corrupt` —— 读腿抛了**坏体类**错(属主谓词 `isJsonColumnBodyProblem`:`not-json` / `malformed`)。
15
+ * **不折成缺席**:两者的运维动作完全不同(前者去查那一行,后者什么都不用做)。
16
+ * ⚠️ `not-text`(投影漂 = 我方的码病)**刻意不进这一态**,原样上抛、响亮成 500 —— 不许伪装成
17
+ * 「数据坏了」,更不许折成缺席(那会让一次代码漂看起来像「这条会话没有上下文」)。
18
+ */
19
+ import type { CheckpointCtx } from "./route-ctx.js";
20
+ export type ResumeCtxRead = {
21
+ readonly kind: "ok";
22
+ readonly ctx: CheckpointCtx;
23
+ } | {
24
+ readonly kind: "absent";
25
+ } | {
26
+ readonly kind: "corrupt";
27
+ readonly where: string;
28
+ };
29
+ export declare function readResumeCtx(load: Promise<CheckpointCtx | null>): Promise<ResumeCtxRead>;
30
+ /** 坏体的对外答复 —— 与会话策略面**同码**(`session-policy-wire.ts` 的 `corrupt` ⇒ 500),不铸新词。
31
+ * 文案只说「哪一行读不出来 + 该找谁」,不把店层 detail 拼上 wire(内部机件不上 wire,S-376 同律)。 */
32
+ export declare function resumeCtxCorruptAnswer(where: string): {
33
+ status: number;
34
+ body: {
35
+ error: string;
36
+ errorCode: string;
37
+ where: string;
38
+ };
39
+ };
40
+ //# sourceMappingURL=resume-ctx-read.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { isJsonColumnBodyProblem, SqlJsonColumnError } from "../plugins/sql-json-column.js";
2
+ export async function readResumeCtx(load) {
3
+ try {
4
+ const ctx = await load;
5
+ return ctx == null ? { kind: "absent" } : { kind: "ok", ctx };
6
+ }
7
+ catch (e) {
8
+ if (isJsonColumnBodyProblem(e))
9
+ return { kind: "corrupt", where: e.where };
10
+ if (e instanceof SqlJsonColumnError)
11
+ throw e;
12
+ return { kind: "absent" };
13
+ }
14
+ }
15
+ export function resumeCtxCorruptAnswer(where) {
16
+ return {
17
+ status: 500,
18
+ body: {
19
+ error: "the stored resume context for this session cannot be read — nothing was decided and nothing was resumed. Ask whoever operates this deployment to look at the row named in `where`: it has to be repaired or removed before this session can resume.",
20
+ errorCode: "corrupt",
21
+ where,
22
+ },
23
+ };
24
+ }
25
+ //# sourceMappingURL=resume-ctx-read.js.map
@@ -35,6 +35,7 @@ import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
35
35
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
36
36
  import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
37
37
  import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
38
+ import { readResumeCtx, resumeCtxCorruptAnswer } from "./resume-ctx-read.js";
38
39
  function isCheckpointReopenedFailure(r) {
39
40
  const code = failureCodeOf(r.terminal);
40
41
  return code === "resume.env_failed" || code === "resume.tool_unavailable" || code === "resume.tool_contract_mismatch";
@@ -278,13 +279,10 @@ export function createResumeLegs(ctx) {
278
279
  : await startFreshHostTurn(plan.hostSessionId, plan.message, admittedPayer, req, parkedResume, plan.runId);
279
280
  }
280
281
  }
281
- let ctx;
282
- try {
283
- ctx = await ctxPromise;
284
- }
285
- catch (e) {
286
- return { status: 409, body: { error: `resume context load failed: ${e instanceof Error ? e.message : String(e)}`, errorCode: "conflict.resume_context_unavailable" } };
287
- }
282
+ const ctxRead0 = await readResumeCtx(ctxPromise);
283
+ if (ctxRead0.kind === "corrupt")
284
+ return resumeCtxCorruptAnswer(ctxRead0.where);
285
+ const ctx = ctxRead0.kind === "ok" ? ctxRead0.ctx : null;
288
286
  if (!ctx)
289
287
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
290
288
  const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
@@ -1036,9 +1034,12 @@ export function createResumeLegs(ctx) {
1036
1034
  if (gateKind !== RESUMABLE_GATE_KIND) {
1037
1035
  return { status: 409, body: { error: `task is suspended on a '${gateKind ?? "unknown"}' gate, not a resumable resource/preempt suspension — an approval gate must be decided via POST /v1/approvals/:id/decide`, errorCode: "gate_not_resumable" } };
1038
1036
  }
1039
- const ctx = await cs.getCtx(sessionId).catch(() => null);
1040
- if (!ctx)
1037
+ const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
1038
+ if (ctxRead.kind === "corrupt")
1039
+ return resumeCtxCorruptAnswer(ctxRead.where);
1040
+ if (ctxRead.kind === "absent")
1041
1041
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
1042
+ const ctx = ctxRead.ctx;
1042
1043
  const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
1043
1044
  const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
1044
1045
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
@@ -1050,7 +1051,10 @@ export function createResumeLegs(ctx) {
1050
1051
  if (!deps.runStore) {
1051
1052
  return { status: 501, body: { error: "delivering this decision requires a durable run store (DB_BACKEND=mysql|pg|local)", errorCode: "capability.run_store_required" } };
1052
1053
  }
1053
- const ctx = await cs.getCtx(sessionId).catch(() => null);
1054
+ const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
1055
+ if (ctxRead.kind === "corrupt")
1056
+ return resumeCtxCorruptAnswer(ctxRead.where);
1057
+ const ctx = ctxRead.kind === "ok" ? ctxRead.ctx : null;
1054
1058
  if (!ctx) {
1055
1059
  return {
1056
1060
  status: 409,
@@ -1140,9 +1144,12 @@ export function createResumeLegs(ctx) {
1140
1144
  throw e;
1141
1145
  }
1142
1146
  }
1143
- const ctx = await cs.getCtx(sessionId).catch(() => null);
1144
- if (!ctx)
1147
+ const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
1148
+ if (ctxRead.kind === "corrupt")
1149
+ return resumeCtxCorruptAnswer(ctxRead.where);
1150
+ if (ctxRead.kind === "absent")
1145
1151
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
1152
+ const ctx = ctxRead.ctx;
1146
1153
  const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
1147
1154
  const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
1148
1155
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
@@ -1222,9 +1229,12 @@ export function createResumeLegs(ctx) {
1222
1229
  },
1223
1230
  };
1224
1231
  }
1225
- const ctx = await cs.getCtx(sessionId).catch(() => null);
1226
- if (!ctx)
1232
+ const ctxRead = await readResumeCtx(cs.getCtx(sessionId));
1233
+ if (ctxRead.kind === "corrupt")
1234
+ return resumeCtxCorruptAnswer(ctxRead.where);
1235
+ if (ctxRead.kind === "absent")
1227
1236
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
1237
+ const ctx = ctxRead.ctx;
1228
1238
  const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
1229
1239
  const effMode = storedBodyPermissionMode(ctx.body);
1230
1240
  if (permissionModeAfter !== undefined && effMode !== "plan") {
@@ -331,6 +331,18 @@ export async function handleImages(req, res, match, ctx) {
331
331
  sendError(res, 400, "request.field_invalid", "profile, repo, digest are required");
332
332
  return;
333
333
  }
334
+ if (b.bands !== undefined && b.bands !== null && !Array.isArray(b.bands)) {
335
+ sendError(res, 400, "request.field_invalid", `bands must be an array when present`);
336
+ return;
337
+ }
338
+ const badObjectMeta = ["toolchainVersions", "capabilities", "podContract"].find((k) => {
339
+ const v = b[k];
340
+ return v !== undefined && v !== null && (typeof v !== "object" || Array.isArray(v));
341
+ });
342
+ if (badObjectMeta !== undefined) {
343
+ sendError(res, 400, "request.field_invalid", `${badObjectMeta} must be an object when present`);
344
+ return;
345
+ }
334
346
  const id = await idx.registerBuilding({
335
347
  profile: b.profile,
336
348
  bands: Array.isArray(b.bands) ? b.bands : [],
@@ -29,6 +29,7 @@ import { assertNever } from "../route-ctx.js";
29
29
  import { mintAndDriveDurableRun } from "../durable-run-leg.js";
30
30
  import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
31
31
  import { withPrincipal } from "../../observability/principal-context.js";
32
+ import { readResumeCtx } from "../resume-ctx-read.js";
32
33
  const ASK_PARKING_RETRY_AFTER_SEC = 2;
33
34
  const ASK_ACTOR_LABEL_MAX = 256;
34
35
  const AskDecisionActorSchema = ActorAssertionWire.strip();
@@ -588,8 +589,12 @@ export async function handleRuns(req, res, match, ctx) {
588
589
  const leafId = await Promise.resolve(deps.sessionStorage.getLeafId(run.sessionId)).catch(() => undefined);
589
590
  if (leafId === undefined || leafId === null)
590
591
  return;
591
- const resumeCtx = await Promise.resolve(cs.getCtx(run.sessionId)).catch(() => null);
592
- if (resumeCtx === null || resumeCtx === undefined) {
592
+ const resumeRead = await readResumeCtx(Promise.resolve(cs.getCtx(run.sessionId)));
593
+ if (resumeRead.kind === "corrupt") {
594
+ mintResult.corruptCtxWhere = resumeRead.where;
595
+ return;
596
+ }
597
+ if (resumeRead.kind === "absent") {
593
598
  mintResult.noResumeCtx = true;
594
599
  return;
595
600
  }
@@ -607,8 +612,13 @@ export async function handleRuns(req, res, match, ctx) {
607
612
  createdAt: Date.now(),
608
613
  sourceTaskId: taskId,
609
614
  });
610
- const ctxStillThere = await Promise.resolve(cs.getCtx(run.sessionId)).catch(() => null);
611
- if (ctxStillThere === null || ctxStillThere === undefined) {
615
+ const stillRead = await readResumeCtx(Promise.resolve(cs.getCtx(run.sessionId)));
616
+ if (stillRead.kind === "corrupt") {
617
+ await cs.expire(wakeToken, cpScope).catch(() => undefined);
618
+ mintResult.corruptCtxWhere = stillRead.where;
619
+ return;
620
+ }
621
+ if (stillRead.kind !== "ok") {
612
622
  await cs.expire(wakeToken, cpScope).catch(() => undefined);
613
623
  mintResult.noResumeCtx = true;
614
624
  return;
@@ -638,6 +648,10 @@ export async function handleRuns(req, res, match, ctx) {
638
648
  sendDuplicateKey();
639
649
  return;
640
650
  }
651
+ if (mintResult.corruptCtxWhere !== undefined) {
652
+ sendError(res, 500, "corrupt", "run already ended and this session's stored resume context cannot be read — no wake card was minted and nothing was queued. Ask whoever operates this deployment to repair or remove the row named in `where` before steering this session again.", { where: mintResult.corruptCtxWhere });
653
+ return;
654
+ }
641
655
  if (mintResult.noResumeCtx) {
642
656
  sendError(res, 409, "conflict.resume_context_unavailable", "run already ended and this session's resume context is gone — a steer queued now could never be delivered by POST /v1/sessions/:id/wake; submit a new task instead");
643
657
  return;
@@ -2,7 +2,8 @@ import { persistedPlaneOf } from "../../terminal.js";
2
2
  import { z } from "zod";
3
3
  import { getWorkflowRun, subscribeWorkflow, deriveAgentDisplayStatus, callKeyOrdinal } from "@sema-agent/core";
4
4
  import { redactSteerIn, STEER_IN_MAX_CHARS, STEER_IN_MAX_REQUEST_CHARS } from "../../orchestration/workflow-agent-steer.js";
5
- import { journalEntryOfRow, journalEntryPayload, journalRowPayloadOf } from "../../orchestration/workflow-journal-entry.js";
5
+ import { journalEntryOfBody, journalEntryPayload, journalRowPayloadOf, readJournalRowBody } from "../../orchestration/workflow-journal-entry.js";
6
+ import { decodeJsonColumn } from "../../plugins/sql-json-column.js";
6
7
  import { redactSecrets } from "../../trace/redact.js";
7
8
  import { createHash } from "node:crypto";
8
9
  import { redactErrorMessage, redactHead } from "../../observability/run-terminal.js";
@@ -108,7 +109,7 @@ export async function handleWorkflows(req, res, match, ctx) {
108
109
  return oversizeStub(row.callKey, row.resultBytes);
109
110
  }
110
111
  try {
111
- const { payload, parked } = journalEntryPayload(journalEntryOfRow(row.callKey, row.resultJson));
112
+ const { payload, parked } = journalEntryPayload(journalEntryOfBody(row.callKey, decodeJsonColumn(row.resultJson, readJournalRowBody, `workflow_journal.result for entry ${row.callKey}`)));
112
113
  return projectEntry(row.callKey, payload, parked);
113
114
  }
114
115
  catch {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 🔴 **腐读披露座的唯一属主**(S-569,core 7.28.1 提货批;#1013 F4 的 server 半场)。
3
+ *
4
+ * ## core 那边发生了什么
5
+ * core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,也会
6
+ * 自锁修复写)。7.27.0(#1013 F4)把这条 fail-open 的披露面收成**一只共用 guard**
7
+ * (`stores/file/corrupt-read-sink.js`:`CorruptReadInfo` + `guardCorruptSink`),并给每一只容错 File 店
8
+ * 都开了座 —— core 自己的原话是那只 guard「hand-written four times」,其中三份包住了 async sink 的
9
+ * rejection、mailbox 那份只包住同步 throw。**同一个病,server 这侧也有**:座的载荷投影当时在本仓被
10
+ * 手抄了四遍(roster / mailbox / run-local 与 store-backend 的两只 FileStorageBackend)。
11
+ *
12
+ * ## 为什么是一个属主,而不是第五、六、七、八份手抄([ref])
13
+ * 7.27.0 之后 server 真构造的店里又多出**四只空座**(`FileCheckpointStore` / `FileWorkflowRunStore` /
14
+ * `FileBackgroundAgentStore` / `FileWorkflowJournalStore`)。按老形接线 = 八处各写一遍
15
+ * `{ path, reason, ...可选成员 }` 的转发 —— 而这八处**本来就该是同一句话**:
16
+ *
17
+ * 「一次持久读因为字节读不出来被当成了缺席。ENOENT 不触发(那是真缺席,不是腐读)。」
18
+ *
19
+ * 载荷形不是本仓铸的:`FileStorageCorruptReadInfo` 是 **core 的导出**,并且是 core 亲口说的**最宽**那一只
20
+ * ——"the widest shape any store this backend constructs can deliver, so ONE sink reaches all of them…
21
+ * the two-field store seats are satisfied by it"。于是本属主就按那只最宽形收参:两个窄座
22
+ * (`CorruptReadInfo = {path, reason}`)与 roster/mailbox 的内联形都因**可选成员**而逐字接受它
23
+ * (`{path,reason}` 可赋给最宽形 ⇒ 吃最宽形的函数可赋给窄座)。**本仓因此零镜像类型**。
24
+ *
25
+ * ## 刻意不做的两件事
26
+ * · **不在这里下统一结论**:后果按脸各不相同(少了会话规则这一层 / 列表静默少行 / rewind 边界读成缺席 /
27
+ * durable park 丢一行 / resume 读到「无事可重放」于是把活重跑),把其中一种写成「整个任务无约束」会误导
28
+ * 事故定级,而且对另外几张脸根本不成立(codex R2-medium 的原案)。所以 `note` 是**按脸传进来的一句话**,
29
+ * 不是本属主里的一张词表 —— 属主里放一张「事件名 → 后果句」的表,等于把调用点的知识搬进属主再抄回去。
30
+ * · **不接 `recordFailOpen`**:那条通道是给 F 类(装饰/缓存)fail-open 的计数与探针用的;这里的 fail-open
31
+ * 是 core 的裁定、本仓不改它,server 的职责只有一条 —— **不许无声**([ref] 安全轴纪律)。响亮 warn 就是
32
+ * 那条职责的兑现;要不要再进 fail-open 台账是另一件事、另一台车的设计。
33
+ *
34
+ * ⚠️ core 那只 guard 的纪律同样适用:sink 是**无绑定**调用的(`this` 是 undefined)。本属主返回的是箭头
35
+ * 闭包,天然满足;但**不要**把某个对象的裸方法引用直接塞进座 —— 读 `this` 会抛,而那个抛会被 core 的
36
+ * guard 吞掉,于是披露**静默丢失**(比没接座更难查)。
37
+ */
38
+ import type { FileStorageCorruptReadInfo } from "@sema-agent/core";
39
+ import type { Logger } from "./logger.js";
40
+ /**
41
+ * 铸一只腐读披露座。
42
+ *
43
+ * @param warn 日志座。形取 `Logger["warn"]` —— 本仓日志 sink 形的既有属主,`plugins/store-backend.ts` 的
44
+ * `BootLogSink` 结构上就是它,所以这里**不引入第三种拼法**。
45
+ * @param event warn 事件名。**留在调用点**:它是那只店的身份,接线锚(test/roster-corrupt-read.test.ts)
46
+ * 正是靠「构造实参里能读到这个名字」来证明座不是个空回调。
47
+ * @param note 这张脸的后果句(可选)。缺席 ⇒ 不写这个键。
48
+ */
49
+ export declare function createCorruptReadSeat(warn: Logger["warn"], event: string, note?: string): (info: FileStorageCorruptReadInfo) => void;
50
+ /**
51
+ * `FileStorageBackend` 那两只座共用的后果句 —— 一个座覆盖 core 转发的三张脸,所以共同事实只有一句,
52
+ * 差别只在第三张脸叫什么(`run-local` 腿看到的是 file-snapshot,HTTP 服务腿看到的是 file-history)。
53
+ * 这一句从前在两处各抄一份、只差一个词;现在词从参数来,句子只有一份。
54
+ */
55
+ export declare function buildDurableAbsenceNote(thirdFace: string): string;
56
+ //# sourceMappingURL=corrupt-read-seat.d.ts.map
@@ -0,0 +1,16 @@
1
+ export function createCorruptReadSeat(warn, event, note) {
2
+ return (info) => warn(event, {
3
+ path: info.path,
4
+ reason: info.reason,
5
+ ...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
6
+ ...(info.principal !== undefined ? { principal: info.principal } : {}),
7
+ ...(note !== undefined ? { note } : {}),
8
+ });
9
+ }
10
+ export function buildDurableAbsenceNote(thirdFace) {
11
+ return ("a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
12
+ "Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
13
+ "layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
14
+ `rows; ${thirdFace}. Inspect the named path before deciding.`);
15
+ }
16
+ //# sourceMappingURL=corrupt-read-seat.js.map
@@ -137,6 +137,10 @@ export declare const FAIL_OPEN_TAGS: {
137
137
  readonly cls: "F";
138
138
  readonly note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。";
139
139
  };
140
+ readonly "server.sql-json.memory-engine.embedding": {
141
+ readonly cls: "F";
142
+ readonly note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。";
143
+ };
140
144
  readonly "server.retention.sweep-report-dropped": {
141
145
  readonly cls: "F";
142
146
  readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
@@ -128,6 +128,10 @@ export const FAIL_OPEN_TAGS = {
128
128
  cls: "F",
129
129
  note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。",
130
130
  },
131
+ "server.sql-json.memory-engine.embedding": {
132
+ cls: "F",
133
+ note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。",
134
+ },
131
135
  "server.retention.sweep-report-dropped": {
132
136
  cls: "F",
133
137
  note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
@@ -16,7 +16,7 @@
16
16
  * 收敛到这里之后,第三臂到货 ⇒ 本文件**一处**编译红。
17
17
  *
18
18
  * 🔴 **park 臂的形是 fail-closed 的**:core 的类型要求 `parked.terminal.kind === "paused"`。从盘上读回来的
19
- * 字节没有类型,所以 {@link journalEntryOfRow} 在铸 park 臂时**亲验**这一位,不合就**抛**——它是 LOAD-BEARING
19
+ * 字节没有类型,所以 {@link readJournalRowBody} 在铸 park 臂时**亲验**这一位,不合就判坏——它是 LOAD-BEARING
20
20
  * 面上的坏行(一条说自己 park 了却拿不出暂停终局的记录,既不能驱动、也不能当成跑完),吞掉它会让 resume
21
21
  * 在一个被钉住的子代 session 旁边重新起一只活跑,正是 core 那条「一次性、响亮」的 schema 台阶要防的形。
22
22
  *
@@ -25,6 +25,7 @@
25
25
  * 而不是把一个暂停终局当成可重放结果)。
26
26
  */
27
27
  import type { TaskResult, WorkflowJournalEntry } from "@sema-agent/core";
28
+ import type { JsonColumnReader } from "../plugins/sql-json-column.js";
28
29
  /** park 臂的载荷型(core 的 `WorkflowJournalEntry` park 成员逐字):一只 `terminal` 收敛到 `paused` 的 `TaskResult`。 */
29
30
  export type ParkedTaskResult = TaskResult & {
30
31
  terminal: Extract<TaskResult["terminal"], {
@@ -63,16 +64,33 @@ export declare function journalEntryPayload(entry: WorkflowJournalEntry): {
63
64
  */
64
65
  export declare function journalRowPayloadOf(entry: WorkflowJournalEntry): string;
65
66
  /**
66
- * {@link journalRowPayloadOf} 的逆 —— 盘上一行的 payload 文本 → 记录。**唯一**的回装点(SQL 店的 `load`
67
- * 与 HTTP journal 读面共读它)。
67
+ * 盘上一行 payload 的**形读者**(S-550 车③a:`workflow_journal.result` 这一列的唯一判据)。
68
68
  *
69
69
  * 判别式 = 解出来的对象上有没有**自有** `parked` 键。它与 `TaskResult` 结构上不相交(core 的 `TaskResult`
70
70
  * 没有这个成员),所以历史字节(裸 TaskResult)恒判 result 臂 —— 不需要任何「老字节 = result」的兼容规则,
71
71
  * 判别式自己就说清了。
72
72
  *
73
- * park 臂验 `terminal.kind === "paused"`;不合 ⇒ **抛**,由 LOAD-BEARING 的 `load` 把它当店故障传上去。
73
+ * park 臂验 `terminal.kind === "paused"`;不合 ⇒ 判坏,由 LOAD-BEARING 的读腿把它当店故障传上去。
74
74
  * 一条「说自己 park 了却拿不出暂停终局」的行既驱动不了、也不能当跑完,吞掉它会让 resume 在被钉住的子代
75
- * session 旁边重新起一只活跑 —— 与上面那条屏障要防的是同一件事。
75
+ * session 旁边重新起一只活跑 —— 与 {@link journalRowPayloadOf} 那道信封屏障要防的是同一件事。
76
+ *
77
+ * 🔴 **「一只 JSON 对象」这一条也必须判**(S-550):此前回装点是裸 `JSON.parse` + `as TaskResult`,于是
78
+ * 一行 `null` 字面 / JSON 字符串标量 / 双重编码的 `result` 会被当成一条**合法的已完成记录**端给 resume
79
+ * (`{callKey, result: null}`)—— 那正是「一次已完成的子代被当成跑过而不再跑」与「一条坏行冒充跑完」
80
+ * 两种最坏形的同一个入口。
81
+ */
82
+ export declare const readJournalRowBody: JsonColumnReader<JournalRowBody>;
83
+ /** {@link readJournalRowBody} 收窄出来的两臂(判别位已定,载荷已验形)。 */
84
+ export type JournalRowBody = {
85
+ parked: true;
86
+ payload: ParkedTaskResult;
87
+ } | {
88
+ parked: false;
89
+ payload: TaskResult;
90
+ };
91
+ /**
92
+ * {@link journalRowPayloadOf} 的逆的**后半**:已解码、已验形的载荷 → 记录。**唯一**的铸点(SQL 店的 `load`
93
+ * 与 HTTP journal 读面共读它;两处各自经 `decodeJsonColumn(text, readJournalRowBody, …)` 把文本收成这只体)。
76
94
  */
77
- export declare function journalEntryOfRow(callKey: string, json: string): WorkflowJournalEntry;
95
+ export declare function journalEntryOfBody(callKey: string, body: JournalRowBody): WorkflowJournalEntry;
78
96
  //# sourceMappingURL=workflow-journal-entry.d.ts.map
@@ -5,16 +5,23 @@ export function journalRowPayloadOf(entry) {
5
5
  const { payload, parked } = journalEntryPayload(entry);
6
6
  return JSON.stringify(parked ? { parked: payload } : payload);
7
7
  }
8
- export function journalEntryOfRow(callKey, json) {
9
- const raw = JSON.parse(json);
10
- if (typeof raw === "object" && raw !== null && Object.hasOwn(raw, "parked")) {
11
- const payload = raw.parked;
12
- const terminal = payload.terminal;
13
- if (terminal === undefined || terminal.kind !== "paused") {
14
- throw new Error(`workflow journal: entry ${callKey} is recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — refusing to admit it (a park row that cannot be driven must not be replayed as a completed result)`);
15
- }
16
- return { callKey, parked: { ...payload, terminal } };
8
+ export const readJournalRowBody = (body) => {
9
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
10
+ return { problem: "expected a JSON object (a TaskResult, or the `{parked: …}` envelope)" };
17
11
  }
18
- return { callKey, result: raw };
12
+ if (!Object.hasOwn(body, "parked"))
13
+ return { value: { parked: false, payload: body } };
14
+ const payload = body.parked;
15
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
16
+ return { problem: "the `parked` envelope does not carry an object" };
17
+ }
18
+ const terminal = payload.terminal;
19
+ if (terminal === undefined || terminal.kind !== "paused") {
20
+ return { problem: `recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — a park row that cannot be driven must not be replayed as a completed result` };
21
+ }
22
+ return { value: { parked: true, payload: { ...payload, terminal } } };
23
+ };
24
+ export function journalEntryOfBody(callKey, body) {
25
+ return body.parked ? { callKey, parked: body.payload } : { callKey, result: body.payload };
19
26
  }
20
27
  //# sourceMappingURL=workflow-journal-entry.js.map
@@ -1,7 +1,7 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { canAskTransition } from "../approval-ask-machine.js";
3
3
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
4
- import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn } from "./sql-json-column.js";
4
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, readTriStateJsonValueColumn } from "./sql-json-column.js";
5
5
  import { ensureIndexes } from "./ensure-index.js";
6
6
  export function assertIntegralEpochMs(field, value) {
7
7
  if (!Number.isSafeInteger(value)) {
@@ -284,7 +284,7 @@ function mapAskRowBody(r, askId) {
284
284
  decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
285
285
  decisionNote: strOrNull(r.decision_note),
286
286
  settledBy: strOrNull(r.settled_by),
287
- ...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readAnyJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
287
+ ...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readTriStateJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
288
288
  decidedAtMs: numOrNull(r.decided_at_ms),
289
289
  deniedReason: strOrNull(r.denied_reason),
290
290
  gateToken: strOrNull(r.gate_token),
@@ -760,9 +760,22 @@ export declare class SqlCheckpointStore implements CheckpointStore {
760
760
  peekPendingScope(sessionId: string, binding?: PendingRowBinding): Promise<string | null | undefined>;
761
761
  /** Upsert the resume rebuild inputs for a session — written at SUBMIT (durable mode on) and, since S-433 F2,
762
762
  * once per resume leg at its CAS win point (`DriveResumeArgs.resumeCtx`). Unconditional per `session_id`:
763
- * the ordering fence lives at the CALLER's CAS win, not in a version column here. */
764
- putCtx(sessionId: string, ctx: unknown): Promise<void>;
765
- getCtx<T = unknown>(sessionId: string): Promise<T | null>;
763
+ * the ordering fence lives at the CALLER's CAS win, not in a version column here.
764
+ *
765
+ * 🔴 `ctx: object`, not `unknown` (S-577): the read leg judges this column as a JSON **object**
766
+ * ({@link decodeCtxColumn}), so a writer that took `unknown` would be the "writes fine, reads back
767
+ * refused" asymmetry this repo keeps paying for — and `putCtx(sessionId, null)` was exactly the
768
+ * reachable arm that made the JSON literal `null` (indistinguishable from "no such row" at every
769
+ * consumption point) a zero-corruption possibility. All six call sites already pass an object.
770
+ *
771
+ * 🔴 The TYPE is not the whole gate (codex adversarial review [medium], verified then fixed as a class):
772
+ * `object` admits arrays, `Date`, and objects whose `toJSON()` yields a scalar or `null` — each of those
773
+ * serializes to bytes THIS STORE'S READ LEG REFUSES, and the upsert is unconditional, so the write would
774
+ * SUCCEED and replace a readable durable row with an unreadable one (caller sees 2xx, the next resume
775
+ * 409s forever). So the serialized value is judged by {@link assertJsonColumnWritable} — the SAME shape
776
+ * reader the read leg uses — BEFORE the statement runs: refuse loudly, leave the row on disk untouched. */
777
+ putCtx(sessionId: string, ctx: object): Promise<void>;
778
+ getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
766
779
  /**
767
780
  * GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak):
768
781
  * a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`.
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
4
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
6
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
7
- import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
7
+ import { assertJsonColumnWritable, createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
9
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
10
10
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
@@ -117,9 +117,6 @@ const readCheckpointBlobBody = createObjectColumnReader();
117
117
  const readResumeOutcomeBody = createObjectColumnReader();
118
118
  const readGateOutcomeBody = createObjectColumnReader();
119
119
  const readRiskDescriptorBody = createObjectColumnReader();
120
- function readJsonArrayBody(body) {
121
- return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
122
- }
123
120
  function decodeCheckpointColumn(cell, where) {
124
121
  return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
125
122
  }
@@ -133,8 +130,7 @@ function decodeToolInputColumn(cell) {
133
130
  return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
134
131
  }
135
132
  function decodeCtxColumn(cell, where) {
136
- const body = decodeJsonColumn(cell, readAnyJsonValueColumn, `checkpoint_ctx.ctx ${where}`);
137
- return body;
133
+ return decodeJsonColumn(cell, createObjectColumnReader(), `checkpoint_ctx.ctx ${where}`);
138
134
  }
139
135
  function refusing(what, read) {
140
136
  try {
@@ -149,7 +145,7 @@ function readRuleOffersCell(cell) {
149
145
  return undefined;
150
146
  let parsed;
151
147
  try {
152
- parsed = decodeJsonColumn(cell, readJsonArrayBody, "checkpoint.rule_suggestions (operator queue row)");
148
+ parsed = decodeJsonColumn(cell, readJsonArrayColumn, "checkpoint.rule_suggestions (operator queue row)");
153
149
  }
154
150
  catch (e) {
155
151
  if (!isJsonColumnBodyProblem(e))
@@ -282,7 +278,7 @@ export function readPendingSteerQueueColumn(queueRaw) {
282
278
  let lossy = false;
283
279
  let items;
284
280
  try {
285
- items = decodeNullableJsonColumn(queueRaw, readJsonArrayBody, "checkpoint.pending_steer_queue");
281
+ items = decodeNullableJsonColumn(queueRaw, readJsonArrayColumn, "checkpoint.pending_steer_queue");
286
282
  }
287
283
  catch (e) {
288
284
  if (!isJsonColumnBodyProblem(e))
@@ -655,9 +651,11 @@ export class SqlCheckpointStore {
655
651
  return rows[0].scope ?? null;
656
652
  }
657
653
  async putCtx(sessionId, ctx) {
654
+ const text = this.json(ctx, "checkpoint ctx");
655
+ assertJsonColumnWritable(text, createObjectColumnReader(), `checkpoint_ctx.ctx for session ${JSON.stringify(sessionId)} (write leg)`);
658
656
  await this.db.query(this.q("INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES (?,?,?) " +
659
657
  "ON DUPLICATE KEY UPDATE ctx = VALUES(ctx), updated_at_ms = VALUES(updated_at_ms)", "INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES ($1,$2::jsonb,$3) " +
660
- "ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, this.json(ctx, "checkpoint ctx"), Date.now()]);
658
+ "ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, text, Date.now()]);
661
659
  }
662
660
  async getCtx(sessionId) {
663
661
  const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);