@sema-agent/server 7.93.7 → 7.95.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 (74) hide show
  1. package/USAGE.md +52 -0
  2. package/dist/boot/resolve-spec.js +14 -1
  3. package/dist/boot/stage-09-leader.js +1 -1
  4. package/dist/boot/stores.js +6 -3
  5. package/dist/boot/workflow-orchestration.js +5 -1
  6. package/dist/config-catalog.js +5 -3
  7. package/dist/config-center/types.d.ts +1 -1
  8. package/dist/config-invariants.d.ts +2 -2
  9. package/dist/config-invariants.js +17 -0
  10. package/dist/config-types.d.ts +44 -3
  11. package/dist/config-types.js +1 -0
  12. package/dist/config.js +24 -4
  13. package/dist/hosted-posture.d.ts +176 -0
  14. package/dist/hosted-posture.js +76 -0
  15. package/dist/http/resume-ctx-read.d.ts +40 -0
  16. package/dist/http/resume-ctx-read.js +25 -0
  17. package/dist/http/resume-legs.js +62 -42
  18. package/dist/http/routes/capabilities.js +3 -0
  19. package/dist/http/routes/images.js +12 -0
  20. package/dist/http/routes/runs.js +18 -4
  21. package/dist/http/routes/workflows.js +3 -2
  22. package/dist/http/wire-types.d.ts +13 -4
  23. package/dist/observability/corrupt-read-seat.d.ts +56 -0
  24. package/dist/observability/corrupt-read-seat.js +16 -0
  25. package/dist/observability/fail-open.d.ts +4 -0
  26. package/dist/observability/fail-open.js +4 -0
  27. package/dist/orchestration/workflow-journal-entry.d.ts +24 -6
  28. package/dist/orchestration/workflow-journal-entry.js +17 -10
  29. package/dist/plugins/approval-ask-store-sql.js +2 -2
  30. package/dist/plugins/checkpoint-store-sql.d.ts +16 -3
  31. package/dist/plugins/checkpoint-store-sql.js +7 -9
  32. package/dist/plugins/image-bake-store-sql.js +85 -13
  33. package/dist/plugins/image-index-sql.js +75 -12
  34. package/dist/plugins/local-checkpoint-store.d.ts +23 -2
  35. package/dist/plugins/local-checkpoint-store.js +40 -33
  36. package/dist/plugins/local-session-store.d.ts +14 -0
  37. package/dist/plugins/local-session-store.js +12 -7
  38. package/dist/plugins/local-sidecar-quarantine.d.ts +10 -0
  39. package/dist/plugins/local-sidecar-quarantine.js +15 -0
  40. package/dist/plugins/local-task-attachment-store.d.ts +1 -1
  41. package/dist/plugins/local-task-attachment-store.js +5 -2
  42. package/dist/plugins/mailbox-store-sql.d.ts +6 -3
  43. package/dist/plugins/mailbox-store-sql.js +45 -43
  44. package/dist/plugins/memory-embedder-fingerprint.js +8 -12
  45. package/dist/plugins/memory-engine-pg.js +34 -16
  46. package/dist/plugins/memory-engine-tidb.js +19 -12
  47. package/dist/plugins/memory-engine-vector-util.d.ts +26 -0
  48. package/dist/plugins/memory-engine-vector-util.js +3 -0
  49. package/dist/plugins/memory-sync-store-pg.d.ts +6 -0
  50. package/dist/plugins/memory-sync-store-pg.js +14 -10
  51. package/dist/plugins/memory-sync-store-tidb.js +7 -11
  52. package/dist/plugins/pg-session-storage.js +26 -28
  53. package/dist/plugins/session-event-payload.d.ts +37 -0
  54. package/dist/plugins/session-event-payload.js +20 -0
  55. package/dist/plugins/sql-json-column.d.ts +124 -22
  56. package/dist/plugins/sql-json-column.js +22 -1
  57. package/dist/plugins/sql-row-helpers.d.ts +10 -16
  58. package/dist/plugins/sql-row-helpers.js +0 -29
  59. package/dist/plugins/store-backend.js +10 -12
  60. package/dist/plugins/store-contracts.d.ts +4 -6
  61. package/dist/plugins/store-contracts.js +0 -60
  62. package/dist/plugins/task-attachment-store.d.ts +21 -12
  63. package/dist/plugins/task-attachment-store.js +12 -0
  64. package/dist/plugins/tidb-session-storage.d.ts +2 -2
  65. package/dist/plugins/tidb-session-storage.js +12 -11
  66. package/dist/plugins/tidb-session-store.d.ts +2 -2
  67. package/dist/plugins/tidb-session-store.js +20 -22
  68. package/dist/plugins/usage-window-store-sql.d.ts +9 -1
  69. package/dist/plugins/usage-window-store-sql.js +23 -9
  70. package/dist/plugins/workflow-journal-store-sql.js +16 -4
  71. package/dist/run-local.js +4 -10
  72. package/dist/usage-analytics.d.ts +14 -1
  73. package/dist/usage-analytics.js +11 -17
  74. package/package.json +2 -2
