@sema-agent/server 7.56.0 → 7.57.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 (110) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +2 -2
  3. package/USAGE.md +101 -9
  4. package/dist/approval-ask-audit-store.d.ts +100 -1
  5. package/dist/approval-ask-audit-store.js +103 -2
  6. package/dist/approval-card.d.ts +148 -13
  7. package/dist/approval-card.js +82 -13
  8. package/dist/approval.d.ts +31 -0
  9. package/dist/approval.js +18 -0
  10. package/dist/auto-mode-face.d.ts +111 -0
  11. package/dist/auto-mode-face.js +99 -0
  12. package/dist/bench/s1/arms.js +5 -5
  13. package/dist/bench/s1/live-deps.js +11 -11
  14. package/dist/bench/s1/run-firm.js +3 -0
  15. package/dist/bench/s1/runner-ctx.d.ts +4 -0
  16. package/dist/bench/s1/runner-ctx.js +7 -0
  17. package/dist/boot/config-center.js +23 -1
  18. package/dist/boot/coordinators.js +13 -1
  19. package/dist/boot/parked-revive-gate.d.ts +7 -2
  20. package/dist/boot/parked-revive-gate.js +12 -1
  21. package/dist/boot/resolve-spec.d.ts +3 -0
  22. package/dist/boot/resolve-spec.js +3 -1
  23. package/dist/boot/runner-deps.d.ts +12 -0
  24. package/dist/boot/runner-deps.js +28 -3
  25. package/dist/boot/runtime-caps.d.ts +19 -9
  26. package/dist/boot/runtime-caps.js +49 -21
  27. package/dist/boot/session-shell-gate-registry.d.ts +39 -0
  28. package/dist/boot/session-shell-gate-registry.js +21 -0
  29. package/dist/config-catalog.js +14 -5
  30. package/dist/config-types.d.ts +41 -10
  31. package/dist/config.d.ts +21 -0
  32. package/dist/config.js +40 -5
  33. package/dist/http/route-ctx.d.ts +75 -0
  34. package/dist/http/routes/a2a-serve.js +5 -3
  35. package/dist/http/routes/approvals-assistant.js +19 -6
  36. package/dist/http/routes/capabilities.js +21 -4
  37. package/dist/http/routes/memory-origin.d.ts +2 -2
  38. package/dist/http/routes/rules.js +3 -2
  39. package/dist/http/routes/runs.d.ts +0 -14
  40. package/dist/http/routes/runs.js +20 -8
  41. package/dist/http/routes/tasks.js +34 -8
  42. package/dist/http/routes/workflows.js +19 -4
  43. package/dist/http/server.d.ts +11 -1
  44. package/dist/http/server.js +215 -43
  45. package/dist/http/wire-types.d.ts +9 -4
  46. package/dist/leader/diffout.js +2 -1
  47. package/dist/leader/endpoint.js +45 -12
  48. package/dist/leader/fanout.js +6 -5
  49. package/dist/leader/leader.js +17 -14
  50. package/dist/leader/merge.js +17 -12
  51. package/dist/leader/planner.js +3 -2
  52. package/dist/leader/repair-oracle.js +6 -4
  53. package/dist/leader/repair-wire.js +5 -3
  54. package/dist/leader/wire.d.ts +30 -1
  55. package/dist/leader/wire.js +36 -19
  56. package/dist/main.js +43 -7
  57. package/dist/observability/err-text.d.ts +6 -0
  58. package/dist/observability/err-text.js +10 -0
  59. package/dist/observability/fail-open.d.ts +40 -0
  60. package/dist/observability/fail-open.js +19 -0
  61. package/dist/observability/metrics.js +1 -1
  62. package/dist/observability/run-terminal-log.d.ts +120 -0
  63. package/dist/observability/run-terminal-log.js +360 -0
  64. package/dist/orchestration/workflow-completion-inbox.d.ts +9 -0
  65. package/dist/orchestration/workflow-completion-inbox.js +8 -0
  66. package/dist/permission-rule-vocab.d.ts +40 -0
  67. package/dist/permission-rule-vocab.js +17 -0
  68. package/dist/plugins/checkpoint-store-sql.d.ts +14 -0
  69. package/dist/plugins/checkpoint-store-sql.js +19 -3
  70. package/dist/plugins/file-run-store.d.ts +3 -34
  71. package/dist/plugins/file-run-store.js +19 -0
  72. package/dist/plugins/local-checkpoint-store.d.ts +10 -0
  73. package/dist/plugins/local-checkpoint-store.js +3 -0
  74. package/dist/plugins/memory-run-store.d.ts +3 -15
  75. package/dist/plugins/memory-run-store.js +19 -0
  76. package/dist/plugins/permission-rule-store-file.js +8 -3
  77. package/dist/plugins/permission-rule-store-sql.js +16 -10
  78. package/dist/plugins/remote-scratchpad.js +3 -2
  79. package/dist/plugins/run-store-sql.d.ts +3 -42
  80. package/dist/plugins/run-store-sql.js +30 -1
  81. package/dist/plugins/store-backend.d.ts +1 -1
  82. package/dist/plugins/store-contracts.d.ts +66 -1
  83. package/dist/plugins/workflow-run-store-sql.d.ts +5 -0
  84. package/dist/plugins/workflow-run-store-sql.js +10 -2
  85. package/dist/rules-consent.js +20 -13
  86. package/dist/run-cancel-context.d.ts +13 -0
  87. package/dist/run-cancel-context.js +15 -0
  88. package/dist/run-local.js +2 -2
  89. package/dist/runs.d.ts +4 -1
  90. package/dist/runs.js +41 -10
  91. package/dist/runtime-caps-resolver.d.ts +133 -17
  92. package/dist/runtime-caps-resolver.js +41 -3
  93. package/dist/task-settings.d.ts +57 -3
  94. package/dist/task-settings.js +78 -5
  95. package/dist/task-workflow.d.ts +32 -2
  96. package/dist/task-workflow.js +8 -3
  97. package/dist/tool-approval.d.ts +26 -3
  98. package/dist/tool-approval.js +96 -13
  99. package/dist/trace/core-keyset-guard.d.ts +2 -2
  100. package/dist/trace/ledger-sink.d.ts +13 -4
  101. package/dist/trace/ledger-sink.js +14 -6
  102. package/dist/trace/project.d.ts +13 -0
  103. package/dist/trace/project.js +10 -1
  104. package/dist/trace/redact.d.ts +22 -11
  105. package/dist/trace/redact.js +655 -20
  106. package/dist/trace/sema-provenance.d.ts +26 -0
  107. package/dist/trace/sema-provenance.js +11 -0
  108. package/dist/turn-activity.d.ts +32 -2
  109. package/dist/turn-activity.js +29 -4
  110. package/package.json +2 -2
