@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,9 +1,10 @@
1
1
  import { uuidv7 } from "@sema-agent/core";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { pgSanitizeText, pgHasUnstorable } from "./pg-safe-json.js";
4
- import { parseJsonOr as parseJson, toIso as iso } from "./sql-row-helpers.js";
4
+ import { toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
5
5
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
- import { mapBakeRow as mapRow, BAKE_SELECT_COLS as SELECT_COLS, } from "./store-contracts.js";
6
+ import { decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn, readStringArrayColumn, } from "./sql-json-column.js";
7
+ import {} from "./store-contracts.js";
7
8
  import { isDupKeyError } from "./sql-errors.js";
8
9
  import { ensureIndexes } from "./ensure-index.js";
9
10
  export const PG_IMAGE_BAKE_SCHEMA = [
@@ -61,6 +62,77 @@ export const PG_IMAGE_BAKE_SCHEMA = [
61
62
  PRIMARY KEY (pool)
62
63
  )`,
63
64
  ];
65
+ function bakeSelectCols(dialect) {
66
+ return [
67
+ "bake_id",
68
+ "profile",
69
+ jsonColumnText(dialect, "bands"),
70
+ "base_ref",
71
+ "push",
72
+ "dry_run",
73
+ "logs",
74
+ jsonColumnText(dialect, "argv"),
75
+ "status",
76
+ "state",
77
+ "digest",
78
+ "repo",
79
+ "ref",
80
+ "index_id",
81
+ "exit_code",
82
+ "error",
83
+ "error_code",
84
+ "manifest_sha",
85
+ "tag",
86
+ "idem_key",
87
+ "ingest_secret",
88
+ "runner_id",
89
+ "lease_until",
90
+ "cancel_requested",
91
+ "requested_by",
92
+ "created_at",
93
+ "updated_at",
94
+ ].join(", ");
95
+ }
96
+ function mapRow(r) {
97
+ return {
98
+ bakeId: r.bake_id,
99
+ profile: r.profile,
100
+ bands: decodeNullableJsonColumn(r.bands, readStringArrayColumn, bakeWhere("bands", r.bake_id)) ?? null,
101
+ baseRef: r.base_ref,
102
+ push: !!r.push,
103
+ dryRun: !!r.dry_run,
104
+ logs: !!r.logs,
105
+ argv: decodeJsonColumn(r.argv, readStringArrayColumn, bakeWhere("argv", r.bake_id)),
106
+ status: r.status,
107
+ state: r.state ?? null,
108
+ digest: r.digest,
109
+ repo: r.repo,
110
+ ref: r.ref,
111
+ indexId: r.index_id,
112
+ exitCode: r.exit_code === null ? null : Number(r.exit_code),
113
+ error: r.error,
114
+ errorCode: r.error_code ?? null,
115
+ manifestSha: r.manifest_sha,
116
+ tag: r.tag,
117
+ idemKey: r.idem_key,
118
+ ingestSecret: r.ingest_secret,
119
+ runnerId: r.runner_id,
120
+ leaseUntil: isoOrNull(r.lease_until),
121
+ cancelRequested: !!r.cancel_requested,
122
+ requestedBy: r.requested_by,
123
+ createdAt: iso(r.created_at),
124
+ updatedAt: iso(r.updated_at),
125
+ };
126
+ }
127
+ const bakeWhere = (column, bakeId) => `image_bake.${column} for bake ${bakeId}`;
128
+ function refusingBakeRow(read) {
129
+ try {
130
+ return read();
131
+ }
132
+ catch (e) {
133
+ throw foldJsonColumn(e, (m) => new Error(`image-bake-store: ${m} — refusing rather than serve a bake row nobody can read`));
134
+ }
135
+ }
64
136
  export const IMAGE_BAKE_INDEXES = [
65
137
  { table: "image_bake", name: "idx_image_bake_status", columns: ["status"] },
66
138
  { table: "image_bake", name: "idx_image_bake_created", columns: ["created_at"] },
@@ -183,23 +255,23 @@ export class SqlImageBake {
183
255
  return { created: true, bake };
184
256
  }
185
257
  async getBake(bakeId) {
186
- const { rows } = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM image_bake WHERE bake_id = ? LIMIT 1`, `SELECT ${SELECT_COLS} FROM image_bake WHERE bake_id = $1 LIMIT 1`), [bakeId]);
187
- return rows[0] ? mapRow(rows[0]) : null;
258
+ const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE bake_id = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE bake_id = $1 LIMIT 1`), [bakeId]);
259
+ return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
188
260
  }
189
261
  async getBakeByIdem(idemKey) {
190
- const { rows } = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM image_bake WHERE idem_key = ? LIMIT 1`, `SELECT ${SELECT_COLS} FROM image_bake WHERE idem_key = $1 LIMIT 1`), [idemKey]);
191
- return rows[0] ? mapRow(rows[0]) : null;
262
+ const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE idem_key = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE idem_key = $1 LIMIT 1`), [idemKey]);
263
+ return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
192
264
  }
193
265
  async findActiveByArgv(argv) {
194
- const { rows } = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND argv = CAST(? AS JSON) ` +
195
- "ORDER BY created_at DESC LIMIT 1", `SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND argv = $1::jsonb ` +
266
+ const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE status IN ('queued','running') AND argv = CAST(? AS JSON) ` +
267
+ "ORDER BY created_at DESC LIMIT 1", `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE status IN ('queued','running') AND argv = $1::jsonb ` +
196
268
  "ORDER BY created_at DESC LIMIT 1"), [JSON.stringify(argv)]);
197
- return rows[0] ? mapRow(rows[0]) : null;
269
+ return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
198
270
  }
199
271
  async findActiveAny() {
200
- const { rows } = await this.db.query(`SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 ` +
272
+ const { rows } = await this.db.query(`SELECT ${bakeSelectCols(this.db.dialect)} FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 ` +
201
273
  "ORDER BY created_at ASC LIMIT 1", []);
202
- return rows[0] ? mapRow(rows[0]) : null;
274
+ return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
203
275
  }
204
276
  async findNextQueuedId() {
205
277
  const { rows } = await this.db.query("SELECT bake_id FROM image_bake WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1", []);
@@ -313,13 +385,13 @@ export class SqlImageBake {
313
385
  return m == null ? 0 : Number(m);
314
386
  }
315
387
  async getEvents(bakeId, afterSeq) {
316
- const { rows } = await this.db.query(this.q("SELECT seq, line_ord, kind, level, data, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, line_ord, kind, level, data, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC"), [bakeId, afterSeq]);
388
+ const { rows } = await this.db.query(this.q(`SELECT seq, line_ord, kind, level, ${jsonColumnText("tidb", "data")}, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, line_ord, kind, level, ${jsonColumnText("pg", "data")}, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC`), [bakeId, afterSeq]);
317
389
  return rows.map((r) => ({
318
390
  seq: Number(r.seq),
319
391
  lineOrd: r.line_ord === null ? null : Number(r.line_ord),
320
392
  kind: String(r.kind),
321
393
  level: r.level ?? null,
322
- data: parseJson(r.data, null),
394
+ data: decodeNullableJsonColumn(r.data, readNonNullJsonValueColumn, `image_bake_event.data for bake ${bakeId} seq ${String(r.seq)}`) ?? null,
323
395
  ts: iso(r.ts),
324
396
  }));
325
397
  }
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { pgSafeJsonStringify, pgSanitizeText, pgHasUnstorable, PgUnstorableError } from "./pg-safe-json.js";
3
- import { mapImageRow as mapRow, } from "./store-contracts.js";
3
+ import {} from "./store-contracts.js";
4
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
+ import { createObjectColumnReader, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn } from "./sql-json-column.js";
6
+ import { normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
5
7
  import { ensureIndexes } from "./ensure-index.js";
6
8
  export const PG_IMAGE_INDEX_SCHEMA = [
7
9
  `CREATE TABLE IF NOT EXISTS sandbox_image_index (
@@ -29,6 +31,67 @@ export const PG_IMAGE_INDEX_SCHEMA = [
29
31
  PRIMARY KEY (id)
30
32
  )`,
31
33
  ];
34
+ function imageSelectCols(dialect) {
35
+ return [
36
+ "id",
37
+ "profile",
38
+ jsonColumnText(dialect, "bands"),
39
+ "repo",
40
+ "tag",
41
+ "digest",
42
+ jsonColumnText(dialect, "toolchain_versions"),
43
+ jsonColumnText(dialect, "capabilities"),
44
+ jsonColumnText(dialect, "pod_contract"),
45
+ "size_bytes",
46
+ "status",
47
+ "visibility",
48
+ "tenant_id",
49
+ "manifest_sha",
50
+ "recipe_git_sha",
51
+ "generator_version",
52
+ "build_date",
53
+ "supersedes",
54
+ "signed",
55
+ "created_at",
56
+ "updated_at",
57
+ ].join(", ");
58
+ }
59
+ const readCapabilitiesColumn = createObjectColumnReader();
60
+ const readPodContractColumn = createObjectColumnReader();
61
+ function mapRow(r) {
62
+ const at = (column) => `sandbox_image_index.${column} for image ${r.id}`;
63
+ return {
64
+ id: r.id,
65
+ profile: r.profile,
66
+ bands: decodeNullableJsonColumn(r.bands, readJsonArrayColumn, at("bands")) ?? [],
67
+ repo: r.repo,
68
+ tag: r.tag,
69
+ digest: r.digest,
70
+ toolchainVersions: decodeNullableJsonColumn(r.toolchain_versions, readJsonObjectColumn, at("toolchain_versions")) ?? {},
71
+ capabilities: decodeNullableJsonColumn(r.capabilities, readCapabilitiesColumn, at("capabilities")) ?? {},
72
+ podContract: decodeNullableJsonColumn(r.pod_contract, readPodContractColumn, at("pod_contract")) ?? {},
73
+ sizeBytes: r.size_bytes === null ? null : Number(r.size_bytes),
74
+ status: r.status,
75
+ visibility: r.visibility,
76
+ tenantId: r.tenant_id,
77
+ manifestSha: r.manifest_sha,
78
+ recipeGitSha: r.recipe_git_sha,
79
+ generatorVersion: r.generator_version,
80
+ buildDate: isoOrNull(r.build_date),
81
+ supersedes: r.supersedes,
82
+ signed: !!r.signed,
83
+ createdAt: isoOrNull(r.created_at) ?? new Date(0).toISOString(),
84
+ updatedAt: isoOrNull(r.updated_at) ?? new Date(0).toISOString(),
85
+ };
86
+ }
87
+ function refusingImageRow(read) {
88
+ try {
89
+ return read();
90
+ }
91
+ catch (e) {
92
+ throw foldJsonColumn(e, (m) => new Error(`image-index: ${m} — refusing rather than serve an image row nobody can read`));
93
+ }
94
+ }
32
95
  export const IMAGE_INDEX_INDEXES = [
33
96
  { table: "sandbox_image_index", name: "uq_repo_digest", columns: ["repo", "digest"], unique: true },
34
97
  { table: "sandbox_image_index", name: "idx_sandbox_image_index_profile_status", columns: ["profile", "status", "visibility"] },
@@ -199,26 +262,26 @@ export class SqlImageIndex {
199
262
  return id;
200
263
  }
201
264
  async getById(id) {
202
- const { rows } = await this.db.query(this.q("SELECT * FROM sandbox_image_index WHERE id=? LIMIT 1", "SELECT * FROM sandbox_image_index WHERE id = $1 LIMIT 1"), [id]);
203
- return rows[0] ? mapRow(rows[0]) : null;
265
+ const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE id=? LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE id = $1 LIMIT 1`), [id]);
266
+ return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
204
267
  }
