@sema-agent/server 7.93.4 → 7.93.6

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 (38) hide show
  1. package/dist/bake-runner/main.d.ts +28 -1
  2. package/dist/bake-runner/main.js +122 -38
  3. package/dist/bake-runner/runner.d.ts +85 -7
  4. package/dist/bake-runner/runner.js +116 -27
  5. package/dist/boot/exit-boundary.d.ts +22 -0
  6. package/dist/boot/exit-boundary.js +73 -0
  7. package/dist/boot/shutdown.d.ts +2 -2
  8. package/dist/boot/shutdown.js +5 -62
  9. package/dist/hooks/hook-runner.js +5 -1
  10. package/dist/observability/fail-open.d.ts +16 -0
  11. package/dist/observability/fail-open.js +16 -0
  12. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  13. package/dist/plugins/approval-ask-store-sql.js +36 -19
  14. package/dist/plugins/background-agent-store-sql.js +36 -16
  15. package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
  16. package/dist/plugins/checkpoint-store-sql.js +111 -66
  17. package/dist/plugins/device-store-sql.d.ts +10 -0
  18. package/dist/plugins/device-store-sql.js +12 -6
  19. package/dist/plugins/host-platform.d.ts +70 -6
  20. package/dist/plugins/host-platform.js +14 -3
  21. package/dist/plugins/leader-run-store-sql.js +16 -6
  22. package/dist/plugins/one-time-migrations.d.ts +3 -0
  23. package/dist/plugins/one-time-migrations.js +16 -4
  24. package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
  25. package/dist/plugins/permission-rule-store-sql.js +37 -28
  26. package/dist/plugins/remote-env-host.js +9 -21
  27. package/dist/plugins/run-store-sql.d.ts +1 -1
  28. package/dist/plugins/run-store-sql.js +41 -10
  29. package/dist/plugins/session-policy-store-sql.d.ts +24 -0
  30. package/dist/plugins/session-policy-store-sql.js +17 -18
  31. package/dist/plugins/sql-json-column.d.ts +177 -0
  32. package/dist/plugins/sql-json-column.js +74 -0
  33. package/dist/plugins/task-list-store-sql.d.ts +13 -1
  34. package/dist/plugins/task-list-store-sql.js +23 -10
  35. package/dist/plugins/workflow-run-store-sql.js +14 -4
  36. package/dist/run-local.d.ts +15 -0
  37. package/dist/run-local.js +32 -0
  38. package/package.json +1 -1
@@ -1,16 +1,20 @@
1
1
  import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
2
  import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
+ import { jsonColumnTextExpr } from "./sql-json-column.js";
3
4
  export const NO_SQL_MIGRATIONS = {
4
5
  retiredSnapshotTable: "absent",
5
6
  retiredSnapshotRows: 0,
6
- retiredSteerSeatColumn: "absent",
7
7
  retiredSteerSeatStaleRows: 0,
8
+ retiredSteerSeatColumn: "absent",
9
+ auditDetailJsonNullRows: 0,
8
10
  };
9
11
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
10
12
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
11
13
  const DELIVERABLE_STEER_SEAT_WHERE = "(status = 'pending' OR (status = 'resolved' AND execution_outcome IS NULL))";
12
14
  const RETIRED_ASK_ORIGIN = { from: "unresolvable", to: "ancestor_marked" };
13
15
  const GATE_BEARING_LEDGER_EVENT_TYPE = "tool_end";
16
+ const AUDIT_TABLE = "device_audit";
17
+ const AUDIT_DETAIL_COLUMN = "detail";
14
18
  export class RetiredSteerSeatError extends Error {
15
19
  rows;
16
20
  code = "checkpoint.pending_steer_seat_retired";
@@ -130,6 +134,11 @@ async function migrateOn(db, dialect) {
130
134
  `WHERE type = '${GATE_BEARING_LEDGER_EVENT_TYPE}' AND data->'gate'->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`)) +
131
135
  (await db.exec(`UPDATE checkpoint SET execution_outcome = jsonb_set(execution_outcome, '{origin}', '"${RETIRED_ASK_ORIGIN.to}"') ` +
132
136
  `WHERE execution_outcome->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`));