@@ -1,17 +1,41 @@
1
+ import { z } from "zod";
1
2
  import { ASK_USER_QUESTION_TOOL_NAME, contentKindOf } from "../approval-content-kind.js";
2
3
  import { createHash } from "node:crypto";
3
- import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
4
+ import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { isApprovalGateKind } from "../tool-approval.js";
6
7
  import { FileCheckpointStore, atomicWriteFile, executionVerdict, sanitizePathComponent, } from "@sema-agent/core";
7
8
  import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, pendingActionToolCallId, pendingRowPrecedes, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
9
+ import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
10
+ import { createZodColumnReader, decodeJsonColumn, readStringArrayColumn } from "./sql-json-column.js";
8
11
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
12
+ import { assertJsonColumnWritable, createObjectColumnReader } from "./sql-json-column.js";
9
13
  function terminalAtOf(cp) {
10
14
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
11
15
  }
12
16
  function ctxFileOf(sessionId) {
13
17
  return `${sanitizePathComponent(sessionId).slice(0, 48)}-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}.json`;
14
18
  }
19
+ const CtxRecordSchema = z.object({
20
+ sessionId: z.string(),
21
+ updatedAt: z.number(),
22
+ ctx: z.unknown(),
23
+ });
24
+ const readCtxRecord = createZodColumnReader(CtxRecordSchema);
25
+ const readScopeRegistry = readStringArrayColumn;
26
+ const readSessionTokenIndex = createZodColumnReader(z.record(z.string(), z.array(z.string())));
27
+ function createCtxRecordReader() {
28
+ const readPayload = createObjectColumnReader();
29
+ return (body) => {
30
+ const envelope = readCtxRecord(body);
31
+ if (envelope.problem !== undefined)
32
+ return { problem: envelope.problem };
33
+ const payload = readPayload(envelope.value.ctx);
34
+ if (payload.problem !== undefined)
35
+ return { problem: `its \`ctx\` member is not a JSON object (${payload.problem})` };
36
+ return { value: { ...envelope.value, ctx: payload.value } };
37
+ };
38
+ }
15
39
  export class LocalCheckpointStore {
16
40
  retention = UNMANAGED_RETENTION;
17
41
  durability = "durable";
@@ -37,37 +61,23 @@ export class LocalCheckpointStore {
37
61
  mkdirSync(this.tmpDir, { recursive: true });
38
62
  if (existsSync(this.scopesPath)) {
39
63
  try {
40
- const arr = JSON.parse(readFileSync(this.scopesPath, "utf8"));
41
- if (Array.isArray(arr))
42
- for (const s of arr)
43
- if (typeof s === "string")
44
- this.scopes.add(s);
64
+ for (const s of decodeJsonColumn(readFileSync(this.scopesPath, "utf8"), readScopeRegistry, "checkpoint-scopes.json (the local scope registry)"))
65
+ this.scopes.add(s);
45
66
  }
46
67
  catch (e) {
47
- const quarantine = `${this.scopesPath}.corrupt-${Date.now()}`;
48
- try {
49
- renameSync(this.scopesPath, quarantine);
50
- }
51
- catch {
52
- }
68
+ quarantineUnreadableSidecar(this.scopesPath, "LocalCheckpointStore scope registry", e instanceof Error ? e.message : String(e));
53
69
  this.scopeRegistryCorrupt = true;
54
- console.warn(`LocalCheckpointStore: checkpoint-scopes.json is corrupt (${e instanceof Error ? e.message : String(e)}) — ` +
55
- `will rebuild the scope set from the data plane on first read (core listScopes); ` +
56
- `file quarantined at ${quarantine}`);
70
+ console.warn("LocalCheckpointStore: will rebuild the scope set from the data plane on first read (core listScopes) — enumeration is EMPTY until then");
57
71
  }
58
72
  }
59
73
  if (existsSync(this.sessionTokensPath)) {
60
74
  try {
61
- const rec = JSON.parse(readFileSync(this.sessionTokensPath, "utf8"));
62
- if (rec && typeof rec === "object" && !Array.isArray(rec)) {
63
- for (const [sid, toks] of Object.entries(rec)) {
64
- if (!Array.isArray(toks))
65
- continue;
66
- this.sessionTokens.set(sid, new Set(toks.filter((t) => typeof t === "string")));
67
- }
68
- }
75
+ const rec = decodeJsonColumn(readFileSync(this.sessionTokensPath, "utf8"), readSessionTokenIndex, "checkpoint-session-tokens.json (the local session→tokens index)");
76
+ for (const [sid, toks] of Object.entries(rec))
77
+ this.sessionTokens.set(sid, new Set(toks));
69
78
  }
70
- catch {
79
+ catch (e) {
80
+ quarantineUnreadableSidecar(this.sessionTokensPath, "LocalCheckpointStore session→tokens index", e instanceof Error ? e.message : String(e));
71
81
  }
72
82
  }
73
83
  }
@@ -298,19 +308,16 @@ export class LocalCheckpointStore {
298
308
  }
299
309
  async putCtx(sessionId, ctx) {
300
310
  const rec = { sessionId, updatedAt: Date.now(), ctx };
301
- atomicWriteFile(this.tmpDir, join(this.ctxDir, ctxFileOf(sessionId)), JSON.stringify(rec));
311
+ const text = JSON.stringify(rec);
312
+ assertJsonColumnWritable(text, createCtxRecordReader(), `checkpoint ctx for session ${JSON.stringify(sessionId)} (local write leg)`);
313
+ atomicWriteFile(this.tmpDir, join(this.ctxDir, ctxFileOf(sessionId)), text);
302
314
  }
303
315
  async getCtx(sessionId) {
304
316
  const p = join(this.ctxDir, ctxFileOf(sessionId));
305
317
  if (!existsSync(p))
306
318
  return null;
307
- try {
308
- const rec = JSON.parse(readFileSync(p, "utf8"));
309
- return rec.sessionId === sessionId ? (rec.ctx ?? null) : null;
310
- }
311
- catch {
312
- return null;
313
- }
319
+ const rec = decodeJsonColumn(readFileSync(p, "utf8"), createCtxRecordReader(), `checkpoint-ctx ${ctxFileOf(sessionId)} for session ${JSON.stringify(sessionId)}`);
320
+ return rec.sessionId === sessionId ? rec.ctx : null;
314
321
  }
315
322
  async reapCtx(cutoff) {
316
323
  let n = 0;
@@ -320,7 +327,7 @@ export class LocalCheckpointStore {
320
327
  const p = join(this.ctxDir, f);
321
328
  let rec;
322
329
  try {
323
- rec = JSON.parse(readFileSync(p, "utf8"));
330
+ rec = decodeJsonColumn(readFileSync(p, "utf8"), readCtxRecord, `checkpoint-ctx ${f} (reap scan)`);
324
331
  }
325
332
  catch {
326
333
  continue;
@@ -107,6 +107,20 @@ export declare class LocalSessionStore implements SessionStore {
107
107
  private doFlush;
108
108
  /** Throttled best-effort warn for a sidecar I/O failure (bug D): never throws, at most once per ~minute so a
109
109
  * persistently-failing disk doesn't spam the log on every debounced flush. */
110
+ /**
111
+ * 读不出一份边档时的**唯一**处置(两只 sidecar 共用)——先**留证**再降级。
112
+ *
113
+ * 🔴 为什么留证是必需的而不是洁癖(codex 对抗复审一轮 [medium],亲验为真;红先在
114
+ * `test/local-store-bad-body.test.ts`):两只边档都是**整档原子替换**的写法,所以「读不出 ⇒ 空表 +
115
+ * 继续服务」之后,**下一次写就把原档盖掉了**。对一份整份是垃圾的体无所谓;对一份只有某一格值型
116
+ * 不对的**混合体**,那等于把一次局部的、可修的损坏放大成一次全量的、不可逆的丢失。判据不变
117
+ * (半份值仍然不许端出去),改的是拒的那一刻。
118
+ *
119
+ * 两条 warn 各说一件事:{@link quarantineUnreadableSidecar} 说「字节挪到哪了」(每次都说,因为它稀有
120
+ * 且不可重现),{@link warnSidecar} 说「这条腿降级了」(节流,因为写失败可能每拍都发生)。
121
+ * ENOENT(首次启动没有这份档)在调用点就被排除,压根到不了这里。
122
+ */
123
+ private refuseSidecar;
110
124
  private warnSidecar;
111
125
  acquire(sessionId?: string, opts?: {
112
126
  requireExisting?: boolean;
@@ -2,6 +2,8 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { SessionError, validateEntriesForImport } from "@sema-agent/core";
4
4
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
5
+ import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
6
+ import { decodeJsonColumn, readStringRecordColumn } from "./sql-json-column.js";
5
7
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
6
8
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
7
9
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
@@ -62,15 +64,15 @@ export class LocalSessionStore {
62
64
  const path = this.titleSidecarPath;
63
65
  st.loaded = (async () => {
64
66
  try {
65
- const parsed = JSON.parse(await readFile(path, "utf8"));
67
+ const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, "session-titles.json (the local title sidecar)");
66
68
  for (const [id, t] of Object.entries(parsed)) {
67
- if (typeof t === "string" && !st.titles.has(id))
69
+ if (!st.titles.has(id))
68
70
  st.titles.set(id, t);
69
71
  }
70
72
  }
71
73
  catch (e) {
72
74
  if (e?.code !== "ENOENT")
73
- this.warnSidecar("read", e);
75
+ this.refuseSidecar(path, "LocalSessionStore title sidecar", e);
74
76
  }
75
77
  })();
76
78
  }
@@ -113,16 +115,15 @@ export class LocalSessionStore {
113
115
  const path = this.sidecarPath;
114
116
  this.loaded = (async () => {
115
117
  try {
116
- const raw = await readFile(path, "utf8");
117
- const parsed = JSON.parse(raw);
118
+ const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, "session-last-activity.json (the local recency sidecar)");
118
119
  for (const [id, ts] of Object.entries(parsed)) {
119
- if (typeof ts === "string" && !this.lastActivity.has(id))
120
+ if (!this.lastActivity.has(id))
120
121
  this.lastActivity.set(id, ts);
121
122
  }
122
123
  }
123
124
  catch (e) {
124
125
  if (e?.code !== "ENOENT")
125
- this.warnSidecar("read", e);
126
+ this.refuseSidecar(path, "LocalSessionStore lastActivity sidecar", e);
126
127
  }
127
128
  })();
128
129
  }
@@ -164,6 +165,10 @@ export class LocalSessionStore {
164
165
  if (this.dirty)
165
166
  await this.doFlush();
166
167
  }
168
+ refuseSidecar(path, what, e) {
169
+ quarantineUnreadableSidecar(path, what, e instanceof Error ? e.message : String(e));
170
+ this.warnSidecar("read", e);
171
+ }
167
172
  warnSidecar(op, e) {
168
173
  const now = Date.now();
169
174
  if (now - this.lastWarnAt < 60_000)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 把一份读不出形的档挪开并**大声说**。永不抛(它挂在降级臂上,不许把降级变成故障)。
3
+ *
4
+ * @param path 原档路径。
5
+ * @param what 给人读的一句话:这是什么档、读不出会怎样(进 warn 文案)。
6
+ * @param reason 判形失败的原因(属主的错误文案 / 异常消息)。
7
+ * @returns 隔离档的路径;挪不动时 `undefined`(此时 warn 里已说清)。
8
+ */
9
+ export declare function quarantineUnreadableSidecar(path: string, what: string, reason: string): string | undefined;
10
+ //# sourceMappingURL=local-sidecar-quarantine.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { renameSync } from "node:fs";
2
+ export function quarantineUnreadableSidecar(path, what, reason) {
3
+ const target = `${path}.corrupt-${Date.now()}-${process.pid}`;
4
+ try {
5
+ renameSync(path, target);
6
+ }
7
+ catch (e) {
8
+ console.warn(`${what}: ${path} is unreadable (${reason}) and could NOT be quarantined (${e instanceof Error ? e.message : String(e)}) — ` +
9
+ `the bytes are still at that path; a later write to it may replace them`);
10
+ return undefined;
11
+ }
12
+ console.warn(`${what}: ${path} is unreadable (${reason}) — quarantined at ${target} so the next write cannot replace it`);
13
+ return target;
14
+ }
15
+ //# sourceMappingURL=local-sidecar-quarantine.js.map
@@ -1,4 +1,4 @@
1
- import type { TaskAttachmentStore, TaskAttachmentRecord } from "./task-attachment-store.js";
1
+ import { type TaskAttachmentStore, type TaskAttachmentRecord } from "./task-attachment-store.js";
2
2
  export declare class LocalTaskAttachmentStore implements TaskAttachmentStore {
3
3
  private readonly dir;
4
4
  private metas;
@@ -1,6 +1,9 @@
1
1
  import { promises as fsp } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { TaskAttachmentRecordSchema } from "./task-attachment-store.js";
4
+ import { createZodColumnReader, decodeJsonColumn } from "./sql-json-column.js";
3
5
  const ID_RE = /^[0-9a-f-]{16,64}$/i;
6
+ const readAttachmentMeta = createZodColumnReader(TaskAttachmentRecordSchema);
4
7
  export class LocalTaskAttachmentStore {
5
8
  dir;
6
9
  metas;
@@ -19,8 +22,8 @@ export class LocalTaskAttachmentStore {
19
22
  if (!ID_RE.test(id))
20
23
  continue;
21
24
  try {
22
- const meta = JSON.parse(await fsp.readFile(join(this.dir, f), "utf8"));
23
- if (meta && meta.id === id)
25
+ const meta = decodeJsonColumn(await fsp.readFile(join(this.dir, f), "utf8"), readAttachmentMeta, `attachments/${f} (task attachment meta)`);
26
+ if (meta.id === id)
24
27
  m.set(id, meta);
25
28
  }
26
29
  catch {
@@ -55,9 +55,12 @@ export declare const MAILBOX_MSG_TABLE = "mailbox_message";
55
55
  * —— `mailbox` 行是**盒**事实(next_seq / lease),墓碑是**收件人**事实,两者的生命周期本来就不同
56
56
  * (`drop` 同时终结两者 —— 墓碑随箱亡,理由全文见 `mailbox-recipient-lifecycle.ts` 模块头)。 */
57
57
  export declare const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
58
- /** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]:坏 `peer_meta` 行的 fail-closed 抛必须**响亮**——core 唯一的
59
- * claimLease 调用方(peer-session-drain)吞掉该错,店不记这一笔就成了「静默永久卡箱」。抛之前记 `mailbox_peer_meta_corrupt`
60
- * (scope/handle/seq/code),运维按 seq 修行。 */
58
+ /** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]:坏行的 fail-closed 抛必须**响亮**——core 唯一的
59
+ * claimLease 调用方(peer-session-drain)吞掉该错,店不记这一笔就成了「静默永久卡箱」。抛之前记
60
+ * `mailbox_message_column_corrupt`(scope/handle/seq/column/code),运维按 seq 修行。
61
+ * 🔴 S-550 车③a **改名并归一**:旧事件名 `mailbox_peer_meta_corrupt` 在 `hop_chain` 也变成 fail-closed 的
62
+ * 那一刻就说不出真话了(同一条读腿两列同判)。一个事件 + 一个 `column` 字段,而不是两个事件名 —— 运维
63
+ * 面按 column 分桶,店里只有一条留痕规则。 */
61
64
  export interface MailboxStoreLogger {
62
65
  warn(event: string, fields: Record<string, unknown>): void;
63
66
  }
@@ -2,6 +2,7 @@ import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_TOMBSTONED_RECIPIENT_CODE, Mail
2
2
  import { pgHasUnstorable, pgSanitizeText, PgUnstorableError } from "./pg-safe-json.js";
3
3
  import { ensureIndexes } from "./ensure-index.js";
4
4
  import { escapeLike } from "./sql-escape.js";
5
+ import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readStringArrayColumn } from "./sql-json-column.js";
5
6
  export const MAILBOX_TABLE = "mailbox";
6
7
  export const MAILBOX_MSG_TABLE = "mailbox_message";
7
8
  export const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
@@ -46,19 +47,13 @@ function encodeHopChain(hopChain) {
46
47
  }
47
48
  return text;
48
49
  }
49
- function decodeHopChain(raw) {
50
- if (raw === null || raw === undefined)
51
- return undefined;
52
- let parsed;
50
+ function decodeHopChain(raw, seq) {
53
51
  try {
54
- parsed = JSON.parse(bufToStr(raw));
52
+ return decodeNullableJsonColumn(raw, readStringArrayColumn, `${MAILBOX_MSG_TABLE}.hop_chain for seq ${String(seq)}`);
55
53
  }
56
- catch {
57
- return undefined;
54
+ catch (e) {
55
+ throw foldJsonColumn(e, (m) => new Error(`MailboxStore: ${m} — refusing the lease rather than let a message skip peer admission (an absent chain makes core's judgePeerAdmission not run at all)`));
58
56
  }
59
- if (!Array.isArray(parsed) || parsed.some((x) => typeof x !== "string"))
60
- return undefined;
61
- return parsed;
62
57
  }
63
58
  const PEER_META_MAX_BYTES = 60_000;
64
59
  function encodePeerMeta(raw) {
@@ -71,52 +66,59 @@ function encodePeerMeta(raw) {
71
66
  }
72
67
  return text;
73
68
  }
74
- function decodePeerMeta(raw) {
75
- if (raw === null || raw === undefined)
76
- return undefined;
77
- const text = bufToStr(raw);
78
- let parsed;
69
+ function decodePeerMeta(raw, seq) {
79
70
  try {
80
- parsed = JSON.parse(text);
71
+ return decodeNullableJsonColumn(raw, readPeerMetaColumn, `${MAILBOX_MSG_TABLE}.peer_meta for seq ${String(seq)}`);
81
72
  }
82
- catch (err) {
83
- throw new MailboxStoreError(MAILBOX_INVALID_PEER_META_CODE, `MailboxStore: stored peer_meta is not JSON (refusing the lease — a corrupt typed record must not be read as "no metadata"): ${err instanceof Error ? err.message : String(err)}`);
73
+ catch (e) {
74
+ if (e instanceof MailboxStoreError)
75
+ throw e;
76
+ throw foldJsonColumn(e, (m) => new MailboxStoreError(MAILBOX_INVALID_PEER_META_CODE, `MailboxStore: ${m} — refusing the lease rather than read a corrupt typed record as "no metadata"`));
84
77
  }
78
+ }
79
+ const readPeerMetaColumn = (body) => {
85
80
  try {
86
- return readMailboxPeerMeta(parsed);
81
+ const meta = readMailboxPeerMeta(body);
82
+ return meta === undefined ? { problem: "the JSON literal `null` — absence is SQL NULL on this column, so a written `null` can only be a hand edit" } : { value: meta };
87
83
  }
88
84
  catch (err) {
89
85
  if (err instanceof MailboxStoreError)
90
86
  throw err;
91
- throw new MailboxStoreError(MAILBOX_INVALID_PEER_META_CODE, `MailboxStore: stored peer_meta failed validation (refusing the lease): ${err instanceof Error ? err.message : String(err)}`);
87
+ return { problem: err instanceof Error ? err.message : String(err) };
92
88
  }
93
- }
89
+ };
94
90
  function rowsToMessages(rows, scope, handle, logger) {
95
91
  const out = [];
96
92
  for (const r of rows) {
97
- try {
98
- out.push(rowToMessage(r));
99
- }
100
- catch (err) {
101
- if (err instanceof MailboxStoreError)
102
- logger?.warn("mailbox_peer_meta_corrupt", { scope, handle, seq: Number(r.seq), code: err.code, note: "claimLease refused for the whole box (fail-closed); repair or NULL the peer_meta column of this row" });
103
- throw err;
104
- }
93
+ const read = (column, decode) => {
94
+ try {
95
+ return decode();
96
+ }
97
+ catch (err) {
98
+ logger?.warn("mailbox_message_column_corrupt", {
99
+ scope,
100
+ handle,
101
+ seq: Number(r.seq),
102
+ column,
103
+ ...(err instanceof MailboxStoreError ? { code: err.code } : {}),
104
+ note: "claimLease refused for the whole box (fail-closed); repair or NULL that column of this row",
105
+ });
106
+ throw err;
107
+ }
108
+ };
109
+ const hopChain = read("hop_chain", () => decodeHopChain(r.hop_chain, r.seq));
110
+ const peerMeta = read("peer_meta", () => decodePeerMeta(r.peer_meta, r.seq));
111
+ out.push({
112
+ seq: Number(r.seq),
113
+ ...(r.from_id !== null && r.from_id !== undefined ? { from: bufToStr(r.from_id) } : {}),
114
+ content: String(r.content),
115
+ sentAt: Number(r.sent_at_ms),
116
+ ...(hopChain !== undefined ? { hopChain } : {}),
117
+ ...(peerMeta !== undefined ? { peerMeta } : {}),
118
+ });
105
119
  }
106
120
  return out;
107
121
  }
108
- function rowToMessage(r) {
109
- const hopChain = decodeHopChain(r.hop_chain);
110
- const peerMeta = decodePeerMeta(r.peer_meta);
111
- return {
112
- seq: Number(r.seq),
113
- ...(r.from_id !== null && r.from_id !== undefined ? { from: bufToStr(r.from_id) } : {}),
114
- content: String(r.content),
115
- sentAt: Number(r.sent_at_ms),
116
- ...(hopChain !== undefined ? { hopChain } : {}),
117
- ...(peerMeta !== undefined ? { peerMeta } : {}),
118
- };
119
- }
120
122
  export async function ensureTiDBMailboxSchema(pool) {
121
123
  await pool.query(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TABLE} (
122
124
  scope_key VARBINARY(190) NOT NULL,
@@ -250,7 +252,7 @@ export class TiDBMailboxStore {
250
252
  bufToStr(box.lease_owner) !== owner) {
251
253
  return null;
252
254
  }
253
- const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, hop_chain, peer_meta FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
255
+ const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("tidb", "hop_chain")}, ${jsonColumnText("tidb", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
254
256
  if (msgs.length === 0)
255
257
  return null;
256
258
  const maxSeq = Number(msgs[msgs.length - 1].seq);
@@ -397,7 +399,7 @@ export class PgMailboxStore {
397
399
  const box = boxes[0];
398
400
  if (box.lease_owner !== null && Number(box.lease_expires_at_ms) > now && String(box.lease_owner) !== owner)
399
401
  return null;
400
- const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, hop_chain, peer_meta FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
402
+ const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("pg", "hop_chain")}, ${jsonColumnText("pg", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
401
403
  if (msgs.length === 0)
402
404
  return null;
403
405
  const maxSeq = Number(msgs[msgs.length - 1].seq);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { PG_MEMORY_ENGINE_TABLES } from "./memory-engine-pg.js";
3
+ import { createZodColumnReader, decodeJsonColumn, jsonColumnText, SqlJsonColumnError } from "./sql-json-column.js";
3
4
  export const PG_MEMORY_ENGINE_META_TABLE = "agent_memory_engine_meta";
4
5
  export const EMBEDDER_IDENTITY_META_KEY = "embedder_identity";
5
6
  export const CAS_MAX_ROUNDS = 3;
@@ -13,28 +14,23 @@ export async function ensurePgMemoryEmbedderMetaSchema(query) {
13
14
  meta_value jsonb NOT NULL
14
15
  )`);
15
16
  }
17
+ const readEmbedderIdentityColumn = createZodColumnReader(EmbedderIdentitySchema);
16
18
  function parseStoredText(text) {
17
- let candidate;
18
19
  try {
19
- candidate = JSON.parse(text);
20
+ return { kind: "ok", value: decodeJsonColumn(text, readEmbedderIdentityColumn, `${PG_MEMORY_ENGINE_META_TABLE}.meta_value for key ${EMBEDDER_IDENTITY_META_KEY}`) };
20
21
  }
21
22
  catch (err) {
22
- return { kind: "unreadable", raw: `${text.slice(0, 160)} [JSON.parse: ${String(err).slice(0, 80)}]` };
23
+ if (!(err instanceof SqlJsonColumnError))
24
+ throw err;
25
+ return { kind: "unreadable", raw: `${text.slice(0, 160)} [${err.problem}: ${err.message.slice(0, 120)}]` };
23
26
  }
24
- if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
25
- return { kind: "unreadable", raw: `${text.slice(0, 160)} [not a JSON object]` };
26
- }
27
- const parsed = EmbedderIdentitySchema.safeParse(candidate);
28
- if (!parsed.success)
29
- return { kind: "unreadable", raw: text.slice(0, 200) };
30
- return { kind: "ok", value: parsed.data };
31
27
  }
32
28
  async function readStored(query) {
33
- const r = await query(`SELECT meta_value::text AS meta_value_text FROM ${PG_MEMORY_ENGINE_META_TABLE} WHERE meta_key = $1`, [EMBEDDER_IDENTITY_META_KEY]);
29
+ const r = await query(`SELECT ${jsonColumnText("pg", "meta_value")} FROM ${PG_MEMORY_ENGINE_META_TABLE} WHERE meta_key = $1`, [EMBEDDER_IDENTITY_META_KEY]);
34
30
  const row = r.rows[0];
35
31
  if (row === undefined)
36
32
  return { kind: "absent" };
37
- const text = row.meta_value_text;
33
+ const text = row.meta_value;
38
34
  if (typeof text !== "string")
39
35
  return { kind: "unreadable", raw: `[meta_value::text did not return text: ${typeof text}]` };
40
36
  return parseStoredText(text);
@@ -1,5 +1,7 @@
1
1
  import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
2
- import { isUniqueViolation } from "./memory-engine-vector-util.js";
2
+ import { isUniqueViolation, readEmbeddingColumn, readFrontmatterColumn } from "./memory-engine-vector-util.js";
3
+ import { decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText } from "./sql-json-column.js";
4
+ import { recordFailOpen } from "../observability/fail-open.js";
3
5
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
4
6
  import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
5
7
  import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js";
@@ -44,8 +46,24 @@ function vectorLiteral(v) {
44
46
  function inPlaceholders(first, count) {
45
47
  return Array.from({ length: count }, (_, i) => `$${first + i}`).join(", ");
46
48
  }
47
- function fromJson(v) {
48
- return (typeof v === "string" ? JSON.parse(v) : v);
49
+ function readFrontmatter(cell, id) {
50
+ try {
51
+ return decodeJsonColumn(cell, readFrontmatterColumn, `${PG_MEMORY_ENGINE_TABLES.entry}.frontmatter for entry ${String(id)}`);
52
+ }
53
+ catch (e) {
54
+ throw foldJsonColumn(e, (m) => new Error(`memory-engine: ${m} — refusing rather than serve a note whose frontmatter (the origin/exposure law carrier) nobody can read`));
55
+ }
56
+ }
57
+ function readEmbedding(cell, id) {
58
+ try {
59
+ return decodeNullableJsonColumn(cell, readEmbeddingColumn, `${PG_MEMORY_ENGINE_TABLES.entry}.embedding for entry ${String(id)}`);
60
+ }
61
+ catch (e) {
62
+ if (!isJsonColumnBodyProblem(e))
63
+ throw e;
64
+ recordFailOpen("server.sql-json.memory-engine.embedding");
65
+ return undefined;
66
+ }
49
67
  }
50
68
  export class PgMemoryEngineBackend {
51
69
  query;
@@ -78,7 +96,7 @@ export class PgMemoryEngineBackend {
78
96
  return this.pgvectorSql ? vectorLiteral(v) : pgSafeJsonStringify(v);
79
97
  }
80
98
  headerFromRow(r) {
81
- const fm = fromJson(r.frontmatter);
99
+ const fm = readFrontmatter(r.frontmatter, r.id);
82
100
  return {
83
101
  id: r.id,
84
102
  slug: r.slug,
@@ -98,7 +116,7 @@ export class PgMemoryEngineBackend {
98
116
  id: r.id,
99
117
  slug: r.slug,
100
118
  scope: r.scope,
101
- frontmatter: fromJson(r.frontmatter),
119
+ frontmatter: readFrontmatter(r.frontmatter, r.id),
102
120
  body: r.body,
103
121
  rev: r.rev,
104
122
  };
@@ -107,14 +125,14 @@ export class PgMemoryEngineBackend {
107
125
  const uniq = [...new Set(scopes)];
108
126
  if (uniq.length === 0)
109
127
  return [];
110
- const res = await this.query(`SELECT id, scope, slug, frontmatter, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
128
+ const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
111
129
  return res.rows.map((r) => this.headerFromRow(r));
112
130
  }
113
131
  async getByIds(ids) {
114
132
  const uniq = [...new Set(ids)];
115
133
  if (uniq.length === 0)
116
134
  return [];
117
- const res = await this.query(`SELECT id, scope, slug, frontmatter, body, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
135
+ const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, body, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
118
136
  return res.rows.map((r) => this.entryFromRow(r));
119
137
  }
120
138
  async search(query, scopes, opts) {
@@ -132,7 +150,7 @@ export class PgMemoryEngineBackend {
132
150
  if (Array.isArray(qv) && qv.length === this.embedder.dimensions) {
133
151
  const vecParam = scopes.length + 1;
134
152
  const bandExpr = bands ? `(CASE WHEN ${originMarkedSqlPredicate("frontmatter")} THEN 1 ELSE 0 END) ASC, ` : "";
135
- const res = await this.query(`SELECT id, scope, slug, frontmatter, rev, mtime_ms, size_bytes, embedding <=> $${vecParam}::vector AS dist
153
+ const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, rev, mtime_ms, size_bytes, embedding <=> $${vecParam}::vector AS dist
136
154
  FROM ${PG_MEMORY_ENGINE_TABLES.entry}
137
155
  WHERE scope IN (${inPlaceholders(1, scopes.length)}) AND embedding IS NOT NULL
138
156
  ORDER BY ${bandExpr}embedding <=> $${vecParam}::vector ASC, id ASC
@@ -141,16 +159,16 @@ export class PgMemoryEngineBackend {
141
159
  return hits.sort(contractOrder);
142
160
  }
143
161
  }
144
- const res = await this.query(`SELECT id, scope, slug, frontmatter, body, rev, mtime_ms, size_bytes, embedding FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, scopes.length)})`, [...scopes]);
162
+ const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, body, rev, mtime_ms, size_bytes, ${jsonColumnText("pg", "embedding")} FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, scopes.length)})`, [...scopes]);
145
163
  const queryVec = this.embedder ? await this.embedder.embed(query).catch(() => null) : null;
146
164
  const dims = this.embedder?.dimensions;
147
165
  const scored = [];
148
166
  for (const raw of res.rows) {
149
167
  const r = raw;
150
- const fm = fromJson(r.frontmatter);
168
+ const fm = readFrontmatter(r.frontmatter, r.id);
151
169
  if (queryVec && Array.isArray(queryVec) && queryVec.length === dims && r.embedding != null) {
152
- const stored = fromJson(r.embedding);
153
- if (Array.isArray(stored) && stored.length === dims) {
170
+ const stored = readEmbedding(r.embedding, r.id);
171
+ if (stored !== undefined && stored.length === dims) {
154
172
  scored.push({ ...this.headerFromRow(r), score: cosineDistance(queryVec, stored) });
155
173
  continue;
156
174
  }
@@ -233,10 +251,10 @@ export class PgMemoryEngineBackend {
233
251
  return;
234
252
  }
235
253
  const entry = rawEntry;
236
- const prior = await this.query(`SELECT scope, slug, rev, frontmatter FROM ${T} WHERE id = $1`, [entry.id]);
254
+ const prior = await this.query(`SELECT scope, slug, rev, ${jsonColumnText("pg", "frontmatter")} FROM ${T} WHERE id = $1`, [entry.id]);
237
255
  const priorRow = prior.rows.length > 0 ? prior.rows[0] : undefined;
238
256
  {
239
- const committedBaseFm = rememberBatchCommitted(originBaseline, entry.id, priorRow !== undefined ? fromJson(priorRow.frontmatter) : undefined);
257
+ const committedBaseFm = rememberBatchCommitted(originBaseline, entry.id, priorRow !== undefined ? readFrontmatter(priorRow.frontmatter, entry.id) : undefined);
240
258
  const refusal = originWhitewashRefusal("add", committedBaseFm, entry.frontmatter);
241
259
  if (refusal !== undefined) {
242
260
  report.conflicts.push({ op: "add", id: entry.id, reason: refusal });
@@ -304,7 +322,7 @@ export class PgMemoryEngineBackend {
304
322
  slug = `${entry.slug}-${n}`;
305
323
  }
306
324
  }
307
- const cur = await this.query(`SELECT slug, rev, scope, frontmatter FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id = $1`, [patch.id]);
325
+ const cur = await this.query(`SELECT slug, rev, scope, ${jsonColumnText("pg", "frontmatter")} FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id = $1`, [patch.id]);
308
326
  if (cur.rows.length === 0) {
309
327
  report.conflicts.push({ op: patch.op, id: patch.id, reason: "unknown id (entry not found in backend)" });
310
328
  return;
@@ -312,7 +330,7 @@ export class PgMemoryEngineBackend {
312
330
  const currentRev = String(cur.rows[0].rev);
313
331
  const currentSlug = String(cur.rows[0].slug);
314
332
  const currentScope = String(cur.rows[0].scope);
315
- const committedFm = fromJson(cur.rows[0].frontmatter);
333
+ const committedFm = readFrontmatter(cur.rows[0].frontmatter, patch.id);
316
334
  const committedBaseFm = rememberBatchCommitted(originBaseline, patch.id, committedFm);
317
335
  if (patch.op === "update" && patch.entry !== undefined) {
318
336
  if (committedFm.provenance?.kind === "repo_file") {