@@ -231,6 +231,9 @@ export class LocalCheckpointStore {
231
231
  }
232
232
  return out;
233
233
  }
234
+ async peekScopeByToken(token) {
235
+ return (await this.inner.get(token))?.scope;
236
+ }
234
237
  async peekPendingScope(sessionId, binding) {
235
238
  return this.pickPendingRow(await this.pendings(), sessionId, binding)?.cp.scope;
236
239
  }
@@ -1,18 +1,6 @@
1
- /**
2
- * In-memory async run registry + replayable event log — the LOCAL (DB-less) twin of {@link TiDBRunStore}/
3
- * {@link PgRunStore} (clay 2026-06-25: the seamless local↔cloud switch — a locally-run HTTP service must serve the
4
- * SAME contract a cloud worker does, incl. `/v1/runs` async runs + the trace event log). Behaviourally identical to
5
- * the SQL twins (the shared `storeBehaviorSuite` keeps them in lock-step), but process-local + ephemeral: a restart
6
- * loses run history (file-backed durability is the immediate follow-on; the conversation HISTORY + checkpoints DO
7
- * persist via core's FileStorageBackend). Single-process, so the cross-replica/durable reapers degrade — see below.
8
- *
9
- * Mirrors the SQL semantics exactly: the `task_active` single-active-run claim (a Map keyed by sessionId), the
10
- * NON-terminal suspend/needs_review parks that KEEP the claim, the markResuming CAS that resets cancel/preempt, the
11
- * owner null-safe guard on the flag/heartbeat paths (`<=>` → JS `(a??null)===(b??null)`), and the keyset list order.
12
- */
13
1
  import { type UsageRow } from "../usage-analytics.js";
14
- import type { TaskResult, TaskStatus } from "@sema-agent/core";
15
- import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
2
+ import type { TaskStatus } from "@sema-agent/core";
3
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
16
4
  import type { LedgerEventType } from "../trace/ledger-events.js";
17
5
  /**
18
6
  * [ref] P0 — the local lane's stand-in for the SQL twins' checkpoint-table JOIN. The file/memory run stores
@@ -66,7 +54,7 @@ export declare class MemoryRunStore {
66
54
  getEvents(taskId: string, afterSeq: number): Promise<RunEvent[]>;
67
55
  /** [ref] FIRST terminal writer wins(SQL 孪生同款正向 CAS on running/suspended/needs_review,
68
56
  * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
69
- setTerminal(taskId: string, status: TaskStatus, result: TaskResult | null, error: string | null): Promise<void>;
57
+ setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
70
58
  /** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard). */
71
59
  setSuspended(taskId: string): Promise<void>;
72
60
  /** [ref] D-B: park NON-terminal `needs_review`, KEEP the claim. CAS on running/needs_review. */