137
+ const auditTablePresent = await tableExists(db, dialect, AUDIT_TABLE);
138
+ const auditDetailJsonNullRows = !auditTablePresent
139
+ ? 0
140
+ : await db.exec(`UPDATE ${AUDIT_TABLE} SET ${AUDIT_DETAIL_COLUMN} = NULL ` +
141
+ `WHERE ${AUDIT_DETAIL_COLUMN} IS NOT NULL AND ${jsonColumnTextExpr(dialect, AUDIT_DETAIL_COLUMN)} = 'null'`);
133
142
  let retiredSnapshotTable = "absent";
134
143
  let retiredSnapshotRows = 0;
135
144
  let retiredSteerSeatColumn = "absent";
@@ -160,7 +169,7 @@ async function migrateOn(db, dialect) {
160
169
  throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
161
170
  `report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
162
171
  }
163
- return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated };
172
+ return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows };
164
173
  }
165
174
  export function migrateRetiredAskOrigin(type, data) {
166
175
  if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
@@ -177,7 +186,7 @@ export function migrateRetiredAskOrigin(type, data) {
177
186
  return { ...d, gate: { ...g, origin: RETIRED_ASK_ORIGIN.to } };
178
187
  }
179
188
  export function logOneTimeMigrations(log, backend, report) {
180
- const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0;
189
+ const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0;
181
190
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
182
191
  backend,
183
192
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -185,8 +194,11 @@ export function logOneTimeMigrations(log, backend, report) {
185
194
  retiredSteerSeatColumn: report.retiredSteerSeatColumn,
186
195
  retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
187
196
  askOriginRowsMigrated: report.askOriginRowsMigrated,
197
+ auditDetailJsonNullRows: report.auditDetailJsonNullRows,
188
198
  note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
189
- "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place). This " +
199
+ "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place; audit " +
200
+ "details written as the JSON text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly " +
201
+ "one on-disk spelling). This " +
190
202
  "whole migration step is removed in the next minor — start this version at least once before upgrading past it.",
191
203
  });
192
204
  }
@@ -157,6 +157,7 @@ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordSto
157
157
  private q;
158
158
  private enc;
159
159
  get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
160
+ private readApprovalRow;
160
161
  create(record: RuleApprovalRecord): Promise<void>;
161
162
  /**
162
163
  * 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
@@ -6,6 +6,7 @@ import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
7
  import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
8
8
  import { ensureIndexes } from "./ensure-index.js";
9
+ import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
9
10
  export function writerOfSqlRuleStore(store) {
10
11
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
11
12
  if (w === null || typeof w !== "object")
@@ -211,7 +212,7 @@ export async function ensureTiDBPermissionRuleSchema(pool) {
211
212
  }
212
213
  export async function assertPermissionRuleApprovalSchema(query, dialect) {
213
214
  try {
214
- await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
215
+ await query(`SELECT command, ${jsonColumnText(dialect, "edited_json")}, version, ${jsonColumnText(dialect, "offers_json")}, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
215
216
  }