205
268
  async getByDigest(digest) {
206
- const { rows } = await this.db.query(this.q("SELECT * FROM sandbox_image_index WHERE digest=? ORDER BY updated_at DESC LIMIT 1", "SELECT * FROM sandbox_image_index WHERE digest = $1 ORDER BY updated_at DESC LIMIT 1"), [digest]);
207
- return rows[0] ? mapRow(rows[0]) : null;
269
+ const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE digest=? ORDER BY updated_at DESC LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE digest = $1 ORDER BY updated_at DESC LIMIT 1`), [digest]);
270
+ return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
208
271
  }
209
272
  async latestPublished(profile, viewer) {
210
273
  if (this.db.dialect === "tidb") {
211
274
  const vis = visibilityClauseTidb(viewer);
212
- const { rows } = await this.db.query(`SELECT * FROM sandbox_image_index
275
+ const { rows } = await this.db.query(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index
213
276
  WHERE profile=? AND status='published' AND ${vis.sql}
214
277
  ORDER BY (build_date IS NULL), build_date DESC, created_at DESC LIMIT 1`, [profile, ...vis.params]);
215
- return rows[0] ? mapRow(rows[0]) : null;
278
+ return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
216
279
  }
217
280
  const vis = visibilityClausePg(viewer, 2);
218
- const { rows } = await this.db.query(`SELECT * FROM sandbox_image_index
281
+ const { rows } = await this.db.query(`SELECT ${imageSelectCols("pg")} FROM sandbox_image_index
219
282
  WHERE profile = $1 AND status = 'published' AND ${vis.sql}
220
283
  ORDER BY build_date DESC NULLS LAST, created_at DESC LIMIT 1`, [profile, ...vis.params]);
221
- return rows[0] ? mapRow(rows[0]) : null;
284
+ return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
222
285
  }
223
286
  async list(filter) {
224
287
  const limit = Math.min(Math.max(1, filter.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
@@ -246,7 +309,7 @@ export class SqlImageIndex {
246
309
  params.push(new Date(c.createdAt), new Date(c.createdAt), c.id);
247
310
  }
248
311
  }
249
- const sql = `SELECT * FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
312
+ const sql = `SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
250
313
  `ORDER BY created_at DESC, id DESC LIMIT ?`;
251
314
  const { rows } = await this.db.query(sql, [...params, limit + 1]);
252
315
  rawRows = rows;
@@ -276,13 +339,13 @@ export class SqlImageIndex {
276
339
  idx += 3;
277
340
  }
278
341
  }
279
- const sql = `SELECT * FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
342
+ const sql = `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
280
343
  `ORDER BY created_at DESC, id DESC LIMIT $${idx}`;
281
344
  params.push(limit + 1);
282
345
  const { rows } = await this.db.query(sql, params);
283
346
  rawRows = rows;
284
347
  }
285
- let entries = rawRows.map(mapRow);
348
+ let entries = refusingImageRow(() => rawRows.map(mapRow));
286
349
  let nextCursor = null;
287
350
  if (entries.length > limit) {
288
351
  entries = entries.slice(0, limit);
@@ -183,8 +183,29 @@ export declare class LocalCheckpointStore {
183
183
  sessionId: string;
184
184
  scope: string;
185
185
  }>>;
186
- putCtx(sessionId: string, ctx: unknown): Promise<void>;
187
- getCtx<T = unknown>(sessionId: string): Promise<T | null>;
186
+ /**
187
+ * 🔴 **写腿的形闸与 SQL 孪生逐字同一条**(codex 对抗复审 r1 [medium],验真后按类修):TS 的 `object` 说不出
188
+ * 「序列化之后是一只 JSON 对象」—— 数组 / `Date` / `toJSON()` 返回标量或 `null` 的对象全都通得过类型面,
189
+ * 各自写出一份读腿判坏的字节。而这一写是**无条件覆盖**,所以它会「成功」地把一份读得回来的耐久 ctx 换成
190
+ * 读不回来的 —— 调用方拿 2xx,下一次续跑永久 409。⇒ 闸在落盘之前,盘上那一份原样不动。
191
+ *
192
+ * 🔴 **闸判的必须是「将要落盘的那一串字节」本身**(codex r2 [medium],验真后修):初版判的是
193
+ * `JSON.stringify(ctx)`,而真正落盘的是 `JSON.stringify(rec)` —— `toJSON(key)` 拿得到**键名**,所以
194
+ * 一个 `toJSON(k){ return k === "ctx" ? null : {…} }` 的对象验的是 `{…}`、写的是 `{"ctx":null}`
195
+ * (实测两串逐字不同)。有状态的序列化器同理。⇒ 整条记录**只序列化一次**,闸判那一串,写也写那一串。
196
+ */
197
+ putCtx(sessionId: string, ctx: object): Promise<void>;
198
+ /**
199
+ * 🔴 **坏体 ⇒ 拒,绝不读成缺席**(S-577,与 SQL 孪生同一条判据)。修前这里是
200
+ * `catch { return null }` + `(rec.ctx as T) ?? null`,于是一个撕裂 / 手改 / 权限读不动的 ctx 文件与
201
+ * 「这条会话根本没有 ctx」在消费点上**逐字同答**(409 `resume context missing`)—— 运维据此去「重新
202
+ * 提交一个任务」,而真因是盘上那一格坏了。三个店同向:不是「这一行不在」,是「这一行读不出来」。
203
+ *
204
+ * `ctx` 的形判据同 SQL 孪生:**一只 JSON 对象** —— 而且是**同一只读者**({@link createCtxRecordReader}
205
+ * 里合成的 {@link createObjectColumnReader}),不是「同一个意思的两份判据」。写腿 `putCtx(…, ctx: object)`
206
+ * 只收对象,读腿只认对象,两条腿一个类型。
207
+ */
208
+ getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
188
209
  /** GC ctx files whose lifecycle is over — same three guards as the SQL twins (stale ∧ no pending ∧ no active run). */
189
210
  reapCtx(cutoff: number): Promise<number>;
190
211
  /**
@@ -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
  }