@@ -1,4 +1,6 @@
1
+ import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
1
2
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
3
+ import { notifyRunTerminal } from "../observability/run-terminal-log.js";
2
4
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
3
5
  export class MemoryRunStore {
4
6
  runs = new Map();
@@ -78,6 +80,7 @@ export class MemoryRunStore {
78
80
  r.updatedAt = new Date();
79
81
  }
80
82
  async appendEvent(taskId, seq, type, data) {
83
+ data = redactLedgerEventData(type, data);
81
84
  const list = this.events.get(taskId) ?? [];
82
85
  if (list.some((e) => e.seq === seq))
83
86
  return;
@@ -100,6 +103,8 @@ export class MemoryRunStore {
100
103
  .map((e) => ({ ...e, data: e.data == null ? null : structuredClone(e.data) }));
101
104
  }
102
105
  async setTerminal(taskId, status, result, error) {
106
+ result = redactTerminalResult(result);
107
+ error = error === null ? null : (redactErrorMessage(error) ?? null);
103
108
  const r = this.runs.get(taskId);
104
109
  if (!r) {
105
110
  for (const [sessionId, tid] of this.active)
@@ -109,15 +114,29 @@ export class MemoryRunStore {
109
114
  }
110
115
  return;
111
116
  }
117
+ let pending;
112
118
  if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
113
119
  r.status = status;
114
120
  r.result = result;
115
121
  r.error = error;
116
122
  r.errorCode = result?.errorCode ?? null;
117
123
  r.updatedAt = new Date();
124
+ pending = {
125
+ runId: taskId,
126
+ sessionId: r.sessionId,
127
+ owner: r.owner,
128
+ status,
129
+ resultTaskId: result?.taskId,
130
+ model: result?.model,
131
+ errorCode: r.errorCode ?? undefined,
132
+ errorMessage: result?.errorMessage ?? error ?? undefined,
133
+ elapsedMs: Math.max(0, Date.now() - r.createdAt.getTime()),
134
+ };
118
135
  }
119
136
  if (this.active.get(r.sessionId) === taskId)
120
137
  this.active.delete(r.sessionId);
138
+ if (pending)
139
+ notifyRunTerminal(pending);
121
140
  }
122
141
  async setSuspended(taskId) {
123
142
  const r = this.runs.get(taskId);
@@ -5,6 +5,7 @@ import { performance } from "node:perf_hooks";
5
5
  import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
6
6
  import { z } from "zod";
7
7
  import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
+ import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
8
9
  const RuleScopeSchema = z.union([
9
10
  z.object({ kind: z.literal("global") }).strict(),
10
11
  z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
@@ -19,6 +20,7 @@ const RuleOffer2Schema = z.union([
19
20
  candidates: z.array(z.number().int().nonnegative().safe()),
20
21
  segments: z.array(z.string()).optional(),
21
22
  uncoveredSegments: z.number().int().nonnegative().safe().optional(),
23
+ uncoveredDetail: z.array(z.object({ segment: z.string(), reason: z.enum(UNCOVERED_SEGMENT_REASONS) }).strict()).optional(),
22
24
  })
23
25
  .strict(),
24
26
  ]);
@@ -27,7 +29,7 @@ const ApprovalRecordSchema = z
27
29
  id: z.string().min(1),
28
30
  principal: z.string().optional(),
29
31
  owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
30
- schema: z.literal(2),
32
+ schema: z.literal(RULE_APPROVAL_RECORD_SCHEMA),
31
33
  kind: z.enum(["card", "import", "starter"]),
32
34
  state: z.enum(["pending", "approved", "redeemed"]),
33
35
  candidates: z.array(RuleCandidateSchema),
@@ -36,7 +38,10 @@ const ApprovalRecordSchema = z
36
38
  toolCallId: z.string().optional(),
37
39
  boundInputHash: z.string().optional(),
38
40
  command: z.string().optional(),
39
- edited: z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict().optional(),
41
+ edited: z
42
+ .object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string(), warnings: z.array(z.enum(EDITED_RULE_BREADTH_WARNING_CODES)).optional() })
43
+ .strict()
44
+ .optional(),
40
45
  rev: z.number().int().nonnegative().safe(),
41
46
  selectedOffer: z.number().int().nonnegative().safe().optional(),
42
47
  redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
@@ -138,7 +143,7 @@ export class FileRuleApprovalRecordStore {
138
143
  const parsed = ApprovalLineSchema.safeParse(raw);
139
144
  if (!parsed.success) {
140
145
  const stale = StaleApprovalLineSchema.safeParse(raw);
141
- if (stale.success && stale.data.record.schema !== 2) {
146
+ if (stale.success && stale.data.record.schema !== RULE_APPROVAL_RECORD_SCHEMA) {
142
147
  this.rows.set(stale.data.record.id, toStaleEnvelope(stale.data.record));
143
148
  continue;
144
149
  }
@@ -4,6 +4,7 @@ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuar
4
4
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
+ import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, PERSISTED_RULE_TOOLS, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
7
8
  export function writerOfSqlRuleStore(store) {
8
9
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
9
10
  if (w === null || typeof w !== "object")
@@ -26,8 +27,8 @@ const RuleAddSchema = z
26
27
  const PersistedAllowRuleSchema = z
27
28
  .object({
28
29
  rule: z.string(),
29
- tool: z.literal("Bash"),
30
- match: z.enum(["exact", "prefix"]),
30
+ tool: z.enum(PERSISTED_RULE_TOOLS),
31
+ match: z.enum(PERSISTED_RULE_MATCHES),
31
32
  command: z.string(),
32
33
  scope: DurableRuleScopeSchema,
33
34
  adds: z.array(RuleAddSchema),
@@ -52,7 +53,9 @@ const QuarantinedRuleAddSchema = z
52
53
  const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
53
54
  const RuleCandidateSchema = z.object({ rule: z.string(), scope: DurableRuleScopeSchema }).strict();
54
55
  const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
55
- const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
56
+ const RuleEditedSchema = z
57
+ .object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string(), warnings: z.array(z.enum(EDITED_RULE_BREADTH_WARNING_CODES)).optional() })
58
+ .strict();
56
59
  const RuleOffer2Schema = z.union([
57
60
  z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
58
61
  z
@@ -61,6 +64,7 @@ const RuleOffer2Schema = z.union([
61
64
  candidates: z.array(z.number().int().nonnegative().safe()),
62
65
  segments: z.array(z.string()).optional(),
63
66
  uncoveredSegments: z.number().int().nonnegative().safe().optional(),
67
+ uncoveredDetail: z.array(z.object({ segment: z.string(), reason: z.enum(UNCOVERED_SEGMENT_REASONS) }).strict()).optional(),
64
68
  })
65
69
  .strict(),
66
70
  ]);
@@ -104,8 +108,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
104
108
  state VARCHAR(32) NOT NULL,
105
109
  rev BIGINT NOT NULL DEFAULT 0,
106
110
  candidates_json LONGTEXT NOT NULL,
107
- -- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 2;OCC 守卫另在 rev,
108
- -- 两轴禁混,schema-naming 门③ 白名单登记)。读侧对非 2 的行交还 design/375 §4.5 的
111
+ -- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 3 —— design/382 §3.5 由 2 升,
112
+ -- 代码里的单点是 RULE_APPROVAL_RECORD_SCHEMA;OCC 守卫另在 rev,两轴禁混,schema-naming 门③ 白名单
113
+ -- 登记)。读侧对非 3 的行(含上一形的 2)交还 design/375 §4.5 的
109
114
  -- StaleRuleApprovalRecord 信封(identity 键 only)—— 本仓 SCHEMA POLICY 是删表重建,重建后的表
110
115
  -- 不该有这种行,这一臂是「未来再换形」的防御位,绝不静默 widen 成一条本形担保不了的记录。
111
116
  version INT NOT NULL,
@@ -258,7 +263,8 @@ export async function ensurePgPermissionRuleSchema(q) {
258
263
  rev BIGINT NOT NULL DEFAULT 0,
259
264
  candidates_json TEXT COLLATE "C" NOT NULL,
260
265
  -- version / offers_json / selected_offer:design/375 换形三列(MySQL 孪生的行内注写了理由:
261
- -- version=数据格式版本恒 2,非 2 的行读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
266
+ -- version=数据格式版本恒 3(design/382 §3.5 2 升;单点 RULE_APPROVAL_RECORD_SCHEMA),非 3 的行(含 2)
267
+ -- 读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
262
268
  -- 禁存文本副本;selected_offer=确认的 OFFER index,与 ticket/redeemed_dots 的 CANDIDATE index
263
269
  -- 两空间分离)。
264
270
  version INT NOT NULL,
@@ -525,7 +531,7 @@ export class SqlRuleApprovalRecordStore {
525
531
  const ownerKind = String(row.owner_kind);
526
532
  const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
527
533
  const identity = ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } };
528
- if (Number(row.version) !== 2) {
534
+ if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
529
535
  return { staleSchema: true, id: String(row.record_id), ...identity };
530
536
  }
531
537
  const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
@@ -541,7 +547,7 @@ export class SqlRuleApprovalRecordStore {
541
547
  return {
542
548
  id: String(row.record_id),
543
549
  ...identity,
544
- schema: 2,
550
+ schema: RULE_APPROVAL_RECORD_SCHEMA,
545
551
  kind: kind.data,
546
552
  state: state.data,
547
553
  candidates,
@@ -584,14 +590,14 @@ export class SqlRuleApprovalRecordStore {
584
590
  ]);
585
591
  }
586
592
  async discardPendingRecord(recordId) {
587
- const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = 2`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = 2`), [recordId]);
593
+ const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [recordId]);
588
594
  return affected === 1;