216
217
  catch (err) {
217
218
  if (!isMissingColumnError(err, dialect))
@@ -231,7 +232,7 @@ export async function assertPermissionRuleApprovalSchema(query, dialect) {
231
232
  }
232
233
  export async function assertPermissionRuleTicketSchema(query, dialect) {
233
234
  try {
234
- await query(`SELECT redeemed_outcome, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
235
+ await query(`SELECT ${jsonColumnText(dialect, "redeemed_outcome")}, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
235
236
  }
236
237
  catch (err) {
237
238
  if (!isMissingColumnError(err, dialect))
@@ -332,21 +333,21 @@ export function buildRuleBucketChecksum(body) {
332
333
  }
333
334
  const NEXT_DOT_MAX_ATTEMPTS = 8;
334
335
  const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
335
- function parseColumn(schema, raw, column, ownerKey) {
336
- const text = typeof raw === "string" ? raw : raw === null || raw === undefined ? undefined : String(raw);
337
- if (text === undefined)
338
- throw new Error(`permission_rule.${column} is absent for bucket ${ownerKey}; refusing to guess its content`);
339
- let json;
336
+ const readRulesJson = createZodColumnReader(z.array(PersistedRuleRowSchema));
337
+ const readTombstonesJson = createZodColumnReader(z.array(RuleTombstoneSchema));
338
+ const readQuarantinedJson = createZodColumnReader(z.array(QuarantinedRuleAddRowSchema));
339
+ const readObservedVectorJson = createZodColumnReader(FrontierSchema);
340
+ const readCandidatesJson = createZodColumnReader(z.array(RuleCandidateSchema));
341
+ const readOffersJson = createZodColumnReader(z.array(RuleOffer2Schema));
342
+ const readRedeemedDotsJson = createZodColumnReader(RedeemedDotsSchema);
343
+ const readEditedJson = createZodColumnReader(RuleEditedSchema);
344
+ async function refusingUnreadableColumns(what, fn) {
340
345
  try {
341
- json = JSON.parse(text);
346
+ return await fn();
342
347
  }
343
- catch (err) {
344
- throw new Error(`permission_rule.${column} for bucket ${ownerKey} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
348
+ catch (e) {
349
+ throw foldJsonColumn(e, (m) => new Error(`${m} — the ${what} does not carry a readable shape; refusing the whole of it rather than report a partial one`));
345
350
  }
346
- const parsed = schema.safeParse(json);
347
- if (!parsed.success)
348
- throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
349
- return parsed.data;
350
351
  }
351
352
  class SqlDurableRulePartition {
352
353
  db;
@@ -368,14 +369,18 @@ class SqlDurableRulePartition {
368
369
  return this.db.dialect === "tidb" ? tidbSql : pgSql;
369
370
  }
370
371
  async read() {
371
- const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
372
+ return refusingUnreadableColumns(`bucket ${this.ownerKey}`, () => this.readBucketRow());
373
+ }
374
+ async readBucketRow() {
375
+ const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, ${jsonColumnText("tidb", "rules_json")}, ${jsonColumnText("tidb", "tombstones_json")}, ${jsonColumnText("tidb", "quarantined_json")}, ${jsonColumnText("tidb", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, ${jsonColumnText("pg", "rules_json")}, ${jsonColumnText("pg", "tombstones_json")}, ${jsonColumnText("pg", "quarantined_json")}, ${jsonColumnText("pg", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
372
376
  const row = rows[0];
373
377
  if (row === undefined)
374
378
  return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
375
- const rulesRaw = parseColumn(z.array(PersistedRuleRowSchema), row.rules_json, "rules_json", this.ownerKey);
376
- const tombstonesRaw = parseColumn(z.array(RuleTombstoneSchema), row.tombstones_json, "tombstones_json", this.ownerKey);
377
- const quarantinedRaw = parseColumn(z.array(QuarantinedRuleAddRowSchema), row.quarantined_json, "quarantined_json", this.ownerKey);
378
- const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : parseColumn(FrontierSchema, row.observed_vector_json, "observed_vector_json", this.ownerKey);
379
+ const at = `${PERMISSION_RULE_TABLE} bucket ${this.ownerKey}`;
380
+ const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
381
+ const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
382
+ const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
383
+ const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
379
384
  const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
380
385
  if (String(row.checksum ?? "") !== expected) {
381
386
  throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
@@ -548,7 +553,10 @@ export class SqlRuleApprovalRecordStore {
548
553
  return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
549
554
  }
550
555
  async get(id) {
551
- const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
556
+ return refusingUnreadableColumns(`approval record ${id}`, () => this.readApprovalRow(id));
557
+ }
558
+ async readApprovalRow(id) {
559
+ const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("tidb", "candidates_json")}, ${jsonColumnText("tidb", "offers_json")}, selected_offer, ${jsonColumnText("tidb", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("tidb", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("pg", "candidates_json")}, ${jsonColumnText("pg", "offers_json")}, selected_offer, ${jsonColumnText("pg", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("pg", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
552
560
  const row = rows[0];
553
561
  if (row === undefined)
554
562
  return undefined;
@@ -558,10 +566,11 @@ export class SqlRuleApprovalRecordStore {
558
566
  if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
559
567
  return { staleSchema: true, id: String(row.record_id), ...identity };
560
568
  }
561
- const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
562
- const offers = parseColumn(z.array(RuleOffer2Schema), row.offers_json, "offers_json", id);
563
- const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
564
- const edited = row.edited_json === null || row.edited_json === undefined ? undefined : parseColumn(RuleEditedSchema, row.edited_json, "edited_json", id);
569
+ const at = `${PERMISSION_RULE_APPROVAL_TABLE} row ${id}`;
570
+ const candidates = decodeJsonColumn(row.candidates_json, readCandidatesJson, `${at}.candidates_json`);
571
+ const offers = decodeJsonColumn(row.offers_json, readOffersJson, `${at}.offers_json`);
572
+ const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : decodeJsonColumn(row.redeemed_dots_json, readRedeemedDotsJson, `${at}.redeemed_dots_json`);
573
+ const edited = row.edited_json === null || row.edited_json === undefined ? undefined : decodeJsonColumn(row.edited_json, readEditedJson, `${at}.edited_json`);
565
574
  const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
566
575
  const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
567
576
  if (!kind.success || !state.success) {
@@ -716,10 +725,10 @@ export class SqlRuleImportTicketStore {
716
725
  if (row.redeemedOutcome !== undefined) {
717
726
  let outcome;
718
727
  try {
719
- outcome = JSON.parse(row.redeemedOutcome);
728
+ outcome = decodeJsonColumn(row.redeemedOutcome, readJsonObjectColumn, `${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId}`);
720
729
  }
721
- catch (err) {
722
- throw new Error(`${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
730
+ catch (e) {
731
+ throw foldJsonColumn(e, (m) => new Error(`${m} — refusing rather than read a settled ticket as still open (which re-runs a redemption that already ran)`));
723
732
  }
724
733
  return { state: "settled", outcome };
725
734
  }
@@ -738,7 +747,7 @@ export class SqlRuleImportTicketStore {
738
747
  return affected === 1 ? { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId } : { ok: false };
739
748
  }
740
749
  async readRow(ticketId) {
741
- const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
750
+ const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("tidb", "redeemed_outcome")}, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("pg", "redeemed_outcome")}, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
742
751
  const row = rows[0];
743
752
  if (row === undefined)
744
753
  return undefined;
@@ -7,7 +7,7 @@ import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdi
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
9
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
10
- import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, trackHostForegroundChild, untrackHostForegroundChild } from "./host-platform.js";
10
+ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
12
12
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
13
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
@@ -403,15 +403,12 @@ export class RemoteHostExecutionEnv {
403
403
  env: this.mergeEnv(options?.env),
404
404
  ...spawnGroupOptions(),
405
405
  });
406
- const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
407
- if (ticket)
408
- child.once("exit", () => untrackHostForegroundChild(ticket));
406
+ const ticket = adoptHostForegroundChild(child);
409
407
  const finish = (r) => {
410
408
  if (settled)
411
409
  return;
412
410
  settled = true;
413
- if (ticket)
414
- untrackHostForegroundChild(ticket);
411
+ ticket?.release();
415
412
  clearTimeout(timer);
416
413
  options?.abortSignal?.removeEventListener("abort", onAbort);
417
414
  removeDetachListener?.();
@@ -460,8 +457,7 @@ export class RemoteHostExecutionEnv {
460
457
  if (forceSettleTimer)
461
458
  clearTimeout(forceSettleTimer);
462
459
  options?.abortSignal?.removeEventListener("abort", onAbort);
463
- if (ticket)
464
- untrackHostForegroundChild(ticket);
460
+ ticket?.release();
465
461
  settled = true;
466
462
  resolve(ok({ stdout: seedOut, stderr: seedErr, exitCode: 0, detached: { shellId } }));
467
463
  };
@@ -621,8 +617,7 @@ export class RemoteHostExecutionEnv {
621
617
  clearInterval(pump);
622
618
  pump = undefined;
623
619
  }
624
- if (ticket)
625
- untrackHostForegroundChild(ticket);
620
+ ticket?.release();
626
621
  if (drainTimer) {
627
622
  clearTimeout(drainTimer);
628
623
  drainTimer = undefined;
@@ -675,8 +670,7 @@ export class RemoteHostExecutionEnv {
675
670
  cleanupSpool();
676
671
  throw e;
677
672
  }
678
- if (child.pid != null)
679
- ticket = trackHostForegroundChild(child.pid);
673
+ ticket = adoptHostForegroundChild(child);
680
674
  closeSpoolFds();
681
675
  child.on("error", (e) => {
682
676
  finish({ ok: false, error: new ExecutionError("spawn_error", `host spawn failed: ${e.message}`) });
@@ -765,8 +759,6 @@ export class RemoteHostExecutionEnv {
765
759
  settleFromTermination(code, signal);
766
760
  });
767
761
  child.on("exit", (code, signal) => {
768
- if (ticket)
769
- untrackHostForegroundChild(ticket);
770
762
  exited = true;
771
763
  drainTimer = setTimeout(() => settleFromTermination(code, signal), EXIT_SETTLE_DRAIN_MS);
772
764
  drainTimer.unref?.();
@@ -811,9 +803,7 @@ export class RemoteHostExecutionEnv {
811
803
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
812
804
  const sh = hostShell();
813
805
  const child = spawn(sh.shell, [...sh.args, command], { cwd, env: this.mergeEnv(options?.env), ...spawnGroupOptions() });
814
- const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
815
- if (ticket)
816
- child.once("exit", () => untrackHostForegroundChild(ticket));
806
+ const ticket = adoptHostForegroundChild(child);
817
807
  let pipeDestroyTimer;
818
808
  const kill = () => {
819
809
  try {
@@ -848,8 +838,7 @@ export class RemoteHostExecutionEnv {
848
838
  child.on("close", (code, signal) => {
849
839
  if (pipeDestroyTimer)
850
840
  clearTimeout(pipeDestroyTimer);
851
- if (ticket)
852
- untrackHostForegroundChild(ticket);
841
+ ticket?.release();
853
842
  if (finished)
854
843
  return;
855
844
  exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
@@ -925,8 +914,7 @@ export class RemoteHostExecutionEnv {
925
914
  if (wallTimer)
926
915
  clearTimeout(wallTimer);
927
916
  options?.signal?.removeEventListener("abort", onAbort);
928
- if (ticket)
929
- untrackHostForegroundChild(ticket);
917
+ ticket?.release();
930
918
  }
931
919
  }
932
920
  async absolutePath(p) {
@@ -124,7 +124,7 @@ export declare class SqlRunStore {
124
124
  getActiveTaskId(sessionId: string): Promise<string | undefined>;
125
125
  /** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
126
126
  * names + comparable JS types after SqlDriver's result normalization).
127
- * 🔴 **读它的每一条查询都必须选 {@link RUN_ROW_COLS}**(S-230):映射器对「没被 SELECT 的列」是
127
+ * 🔴 **读它的每一条查询都必须选 {@link runRowCols}**(S-230):映射器对「没被 SELECT 的列」是
128
128
  * 结构性失明的 —— `undefined ?? null` 与 `Boolean(undefined)` 都会安静地铸出一个**看上去合法的假值**。
129
129
  * 修前 `listRuns` 的两条方言臂就漏选 `instance_id`,于是列表面的每一行 `instanceId` 恒 `null`
130
130
  * (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */
@@ -3,11 +3,42 @@ import { outwardPlane } from "../terminal.js";
3
3
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
4
4
  import { escapeLike } from "./sql-escape.js";
5
5
  import { pgSanitizeText } from "./pg-safe-json.js";
6
- import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
6
+ import { toIso as iso } from "./sql-row-helpers.js";
7
+ import { recordFailOpen } from "../observability/fail-open.js";
8
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonObjectColumn, readNonNullJsonValueColumn } from "./sql-json-column.js";
7
9
  import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal.js";
8
10
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
9
11
  import { isDupKeyError } from "./sql-errors.js";
10
- const RUN_ROW_COLS = "task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, cancel_requested, objective_preview, created_at, updated_at";
12
+ function runRowCols(dialect) {
13
+ return (`task_id, session_id, owner, status, ${jsonColumnText(dialect, "result")}, error, error_code, job_id, source, ` +
14
+ "instance_id, cancel_requested, objective_preview, created_at, updated_at");
15
+ }
16
+ const readPersistedTaskResultBody = createObjectColumnReader();
17
+ function decodeResultColumn(v, taskId) {
18
+ return decodeNullableJsonColumn(v, readPersistedTaskResultBody, `task_run.result for run ${taskId}`);
19
+ }
20
+ function refusing(what, read) {
21
+ try {
22
+ return read();
23
+ }
24
+ catch (e) {
25
+ throw foldJsonColumn(e, (m) => new Error(`SqlRunStore: ${m} — refusing rather than ${what}`));
26
+ }
27
+ }
28
+ function usageResultBody(v) {
29
+ try {
30
+ return decodeJsonColumn(v, readJsonObjectColumn, "task_run.result (usage scan row)");
31
+ }
32
+ catch (e) {
33
+ if (!isJsonColumnBodyProblem(e))
34
+ throw e;
35
+ recordFailOpen("server.sql-json.task-run.usage-result-unreadable");
36
+ return undefined;
37
+ }
38
+ }
39
+ function decodeEventDataColumn(v, taskId, seq) {
40
+ return decodeNullableJsonColumn(v, readNonNullJsonValueColumn, `task_event.data for run ${taskId} seq ${seq}`);
41
+ }
11
42
  export class SqlRunStore {
12
43
  db;
13
44
  constructor(db) {
@@ -178,7 +209,7 @@ export class SqlRunStore {
178
209
  sessionId: String(r.session_id),
179
210
  owner: r.owner ?? null,
180
211
  status: r.status,
181
- result: parseJson(r.result),
212
+ result: refusing("serve a finished run as if it had produced nothing", () => decodeResultColumn(r.result, String(r.task_id))) ?? null,
182
213
  error: r.error ?? null,
183
214
  errorCode: r.error_code ?? null,
184
215
  jobId: r.job_id ?? null,
@@ -191,7 +222,7 @@ export class SqlRunStore {
191
222
  };
192
223
  }
193
224
  async getRun(taskId) {
194
- const { rows } = await this.db.query(this.q(`SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = ?`, `SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = $1`), [taskId]);
225
+ const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
195
226
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
196
227
  }
197
228
  async listRuns(opts) {
@@ -220,7 +251,7 @@ export class SqlRunStore {
220
251
  where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
221
252
  params.push(new Date(opts.cursor.createdAt), new Date(opts.cursor.createdAt), opts.cursor.taskId);
222
253
  }
223
- const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
254
+ const sql = `SELECT ${runRowCols("tidb")} FROM task_run` +
224
255
  (where.length ? " WHERE " + where.join(" AND ") : "") +
225
256
  " ORDER BY created_at DESC, task_id DESC LIMIT ?";
226
257
  params.push(opts.limit);
@@ -247,7 +278,7 @@ export class SqlRunStore {
247
278
  const t = p(opts.cursor.taskId);
248
279
  where.push(`(created_at < ${a} OR (created_at = ${b} AND task_id < ${t}))`);
249
280
  }
250
- const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
281
+ const sql = `SELECT ${runRowCols("pg")} FROM task_run` +
251
282
  (where.length ? " WHERE " + where.join(" AND ") : "") +
252
283
  ` ORDER BY created_at DESC, task_id DESC LIMIT ${p(opts.limit)}`;
253
284
  const { rows } = await this.db.query(sql, params);
@@ -359,7 +390,7 @@ export class SqlRunStore {
359
390
  where.push(this.q("owner = ?", "owner = $3"));
360
391
  params.push(opts.owner);
361
392
  }
362
- const { rows } = await this.db.query(`SELECT owner, created_at, status, result FROM task_run WHERE ${where.join(" AND ")} ORDER BY created_at LIMIT ${limit + 1}`, params);
393
+ const { rows } = await this.db.query(`SELECT owner, created_at, status, ${jsonColumnText(this.db.dialect, "result")} FROM task_run WHERE ${where.join(" AND ")} ORDER BY created_at LIMIT ${limit + 1}`, params);
363
394
  const truncated = rows.length > limit;
364
395
  const page = truncated ? rows.slice(0, limit) : rows;
365
396
  return {
@@ -368,7 +399,7 @@ export class SqlRunStore {
368
399
  owner: r.owner ?? null,
369
400
  createdAtMs: new Date(r.created_at).getTime(),
370
401
  status: String(r.status),
371
- ...(r.result != null ? { stats: projectUsageStats(r.result) } : {}),
402
+ ...(r.result === null ? {} : { stats: projectUsageStats(usageResultBody(r.result)) }),
372
403
  })),
373
404
  };
374
405
  }
@@ -378,11 +409,11 @@ export class SqlRunStore {
378
409
  return m == null ? 0 : Number(m);
379
410
  }
380
411
  async getEvents(taskId, afterSeq) {
381
- const { rows } = await this.db.query(this.q("SELECT seq, type, data, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, type, data, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC"), [taskId, afterSeq]);
412
+ const { rows } = await this.db.query(this.q(`SELECT seq, type, ${jsonColumnText("tidb", "data")}, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, type, ${jsonColumnText("pg", "data")}, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC`), [taskId, afterSeq]);
382
413
  return rows.map((r) => ({
383
414
  seq: Number(r.seq),
384
415
  type: String(r.type),
385
- data: parseJson(r.data),
416
+ data: refusing("replay it as an empty ledger frame", () => decodeEventDataColumn(r.data, taskId, Number(r.seq))) ?? null,
386
417
  ts: iso(r.ts),
387
418
  }));
388
419
  }
@@ -1,6 +1,7 @@
1
1
  import { type SessionPolicyStore, type SessionPermissionRules, type StoredSessionRules, type SessionRulesRecord, type PutRulesOptions } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
+ import { type JsonColumnShape } from "./sql-json-column.js";
4
5
  import { type SqlDriver } from "./sql-driver.js";
5
6
  /** `session_policy.policy_key` 的**唯一铸造点**(core 复合键的 sha256)。
6
7
  *
@@ -8,6 +9,29 @@ import { type SqlDriver } from "./sql-driver.js";
8
9
  * 逐行重算这把键(身份在 hash 的**原像**里,等值 UPDATE 够不着)。两处各写一遍 sha256 表达式的下场是
9
10
  * 某天有人在这里改了 key 公式而收编腿还在按旧式算 —— 迁完的行从此谁也读不到,且**静默**。 */
10
11
  export declare const policyKeyFor: (sessionId: string, principal: string | undefined) => string;
12
+ /**
13
+ * 一行的 **`rules` 体**(已按文本取出的那一串)→ 收窄好的 `SessionPermissionRules`(C1)。读不出形 ⇒
14
+ * `SessionPolicyError("corrupt")`,与 rev / gen 两道拒同口径、同一个码。
15
+ *
16
+ * 形判据复用**现成的那一只**读者 `readSessionPermissionRules`(五桶 `string[]` 的单一属主;未知键照旧忽略,
17
+ * 与 core 的 `normalizeRules` 同口径),不在这里铸第二份键集。
18
+ *
19
+ * 🔴 **恰好解一次码,而这是由读腿的投影保证的**(codex 对抗复审两轮,两条读法各自实测被推翻后的终形):
20
+ * · 「体是什么就直接判形」——**错**:MariaDB(本仓 README 声明支持的 MySQL 协议后端之一)的 `JSON` 是
21
+ * `LONGTEXT` 的**别名**,驱动只对真 JSON 传输类型自动解码 ⇒ 那台机器上每一条**合法**规则都以文本到达,
22
+ * 会被判成 `corrupt`(三个读点全挂,一台好机器变砖);
23
+ * · 「体是串就再解一次」——**也错**:`JSON`/`JSONB` 列上驱动已经解过一次,于是一行体为 `"{}"`(一个把
24
+ * `JSON.stringify` 多调了一次的迁移脚本的典型产物)会被解成 `{}` 并当作**空规则**端出去 —— 与本道拒
25
+ * 要关的 `null` 洞逐字同形,只是换了个入口。
26
+ * 两条读法错在**同一个地方**:从值本身分不出「驱动解过了」还是「没解过」。⇒ 让这个歧义在**构造上**不存在:
27
+ * 三个读点都把这一列**按文本**投影(`CAST(rules AS CHAR)` / `::text`),于是到这里恒是一只串、恰好解一次。
28
+ * JSON 列里的字符串标量按文本取出来**带着引号**(真库实测两方言同形:`"\"{}\""`)⇒ 解一次得到一只串 ⇒ 判坏;
29
+ * 文本承载的列取出来就是它自己 ⇒ 解一次得到对象 ⇒ 好体。**一条规则,两种承载,零方言分叉,不依赖驱动行为。**
30
+ *
31
+ * 🔴 S-550:上面这一整段现在是**整仓**的口径,实现搬进唯一属主 `sql-json-column.ts`(投影 `jsonColumnText`
32
+ * + 恰好解一次 `decodeJsonColumn` + 折码 `foldJsonColumn`);本店只留「形判据是哪一只」与「坏体折成哪个码」。
33
+ */
34
+ export declare function readSessionRulesBody(body: unknown): JsonColumnShape<SessionPermissionRules>;
11
35
  /** Dual-dialect durable SessionPolicyStore. See the file header for the dialect-delta ledger. */
12
36
  export declare class SqlSessionPolicyStore implements SessionPolicyStore {
13
37
  private readonly db;
@@ -1,40 +1,39 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
3
3
  import { readSessionPermissionRules, describeSessionPermissionRulesProblem } from "../session-policy-wire.js";
4
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
4
5
  import { pgHasUnstorable } from "./pg-safe-json.js";
5
6
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
6
7
  import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
7
8
  export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
8
9
  const policyKey = policyKeyFor;
9
10
  const RETRY_LIMIT = 8;
11
+ export function readSessionRulesBody(body) {
12
+ const read = readSessionPermissionRules(body);
13
+ return read.problem !== undefined ? { problem: describeSessionPermissionRulesProblem(read.problem) } : { value: read.rules };
14
+ }
10
15
  function readRulesBody(text, where) {
11
- let body;
12
16
  try {
13
- body = JSON.parse(text);
14
- }
15
- catch {
16
- throw new SessionPolicyError("corrupt", `session rules row ${where} carries a rules body that is not JSON (a torn or hand-edited write) — refusing rather than serve a row nobody can read as rules`);
17
+ return decodeJsonColumn(text, readSessionRulesBody, where);
17
18
  }
18
- const read = readSessionPermissionRules(body);
19
- if (read.problem !== undefined) {
20
- throw new SessionPolicyError("corrupt", `session rules row ${where} carries a malformed rules body (${describeSessionPermissionRulesProblem(read.problem)}) — refusing rather than serve it as an EMPTY rule set, which would silently drop every tightening this session carries`);
19
+ catch (e) {
20
+ throw foldJsonColumn(e, (m) => new SessionPolicyError("corrupt", `${m} — an unreadable rules body served as an EMPTY rule set would silently drop every tightening this session carries`));
21
21
  }
22
- return read.rules;
23
22
  }
24
23
  function rowToStored(r, where) {
25
24
  const rev = Number(r.rev);
26
25
  if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
27
26
  throw new SessionPolicyError("corrupt", `session rules row ${where} carries rev ${String(r.rev)} — a stored row's rev is always ≥ 1 (the stamp is floor+1), and rev 0 is the live reader's synthetic empty seed; refusing rather than serve it as rules`);
28
27
  }
29
- const gen = Number(r.gen ?? 0);
28
+ if (r.gen === undefined || r.gen === null) {
29
+ throw new Error(`session_policy row ${where} came back without a \`gen\` column — a read leg did not project it (the column is BIGINT NOT NULL on both dialects). ` +
30
+ "Refusing rather than judge this row's lineage as generation 0, which is a value nothing minted.");
31
+ }
32
+ const gen = Number(r.gen);
30
33
  if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
31
34
  throw new SessionPolicyError("corrupt", `session rules row ${where} carries lineage gen ${String(r.gen)} — a generation is a whole number ≥ 0; refusing rather than let a reader judge lineage by a value nothing minted`);
32
35
  }
33
- const raw = r.rules;
34
- if (typeof raw !== "string") {
35
- throw new Error(`session rules row ${where}: the read did not project the rules column as TEXT (got ${typeof raw}) — the SELECT and rowToStored have drifted apart`);
36
- }
37
- return { ...readRulesBody(raw, where), rev, gen };
36
+ return { ...readRulesBody(r.rules, `session_policy.rules ${where}`), rev, gen };
38
37
  }
39
38
  export class SqlSessionPolicyStore {
40
39
  db;
@@ -48,13 +47,13 @@ export class SqlSessionPolicyStore {
48
47
  return isDupKeyError(this.db.dialect, e);
49
48
  }
50
49
  async getRules(sessionId, principal) {
51
- const { rows } = await this.db.query(this.q("SELECT CAST(rules AS CHAR) AS rules, rev, gen FROM session_policy WHERE policy_key = ?", "SELECT rules::text AS rules, rev, gen FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
50
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE policy_key = ?`, `SELECT ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE policy_key = $1`), [policyKey(sessionId, principal)]);
52
51
  if (!rows[0])
53
52
  return null;
54
53
  return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
55
54
  }
56
55
  async listBySession(sessionId) {
57
- const { rows } = await this.db.query(this.q("SELECT principal, CAST(rules AS CHAR) AS rules, rev, gen FROM session_policy WHERE session_id = ?", "SELECT principal, rules::text AS rules, rev, gen FROM session_policy WHERE session_id = $1"), [sessionId]);
56
+ const { rows } = await this.db.query(this.q(`SELECT principal, ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE session_id = ?`, `SELECT principal, ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE session_id = $1`), [sessionId]);
58
57
  return rows.map((r) => ({
59
58
  principal: r.principal == null ? undefined : String(r.principal),
60
59
  rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
@@ -63,7 +62,7 @@ export class SqlSessionPolicyStore {
63
62
  async putRules(sessionId, principal, rules, opts) {
64
63
  const key = policyKey(sessionId, principal);
65
64
  return this.retrying(() => this.tx(async (conn) => {
66
- const { rows } = await conn.query(this.q("SELECT CAST(rules AS CHAR) AS rules, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules::text AS rules, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
65
+ const { rows } = await conn.query(this.q(`SELECT ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE`, `SELECT ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE`), [key]);
67
66
  const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
68
67
  const priorRev = prior?.rev ?? 0;
69
68
  if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {