@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
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
4
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
6
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
7
- import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
7
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
9
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
10
10
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
@@ -113,22 +113,61 @@ export function boundedRuleOffers(raw, opts = {}) {
113
113
  }
114
114
  return out.length > 0 ? out : undefined;
115
115
  }
116
+ const readCheckpointBlobBody = createObjectColumnReader();
117
+ const readResumeOutcomeBody = createObjectColumnReader();
118
+ const readGateOutcomeBody = createObjectColumnReader();
119
+ const readRiskDescriptorBody = createObjectColumnReader();
120
+ function readJsonArrayBody(body) {
121
+ return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
122
+ }
123
+ function decodeCheckpointColumn(cell, where) {
124
+ return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
125
+ }
126
+ function decodeOutcomeColumn(cell, where) {
127
+ return decodeNullableJsonColumn(cell, readResumeOutcomeBody, `checkpoint.outcome ${where}`);
128
+ }
129
+ function decodeExecutionOutcomeColumn(cell, where) {
130
+ return decodeNullableJsonColumn(cell, readGateOutcomeBody, `checkpoint.execution_outcome ${where}`);
131
+ }
132
+ function decodeToolInputColumn(cell) {
133
+ return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
134
+ }
135
+ function decodeCtxColumn(cell, where) {
136
+ const body = decodeJsonColumn(cell, readAnyJsonValueColumn, `checkpoint_ctx.ctx ${where}`);
137
+ return body;
138
+ }
139
+ function refusing(what, read) {
140
+ try {
141
+ return read();
142
+ }
143
+ catch (e) {
144
+ throw foldJsonColumn(e, (m) => new Error(`SqlCheckpointStore: ${m} — refusing rather than serve ${what}`));
145
+ }
146
+ }
116
147
  function readRuleOffersCell(cell) {
148
+ if (cell === null)
149
+ return undefined;
117
150
  let parsed;
118
151
  try {
119
- parsed = parseJson(cell);
152
+ parsed = decodeJsonColumn(cell, readJsonArrayBody, "checkpoint.rule_suggestions (operator queue row)");
120
153
  }
121
- catch {
154
+ catch (e) {
155
+ if (!isJsonColumnBodyProblem(e))
156
+ throw e;
122
157
  recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
123
158
  return undefined;
124
159
  }
125
160
  return boundedRuleOffers(parsed, { countRedactions: false });
126
161
  }
127
162
  function readRiskDescriptorCell(cell) {
163
+ if (cell === null)
164
+ return null;
128
165
  try {
129
- return parseJson(cell);
166
+ return decodeJsonColumn(cell, readRiskDescriptorBody, "checkpoint.risk_descriptor (operator queue row)");
130
167
  }
131
- catch {
168
+ catch (e) {
169
+ if (!isJsonColumnBodyProblem(e))
170
+ throw e;
132
171
  recordFailOpen("server.approvals.risk-descriptor-cell-unreadable");
133
172
  return null;
134
173
  }
@@ -166,23 +205,16 @@ export function approvalDecisionOfWinner(winner, boundCallId) {
166
205
  }
167
206
  }
168
207
  }
169
- export function pendingActionToolCallId(blob) {
170
- let parsed;
171
- try {
172
- parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
173
- }
174
- catch {
175
- return { readable: false };
176
- }
177
- if (typeof parsed !== "object" || parsed === null)
208
+ export function pendingActionToolCallId(body) {
209
+ if (typeof body !== "object" || body === null)
178
210
  return { readable: false };
179
- const rawSource = "sourceTaskId" in parsed ? parsed.sourceTaskId : null;
211
+ const rawSource = "sourceTaskId" in body ? body.sourceTaskId : null;
180
212
  if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
181
213
  return { readable: false };
182
214
  const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
183
- if (!("pendingAction" in parsed))
215
+ if (!("pendingAction" in body))
184
216
  return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
185
- const pa = parsed.pendingAction;
217
+ const pa = body.pendingAction;
186
218
  if (typeof pa !== "object" || pa === null)
187
219
  return { readable: false };
188
220
  const kind = "kind" in pa ? pa.kind : null;
@@ -200,6 +232,18 @@ export function pendingActionToolCallId(blob) {
200
232
  return { readable: false };
201
233
  return { readable: true, toolCallId: id, boundInputHash: bih, kind, sourceTaskId };
202
234
  }
235
+ function pendingActionOfColumn(cell, where) {
236
+ let body;
237
+ try {
238
+ body = decodeJsonColumn(cell, readJsonObjectColumn, `checkpoint.checkpoint ${where}`);
239
+ }
240
+ catch (e) {
241
+ if (!isJsonColumnBodyProblem(e))
242
+ throw e;
243
+ return { readable: false };
244
+ }
245
+ return pendingActionToolCallId(body);
246
+ }
203
247
  function readActorAssertion(v) {
204
248
  if (typeof v !== "object" || v === null)
205
249
  return undefined;
@@ -236,33 +280,24 @@ function readQueueEntry(v) {
236
280
  }
237
281
  export function readPendingSteerQueueColumn(queueRaw) {
238
282
  let lossy = false;
239
- const parse = (raw) => {
240
- if (raw === null || raw === undefined)
241
- return undefined;
242
- if (typeof raw !== "string")
243
- return raw;
244
- try {
245
- return JSON.parse(raw);
246
- }
247
- catch {
248
- lossy = true;
249
- return undefined;
250
- }
251
- };
252
- const queueParsed = parse(queueRaw);
283
+ let items;
284
+ try {
285
+ items = decodeNullableJsonColumn(queueRaw, readJsonArrayBody, "checkpoint.pending_steer_queue");
286
+ }
287
+ catch (e) {
288
+ if (!isJsonColumnBodyProblem(e))
289
+ throw e;
290
+ lossy = true;
291
+ }
253
292
  let queue;
254
- if (queueParsed !== undefined) {
255
- if (!Array.isArray(queueParsed))
256
- lossy = true;
257
- else {
258
- queue = [];
259
- for (const item of queueParsed) {
260
- const entry = readQueueEntry(item);
261
- if (entry === undefined)
262
- lossy = true;
263
- else
264
- queue.push(entry);
265
- }
293
+ if (items !== undefined) {
294
+ queue = [];
295
+ for (const item of items) {
296
+ const entry = readQueueEntry(item);
297
+ if (entry === undefined)
298
+ lossy = true;
299
+ else
300
+ queue.push(entry);
266
301
  }
267
302
  }
268
303
  return { state: { ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) }, lossy };
@@ -359,7 +394,9 @@ export class SqlCheckpointStore {
359
394
  }
360
395
  }
361
396
  async get(token) {
362
- const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
397
+ const { rows } = await this.db.query(this.q(`SELECT version, status, ${jsonColumnText("tidb", "checkpoint")}, ${jsonColumnText("tidb", "outcome")}, reopen_reason, rev, ` +
398
+ `${jsonColumnText("tidb", "pending_steer_queue")}, ${jsonColumnText("tidb", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = ?`, `SELECT version, status, ${jsonColumnText("pg", "checkpoint")}, ${jsonColumnText("pg", "outcome")}, reopen_reason, rev, ` +
399
+ `${jsonColumnText("pg", "pending_steer_queue")}, ${jsonColumnText("pg", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = $1`), [token]);
363
400
  const r = rows[0];
364
401
  if (!r)
365
402
  return null;
@@ -367,21 +404,22 @@ export class SqlCheckpointStore {
367
404
  if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
368
405
  throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
369
406
  }
370
- const cp = parseJson(r.checkpoint);
407
+ const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `for token ${tokenFingerprint(token)}`));
371
408
  cp.status = r.status;
372
- const outcomeRaw = parseJson(r.outcome);
409
+ const outcomeRaw = refusing("a decision record nobody can read", () => decodeOutcomeColumn(r.outcome, `for token ${tokenFingerprint(token)}`));
373
410
  if (outcomeRaw)
374
411
  cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
375
412
  cp.reopenReason = r.reopen_reason ?? undefined;
376
413
  cp.rev = r.rev == null ? 0 : Number(r.rev);
377
- const executionOutcome = parseJson(r.execution_outcome);
378
- if (executionOutcome !== null) {
414
+ const executionOutcome = refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(r.execution_outcome, `for token ${tokenFingerprint(token)}`));
415
+ if (executionOutcome !== undefined) {
379
416
  cp.executionOutcome = executionOutcome;
380
417
  if (r.execution_at_ms != null)
381
418
  cp.executionAtMs = Number(r.execution_at_ms);
382
419
  }
383
420
  const steer = readPendingSteerQueueColumn(r.pending_steer_queue);
384
421
  if (steer.lossy) {
422
+ recordFailOpen("server.sql-json.checkpoint.pending-steer-queue-lossy");
385
423
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
386
424
  }
387
425
  if (cp.state) {
@@ -412,14 +450,14 @@ export class SqlCheckpointStore {
412
450
  }
413
451
  async recordExecutionOutcome(token, scope, gate) {
414
452
  for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
415
- const { rows } = await this.db.query(this.q("SELECT scope, status, execution_outcome FROM checkpoint WHERE token = ?", "SELECT scope, status, execution_outcome FROM checkpoint WHERE token = $1"), [token]);
453
+ const { rows } = await this.db.query(this.q(`SELECT scope, status, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint WHERE token = ?`, `SELECT scope, status, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint WHERE token = $1`), [token]);
416
454
  const row = rows[0];
417
455
  const facts = row === undefined
418
456
  ? undefined
419
457
  : {
420
458
  scope: String(row.scope),
421
459
  status: row.status,
422
- ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(row.execution_outcome)),
460
+ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(row.execution_outcome, `for token ${tokenFingerprint(token)}`))),
423
461
  };
424
462
  const disposition = executionRecordDisposition(facts, scope, gate);
425
463
  if (disposition === "conflict")
@@ -436,7 +474,7 @@ export class SqlCheckpointStore {
436
474
  async setPendingSteer(token, scope, steer) {
437
475
  const clean = validatePendingSteer(steer);
438
476
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
439
- const { rows } = await this.db.query(this.q("SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
477
+ const { rows } = await this.db.query(this.q(`SELECT status, ${jsonColumnText("tidb", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?`, `SELECT status, ${jsonColumnText("pg", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2`), [token, scope]);
440
478
  const row = rows[0];
441
479
  if (!row || row.status !== "pending")
442
480
  return false;
@@ -480,7 +518,9 @@ export class SqlCheckpointStore {
480
518
  return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
481
519
  }
482
520
  async listPending(scope) {
483
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.rule_suggestions, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
521
+ const d = this.db.dialect;
522
+ const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
523
+ `${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
484
524
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
485
525
  const { rows } = scope
486
526
  ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
@@ -500,7 +540,7 @@ export class SqlCheckpointStore {
500
540
  ...(boundInputHash !== null ? { boundInputHash } : {}),
501
541
  ...(gateKind !== null ? { gateKind } : {}),
502
542
  taskId: r.task_id ?? null,
503
- input: r.tool_input ?? null,
543
+ input: refusing("a tool input the operator would be approving unseen", () => decodeToolInputColumn(r.tool_input)) ?? null,
504
544
  createdAt: Number(r.created_at_ms),
505
545
  deadline: r.deadline == null ? null : Number(r.deadline),
506
546
  riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
@@ -512,12 +552,10 @@ export class SqlCheckpointStore {
512
552
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
513
553
  }
514
554
  async listByScope(scope) {
515
- const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
555
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500`, `SELECT ${jsonColumnText("pg", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500`), [scope]);
516
556
  const out = [];
517
557
  for (const r of rows) {
518
- const cp = parseJson(r.checkpoint);
519
- if (!cp)
520
- continue;
558
+ const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `in the ${scope} scheduler view`));
521
559
  cp.status = r.status;
522
560
  out.push(summarizeCheckpoint(cp));
523
561
  }
@@ -529,8 +567,8 @@ export class SqlCheckpointStore {
529
567
  return rows[0] ? String(rows[0].token) : null;
530
568
  }
531
569
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
532
- const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
533
- "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
570
+ const { rows } = await this.db.query(this.q(`SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("tidb", "checkpoint")} FROM checkpoint ` +
571
+ "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", `SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("pg", "checkpoint")} FROM checkpoint ` +
534
572
  "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
535
573
  const out = [];
536
574
  for (const r of rows) {
@@ -545,7 +583,7 @@ export class SqlCheckpointStore {
545
583
  out.push({ ...base, boundCallId: null, unparseable: true });
546
584
  continue;
547
585
  }
548
- const derived = pendingActionToolCallId(r.checkpoint);
586
+ const derived = pendingActionOfColumn(r.checkpoint, `for token ${tokenFingerprint(String(r.token))}`);
549
587
  if (!derived.readable) {
550
588
  out.push({ ...base, boundCallId: null, unparseable: true });
551
589
  continue;
@@ -567,24 +605,31 @@ export class SqlCheckpointStore {
567
605
  return out;
568
606
  }
569
607
  async findDecidedApprovalsForBinding(sessionId, boundCallId) {
570
- const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
571
- `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
608
+ const { rows } = await this.db.query(this.q(`SELECT scope, bound_input_hash, ${jsonColumnText("tidb", "outcome")}, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint ` +
609
+ `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, `SELECT scope, bound_input_hash, ${jsonColumnText("pg", "outcome")}, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint ` +
572
610
  `WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
573
611
  return rows.map((r) => {
574
612
  let winner;
575
613
  try {
576
- const outcome = parseJson(r.outcome);
614
+ const outcome = decodeOutcomeColumn(r.outcome, `for the decided row of session ${sessionId}`);
577
615
  winner = outcome ? winnerFromOutcome(outcome) : undefined;
578
616
  }
579
617
  catch (e) {
618
+ if (!isJsonColumnBodyProblem(e))
619
+ throw e;
620
+ recordFailOpen("server.sql-json.checkpoint.outcome-unreadable");
580
621
  this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
581
622
  }
582
623
  let executed = null;
583
624
  try {
584
- const verdict = executionVerdict({ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(r.execution_outcome)) });
625
+ const cell = decodeExecutionOutcomeColumn(r.execution_outcome, `for the decided row of session ${sessionId}`);
626
+ const verdict = executionVerdict({ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(cell) });
585
627
  executed = verdict.kind === "executed" ? verdict.gate : null;
586
628
  }
587
629
  catch (e) {
630
+ if (!isJsonColumnBodyProblem(e))
631
+ throw e;
632
+ recordFailOpen("server.sql-json.checkpoint.execution-outcome-unreadable");
588
633
  this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
589
634
  }
590
635
  return {
@@ -615,8 +660,8 @@ export class SqlCheckpointStore {
615
660
  "ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, this.json(ctx, "checkpoint ctx"), Date.now()]);
616
661
  }
617
662
  async getCtx(sessionId) {
618
- const { rows } = await this.db.query(this.q("SELECT ctx FROM checkpoint_ctx WHERE session_id=?", "SELECT ctx FROM checkpoint_ctx WHERE session_id=$1"), [sessionId]);
619
- return rows[0] ? parseJson(rows[0].ctx) : null;
663
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);
664
+ return rows[0] ? refusing("a resume context nobody can read", () => decodeCtxColumn(rows[0].ctx, `for session ${JSON.stringify(sessionId)}`)) : null;
620
665
  }
621
666
  async reapCtx(cutoff) {
622
667
  const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +
@@ -186,6 +186,16 @@ export declare class SqlDeviceStore implements DeviceStore {
186
186
  appendAudit(input: AppendAuditInput): Promise<void>;
187
187
  /** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */
188
188
  private insertAuditOn;
189
+ /**
190
+ * 审计列表。
191
+ *
192
+ * 🔴 **一行读不出形 ⇒ 整页拒,刻意不做逐行隔离**(S-550 修复验证轮,主会话裁定后入档):方向由设计
193
+ * §2「失败方向 = 拒(fail-closed),全店同向」定。逐行隔离(坏行跳过 / 占位)在**审计**面上尤其危险 ——
194
+ * 它把「这一页少了一条」变得和「本来就没有那一条」同形,而审计面的全部意义就是「发生过的事都在」。
195
+ * 一页 5xx 是响亮的运维事件,一条静默消失不是。
196
+ *
197
+ * ⇒ 存量库里旧写腿留下的坏字节由**一次性迁移**清掉(`one-time-migrations.ts` M4),不靠读侧容忍。
198
+ */
189
199
  listAudit(filter?: ListAuditFilter): Promise<DeviceAuditRow[]>;
190
200
  }
191
201
  /** 方言绑定薄壳(A12 形:ctor 收池,行为全在基类)。 */
@@ -2,6 +2,7 @@ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
3
3
  import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
4
4
  import { ensureIndexes } from "./ensure-index.js";
5
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
5
6
  export const DEVICES_TABLE = "device";
6
7
  export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
7
8
  export const DEVICE_SESSIONS_TABLE = "device_session";
@@ -237,7 +238,7 @@ function sessionCols(dialect) {
237
238
  return ["root_session_id", "device_id", "owner_tenant", "owner_subject", ms(dialect, "bound_at", "bound_at_ms"), "rev"].join(", ");
238
239
  }
239
240
  function auditCols(dialect) {
240
- return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", "detail"].join(", ");
241
+ return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", jsonColumnText(dialect, "detail")].join(", ");
241
242
  }
242
243
  function bufToStr(v) {
243
244
  return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
@@ -295,9 +296,6 @@ function mapSessionRow(r) {
295
296
  rev: Number(r.rev),
296
297
  };
297
298
  }
298
- function parseJsonColumn(v) {
299
- return JSON.parse(v);
300
- }
301
299
  function mapAuditRow(r) {
302
300
  const tenant = strOrNull(r.owner_tenant);
303
301
  const subject = strOrNull(r.owner_subject);
@@ -308,9 +306,17 @@ function mapAuditRow(r) {
308
306
  event: parseDeviceAuditEvent(bufToStr(r.event)),
309
307
  deviceId: strOrNull(r.device_id),
310
308
  owner: tenant !== null && subject !== null ? { tenant, subject } : null,
311
- detail: rawDetail === null || rawDetail === undefined ? null : typeof rawDetail === "string" ? parseJsonColumn(rawDetail) : rawDetail,
309
+ detail: rawDetail === null || rawDetail === undefined ? null : readAuditDetail(rawDetail, String(r.id)),
312
310
  };
313
311
  }
312
+ function readAuditDetail(raw, id) {
313
+ try {
314
+ return decodeJsonColumn(raw, readJsonObjectColumn, `${DEVICE_AUDIT_TABLE}.detail for audit row ${id}`);
315
+ }
316
+ catch (e) {
317
+ throw foldJsonColumn(e, (m) => new Error(`device-store: ${m} — refusing rather than serve an audit row whose detail nobody can read`));
318
+ }
319
+ }
314
320
  function ph(dialect, n) {
315
321
  return dialect === "tidb" ? "?" : `$${n}`;
316
322
  }
@@ -637,7 +643,7 @@ export class SqlDeviceStore {
637
643
  assertDeviceIdShape(input.deviceId);
638
644
  if (input.owner)
639
645
  assertDeviceOwnerShape(input.owner);
640
- await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail === undefined ? null : this.json(input.detail)]);
646
+ await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail == null ? null : this.json(input.detail)]);
641
647
  }
642
648
  async listAudit(filter) {
643
649
  const dialect = this.db.dialect;
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Host-lane PLATFORM seam (DESIGN-windows-native.md, FINAL r3) — the ONE place the host exec adapter's
3
+ * POSIX/win32 differences live, so `remote-env-host.ts` stays a single code path with platform-gated leaves.
4
+ *
5
+ * 🔴 Iron invariant (design §4.1): the POSIX path is BYTE-IDENTICAL to the pre-Windows code — every helper is
6
+ * `win32 ? new : exactly-what-the-inline-code-did` (same syscall, same throw behavior). Never "improve" POSIX
7
+ * here; the existing full test suite is the regression net.
8
+ *
9
+ * win32 semantics (design D1-D4):
10
+ * - shell = Git Bash via core 1.224 `getShellConfig` (WSL-launcher-filtered — the `System32\bash.exe`
11
+ * trap). Fail-LOUD when absent; never silently degrade to cmd (D1).
12
+ * - kill = core 1.224 `signalProcessTree` (taskkill /T, /F for hard). Soft is a no-op for console trees
13
+ * (taskkill errors "can only be terminated forcefully") — the SIGTERM→grace→SIGKILL ladder is
14
+ * effectively delay→hard-kill on win32; accepted, CC-identical (D2).
15
+ * - spawn = `detached:false` + `windowsHide:true` (no console window; no POSIX process group — the kill
16
+ * side uses the tree, not the group) (D3).
17
+ * - env = case-insensitive key collapse before spawn (win32 env keys are case-insensitive; a `Path`+`PATH`
18
+ * pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
19
+ * entry). Canonical casing = the first-seen key (process.env's native casing wins since the
20
+ * inherit base is spread first).
21
+ *
22
+ * ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
23
+ * structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
24
+ */
25
+ import type { ChildProcess } from "node:child_process";
1
26
  import { killProcessTree } from "@sema-agent/core";
2
27
  export declare const IS_WIN32: boolean;
3
28
  export interface HostShell {
@@ -48,15 +73,52 @@ export declare function killTreeSoft(pid: number): void;
48
73
  /** Graceful tree kill where the CALLER has no escalation timer of its own (currently unused by the host lane —
49
74
  * exported for parity with core's surface; the bg driver keeps its own HOST_BG_KILL_GRACE_MS ladder). */
50
75
  export { killProcessTree };
51
- /** 登记回执 = 注销的唯一凭据(纯数据;身份即凭据,见簿注)。 */
76
+ /**
77
+ * 登记回执 = 注销的**唯一**凭据(身份即凭据,见簿注)+ 一只幂等的注销把手。
78
+ *
79
+ * S-566 归一轮:注销**只有这一张脸**(`release()`)。此前另有一只 `untrackHostForegroundChild(ticket)`
80
+ * 自由函数,于是「把这只回执从簿上删掉」有两种写法 —— 零别名纪律下这属于歧义面,已**整个删除**
81
+ * (硬 breaking 三句:没有别名、没有过渡方法,调用方的 tsc 红就是通知)。
82
+ */
52
83
  export interface ForegroundChildTicket {
53
84
  /** 组领袖 pid = pgid(POSIX `detached:true` 的直接后果)。 */
54
85
  readonly pid: number;
86
+ /**
87
+ * 提前注销 —— 给各腿**特有**的兜底时点用(settle / `close` / 领养移交 / 生成器 `finally`)。
88
+ * 幂等(同一只回执 release 多少次都一样),且按**身份**删 ⇒ 迟到的那一次**绝不**碰内核复用同号之后
89
+ * 的新登记。主时点(`exit` / `error`)由 {@link adoptHostForegroundChild} 自己挂,调用点不必重复写。
90
+ */
91
+ release(): void;
55
92
  }
56
- /** 登记一只自起的前台子进程,返回注销用的回执。 */
93
+ /**
94
+ * 登记一只自起的前台子进程(**低层原语**:只有 pid 在手时用它 —— 产品码一律走
95
+ * {@link adoptHostForegroundChild},见那条形状钉)。
96
+ */
57
97
  export declare function trackHostForegroundChild(pid: number): ForegroundChildTicket;
58
- /** 注销(`exit` / settle / 领养移交)。按回执身份删 ⇒ 幂等,且**绝不**碰同号的其它登记。 */
59
- export declare function untrackHostForegroundChild(ticket: ForegroundChildTicket): void;
98
+ /**
99
+ * 🔴 **本簿在 `src/` 的唯一登记口**(S-566 归一轮)—— 一只自起的 detached 前台子进程「进簿 + 两只主注销」
100
+ * 的**全部**语义收在这里。
101
+ *
102
+ * ── 为什么是一只函数而不是五处同一句 ────────────────────────────────────────────────────────────
103
+ * 归一前,这一句在仓里**手抄五份**(`remote-env-host.ts` 三条 fg 腿 + `hook-runner.ts` 两条):
104
+ * 「`pid != null` 才登记」「`exit` 主注销」「`error` 之后 `exit` 可能不来,所以也要注销」——
105
+ * 每多一条 detached 腿就要再抄一遍,而**第六处只要漏抄一臂就是一个静默的孤儿洞**(S-566 的病正是
106
+ * hook 那两条腿整份没抄)。一条规则答完所有腿,规则集因此变小而不是变大。
107
+ *
108
+ * ── 语义(三条,逐条有理由)────────────────────────────────────────────────────────────────────
109
+ * · **无 pid ⇒ `undefined`,不登记**:spawn 起手失败那一族根本没有组领袖,簿上放一个 `undefined` 的坐标
110
+ * 等于给收割腿一条假线索;调用点因此拿到的是一个「可能不在」的把手,漏判会被类型挡住。
111
+ * · **`exit` = 主注销时点**(不是 `close` / settle):`close` 是**管道**驱动的,一个继承了 stdout 的孙进程
112
+ * 能把它按住任意久;而一条已退的 pid 留在簿里 = 给收割腿一个**过期坐标** —— 内核复用那个号之后
113
+ * `kill(-pid)` 打的是**别人**的组。
114
+ * · **`error` 也注销**:Node 契约里 `error` 之后 `exit` **可能不来**(异步起手失败),那一形没有第二次机会。
115
+ * 本函数只挂注销,**不**吞 `error` —— 五条腿各自的 `error` 处置(记账 / settle / 铸故障帧)原样在位,
116
+ * 本监听是第 N 只监听器,不改变「`error` 有没有人接」这件事(亲核:五处在归一前就都已有 `error` 监听)。
117
+ *
118
+ * 各腿**特有**的兜底(settle / `close` / 领养移交 / 生成器 `finally`)留在调用点,写成一行
119
+ * `ticket?.release()` —— 它们是**那条腿自己的**生命周期事实,不是本簿的语义。
120
+ */
121
+ export declare function adoptHostForegroundChild(child: ChildProcess): ForegroundChildTicket | undefined;
60
122
  /** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
61
123
  * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
62
124
  *
@@ -75,8 +137,10 @@ export interface ForegroundReapReading {
75
137
  * 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
76
138
  * 是 uncaught 出口。
77
139
  *
78
- * **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是收尾链的
79
- * `process.once("exit")` 钩 —— `exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
140
+ * **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是退出边界装配器
141
+ * (`boot/exit-boundary.ts`)那只 `process.once("exit")` 钩 —— S-560 起它由**两条车道共用**(HTTP 服务的
142
+ * 收尾链 + `run-local` 一次性 CLI;后者另装 SIGINT/SIGTERM 处理器,因为前台 CLI 的 Ctrl-C 若走默认终止,
143
+ * `exit` 钩不触发)。`exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
80
144
  * 时刻上(`hardShutdown` 终点那次早按只是优化,见 `boot/shutdown.ts` 的两处注)。上界 = 簿上条数 ×
81
145
  * 一次 `kill(-pid)` syscall(簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
82
146
  *
@@ -56,12 +56,23 @@ export function killTreeSoft(pid) {
56
56
  export { killProcessTree };
57
57
  const liveHostForegroundGroups = new Set();
58
58
  export function trackHostForegroundChild(pid) {
59
- const ticket = { pid };
59
+ const ticket = {
60
+ pid,
61
+ release() {
62
+ liveHostForegroundGroups.delete(ticket);
63
+ },
64
+ };
60
65
  liveHostForegroundGroups.add(ticket);
61
66
  return ticket;
62
67
  }
63
- export function untrackHostForegroundChild(ticket) {
64
- liveHostForegroundGroups.delete(ticket);
68
+ export function adoptHostForegroundChild(child) {
69
+ const pid = child.pid;
70
+ if (pid == null)
71
+ return undefined;
72
+ const ticket = trackHostForegroundChild(pid);
73
+ child.once("exit", () => ticket.release());
74
+ child.once("error", () => ticket.release());
75
+ return ticket;
65
76
  }
66
77
  export function reapHostForegroundChildren() {
67
78
  let signalled = 0;
@@ -2,7 +2,7 @@ import { uuidv7 } from "@sema-agent/core";
2
2
  import { createHash } from "node:crypto";
3
3
  import { isDupKeyError } from "./sql-errors.js";
4
4
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
5
- import { parseJsonOr } from "./sql-row-helpers.js";
5
+ import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn } from "./sql-json-column.js";
6
6
  import { ensureIndexes } from "./ensure-index.js";
7
7
  export const LEADER_RUN_TABLE = "leader_run";
8
8
  const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
@@ -59,7 +59,17 @@ export async function ensurePgLeaderRunSchema(q) {
59
59
  }
60
60
  export const STALE_SWEEP_REASON = "leader run went stale: no replica settled it within LEADER_RUN_STALE_MS (the replica driving it most likely died). " +
61
61
  "The run was NOT taken over — re-submit with a NEW Idempotency-Key to run it again.";
62
- const SELECT_COLS = "leader_run_id, owner, objective_preview, status, result, error, started_at_ms, finished_at_ms";
62
+ function selectCols(dialect) {
63
+ return `leader_run_id, owner, objective_preview, status, ${jsonColumnText(dialect, "result")}, error, started_at_ms, finished_at_ms`;
64
+ }
65
+ function readResultColumn(text, id) {
66
+ try {
67
+ return decodeNullableJsonColumn(text, readNonNullJsonValueColumn, `${LEADER_RUN_TABLE}.result for leader run ${id}`);
68
+ }
69
+ catch (e) {
70
+ throw foldJsonColumn(e, (m) => new Error(`leader_run: ${m} — refusing rather than serve this run as if it had finished empty-handed`));
71
+ }
72
+ }
63
73
  function statusOf(raw, id) {
64
74
  const s = String(raw);
65
75
  if (!LEADER_RUN_STATUSES.has(s)) {
@@ -69,7 +79,7 @@ function statusOf(raw, id) {
69
79
  }
70
80
  function mapRow(r) {
71
81
  const id = String(r.leader_run_id);
72
- const result = r.result == null ? undefined : parseJsonOr(r.result, undefined);
82
+ const result = readResultColumn(r.result, id);
73
83
  const error = r.error == null ? undefined : String(r.error);
74
84
  const finished = r.finished_at_ms == null ? undefined : Number(r.finished_at_ms);
75
85
  return {
@@ -113,12 +123,12 @@ export class SqlLeaderRunStore {
113
123
  return { created: true, run };
114
124
  }
115
125
  async getRun(id) {
116
- const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
126
+ const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
117
127
  const row = res.rows[0];
118
128
  return row ? mapRow(row) : undefined;
119
129
  }
120
130
  async getByIdem(idemKey) {
121
- const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
131
+ const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
122
132
  const row = res.rows[0];
123
133
  return row ? mapRow(row) : undefined;
124
134
  }
@@ -132,7 +142,7 @@ export class SqlLeaderRunStore {
132
142
  }
133
143
  async finishRun(id, patch) {
134
144
  const json = dialectJsonEncoder(this.db.dialect);
135
- const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result === undefined ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
145
+ const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result == null ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
136
146
  return res.affected > 0;
137
147
  }
138
148
  }
@@ -13,6 +13,9 @@ export interface OneTimeMigrationReport {
13
13
  readonly retiredSteerSeatStaleRows: number;
14
14
  /** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
15
15
  readonly askOriginRowsMigrated: number;
16
+ /** M4:`device_audit.detail` 里 JSON 文本 `null` 被改成列 NULL 的行数(表不在 ⇒ 恒 0)。
17
+ * 非零 = 这次迁移真改写了存量字节,必须让运维看见(与 M1 的 `retiredSnapshotRows` 同一条纪律)。 */
18
+ readonly auditDetailJsonNullRows: number;
16
19
  }
17
20
  /** 三条迁移都不适用的载体(local 车道没有 SQL 表)。 */
18
21
  export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated">;