589
595
  }
590
596
  async cas(id, expectRev, next) {
591
597
  if (next.rev !== expectRev + 1) {
592
598
  throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
593
599
  }
594
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = 2`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = 2`), [
600
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [
595
601
  next.state,
596
602
  next.rev,
597
603
  this.enc(next.candidates, "permission_rule_approval.candidates_json"),
@@ -1,3 +1,4 @@
1
+ import { redactHead } from "../observability/run-terminal-log.js";
1
2
  const REMOTE_SCRATCHPAD_LANES = new Set(["e2b", "k8s", "local-docker", "ssh", "device"]);
2
3
  export function isRemoteScratchpadLane(provider) {
3
4
  return provider !== undefined && REMOTE_SCRATCHPAD_LANES.has(provider);
@@ -28,12 +29,12 @@ export function withRemoteScratchpad(factory, logger) {
28
29
  if (!r.ok || r.value.exitCode !== 0) {
29
30
  logger?.warn?.("remote_scratchpad_mkdir_failed", {
30
31
  dir,
31
- ...(r.ok ? { exitCode: r.value.exitCode, stderr: r.value.stderr.slice(0, 256) } : { error: r.error.message }),
32
+ ...(r.ok ? { exitCode: r.value.exitCode, stderr: redactHead(r.value.stderr, 256) } : { error: redactHead(r.error.message, 512) }),
32
33
  });
33
34
  }
34
35
  }
35
36
  catch (err) {
36
- logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: err instanceof Error ? err.message : String(err) });
37
+ logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: redactHead(err instanceof Error ? err.message : String(err), 512) });
37
38
  }
38
39
  })());
