@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,4 +1,3 @@
1
- import { parseJsonOr, toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
2
1
  export class StoreBootRefusalError extends Error {
3
2
  }
4
3
  export const RUN_STATUS_CLASS = {
@@ -36,63 +35,4 @@ export function sessionTempoOfRunStatus(lastStatus) {
36
35
  export function runProvenElsewhere(run, thisInstanceId) {
37
36
  return typeof run.instanceId === "string" && run.instanceId.length > 0 && thisInstanceId !== undefined && run.instanceId !== thisInstanceId;
38
37
  }
39
- export function mapBakeRow(r) {
40
- return {
41
- bakeId: r.bake_id,
42
- profile: r.profile,
43
- bands: r.bands == null ? null : parseJsonOr(r.bands, []),
44
- baseRef: r.base_ref,
45
- push: !!r.push,
46
- dryRun: !!r.dry_run,
47
- logs: !!r.logs,
48
- argv: parseJsonOr(r.argv, []),
49
- status: r.status,
50
- state: r.state ?? null,
51
- digest: r.digest,
52
- repo: r.repo,
53
- ref: r.ref,
54
- indexId: r.index_id,
55
- exitCode: r.exit_code === null ? null : Number(r.exit_code),
56
- error: r.error,
57
- errorCode: r.error_code ?? null,
58
- manifestSha: r.manifest_sha,
59
- tag: r.tag,
60
- idemKey: r.idem_key,
61
- ingestSecret: r.ingest_secret,
62
- runnerId: r.runner_id,
63
- leaseUntil: isoOrNull(r.lease_until),
64
- cancelRequested: !!r.cancel_requested,
65
- requestedBy: r.requested_by,
66
- createdAt: iso(r.created_at),
67
- updatedAt: iso(r.updated_at),
68
- };
69
- }
70
- export const BAKE_SELECT_COLS = "bake_id, profile, bands, base_ref, push, dry_run, logs, argv, status, state, digest, repo, ref, index_id, " +
71
- "exit_code, error, error_code, manifest_sha, tag, idem_key, ingest_secret, runner_id, lease_until, " +
72
- "cancel_requested, requested_by, created_at, updated_at";
73
- export function mapImageRow(r) {
74
- return {
75
- id: r.id,
76
- profile: r.profile,
77
- bands: parseJsonOr(r.bands, []),
78
- repo: r.repo,
79
- tag: r.tag,
80
- digest: r.digest,
81
- toolchainVersions: parseJsonOr(r.toolchain_versions, {}),
82
- capabilities: parseJsonOr(r.capabilities, {}),
83
- podContract: parseJsonOr(r.pod_contract, {}),
84
- sizeBytes: r.size_bytes === null ? null : Number(r.size_bytes),
85
- status: r.status,
86
- visibility: r.visibility,
87
- tenantId: r.tenant_id,
88
- manifestSha: r.manifest_sha,
89
- recipeGitSha: r.recipe_git_sha,
90
- generatorVersion: r.generator_version,
91
- buildDate: isoOrNull(r.build_date),
92
- supersedes: r.supersedes,
93
- signed: !!r.signed,
94
- createdAt: isoOrNull(r.created_at) ?? new Date(0).toISOString(),
95
- updatedAt: isoOrNull(r.updated_at) ?? new Date(0).toISOString(),
96
- };
97
- }
98
38
  //# sourceMappingURL=store-contracts.js.map
@@ -24,21 +24,30 @@
24
24
  * - 绑定:`bind` 把行钉到 sessionId(重复绑定=改绑,同 owner 内 last-wins;同 session 多 task 共享
25
25
  * 自然成立——listBySession 是物化输入)。未绑定行 = 上传后从未被任何 task 引用 ⇒ reapUnbound(TTL)。
26
26
  */
27
+ import { z } from "zod";
27
28
  import type { Pool } from "mysql2/promise";
28
29
  import type { PgQueryFn } from "./pg-query.js";
29
30
  import { type IndexSpec } from "./ensure-index.js";
30
- export interface TaskAttachmentRecord {
31
- id: string;
32
- owner: string;
33
- /** 消毒后的 basename(http 面用 core sanitizePathComponent 处理;store 只存不再消毒)。 */
34
- name: string;
35
- mime: string;
36
- sha256: string;
37
- sizeBytes: number;
38
- createdAtMs: number;
39
- sessionId?: string;
40
- boundAtMs?: number;
41
- }
31
+ /**
32
+ * 一条附件 meta 的**形属主**(整仓唯一那份键集)。
33
+ *
34
+ * 🔴 为什么是一只 schema 而不只是一个 interface(S-550 车③b):SQL 车道把这些字段拆成**标量列**,
35
+ * 形由 DDL 管;local 车道把整条记录**序列化成一个 JSON 档**(`attachments/<id>.json`),于是它需要一个
36
+ * 运行期的形判据 —— 而那个判据必须与这里的键集是**同一份**(第二份键集分叉的那天 = 「写得进、读不出」)。
37
+ * 所以类型从 schema 推导({@link TaskAttachmentRecord}),两者按构造不可能漂。
38
+ */
39
+ export declare const TaskAttachmentRecordSchema: z.ZodObject<{
40
+ id: z.ZodString;
41
+ owner: z.ZodString;
42
+ name: z.ZodString;
43
+ mime: z.ZodString;
44
+ sha256: z.ZodString;
45
+ sizeBytes: z.ZodNumber;
46
+ createdAtMs: z.ZodNumber;
47
+ sessionId: z.ZodOptional<z.ZodString>;
48
+ boundAtMs: z.ZodOptional<z.ZodNumber>;
49
+ }, z.core.$strip>;
50
+ export type TaskAttachmentRecord = z.infer<typeof TaskAttachmentRecordSchema>;
42
51
  export interface TaskAttachmentStore {
43
52
  put(rec: TaskAttachmentRecord & {
44
53
  content: Uint8Array;
@@ -1,4 +1,16 @@
1
+ import { z } from "zod";
1
2
  import { ensureIndexes } from "./ensure-index.js";
3
+ export const TaskAttachmentRecordSchema = z.object({
4
+ id: z.string(),
5
+ owner: z.string(),
6
+ name: z.string(),
7
+ mime: z.string(),
8
+ sha256: z.string(),
9
+ sizeBytes: z.number(),
10
+ createdAtMs: z.number(),
11
+ sessionId: z.string().optional(),
12
+ boundAtMs: z.number().optional(),
13
+ });
2
14
  export const TASK_ATTACHMENT_TABLE = "task_attachment";
3
15
  export function materializedRelPaths(atts) {
4
16
  const used = new Set();
@@ -1,8 +1,8 @@
1
1
  import { BaseSessionStorage, type PromptEpochDescriptor, type SessionMetadata, type SessionTreeEntry, type SessionWriteOptions } from "@sema-agent/core";
2
2
  import type { Pool } from "mysql2/promise";
3
3
  export declare const SESSION_EVENT_LATEST_BY_TYPE_SQL: {
4
- readonly floor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1";
5
- readonly epochAnchor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100";
4
+ readonly floor: `SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ ${string} FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1`;
5
+ readonly epochAnchor: `SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ seq, ${string} FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100`;
6
6
  };
7
7
  /**
8
8
  * TiDB-backed {@link BaseSessionStorage} — the durable L1 event log for one session.
@@ -3,10 +3,12 @@ import { BaseSessionStorage, SessionError, leafIdAfterEntry, normalizePromptEpoc
3
3
  import { toIso } from "./sql-row-helpers.js";
4
4
  import { isMysqlDupKeyError } from "./sql-errors.js";
5
5
  import { PLACEMENT_SELECT_COLUMNS, placementFromRow } from "./session-placement.js";
6
+ import { decodeSessionEventPayload } from "./session-event-payload.js";
7
+ import { jsonColumnText } from "./sql-json-column.js";
6
8
  const SESSION_EVENT_BY_TYPE_HINT = "/*+ USE_INDEX(session_event, idx_session_event_type) */";
7
9
  export const SESSION_EVENT_LATEST_BY_TYPE_SQL = {
8
- floor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1`,
9
- epochAnchor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100`,
10
+ floor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1`,
11
+ epochAnchor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} seq, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100`,
10
12
  };
11
13
  const isDupKey = isMysqlDupKeyError;
12
14
  export class TiDBSessionStorage extends BaseSessionStorage {
@@ -28,7 +30,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
28
30
  if (rows.length === 0)
29
31
  return undefined;
30
32
  for (const r of rows) {
31
- const e = (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload);
33
+ const e = decodeSessionEventPayload(r.payload, this.sessionId, r.seq);
32
34
  const d = e.type === "prompt_epoch" ? normalizePromptEpoch(e) : normalizePromptEpoch(e.details?.promptEpoch);
33
35
  if (d)
34
36
  return d;
@@ -42,8 +44,8 @@ export class TiDBSessionStorage extends BaseSessionStorage {
42
44
  if (!meta)
43
45
  return null;
44
46
  const { floorEntryId, floorSeq } = await TiDBSessionStorage.computeFloor(pool, sessionId);
45
- const [eventRows] = await pool.query("SELECT payload FROM session_event WHERE session_id = ? AND seq >= ? ORDER BY seq ASC", [sessionId, floorSeq]);
46
- const entries = eventRows.map((r) => typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload);
47
+ const [eventRows] = await pool.query(`SELECT seq, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? AND seq >= ? ORDER BY seq ASC`, [sessionId, floorSeq]);
48
+ const entries = eventRows.map((r) => decodeSessionEventPayload(r.payload, sessionId, r.seq));
47
49
  const placement = placementFromRow(meta);
48
50
  const metadata = { id: sessionId, createdAt: toIso(meta.created_at), ...(placement !== undefined ? { placement } : {}) };
49
51
  return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, floorEntryId, floorSeq);
@@ -52,8 +54,8 @@ export class TiDBSessionStorage extends BaseSessionStorage {
52
54
  const [compRows] = await pool.query(SESSION_EVENT_LATEST_BY_TYPE_SQL.floor, [sessionId]);
53
55
  if (!compRows[0])
54
56
  return { floorEntryId: null, floorSeq: 0 };
55
- const comp = (typeof compRows[0].payload === "string" ? JSON.parse(compRows[0].payload) : compRows[0].payload);
56
- const fk = comp.firstKeptEntryId;
57
+ const comp = decodeSessionEventPayload(compRows[0].payload, sessionId, "latest compaction");
58
+ const fk = comp.type === "compaction" ? comp.firstKeptEntryId : undefined;
57
59
  if (!fk)
58
60
  return { floorEntryId: null, floorSeq: 0 };
59
61
  const [seqRows] = await pool.query("SELECT seq FROM session_event WHERE session_id = ? AND entry_id = ?", [sessionId, fk]);
@@ -65,11 +67,10 @@ export class TiDBSessionStorage extends BaseSessionStorage {
65
67
  const inMem = await super.getEntry(id);
66
68
  if (inMem)
67
69
  return inMem;
68
- const [rows] = await this.pool.query("SELECT payload FROM session_event WHERE session_id = ? AND entry_id = ?", [this.sessionId, id]);
69
- const p = rows[0]?.payload;
70
- if (p == null)
70
+ const [rows] = await this.pool.query(`SELECT ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? AND entry_id = ?`, [this.sessionId, id]);
71
+ if (rows[0] === undefined)
71
72
  return undefined;
72
- return (typeof p === "string" ? JSON.parse(p) : p);
73
+ return decodeSessionEventPayload(rows[0].payload, this.sessionId, id);
73
74
  }
74
75
  async appendEntry(entry, opts) {
75
76
  await this.persist(entry, opts);
@@ -155,8 +155,8 @@ export declare class TiDBSessionStore implements SessionStore {
155
155
  *
156
156
  * Resolves to `null` (NOT an empty async-iterable) when the session does not exist — checked via `session_meta`
157
157
  * EAGERLY here (before returning the iterable), exactly like {@link exportEntries}, so a missing session is distinct
158
- * from a real-but-empty one (an empty stream). payload is parsed like {@link exportEntries} (string → JSON.parse,
159
- * object passthrough). The check is eager (the method is async) because a generator body is lazy — it can't decide
158
+ * from a real-but-empty one (an empty stream). payload is decoded like {@link exportEntries}
159
+ * (按文本投影 + `decodeSessionEventPayload` 恰好解一次码,坏体当场拒). The check is eager (the method is async) because a generator body is lazy — it can't decide
160
160
  * `null` vs. iterable on its own first pull.
161
161
  */
162
162
  exportEntriesStream(sessionId: string, opts?: {
@@ -1,5 +1,7 @@
1
1
  import { StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry } from "@sema-agent/core";
2
2
  import { TiDBSessionStorage } from "./tidb-session-storage.js";
3
+ import { decodeSessionEventPayload } from "./session-event-payload.js";
4
+ import { jsonColumnText } from "./sql-json-column.js";
3
5
  import { escapeLike } from "./sql-escape.js";
4
6
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
5
7
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
@@ -186,7 +188,7 @@ export class TiDBSessionStore {
186
188
  if (!metaRows[0])
187
189
  return null;
188
190
  const leafId = metaRows[0].leaf_id ?? null;
189
- const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sourceId]);
191
+ const [eventRows] = await this.pool.query(`SELECT entry_id, parent_id, type, ${jsonColumnText("tidb", "payload")}, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC`, [sourceId]);
190
192
  const newId = uuidv7();
191
193
  const now = new Date();
192
194
  const conn = await this.pool.getConnection();
@@ -194,7 +196,7 @@ export class TiDBSessionStore {
194
196
  await conn.beginTransaction();
195
197
  for (let seq = 0; seq < eventRows.length; seq++) {
196
198
  const r = eventRows[seq];
197
- const payload = typeof r.payload === "string" ? r.payload : JSON.stringify(r.payload);
199
+ const payload = String(r.payload);
198
200
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [newId, seq, r.entry_id, r.parent_id ?? null, r.type, payload, r.ts]);
199
201
  }
200
202
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?)", [newId, owner, now, now, leafId, eventRows.length]);
@@ -217,8 +219,8 @@ export class TiDBSessionStore {
217
219
  const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
218
220
  if (!metaRows[0])
219
221
  return null;
220
- const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sessionId]);
221
- return eventRows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
222
+ const [eventRows] = await this.pool.query(`SELECT seq, entry_id, parent_id, type, ${jsonColumnText("tidb", "payload")}, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC`, [sessionId]);
223
+ return eventRows.map((r) => decodeSessionEventPayload(r.payload, sessionId, r.seq));
222
224
  }
223
225
  async listEntryIds(sessionId) {
224
226
  const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
@@ -236,11 +238,9 @@ export class TiDBSessionStore {
236
238
  async function* gen() {
237
239
  let cursor = opts?.afterSeq ?? -1;
238
240
  for (;;) {
239
- const [rows] = await pool.query("SELECT seq, payload FROM session_event WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [sessionId, cursor, batchSize]);
240
- for (const r of rows) {
241
- const p = r.payload;
242
- yield (typeof p === "string" ? JSON.parse(p) : p);
243
- }
241
+ const [rows] = await pool.query(`SELECT seq, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?`, [sessionId, cursor, batchSize]);
242
+ for (const r of rows)
243
+ yield decodeSessionEventPayload(r.payload, sessionId, r.seq);
244
244
  if (rows.length < batchSize)
245
245
  return;
246
246
  cursor = Number(rows[rows.length - 1].seq);
@@ -321,8 +321,8 @@ export class TiDBSessionStore {
321
321
  }
322
322
  }
323
323
  async readStagedEntries(stagingId) {
324
- const [rows] = await this.pool.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [stagingId]);
325
- return rows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
324
+ const [rows] = await this.pool.query(`SELECT seq, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? ORDER BY seq ASC`, [stagingId]);
325
+ return rows.map((r) => decodeSessionEventPayload(r.payload, stagingId, r.seq));
326
326
  }
327
327
  async sweepStagingSessions() {
328
328
  const cutoff = new Date(Date.now() - STAGING_GC_GRACE_MS);
@@ -416,12 +416,13 @@ class TiDBStagingHandle {
416
416
  throw new SyncConflictError(this.realSessionId, rel);
417
417
  }
418
418
  if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
419
- const [dstFull] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.realSessionId]);
420
- const [stagedFull] = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.stagingId]);
421
- const parseEntry = (p) => (typeof p === "string" ? JSON.parse(p) : p);
419
+ const [dstFull] = await conn.query(`SELECT seq, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? ORDER BY seq ASC`, [this.realSessionId]);
420
+ const [stagedFull] = await conn.query(`SELECT entry_id, ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? ORDER BY seq ASC`, [this.stagingId]);
422
421
  const dstSet = new Set(dstIds);
423
- const dstEntries = dstFull.map((r) => parseEntry(r.payload));
424
- const stagedShared = stagedFull.filter((r) => dstSet.has(String(r.entry_id))).map((r) => parseEntry(r.payload));
422
+ const dstEntries = dstFull.map((r) => decodeSessionEventPayload(r.payload, this.realSessionId, r.seq));
423
+ const stagedShared = stagedFull
424
+ .filter((r) => dstSet.has(String(r.entry_id)))
425
+ .map((r) => decodeSessionEventPayload(r.payload, this.stagingId, r.entry_id));
425
426
  const diverged = fastForwardSharedContentDiverged(stagedShared, dstEntries);
426
427
  if (diverged) {
427
428
  await conn.rollback();
@@ -430,13 +431,10 @@ class TiDBStagingHandle {
430
431
  }
431
432
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [this.realSessionId]);
432
433
  await conn.query("UPDATE session_event SET session_id = ? WHERE session_id = ?", [this.realSessionId, this.stagingId]);
433
- const [lastRow] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq DESC LIMIT 1", [this.realSessionId]);
434
+ const [lastRow] = await conn.query(`SELECT ${jsonColumnText("tidb", "payload")} FROM session_event WHERE session_id = ? ORDER BY seq DESC LIMIT 1`, [this.realSessionId]);
434
435
  let leafId = null;
435
- if (lastRow[0]) {
436
- const p = lastRow[0].payload;
437
- const last = (typeof p === "string" ? JSON.parse(p) : p);
438
- leafId = leafIdAfterEntry(last);
439
- }
436
+ if (lastRow[0])
437
+ leafId = leafIdAfterEntry(decodeSessionEventPayload(lastRow[0].payload, this.realSessionId, "last staged row"));
440
438
  const now = new Date();
441
439
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?) " +
442
440
  "ON DUPLICATE KEY UPDATE owner = VALUES(owner), updated_at = VALUES(updated_at), leaf_id = VALUES(leaf_id), leaf_seq = VALUES(leaf_seq)", [this.realSessionId, owner, now, now, leafId, srcIds.length]);
@@ -25,8 +25,16 @@
25
25
  */
26
26
  import type { Pool as MySqlPool } from "mysql2/promise";
27
27
  import type { Pool as PgPool } from "pg";
28
- import type { UsageWindow, UsageWindowReading, UsageWindowStore } from "@sema-agent/core";
28
+ import type { UsageWindow, UsageWindowReading, UsageWindowRecord, UsageWindowStore } from "@sema-agent/core";
29
+ import { type JsonColumnShape } from "./sql-json-column.js";
29
30
  export declare const USAGE_WINDOW_TABLE = "usage_window";
31
+ /**
32
+ * `usage_window.record` 的**形读者**(S-550 车③a:本店既有的那一份深度判据逐字搬进来,一个字没改判)。
33
+ *
34
+ * 判据深到内层元素:非数值 tokens 参与累加会得到 `"0oops"` ⇒ `exhausted:false` ⇒ **超额账户被判未耗尽**
35
+ * (与 core `FileUsageWindowStore` 的 validateSlots/validateBuckets 同轴)。
36
+ */
37
+ export declare function readUsageWindowRecordBody(body: unknown): JsonColumnShape<UsageWindowRecord>;
30
38
  export declare function ensureTiDBUsageWindowSchema(pool: MySqlPool): Promise<void>;
31
39
  export declare function ensurePgUsageWindowSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
32
40
  export declare class TiDBUsageWindowStore implements UsageWindowStore {
@@ -1,18 +1,32 @@
1
1
  import { EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord } from "@sema-agent/core";
2
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
2
3
  export const USAGE_WINDOW_TABLE = "usage_window";
4
+ class UsageRecordShapeProblem extends Error {
5
+ }
6
+ export function readUsageWindowRecordBody(body) {
7
+ try {
8
+ return { value: validateUsageWindowRecord(body) };
9
+ }
10
+ catch (e) {
11
+ if (e instanceof UsageRecordShapeProblem)
12
+ return { problem: e.message };
13
+ throw e;
14
+ }
15
+ }
3
16
  function parseRecord(raw, key) {
4
- let v;
5
17
  try {
6
- v = JSON.parse(String(raw));
18
+ return decodeJsonColumn(raw, readUsageWindowRecordBody, `${USAGE_WINDOW_TABLE}.record for key "${key}"`);
7
19
  }
8
20
  catch (e) {
9
- throw new Error(`usage_window record for key "${key}" is not valid JSON (${String(e)}) — refusing to mis-count a governance window`);
21
+ throw foldJsonColumn(e, (m) => new Error(`${m} — refusing to mis-count a governance window`));
10
22
  }
23
+ }
24
+ function validateUsageWindowRecord(v) {
11
25
  const r = v;
12
26
  if (typeof v !== "object" || v === null || !Array.isArray(r.slots) || !Array.isArray(r.buckets)) {
13
- throw new Error(`usage_window record for key "${key}" has an invalid shape (expected {slots[],buckets[]}) — refusing to mis-count a governance window`);
27
+ throw new UsageRecordShapeProblem("expected {slots[],buckets[]}");
14
28
  }
15
- const badShape = (detail) => new Error(`usage_window record for key "${key}" has an invalid shape (${detail}) — refusing to mis-count a governance window`);
29
+ const badShape = (detail) => new UsageRecordShapeProblem(detail);
16
30
  const finite = (x) => typeof x === "number" && Number.isFinite(x);
17
31
  const costKeys = (e) => {
18
32
  if (e.costMicroUsd !== undefined && (typeof e.costMicroUsd !== "number" || !Number.isSafeInteger(e.costMicroUsd) || e.costMicroUsd < 0)) {
@@ -70,7 +84,7 @@ export class TiDBUsageWindowStore {
70
84
  try {
71
85
  await c.beginTransaction();
72
86
  await c.query(`INSERT INTO ${USAGE_WINDOW_TABLE} (usage_key, record, updated_at_ms) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE usage_key = usage_key`, [key, JSON.stringify(EMPTY_USAGE_WINDOW_RECORD), at]);
73
- const [rows] = (await c.query(`SELECT record FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = ? FOR UPDATE`, [key]));
87
+ const [rows] = (await c.query(`SELECT ${jsonColumnText("tidb", "record")} FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = ? FOR UPDATE`, [key]));
74
88
  if (rows.length === 0) {
75
89
  throw new Error(`usage_window row for key "${key}" vanished between ensure and lock — refusing to drop a governance charge`);
76
90
  }
@@ -88,7 +102,7 @@ export class TiDBUsageWindowStore {
88
102
  }
89
103
  }
90
104
  async read(key, windows, now) {
91
- const [rows] = (await this.pool.query(`SELECT record FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = ?`, [key]));
105
+ const [rows] = (await this.pool.query(`SELECT ${jsonColumnText("tidb", "record")} FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = ?`, [key]));
92
106
  const record = rows.length === 0 ? EMPTY_USAGE_WINDOW_RECORD : parseRecord(rows[0].record, key);
93
107
  return readUsageRecord(record, windows, now);
94
108
  }
@@ -107,7 +121,7 @@ export class PgUsageWindowStore {
107
121
  JSON.stringify(EMPTY_USAGE_WINDOW_RECORD),
108
122
  at,
109
123
  ]);
110
- const { rows } = await c.query(`SELECT record FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = $1 FOR UPDATE`, [key]);
124
+ const { rows } = await c.query(`SELECT ${jsonColumnText("pg", "record")} FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = $1 FOR UPDATE`, [key]);
111
125
  if (rows.length === 0) {
112
126
  throw new Error(`usage_window row for key "${key}" vanished between ensure and lock — refusing to drop a governance charge`);
113
127
  }
@@ -125,7 +139,7 @@ export class PgUsageWindowStore {
125
139
  }
126
140
  }
127
141
  async read(key, windows, now) {
128
- const { rows } = await this.pool.query(`SELECT record FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = $1`, [key]);
142
+ const { rows } = await this.pool.query(`SELECT ${jsonColumnText("pg", "record")} FROM ${USAGE_WINDOW_TABLE} WHERE usage_key = $1`, [key]);
129
143
  const record = rows.length === 0 ? EMPTY_USAGE_WINDOW_RECORD : parseRecord(rows[0].record, key);
130
144
  return readUsageRecord(record, windows, now);
131
145
  }
@@ -1,8 +1,17 @@
1
1
  import { callKeyOrdinal } from "@sema-agent/core";
2
2
  import { assertJournalEntryFits } from "@sema-agent/core";
3
- import { journalEntryOfRow, journalRowPayloadOf } from "../orchestration/workflow-journal-entry.js";
3
+ import { journalEntryOfBody, journalRowPayloadOf, readJournalRowBody } from "../orchestration/workflow-journal-entry.js";
4
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, jsonColumnTextExpr } from "./sql-json-column.js";
5
6
  const DEFAULT_RESUME_CLAIM_TTL_MS = 60 * 60 * 1000;
7
+ function decodeJournalResult(cell, where) {
8
+ try {
9
+ return decodeJsonColumn(cell, readJournalRowBody, where);
10
+ }
11
+ catch (e) {
12
+ throw foldJsonColumn(e, (m) => new Error(`workflow journal: ${m} — refusing rather than replay a journal entry nobody can read`));
13
+ }
14
+ }
6
15
  export class SqlWorkflowJournalStore {
7
16
  db;
8
17
  resumeClaimTtlMs;
@@ -21,8 +30,11 @@ export class SqlWorkflowJournalStore {
21
30
  "ON CONFLICT (run_id, ordinal) DO UPDATE SET call_key = EXCLUDED.call_key, result = EXCLUDED.result"), [runId, callKeyOrdinal(entry.callKey), scope, entry.callKey, serialized, Date.now()]);
22
31
  }
23
32
  async load(runId, scope) {
24
- const { rows } = await this.db.query(this.q("SELECT call_key, result FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC", "SELECT call_key, result FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC"), [runId, scope]);
25
- return rows.map((r) => journalEntryOfRow(String(r.call_key), String(r.result)));
33
+ const { rows } = await this.db.query(this.q(`SELECT call_key, ${jsonColumnText("tidb", "result")} FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC`, `SELECT call_key, ${jsonColumnText("pg", "result")} FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC`), [runId, scope]);
34
+ return rows.map((r) => {
35
+ const callKey = String(r.call_key);
36
+ return journalEntryOfBody(callKey, decodeJournalResult(r.result, `workflow_journal.result for run ${runId} entry ${callKey}`));
37
+ });
26
38
  }
27
39
  locator(runId, _scope) {
28
40
  return `/v1/workflows/${runId}/journal`;
@@ -31,7 +43,7 @@ export class SqlWorkflowJournalStore {
31
43
  const params = this.db.dialect === "tidb"
32
44
  ? [opts.maxResultBytes, runId, scope, opts.limit, opts.offset]
33
45
  : [runId, scope, opts.maxResultBytes, opts.limit, opts.offset];
34
- const { rows } = await this.db.query(this.q("SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE result END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?", "SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE result END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5"), params);
46
+ const { rows } = await this.db.query(this.q(`SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE ${jsonColumnTextExpr("tidb", "result")} END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?`, `SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE ${jsonColumnTextExpr("pg", "result")} END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5`), params);
35
47
  return rows.map((r) => ({ callKey: String(r.call_key), resultJson: r.result_json === null ? null : String(r.result_json), resultBytes: Number(r.result_bytes) }));
36
48
  }
37
49
  async deleteByRun(runId) {
package/dist/run-local.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { DEFAULT_MODEL_REF } from "./model-select.js";
3
+ import { buildDurableAbsenceNote, createCorruptReadSeat } from "./observability/corrupt-read-seat.js";
3
4
  import { outwardPlane } from "./terminal.js";
4
5
  import { homedir } from "node:os";
5
6
  import { join } from "node:path";
@@ -260,16 +261,7 @@ export async function runLocal(argv, deps = {}) {
260
261
  fileBackend = acquireLocalBackendWithWait(() => new FileStorageBackend({
261
262
  root,
262
263
  ...(config.fileHistory !== undefined ? { fileHistory: config.fileHistory } : {}),
263
- onCorruptRead: (info) => logger.warn("file_store_corrupt_read", {
264
- path: info.path,
265
- reason: info.reason,
266
- ...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
267
- ...(info.principal !== undefined ? { principal: info.principal } : {}),
268
- note: "a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
269
- "Consequence depends on which store read it: session-policy = this run lost its SUBTRACTIVE session-rule " +
270
- "layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
271
- "rows; file-snapshot = a snapshot or scope read as missing. Inspect the named path before deciding.",
272
- }),
264
+ onCorruptRead: createCorruptReadSeat(logger.warn.bind(logger), "file_store_corrupt_read", buildDurableAbsenceNote("file-snapshot = a snapshot or scope read as missing")),
273
265
  }), config.storeLockWaitMs, (fields) => logger.warn("store_dir_lock_wait", { ...fields }));
274
266
  }
275
267
  catch (e) {
@@ -93,7 +93,20 @@ export type UsageDimension = "principal" | "model";
93
93
  export declare function usageBreakdown(rows: UsageRow[], dimension: UsageDimension): Array<{
94
94
  key: string;
95
95
  } & UsageTotals>;
96
- /** store 行投影 helper:result JSON(已 parse 或原始字符串)→ UsageRow.stats。所有后端复用(方言只在 SQL)。 */
96
+ /**
97
+ * store 行投影 helper:**已解码的** result 体 → UsageRow.stats。三个后端复用(方言只在 SQL)。
98
+ *
99
+ * 🔴 **本函数不解码**(S-550 车③b 删掉了它的 `typeof result === "string" ? JSON.parse(result) : result`
100
+ * 那一跳):那正是设计稿点名的歧义臂 ——「体是串就再解一次」在一列真 `JSON`/`JSONB` 上等于**解第二次**,
101
+ * 于是一个双重编码的 `task_run.result`(解一次得到的是串)会被解回好体、当成真的用量端出去。三个调用点
102
+ * 逐行亲核过,今天**一处都不递串**:
103
+ * · `plugins/run-store-sql.ts#usageScan` → `usageResultBody(r.result)`(属主按文本投影 + `readJsonObjectColumn`
104
+ * 判形,交出来的是对象或 `undefined`;串在那只读者上就是坏体);
105
+ * · `plugins/file-run-store.ts#usageScan` / `plugins/memory-run-store.ts#usageScan` → 行上的
106
+ * `result: PersistedTaskResult | null`(File 形由 core 的 `readJsonlRecords` 解,内存形本来就是对象)。
107
+ * ⇒ 解码的属主是**店**(列 / 文件 / 内存三种承载各自一个),不是这只聚合投影。递进来一个串不会再被
108
+ * 「宽容地」解开,它会在下面那条「`stats` 不是对象 ⇒ `undefined`」的臂上落成诚实的降级。
109
+ */
97
110
  export declare function projectUsageStats(result: unknown): UsageRow["stats"];
98
111
  /** usageScan 的统一行数上限(触顶=结果 truncated:true;试点规模远在限内)。 */
99
112
  export declare const USAGE_SCAN_LIMIT = 50000;
@@ -87,24 +87,18 @@ export function usageBreakdown(rows, dimension) {
87
87
  .map(([key, t]) => ({ key, ...finish(t) }));
88
88
  }
89
89
  export function projectUsageStats(result) {
90
- try {
91
- const parsed = typeof result === "string" ? JSON.parse(result) : result;
92
- const stats = parsed?.stats;
93
- if (!stats || typeof stats !== "object")
94
- return undefined;
95
- const out = {};
96
- if (typeof stats.tokens === "number")
97
- out.tokens = stats.tokens;
98
- const mu = stats.modelUsage;
99
- if (mu && typeof mu === "object" && !Array.isArray(mu))
100
- out.modelUsage = mu;
101
- if (stats.usageMissing === true)
102
- out.usageMissing = true;
103
- return out;
104
- }
105
- catch {
90
+ const stats = result?.stats;
91
+ if (!stats || typeof stats !== "object")
106
92
  return undefined;
107
- }
93
+ const out = {};
94
+ if (typeof stats.tokens === "number")
95
+ out.tokens = stats.tokens;
96
+ const mu = stats.modelUsage;
97
+ if (mu && typeof mu === "object" && !Array.isArray(mu))
98
+ out.modelUsage = mu;
99
+ if (stats.usageMissing === true)
100
+ out.usageMissing = true;
101
+ return out;
108
102
  }
109
103
  export const USAGE_SCAN_LIMIT = 50_000;
110
104
  //# sourceMappingURL=usage-analytics.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.93.7",
3
+ "version": "7.94.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -59,7 +59,7 @@
59
59
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
60
60
  },
61
61
  "dependencies": {
62
- "@sema-agent/core": "7.26.2",
62
+ "@sema-agent/core": "7.28.1",
63
63
  "@sema-agent/settings-schema": "3.0.0",
64
64
  "e2b": "^2.28.0",
65
65
  "libsodium-wrappers": "^0.8.4",