39
40
  e.exec = async (command, options) => {
@@ -1,47 +1,8 @@
1
- /**
2
- * Async run registry (S1) + replayable event log (S2) — SINGLE-FILE DUAL-DIALECT ([ref] A12 定型半场).
3
- * ONE implementation, TWO dialects; the historical `TiDBRunStore` / `PgRunStore` class names survive as thin
4
- * ctor subclasses so every consumer (store-backend.ts, the run/reaper/session/usage suites) is untouched.
5
- *
6
- * A run is the background execution of one task. `POST /v1/runs` records it `running` and returns
7
- * immediately; the background driver (`src/runs.ts`) appends events here as they happen and marks the
8
- * run terminal when done. Because runs + events are durable and shared, any stateless instance can
9
- * report status or replay the event stream — a client can reconnect (even to a different instance)
10
- * with `Last-Event-ID` and resume. The two dialects are kept in lock-step by ONE shared integration suite
11
- * (test/run-store-reaper-integration.test.ts) that runs the SAME park→resume→claim scenario against BOTH
12
- * real engines — the anti-drift guard.
13
- *
14
- * ── Dialect deltas, kept EXPLICIT (never hidden behind an abstraction) ────────────────────────────────
15
- * - `?` placeholders vs `$n` (transaction control itself is normalized by `SqlDriver`/`SqlTxConn` —
16
- * mysql2's native begin/commit/rollback vs PG's statement form; see sql-driver.ts)
17
- * - dup-key classification (createRun's task_active claim race) — the PREDICATE has a single owner
18
- * (`sql-errors.ts`, [ref] P1-①); only the dialect DISPATCH is visible here
19
- *
20
- * ⚠️ The run ACTIVE-status word list written out in the `status IN (…)` / `NOT IN (…)` fragments below
21
- * is the closed set owned by `store-contracts.ts` (`isTerminalRunStatus` / `isParkedRunStatus`). The SQL
22
- * text stays literal ON PURPOSE (A12: both dialects readable side by side), so the tie is a PARITY GATE:
23
- * `test/source-hygiene-gate.test.ts` → "[ref] P1-② run 活状态 SQL 字面 ↔ store-contracts 闭集属主 parity".
24
- * core adding a TaskStatus member turns that gate red on every fragment here — update them in the same batch.
25
- * - null-safe owner compare: `<=>` (TiDB) vs `IS NOT DISTINCT FROM` (PG) — a null-owner run still matches
26
- * `owner=null` on both, single-DB-fleet defense-in-depth (see {@link requestCancel})
27
- * - multi-table DELETE: `DELETE t FROM t JOIN j …` (TiDB) vs `DELETE FROM t USING j …` (PG)
28
- * - `UPDATE t AS a SET col=…` — PG's SET columns are NOT alias-qualified (PG rejects `SET a.col`), while
29
- * TiDB's SET clause DOES qualify (`SET tr.col = …`) — see reapSuspended / failSuspendedWithExpiredCheckpoint
30
- * - JSON binding: TiDB `JSON.stringify` verbatim vs PG `pgSafeJsonStringify` + `$n::jsonb` casts
31
- * - PG-only protocol-byte defenses on plain-TEXT columns: `pgSanitizeText` on objective_preview (createRun)
32
- * and error/error_code (setTerminal) — a raw NUL would throw 22P05 and roll back the whole terminal write,
33
- * stranding the run in a live status forever (codex R12); TiDB stores these verbatim
34
- * - dynamic multi-clause builders (listRuns/listSessions): TiDB's `?` is position-agnostic so the WHERE
35
- * array is built with plain string pushes; PG's `$n` needs numbered placeholders, so those two methods
36
- * keep an EXPLICIT per-dialect branch (a shared positional-counter abstraction would hide the very
37
- * placeholder-numbering divergence this file exists to keep visible)
38
- * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
39
- */
40
- import type { TaskResult, TaskStatus } from "@sema-agent/core";
1
+ import type { TaskStatus } from "@sema-agent/core";
41
2
  import type { Pool as MySqlPool } from "mysql2/promise";
42
3
  import type { Pool as PgPool } from "pg";
43
4
  import { type UsageRow } from "../usage-analytics.js";
44
- import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
5
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
45
6
  import { type SqlDriver } from "./sql-driver.js";
46
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
47
8
  export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
@@ -119,7 +80,7 @@ export declare class SqlRunStore {
119
80
  * abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the
120
81
  * blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
121
82
  * unconditional (idempotent; the first writer already released it). */
122
- setTerminal(taskId: string, status: TaskStatus, result: TaskResult | null, error: string | null): Promise<void>;
83
+ setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
123
84
  /**
124
85
  * Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
125
86
  * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
@@ -1,7 +1,9 @@
1
+ import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
1
2
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
2
3
  import { escapeLike } from "./sql-escape.js";
3
4
  import { pgSanitizeText } from "./pg-safe-json.js";
4
5
  import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
6
+ import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal-log.js";
5
7
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
8
  import { isDupKeyError } from "./sql-errors.js";
7
9
  export class SqlRunStore {
@@ -105,6 +107,7 @@ export class SqlRunStore {
105
107
  await this.db.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2 AND owner IS NOT DISTINCT FROM $3 AND status = 'running'"), [new Date(), taskId, owner]);
106
108
  }
107
109
  async appendEvent(taskId, seq, type, data) {
110
+ data = redactLedgerEventData(type, data);
108
111
  await this.db.query(this.q("INSERT INTO task_event (task_id, seq, type, data, ts) VALUES (?,?,?,?,?)", "INSERT INTO task_event (task_id, seq, type, data, ts) VALUES ($1,$2,$3,$4::jsonb,$5)"), [taskId, seq, type, data == null ? null : this.json(data), new Date()]);
109
112
  }
110
113
  async maxSeq(taskId) {
@@ -113,9 +116,12 @@ export class SqlRunStore {
113
116
  return m == null ? 0 : Number(m);
114
117
  }
115
118
  async setTerminal(taskId, status, result, error) {
119
+ result = redactTerminalResult(result);
120
+ error = error === null ? null : (redactErrorMessage(error) ?? null);
116
121
  const errorCode = result?.errorCode ?? null;
122
+ let observed;
117
123
  await this.tx(async (conn) => {
118
- await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
124
+ const w = await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
119
125
  status,
120
126
  result == null ? null : this.json(result),
121
127
  this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
@@ -123,8 +129,31 @@ export class SqlRunStore {
123
129
  new Date(),
124
130
  taskId,
125
131
  ]);
132
+ if (w.affected > 0 && runTerminalLogLevel(status, errorCode ?? undefined) !== undefined) {
133
+ const { rows } = await conn.query(this.q("SELECT owner, session_id, created_at FROM task_run WHERE task_id = ?", "SELECT owner, session_id, created_at FROM task_run WHERE task_id = $1"), [taskId]);
134
+ const row = rows[0];
135
+ const createdAt = row ? Date.parse(iso(row.created_at)) : NaN;
136
+ observed = {
137
+ owner: row?.owner ?? null,
138
+ sessionId: String(row?.session_id ?? result?.sessionId ?? ""),
139
+ createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
140
+ };
141
+ }
126
142
  await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
127
143
  });
144
+ if (observed) {
145
+ notifyRunTerminal({
146
+ runId: taskId,
147
+ sessionId: observed.sessionId,
148
+ owner: observed.owner,
149
+ status,
150
+ resultTaskId: result?.taskId,
151
+ model: result?.model,
152
+ errorCode: errorCode ?? undefined,
153
+ errorMessage: result?.errorMessage ?? error ?? undefined,
154
+ elapsedMs: observed.createdAt === undefined ? undefined : Math.max(0, Date.now() - observed.createdAt),
155
+ });
156
+ }
128
157
  }
129
158
  async setSuspended(taskId) {
130
159
  await this.db.query(this.q("UPDATE task_run SET status = 'suspended', updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', updated_at = $1 WHERE task_id = $2 AND status IN ('running','suspended')"), [new Date(), taskId]);
@@ -95,7 +95,7 @@ export type ImageBake = TiDBImageBake | PgImageBake;
95
95
  /** [ref] 车4 件1:durable leader-run 登记表的双方言孪生(union 同族——TS 私有字段让具体类名义上不同)。 */
96
96
  export type LeaderRunStore = TiDBLeaderRunStore | PgLeaderRunStore;
97
97
  /** The FULL checkpoint store (core CheckpointStore + the service operator-queue/ctx methods listPending/
98
- * listByScope/findPendingTokenBySession/peekPendingScope/putCtx/getCtx/reapCtx) — the SQL twins carry them,
98
+ * listByScope/findPendingTokenBySession/peekPendingScope/peekScopeByToken/putCtx/getCtx/reapCtx) — the SQL twins carry them,
99
99
  * and the LOCAL lane now does too (core FileCheckpointStore + the service half — TOC plan-mode /
100
100
  * durable HITL work on one box). */
101
101
  export type CheckpointStoreFull = TiDBCheckpointStore | PgCheckpointStore | LocalCheckpointStore;
@@ -15,12 +15,77 @@
15
15
  * code should import from this file.
16
16
  */
17
17
  import type { TaskResult, TaskStatus } from "@sema-agent/core";
18
+ /**
19
+ * 取消便签里的**大脑相位**快照 —— core `BrainStatus` 帧经 `brainStatusEventData` 投影后的五键子集。
20
+ *
21
+ * 刻意**不含** `detail`(provider 自由文本,宽面)与 `retryInMs`/`retryInSec`(相对量,过期即失真;
22
+ * 绝对量 `retryAtMs` 才是可跨进程读的那个,core 顶注已裁)。`phase` 是这只快照的存在理由:没有它
23
+ * 就没有快照(不铸半只)。
24
+ */
25
+ export interface RunBrainStatusSnapshot {
26
+ phase: string;
27
+ attempt?: number;
28
+ maxRetries?: number;
29
+ /** 等待结束的**墙钟**时刻(发端进程钟域,非单调)—— 只当近似,别与本地单调计时器比。 */
30
+ retryAtMs?: number;
31
+ errClass?: string;
32
+ }
33
+ /**
34
+ * [ref] 件3(cli [ref] sema-bug4 (d) / server [ref]):**用户取消时的现场快照**。
35
+ *
36
+ * 病(取证 `audits/evidence/2026-09-03-model-failure-visibility/`):模型挂起 7 分 26 秒后用户放弃,
37
+ * 账本把这条 run 记成 `failed` + `errorCode:"cancelled"` + 「stream client disconnected before
38
+ * completion」—— 与「用户看了一眼不想跑了,秒按 Esc」**同形**。历史 314 条里 96 条 failed、其中 63 条
39
+ * 是 `user_disconnect`,「其中有多少条其实是模型挂死」在数据面**不可考**。
40
+ *
41
+ * 本结构是那一维判别材料:取消发生时,距离**本 run 上一条引擎事件**过了多久。
42
+ *
43
+ * 【语义边界(勿漂)】
44
+ * - 它**不**描述模型调用相位(首字节/重试在 core 的 stream-engine/with-retry 里,server 结构上看不见
45
+ * ——那半场是 [ref] 的 core 钩,别在这里编造)。它只说「server 这一侧多久没收到这条 run 的动静了」。
46
+ * - `lastEventKind`/`lastEventAgeMs` 的数据源是**同副本进程内**的 turn 活性登记(`turn-activity.ts`)。
47
+ * 跨副本 / 本副本重启后读不到 ⇒ 两键**诚实缺席**(缺席 = 「证不出」,不是「0ms 前刚活过」)。
48
+ * - `elapsedMs` 恒在场:run 起跑到取消的墙钟毫秒。
49
+ * - 分诊材料,不是状态机输入:任何门 / CAS / resume 判定都不许读它。
50
+ *
51
+ * 落点 = 终局 `result` blob 里的一个 additive 可缺席键({@link PersistedTaskResult}),**零新列**
52
+ * ——run 行的 `result` 本来就是整只 JSON 存(SQL 双方言同形),所以两个孪生都不需要 DDL 变更。
53
+ */
54
+ export interface RunCancelContext {
55
+ /** 最后一条进本 run durable 账本的事件类型(`text` / `tool_start` / `status` / …)。
56
+ * 🔴 它比 {@link RunCancelContext.lastEventAgeMs} **更容易缺席**(codex 交叉复审 R1-[medium],验真后
57
+ * 改的正是这条契约):打点方里有几席拿不到事件类型(core 的 `onActivity` 第五席、resume 腿的手动
58
+ * 打点),它们只推进「时刻」不带「词」。所以两键**不是**同在同缺 —— 有 age 无 kind 是合法读数,
59
+ * 意思是「本副本知道那一刻,但说不出那是条什么事件」。 */
60
+ lastEventKind?: string;
61
+ /** 距最后一条 durable 事件过了多久(ms,≥0)。同副本有活性登记就在场。 */
62
+ lastEventAgeMs?: number;
63
+ /** 本 run 账本尾**最后一条** `status`(BrainStatus)帧的五键快照([ref]/[ref])——「已重试几次 /
64
+ * 在等什么 / 什么错类」。这条帧 server 早就在透传与落账(`trace/project.ts` 的 `brainStatusEventData`),
65
+ * 取消时把最后一条摘下来当便签,**不需要 core 新钩**。sticky:后续 text/tool 事件不清它。
66
+ * 这条 run 从没出过 status 帧(或跨副本读不到)⇒ 缺席,恒不铸 null。 */
67
+ lastBrainStatus?: RunBrainStatusSnapshot;
68
+ /** run 起跑到本次取消的墙钟毫秒(≥0)。常态在场;行的 `createdAt` 坏到解析不出时缺席
69
+ * ——**绝不折成 0**(0 会被读成「刚起跑就取消」,与本结构要判别的那件事正好相反)。 */
70
+ elapsedMs?: number;
71
+ }
72
+ /**
73
+ * 落库/上 wire 的终局结果 = core 的 `TaskResult` **加上** server 自铸的可缺席侧记键。
74
+ *
75
+ * 今天只有一个成员({@link RunCancelContext});additive 且可缺席,所以旧行、旧消费端逐字不受影响
76
+ * (`TaskResult` 仍可原样赋给本型)。⚠️ 这不是给「往 core 结果里塞私货」开的口子:每加一个键都要能
77
+ * 回答「为什么它属于 run 账本而不属于 core 的任务结果」——`cancelContext` 的答案是它描述的是
78
+ * **server 侧的取消现场**,core 那一侧根本不知道有人按了取消。
79
+ */
80
+ export type PersistedTaskResult = TaskResult & {
81
+ cancelContext?: RunCancelContext;
82
+ };
18
83
  export interface RunRecord {
19
84
  taskId: string;
20
85
  sessionId: string;
21
86
  owner: string | null;
22
87
  status: "running" | TaskStatus;
23
- result: TaskResult | null;
88
+ result: PersistedTaskResult | null;
24
89
  error: string | null;
25
90
  /** Structured failure code (1.36/1.37) denormalized from the result for SQL queryability. */
26
91
  errorCode: string | null;
@@ -75,6 +75,11 @@ export type InboxWarn = typeof onWarnType;
75
75
  * with an explicit marker. Returns null only when even the skeleton is oversize (physics — caller keeps the
76
76
  * prior revision, matching the old behavior for that corner). Dialect-neutral (pure JS, no SQL) — shared
77
77
  * verbatim by both twins. */
78
+ /** S-107:workflow_run 行的 `error`(脚本抛出的 `err.message`,core 源头不脱)在**两条写腿**(put / update 常态 + oversize 降级)
79
+ * 同一口脱;`result` 由 core 完成时源头脱。幂等,不抛(超大 ⇒ 占位)。 */
80
+ export declare function withRedactedError<T extends {
81
+ error?: string | undefined;
82
+ }>(run: T): T;
78
83
  export declare function slimOversizeRun(run: WorkflowRun & {
79
84
  id: string;
80
85
  scope: string;
@@ -1,16 +1,23 @@
1
+ import { redactErrorMessage, redactHead } from "../observability/run-terminal-log.js";
1
2
  import { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError } from "@sema-agent/core";
2
3
  import { foldKeyFamily, MAX_PENDING_PER_SESSION, PURGE_FENCE_MS, SERVED_FENCE_MS, } from "../orchestration/workflow-completion-inbox.js";
3
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
4
5
  import { isDupKeyError } from "./sql-errors.js";
5
6
  export const MAX_RUN_BLOB_BYTES = 4 * 1024 * 1024;
6
7
  const onWarnType = (msg, meta) => void [msg, meta];
8
+ export function withRedactedError(run) {
9
+ if (typeof run.error !== "string")
10
+ return run;
11
+ const error = redactErrorMessage(run.error) ?? "";
12
+ return error === run.error ? run : { ...run, error };
13
+ }
7
14
  export function slimOversizeRun(run, maxBytes) {
8
15
  const MARK = "…[truncated: run blob exceeded the store cap]";
9
16
  const cut = (s) => s.slice(0, 4000) + MARK;
10
17
  const step1 = {
11
18
  ...run,
12
19
  ...(typeof run.result === "string" && run.result.length > 4000 ? { result: cut(run.result) } : {}),
13
- ...(typeof run.error === "string" && run.error.length > 4000 ? { error: cut(run.error) } : {}),
20
+ ...(typeof run.error === "string" && run.error.length > 4000 ? { error: redactHead(run.error, 4000) + MARK } : {}),
14
21
  };
15
22
  let blob = JSON.stringify(step1);
16
23
  if (Buffer.byteLength(blob) <= maxBytes)
@@ -30,7 +37,7 @@ export class SqlWorkflowRunStore {
30
37
  return this.db.dialect === "tidb" ? tidb : pg;
31
38
  }
32
39
  async put(id, run) {
33
- const stored = { ...run, id, rev: run.rev ?? 0 };
40
+ const stored = { ...withRedactedError(run), id, rev: run.rev ?? 0 };
34
41
  try {
35
42
  await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
36
43
  }
@@ -51,6 +58,7 @@ export class SqlWorkflowRunStore {
51
58
  return run;
52
59
  }
53
60
  async update(id, scope, run, expect) {
61
+ run = withRedactedError(run);
54
62
  let blob = JSON.stringify({ ...run, id, scope });
55
63
  if (Buffer.byteLength(blob) > MAX_RUN_BLOB_BYTES) {
56
64
  this.onWarn?.("workflow_run_blob_oversize_slimmed", { id, scope, fullBytes: Buffer.byteLength(blob), capBytes: MAX_RUN_BLOB_BYTES });
@@ -80,7 +80,10 @@ function isSupportedRuleMatch(match) {
80
80
  switch (match) {
81
81
  case "exact":
82
82
  case "prefix":
83
+ case "wildcard":
83
84
  return true;
85
+ case "subpath":
86
+ return false;
84
87
  default: {
85
88
  const unknown = match;
86
89
  void unknown;
@@ -166,25 +169,28 @@ function screenLocalImportRow(row) {
166
169
  detail: "this project root carries control bytes (C0/DEL) — it is not a path, one SQL dialect refuses to store it at all, and it breaks the separator assumption this lane's comparison keys rest on",
167
170
  };
168
171
  }
172
+ const consistent = (derived) => {
173
+ if (derived.tool !== row.tool || derived.match !== row.match || derived.command !== row.command) {
174
+ return {
175
+ reason: "row_inconsistent",
176
+ detail: `this row's tool/match/command are derived facts of its rule text, and they disagree: the rule reads as ` +
177
+ `${derived.tool}/${derived.match}/${JSON.stringify(derived.command)} but the row says ${String(row.tool)}/${String(row.match)}/${JSON.stringify(row.command)}`,
178
+ };
179
+ }
180
+ return { rule: derived.rule, scope };
181
+ };
182
+ const written = parseAllowRuleText(row.rule);
183
+ if ("reject" in written)
184
+ return { reason: "rule_rejected", detail: `${written.reject.code}: ${written.reject.message}` };
185
+ if (written.rule.tool === "Read")
186
+ return consistent(written.rule);
169
187
  const pre = precheckCardRuleText(row.rule, row.command);
170
188
  if (pre === undefined) {
171
189
  return { reason: "rule_uncheckable", detail: "the rule text gate could not be asked about this row (its command is absent or not a string) — the row itself is malformed" };
172
190
  }
173
191
  if (!pre.ok)
174
192
  return { reason: "rule_rejected", detail: pre.message };
175
- const canonical = parseAllowRuleText(pre.canonicalRule);
176
- if ("reject" in canonical) {
177
- return { reason: "rule_rejected", detail: `the canonical spelling the engine minted does not re-parse: ${canonical.reject.message}` };
178
- }
179
- const derived = canonical.rule;
180
- if (derived.tool !== row.tool || derived.match !== row.match || derived.command !== row.command) {
181
- return {
182
- reason: "row_inconsistent",
183
- detail: `this row's tool/match/command are derived facts of its rule text, and they disagree: the rule reads as ` +
184
- `${derived.tool}/${derived.match}/${JSON.stringify(derived.command)} but the row says ${String(row.tool)}/${String(row.match)}/${JSON.stringify(row.command)}`,
185
- };
186
- }
187
- return { rule: derived.rule, scope };
193
+ return consistent(written.rule);
188
194
  }
189
195
  const LOCAL_IMPORT_LAYER_PATH = "local-import:rules";
190
196
  const CONTROL_AND_BIDI_DETECT_RE = new RegExp(`[${CONTROL_AND_BIDI_CHARS}]`);
@@ -241,6 +247,7 @@ export function createRuleConsentLane(stores, opts) {
241
247
  ...(input.toolCallId !== undefined ? { toolCallId: input.toolCallId } : {}),
242
248
  ...(input.boundInputHash !== undefined ? { boundInputHash: input.boundInputHash } : {}),
243
249
  ...(input.scope !== undefined ? { scope: input.scope } : {}),
250
+ ...(input.scope?.kind === "project" ? { cwd: input.scope.root } : {}),
244
251
  deps,
245
252
  });
246
253
  }
@@ -0,0 +1,13 @@
1
+ import type { RunCancelContext } from "./plugins/store-contracts.js";
2
+ /**
3
+ * @param taskId 这条 run 的账本 id(turn 活性登记的键)。
4
+ * @param startedAtMs 这条 run 起跑的墙钟时刻(epoch ms):取消腿手上有行就用行的 `createdAt`,
5
+ * 只有本腿起点时用本腿起点(两者在同一条 run 上相差 ms 级)。**非有限值**
6
+ * (坏 `createdAt` 解析出的 NaN)⇒ `elapsedMs` 诚实缺席,绝不折成 0
7
+ * ——0 会被读成「刚起跑就取消了」,与「模型挂死 8 分钟后用户放弃」正好相反。
8
+ * @param nowMs 取样时刻(缺省 `Date.now()`;测试注入用)。
9
+ * @returns 三键至少有一键可证时的快照;**一键都证不出** ⇒ `undefined`(写一只空对象等于在 wire 上
10
+ * 断言「我看过现场,什么都没有」,而事实是「我什么都没看见」)。
11
+ */
12
+ export declare function buildCancelContext(taskId: string, startedAtMs: number, nowMs?: number): RunCancelContext | undefined;
13
+ //# sourceMappingURL=run-cancel-context.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { readTurnActivity } from "./turn-activity.js";
2
+ export function buildCancelContext(taskId, startedAtMs, nowMs = Date.now()) {
3
+ const activity = readTurnActivity(taskId);
4
+ const age = activity === undefined ? undefined : Math.max(0, nowMs - activity.at);
5
+ const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, nowMs - startedAtMs) : undefined;
6
+ if (age === undefined && elapsedMs === undefined)
7
+ return undefined;
8
+ return {
9
+ ...(activity?.kind !== undefined ? { lastEventKind: activity.kind } : {}),
10
+ ...(age !== undefined ? { lastEventAgeMs: age } : {}),
11
+ ...(activity?.brainStatus !== undefined ? { lastBrainStatus: activity.brainStatus } : {}),
12
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}),
13
+ };
14
+ }
15
+ //# sourceMappingURL=run-cancel-context.js.map