@sema-agent/server 7.124.1 → 7.125.1

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 (152) hide show
  1. package/MIGRATION.md +138 -2
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +42 -9
  138. package/dist/trace/self-minted-wire.d.ts +25 -0
  139. package/dist/trace/self-minted-wire.js +9 -0
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
@@ -44,14 +44,16 @@ export interface ParseHooksConfigResult {
44
44
  * 其实没跑,硬破坏三句要求的「响亮」没有着落。7.112.0(S-924)只拒**字面**别名;同一个错误写成 `^ListPeers$` / `ListPeers.*` / `(ListPeers)` 照样 200(S-995)。
45
45
  *
46
46
  * 规则(一条;字面形是它的特例):{@link deadToolMatcherIssue} 对 core 静态目录里**每一条退役别名**问「这只 matcher 选不选它 / 选不选它的正名」:
47
- * matcher **命中别名 ∧ 不命中它的正名** ⇒ `400 request.field_invalid`(`admission.ts` fresh 门)。「选不选」与运行期 {@link hookMatcherMatches} 同一套读法,按**一条顺序**判(两档共用,7.114.0 修复轮):
47
+ * matcher **命中别名 ∧ 不命中它的正名** ⇒ `400 request.field_invalid`(`admission.ts` fresh 门)。「选不选」**只有一只读法**:契约包的 `hookMatcherSelects`
48
+ * (名字表 / 空 / `*` / 正则三形都由它答;正则形**不锚定**,与壳同形;运行期 {@link hookMatcherMatches} 问的也是它,本仓不留副本)。按**一条顺序**判(两档共用,7.114.0 修复轮):
48
49
  * ① **长度上限**:超 {@link MAX_MATCHER_EVAL_CHARS} 的 matcher 两档都不读 ⇒ 未判(reason = oversize);
49
- * ② 空 / `*` 命中一切 ⇒ 永不「死」;
50
- * ③ **字面 / 名字表读法**({@link matcherLiteralNames},与运行期共用:`|` 与 `,` 分隔、每段 trim〔容忍两端与分隔符两侧空白〕、不折大小写、不剥词内空白)
51
- * 两档都先走、每只 matcher 读一次、**不占求值预算**,按名字表成员答 —— 所以两档在字面子集上答案逐位相同是构造出来的(R5f 语料再钉);
52
- * ④ 只剩**正则形**:**`"regex"` 档**(hooks 会被采纳的车道,`taskHooksHonored`)问运行期正则臂的那一只函数 {@link matcherPatternMatches}(同一个函数对象,锚定正则),
53
- * 经 `node:vm` 的 `timeout` 带一次受理共享的预算调用({@link matcherPatternMatchesWithin});**`"literal"` 档**(多租户车道:hooks 整只被 resolveSpec 忽略)
54
- * = **不适用**:该车道没有被守的对象(hooks 不装配),不判、不算未判、不登记 —— **零正则求值、零调用方正则执行**(7.112.0 的字面判保留:裸 `ListPeers` / `Read|ListPeers` 照旧 400)。
50
+ * ② **形只问契约包的 `hookMatcherForm`**(与 `hookMatcherSelects` 同一只解析步,本仓不留形判),闭集三词、两档一张表:
51
+ * · `literal`(缺席 / 空 / `*` / 名字表形;契约不编、不跑调用方的正则)⇒ 两档都**直接问契约函数**、**不占求值预算**;
52
+ * · `regex` ⇒ **`"regex"` 档**(hooks 会被采纳的车道,`taskHooksHonored`)把**同一只契约函数**经 `node:vm` 的 `timeout` 带一次受理共享的预算调用({@link matcherSelectsWithin});
53
+ * **`"literal"` 档**(多租户车道:hooks 整只被 resolveSpec 忽略)= **不适用**:该车道没有被守的对象(hooks 不装配),不判、不算未判、不登记 —— **零调用方正则执行**
54
+ * (7.112.0 的字面判保留:裸 `ListPeers` / `Read|ListPeers` 照旧 400);
55
+ * · `invalid`(编不过)⇒ `"regex"` 档 = 未判 `invalid_regex`(不进预算求值 —— 形判已答「读不了」);`"literal"` 档 = 不适用(同上)。
56
+ * 形判在 `literal` 档也跑:契约的解析步对非名字表形**构造**(只解析、不执行)一次调用方的 pattern 以分出 regex / invalid —— 长度已被 ① 界住;执行恒为零。
55
57
  * · **为什么两档**(codex r1 [high] + 主会话裁):受理面是**所有**车道,而运行期只有单用户车道跑 hooks。在受理线程上跑调用方的正则 = 给多租户加一个事件循环 DoS 面
56
58
  * (实测:`(.*)*` ×10 + `X` 对 9 字符名字首次执行 1.7 s,18 字符嵌套形 15 s,24 字符 157 s;8 MiB 交替串编译 + 求值 9 s)—— 预算只界住一次请求、界不住「每个请求再拿一份」。
57
59
  * 但也不能因此在多租户车道「不判决」(那会放宽已发行为:裸 `ListPeers` 的 400)。所以两车道同一条规则、两档深度。谓词 `taskHooksHonored` 是唯一属主(与 resolveSpec / capabilities 同调)。
@@ -66,7 +68,7 @@ export interface ParseHooksConfigResult {
66
68
  * · **正名同在则不拒**:`正名|别名` / `List.*` 会命中正名,不是「静默不命中」,且正是 7.107.0 CHANGELOG 给的「滚动期两名都写」补偿形。
67
69
  * 只拒「别名那一路永远打不中、而它的正名不在选中集里」—— 含 `Read|ListPeers`(7.112.0 已拒;主会话裁:规则形采「按条目逐条问」,不放宽;卡面整 matcher「匹配不到任何现役名」形会把它放回 200)。
68
70
  * 多拒(相对卡面形)三例与出路:`Read|ListPeers` / `ListPeers|mcp__.*`(别名支永不命中,出路:改成正名)/ 排除式 `^(?!ListAgents).*`(出路:`^(?!ListAgents|ListPeers).*` 两名都不命中 ⇒ 放)。
69
- * · **空白与大小写(S-995 / sema-test [ref] 起,运行期与 CC 2.1.280 同形)**:字面 / 名字表读法({@link matcherLiteralNames};`|` 与 `,` 分隔)容忍两端与分隔符两侧空白 ⇒ `" ListPeers"` / `"ListPeers "` / `"Read | ListPeers"` 会命中别名 ⇒ 落进本规则 400;
71
+ * · **空白与大小写(S-995 / sema-test [ref] 起,运行期与 CC 同形)**:名字表读法(契约函数;`|` 与 `,` 分隔)容忍两端与分隔符两侧空白 ⇒ `" ListPeers"` / `"ListPeers "` / `"Read | ListPeers"` 会命中别名 ⇒ 落进本规则 400;
70
72
  * **区分大小写、不剥词内空白** ⇒ `"listpeers"` / `"List Peers"` 对别名与正名都选不中,照旧受理(CC 同形)。
71
73
  * · **不在本规则内**:两边都选不中的 matcher(大小写错 / 词内空白 / 纯错字;目录之外的 MCP / 嵌入方工具名同样「选不中目录里任何名」,受理面无从区分错字与它们)=
72
74
  * **「选不到目录里任何名」另一类,候设计(台账另立号,主会话裁)**。
@@ -89,7 +91,7 @@ export interface ParseHooksConfigResult {
89
91
  export declare const MAX_MATCHER_EVAL_CHARS = 8192;
90
92
  /** 一次 fresh 受理里 regex 档**正则形**求值共享的墙钟预算(毫秒)。字面形不占它。良性求值 ~0.1 ms;灾难回溯的 matcher 把预算吃完就停,后面的正则形组直接「未判」。 */
91
93
  export declare const ADMISSION_MATCHER_EVAL_BUDGET_MS = 100;
92
- /** 受理面判据的求值深度(见上头注):`"regex"` = 字面形按名字表答、正则形问运行期同一只正则求值(带预算);`"literal"` = 7.112.0 的字面判(零正则,正则形不适用)。 */
94
+ /** 受理面判据的求值深度(见上头注):`"regex"` = 契约字面形直接问、正则形带预算问同一只契约函数;`"literal"` = 7.112.0 的字面判(只判契约字面形,零调用方正则执行;正则形 / 编不过的形不适用)。 */
93
95
  export type MatcherEvalDepth = "regex" | "literal";
94
96
  /**
95
97
  * **仅供测试注入**的求值预算(生产调用点不传 ⇒ 预算 = {@link ADMISSION_MATCHER_EVAL_BUDGET_MS}、时钟 = `performance.now`)。
@@ -134,25 +136,11 @@ export declare function deadToolMatcherIssue(config: HooksConfig, depth: Matcher
134
136
  */
135
137
  export declare function parseHooksConfig(raw: unknown): ParseHooksConfigResult;
136
138
  /**
137
- * matcher 的**字面 / 名字表读法**(S-995 / sema-test [ref];与 CC 2.1.280 同形):matcher 只含名字字符(`[A-Za-z0-9_-]`)、竖线、**逗号**与**空格**时,它不是正则而是「名字列表」——
138
- * 按 `[|,]` 拆、**每段 trim**、丢空段;返回这张名字表。含任何别的字符(`.` / `*` / 括号 / 制表 / 换行 …)⇒ `undefined` = 不是字面读法,整体当正则(由调用方处置,**一字不动**)。
139
- * 大小写敏感、词内空白不剥(`"Ba sh"` 是一个词 `Ba sh`)。**对所有带 matcher 的事件同一只读法**(工具事件 / StopFailure 的 `error` 词 / PreCompact·PostCompact 的 `trigger` 词 /
140
- * `if` 条件的名部),不按事件分档。
141
- *
142
- * 等价性的依据(收车档 ⑥ / ⑦):
143
- * · 黑盒 —— sema-test `repro/cc-r219-mcpstar-hookmatcher-20261003/`([ref],CC 2.1.280 真 TUI + 真 command 钩子):`" Bash"` / `"Bash "` / `" Bash "` / `"Read | Bash"` 命中 3/3,
144
- * `"Ba sh"`(词内空白)/ `bash` / `BASH` 不命中(零告警),`^Bash$` / `Bash.*` / `(Bash)` / `Read|Bash` 照命中;PreToolUse 与 PostToolUse 同向。**逗号没有黑盒读数**,只有语料。
145
- * · 语料 —— `sema-internal/blueprints/cc-decoded/cli280.js:216638` `uAe(matchValue, matcher, flag, …)` → `$mr(matcher, flag, …)`:`flag` 真时字符集门 `/^[a-zA-Z0-9_|, -]+$/` 过了才
146
- * `matcher.split(/[|,]/).map(trim).filter(Boolean)` 做成员判;过不了才 `new RegExp(matcher)`(CC 的正则路径**不锚定**,本仓锚定 `^(?:…)$` —— 既有差异,不动)。
147
- * 本函数的字符集与拆符与 CC 逐字同(空白只认空格 0x20)。
148
- * · 已知差异(如实登记,本车不做):CC 的空白 / 逗号放行**按事件分档**(`dAe` 集合:工具事件 / PreCompact / PostCompact 等放,StopFailure 不放),本函数不分事件(第二批裁:同一只读法);
149
- * CC 正则路径不锚定、本仓锚定。两条登记在 CHANGELOG 7.114.0 线 · 车 UW 已知边界。
150
- * 零正则:单趟逐字符扫描(7.114.0 修复轮起;只为非空段分配名字,不按分隔符个数分配)。受理面两档({@link deadToolMatcherIssue})与运行期求值**共用这一只**读法,且两档都先走它、不占求值预算 ——
151
- * 两档在字面子集上答案逐位相同是**构造出来的**,不是对拍出来的(超过长度上限的 matcher 两档都不读、同判未判)。
139
+ * 运行期的 matcher 读法席位:**问契约包的 `hookMatcherSelects`,别无其它**(壳与服务端都问这一只,谁都不留副本;读法全文见它的头注,这里不重述)。
140
+ * 三形(名字表 / 空 `*` 缺省 / 不锚定的 JS 正则)与「编不过 ⇒ `"invalid"`」都由它答;**对所有带 matcher 的事件同一只读法**(工具事件 / StopFailure 的 `error` 词 /
141
+ * PreCompact·PostCompact 的 `trigger` 词 / `if` 条件的名部),不按事件分档。`"invalid"` ⇒ 该组不跑(fail-closed 到「不跑」),由调用方记账一次(`hook_matcher_invalid`);
142
+ * 受理面({@link deadToolMatcherIssue})问的是同一只函数。
152
143
  */
153
- export declare function matcherLiteralNames(matcher: string): readonly string[] | undefined;
154
- /** CC matcher 语义:空/`*`/缺省匹配一切;**字面 / 名字表读法**({@link matcherLiteralNames}:`|` 与 `,` 分隔、每段 trim、不剥词内空白、区分大小写)按名字表成员判;
155
- * 其余整体当 JS 正则({@link matcherPatternMatches};锚定全匹配;S-995 起正则路径一字不动);非法正则 → 该条不匹配(fail-closed 到"不跑",由调用方记账一次)。 */
156
144
  export declare function hookMatcherMatches(matcher: string | undefined, value: string): boolean | "invalid";
157
145
  /** `if` 条目条件(CC-exact,对照 CC 2.1.187/198 实测行为核验的真语义,两版一致):
158
146
  * 值 = permission-rule 模式(`ToolName` 或 `ToolName(arg-模式)`),【不是】表达式语言——按当前工具调用
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import vm from "node:vm";
3
3
  import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
4
- import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, } from "@sema-agent/settings-schema/hooks";
4
+ import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, } from "@sema-agent/settings-schema/hooks";
5
5
  import { describeToolCatalog, isEngineRefusal, receiveHookResult } from "@sema-agent/core";
6
6
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
7
7
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -81,13 +81,13 @@ function engineToolAliases() {
81
81
  return table;
82
82
  }
83
83
  let matcherEvalBox;
84
- function matcherPatternMatchesWithin(matcher, name, budget) {
84
+ function matcherSelectsWithin(matcher, name, budget) {
85
85
  const remaining = budget.deadlineMs - budget.now();
86
86
  if (remaining <= 0)
87
87
  return "over_budget";
88
88
  matcherEvalBox ??= {
89
- script: new vm.Script("matcherPatternMatches(matcher, name)"),
90
- context: vm.createContext({ matcherPatternMatches, matcher: "", name: "" }),
89
+ script: new vm.Script("hookMatcherSelects(matcher, name)"),
90
+ context: vm.createContext({ hookMatcherSelects, matcher: "", name: "" }),
91
91
  };
92
92
  matcherEvalBox.context.matcher = matcher;
93
93
  matcherEvalBox.context.name = name;
@@ -101,31 +101,30 @@ function matcherPatternMatchesWithin(matcher, name, budget) {
101
101
  throw err;
102
102
  }
103
103
  }
104
+ const MATCHER_EVALUATION = {
105
+ literal: { regex: "inline", literal: "inline" },
106
+ regex: { regex: "budgeted", literal: "not_applicable" },
107
+ invalid: { regex: "unreadable", literal: "not_applicable" },
108
+ };
104
109
  function deadAliasInMatcher(matcher, depth, budget) {
105
110
  if (matcher.length > MAX_MATCHER_EVAL_CHARS)
106
111
  return { kind: "unjudged", reason: "oversize" };
107
- if (matcher === "" || matcher === "*")
108
- return undefined;
109
- const names = matcherLiteralNames(matcher);
110
- if (names !== undefined) {
111
- for (const [alias, canonical] of engineToolAliases()) {
112
- if (names.includes(alias) && !names.includes(canonical))
113
- return { kind: "dead", alias, canonical };
114
- }
115
- return undefined;
116
- }
117
- switch (depth) {
118
- case "literal":
112
+ const evaluate = MATCHER_EVALUATION[hookMatcherForm(matcher)][depth];
113
+ switch (evaluate) {
114
+ case "not_applicable":
119
115
  return undefined;
120
- case "regex":
116
+ case "unreadable":
117
+ return { kind: "unjudged", reason: "invalid_regex" };
118
+ case "inline":
119
+ case "budgeted":
121
120
  break;
122
121
  default: {
123
- const unreachable = depth;
122
+ const unreachable = evaluate;
124
123
  return unreachable;
125
124
  }
126
125
  }
127
126
  const selects = (name) => {
128
- const verdict = matcherPatternMatchesWithin(matcher, name, budget);
127
+ const verdict = evaluate === "inline" ? hookMatcherSelects(matcher, name) : matcherSelectsWithin(matcher, name, budget);
129
128
  return verdict === "invalid" ? "invalid_regex" : verdict;
130
129
  };
131
130
  for (const [alias, canonical] of engineToolAliases()) {
@@ -214,47 +213,8 @@ export function parseHooksConfig(raw) {
214
213
  }
215
214
  return { config };
216
215
  }
217
- export function matcherLiteralNames(matcher) {
218
- const names = [];
219
- let first = -1;
220
- let last = -1;
221
- for (let i = 0; i <= matcher.length; i++) {
222
- const c = i < matcher.length ? matcher.charCodeAt(i) : 0x7c;
223
- if (c === 0x7c || c === 0x2c) {
224
- if (first !== -1)
225
- names.push(matcher.slice(first, last + 1));
226
- first = -1;
227
- last = -1;
228
- continue;
229
- }
230
- if (c === 0x20)
231
- continue;
232
- const nameChar = (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c === 0x2d;
233
- if (!nameChar)
234
- return undefined;
235
- if (first === -1)
236
- first = i;
237
- last = i;
238
- }
239
- return names;
240
- }
241
- function matcherPatternMatches(matcher, value) {
242
- if (matcher === value)
243
- return true;
244
- try {
245
- return new RegExp(`^(?:${matcher})$`).test(value);
246
- }
247
- catch {
248
- return "invalid";
249
- }
250
- }
251
216
  export function hookMatcherMatches(matcher, value) {
252
- if (matcher === undefined || matcher === "" || matcher === "*")
253
- return true;
254
- const names = matcherLiteralNames(matcher);
255
- if (names !== undefined)
256
- return names.includes(value);
257
- return matcherPatternMatches(matcher, value);
217
+ return hookMatcherSelects(matcher, value);
258
218
  }
259
219
  export function hookIfMatches(cond, payload) {
260
220
  if (cond.length > 500 || (cond.match(/\*/g)?.length ?? 0) > 16)
@@ -149,6 +149,9 @@ export interface ActiveRunConflictDoneResult {
149
149
  stale?: true;
150
150
  pendingGate?: PendingGateMaterial;
151
151
  }
152
+ /** 一份提交拒绝体是不是**会话占用冲突**体(`buildActiveRunConflict` 的产物)—— 判别位 = 机器码,与 sdk 终帧联合里冲突臂的窄化同一个位。
153
+ * 只收那一只铸点的产物:调用方是流式腿把「拒绝」编码成终帧的投影({@link toDoneFrameResult} 的入口),别处的 409 体不过这里。 */
154
+ export declare function isActiveRunConflictBody(body: unknown): body is ActiveRunConflictBody;
152
155
  export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
153
156
  interface ConflictProbeDeps<TToken> {
154
157
  /** 结构形入参,但 `status` 取 run 行的**闭词表**(合并码重扫):判 park 要走词表属主
@@ -27,6 +27,9 @@ function safeCheckpointIdOf(raw, token) {
27
27
  return undefined;
28
28
  return raw;
29
29
  }
30
+ export function isActiveRunConflictBody(body) {
31
+ return typeof body === "object" && body !== null && "activeTaskId" in body && body.errorCode === "conflict.session_active_run";
32
+ }
30
33
  export function toDoneFrameResult(body) {
31
34
  return {
32
35
  status: "failed",
@@ -10,12 +10,12 @@ import * as facts from "../capability-facts.js";
10
10
  import { buildDeadToolMatcherRefusal, deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
11
11
  import { judgeRequestWebSearch } from "../plugins/web-search.js";
12
12
  import { normalizeApproachNotice, skillToolListOk, skillToolListRefusal, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
13
- import { isValidCwd, MAX_ADDITIONAL_DIRS, MAX_CWD_CHARS } from "../task-cwd.js";
13
+ import { MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
14
14
  import { verifyRoundsFromBody } from "./verify-rounds.js";
15
15
  import { skillCarriesRetiredManifest } from "./retired-skill-manifest.js";
16
16
  import { resolveDevicePlacementIntent } from "./device-placement.js";
17
17
  import { sendError, httpErrorCode } from "./send.js";
18
- import { WEB_ADMIN_ENV } from "@sema-agent/settings-schema";
18
+ import { ABSOLUTE_HOST_PATH_MAX, isAbsoluteHostPath, WEB_ADMIN_ENV } from "@sema-agent/settings-schema";
19
19
  const APPROVAL_REACH_WORDS = APPROVAL_REACH.map((word) => JSON.stringify(word)).join(" / ");
20
20
  export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
21
21
  export const MAX_SYSTEM_PROMPT_CHARS = 16_384;
@@ -34,8 +34,8 @@ export function validateUserSkills(skills) {
34
34
  return "skill.description must be a string";
35
35
  if (typeof s["content"] !== "string" || s["content"].length === 0 || s["content"].length > MAX_SKILL_CONTENT_CHARS)
36
36
  return `skill.content must be a non-empty string of at most ${MAX_SKILL_CONTENT_CHARS} characters (the engine rejects skills over this size outright at load time)`;
37
- if (s["baseDir"] !== undefined && !isValidCwd(s["baseDir"]))
38
- return `skill.baseDir must be an absolute path string of at most ${MAX_CWD_CHARS} characters with no ".." segment and no NUL byte (it is not resolved or read here — the engine only substitutes it into the skill's own text)`;
37
+ if (s["baseDir"] !== undefined && !isAbsoluteHostPath(s["baseDir"]))
38
+ return `skill.baseDir must be an absolute path string of at most ${ABSOLUTE_HOST_PATH_MAX} characters with no ".." segment and no NUL byte (it is not resolved or read here — the engine only substitutes it into the skill's own text)`;
39
39
  if (skillCarriesRetiredManifest(s))
40
40
  return SKILL_MANIFEST_RETIRED;
41
41
  for (const key of ["allowedTools", "disallowedTools"]) {
@@ -103,7 +103,7 @@ export function createAdmission(ctx) {
103
103
  return null;
104
104
  }
105
105
  }
106
- if (body.cwd !== undefined && !isValidCwd(body.cwd)) {
106
+ if (body.cwd !== undefined && !isAbsoluteHostPath(body.cwd)) {
107
107
  sendError(res, 400, "request.field_invalid", "cwd must be a non-empty absolute host path");
108
108
  return null;
109
109
  }
@@ -117,7 +117,7 @@ export function createAdmission(ctx) {
117
117
  sendError(res, 400, "request.field_invalid", `additionalDirectories must have at most ${MAX_ADDITIONAL_DIRS} entries`);
118
118
  return null;
119
119
  }
120
- const bad = addDirsRaw.find((d) => !isValidCwd(d));
120
+ const bad = addDirsRaw.find((d) => !isAbsoluteHostPath(d));
121
121
  if (bad !== undefined) {
122
122
  sendError(res, 400, "request.field_invalid", "each additionalDirectories entry must be a non-empty absolute host path (no '..' segments)");
123
123
  return null;
@@ -133,7 +133,7 @@ export function createAdmission(ctx) {
133
133
  sendError(res, 400, "request.field_invalid", `additionalReadDirectories must have at most ${MAX_ADDITIONAL_DIRS} entries`);
134
134
  return null;
135
135
  }
136
- const badRead = addReadDirsRaw.find((d) => !isValidCwd(d));
136
+ const badRead = addReadDirsRaw.find((d) => !isAbsoluteHostPath(d));
137
137
  if (badRead !== undefined) {
138
138
  sendError(res, 400, "request.field_invalid", "each additionalReadDirectories entry must be a non-empty absolute host path (no '..' segments)");
139
139
  return null;
@@ -6,6 +6,7 @@ import { APPROVAL_GATE_KINDS } from "../tool-approval.js";
6
6
  import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
7
7
  import { hasServiceAuth } from "./dispatch.js";
8
8
  import { gatedPrincipal } from "./principal-gate.js";
9
+ import { costQuotaRefusalOf } from "./request-guards.js";
9
10
  import { admissionRefusalOf, readinessOf } from "./readiness.js";
10
11
  import { readResumeCtx } from "./resume-ctx-read.js";
11
12
  import { storedBodyReplayRefusal } from "./stored-body-replay.js";
@@ -116,10 +117,7 @@ export function createApprovalSweeps(ctx) {
116
117
  const d = deps.costQuota.check(quotaPrincipal);
117
118
  if (!d.allowed) {
118
119
  deps.metrics?.inc("cost_quota_rejected_total");
119
- return {
120
- status: 429,
121
- body: { error: "cost quota exceeded", errorCode: "limit.cost_quota_exceeded", usedMicroUsd: d.usedMicroUsd, limitMicroUsd: d.limitMicroUsd, retryAfterSec: d.retryAfterSec },
122
- };
120
+ return costQuotaRefusalOf(d);
123
121
  }
124
122
  }
125
123
  }
@@ -92,6 +92,7 @@ export async function commitPlacement(ctx, runStore, taskId, intent) {
92
92
  const releaseRun = async (why) => {
93
93
  try {
94
94
  await runStore.setTerminal(taskId, "failed", null, why);
95
+ await runStore.releaseIdemKey(taskId);
95
96
  }
96
97
  catch (err) {
97
98
  ctx.logger?.warn("device_placement_run_release_failed", { taskId, err: err instanceof Error ? err.message : String(err) });
@@ -35,7 +35,7 @@ function cors(res, req, origins, principalHeader) {
35
35
  if (allow === undefined)
36
36
  return;
37
37
  res.setHeader("access-control-allow-origin", allow);
38
- res.setHeader("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS");
38
+ res.setHeader("access-control-allow-methods", [...ROUTE_METHOD_ORDER, "OPTIONS"].join(", "));
39
39
  res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, ${PRINCIPAL_TOKEN_HEADER}, ${APPROVAL_MAC_HEADER}, ${APPROVAL_MAC_KID_HEADER}, if-none-match`);
40
40
  res.setHeader("access-control-expose-headers", "x-task-id, retry-after");
41
41
  }
@@ -9,12 +9,13 @@
9
9
  * 当年从路由体里提取出来时,头注写的就是同一条判词)。方向:`routes/runs.ts` 与 `http/server.ts`
10
10
  * 都 import 本模块,本模块谁都不 import —— 与 `http/run-meta.ts` 同一条「两个域共用一件 ⇒ 单独成文件」。
11
11
  *
12
- * 边界(本腿**不**含的东西,留给各自的门):`body.taskId` 幂等重放、`Idempotency-Key` 内存去重、
12
+ * 边界(本腿**不**含的东西,留给各自的门):同键重放的回执(`http/submit-replay.ts`)、`Idempotency-Key` 的在飞合流、
13
13
  * lease / 治理窗 pre-admission、属主门。它们是**提交面**的判据(读请求、写响应),而本腿只吃已经备好的
14
14
  * `PreparedTaskSubmission` 并交回一份收据 —— 这正是它能被一条没有 `ServerResponse` 的内部车道复用的原因。
15
15
  */
16
16
  import { type RunInternals } from "@sema-agent/core";
17
17
  import type { RunStore } from "../plugins/store-backend.js";
18
+ import type { RunRecord } from "../plugins/store-contracts.js";
18
19
  import type { PreparedTaskSubmission, RouteCtxBase } from "./route-ctx.js";
19
20
  /**
20
21
  * 🔴 **TRUSTED run internals** —— 由**部署方**(不是模型、不是请求体、不是工具参数)铸给这一跑的
@@ -34,6 +35,8 @@ export interface DurableRunLegArgs {
34
35
  prepared: PreparedTaskSubmission;
35
36
  /** 调用方自带的 taskId(`POST /v1/runs` 的 `body.taskId` 幂等键);缺席 ⇒ 本腿铸 uuidv7。 */
36
37
  clientTaskId?: string;
38
+ /** 这次提交的幂等键(`idemKeyOf(raw, caller, "run")`)—— 落 `task_run.idem_key`;缺席 = 调用方没带 `Idempotency-Key`。 */
39
+ idemKey?: string;
37
40
  /** 🔒 这条 run 的 **fleet 行 scope** —— 必须与 `GET /v1/fleet/stream` 过滤用的是同一个身份,且绝不是
38
41
  * 自报的 `x-agent-principal` 头(自报头会把行打到受害者的 scope 上,或打到 `default` 而真属主的流把它
39
42
  * 过滤掉)。提交门传 `gatedPrincipal(req) ?? "default"`;`/decide` 的第三条车道传 resume 家族的
@@ -49,16 +52,27 @@ export interface DurableRunLegArgs {
49
52
  trustedInternals?: TrustedRunInternals;
50
53
  }
51
54
  /**
52
- * 建行 + 驱动。返回 `202` = 新行已建并在跑(体带 `taskId`/`sessionId`);`409` = 会话被别的 run 占着,
53
- * **没有**建行(体 = `buildActiveRunConflict` 的既有形,含 `errorCode: "conflict.session_active_run"`)。
54
- * 协议渲染归调用方(HTTP `sendJson` / A2A 协议对象 / `/decide` 的受理体)。
55
+ * 本腿的结局:一份收据,或「这次提交的一把幂等钥匙已经指向一条行」(店的原子赢或观察的**观察**臂 —— 本腿不碰
56
+ * 请求、不判属主,回执归提交面的唯一回执函数 `replayOfExistingRun`)。另一位在每一形上恒 `undefined`。
55
57
  */
56
- export declare function mintAndDriveDurableRun(ctx: RouteCtxBase, runStore: RunStore, args: DurableRunLegArgs): Promise<{
58
+ export type DurableRunLegOutcome = {
57
59
  status: number;
58
60
  body: {
59
61
  taskId: string;
60
62
  sessionId: string;
61
63
  status: string;
62
64
  } | object;
63
- }>;
65
+ existing?: undefined;
66
+ } | {
67
+ existing: RunRecord;
68
+ status?: undefined;
69
+ body?: undefined;
70
+ };
71
+ /**
72
+ * 建行 + 驱动。返回 `202` = 新行已建并在跑(体带 `taskId`/`sessionId`);`409` = 会话被别的 run 占着,
73
+ * **没有**建行(体 = `buildActiveRunConflict` 的既有形,含 `errorCode: "conflict.session_active_run"`);
74
+ * `existing` = 这次提交的钥匙(`idemKey` / `clientTaskId`)已经指向一条行,没建新行。
75
+ * 协议渲染归调用方(HTTP `sendJson` / A2A 协议对象 / `/decide` 的受理体)。
76
+ */
77
+ export declare function mintAndDriveDurableRun(ctx: RouteCtxBase, runStore: RunStore, args: DurableRunLegArgs): Promise<DurableRunLegOutcome>;
64
78
  //# sourceMappingURL=durable-run-leg.d.ts.map
@@ -15,17 +15,16 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
15
15
  const { inflightRuns, preemptableRuns, steerableRuns } = ctx.registry;
16
16
  const sessionId = args.prepared.spec.sessionId ?? uuidv7();
17
17
  const taskId = args.clientTaskId ?? uuidv7();
18
- const created = await runStore.createRun(taskId, sessionId, args.prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(args.prepared, args.source));
18
+ const created = await runStore.createRun(taskId, sessionId, args.prepared.auth?.principal ?? null, deps.instanceId ?? "default", { ...runMeta(args.prepared, args.source), idemKey: args.idemKey ?? null });
19
19
  if (created.ok)
20
20
  clearTurnActivity(taskId);
21
21
  if (created.ok)
22
22
  recordResourceWindow(taskId, args.prepared.spec.resourceSuspend);
23
23
  if (created.ok)
24
24
  deps.sessionTitler?.maybeTitle(sessionId, args.prepared.spec.objective, typeof args.prepared.spec.model === "string" ? args.prepared.spec.model : args.prepared.spec.model?.id);
25
+ if (created.existing)
26
+ return { existing: created.existing };
25
27
  if (!created.ok) {
26
- if (args.clientTaskId && created.activeTaskId === args.clientTaskId) {
27
- return { status: 202, body: { taskId: args.clientTaskId, sessionId, status: "running" } };
28
- }
29
28
  return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
30
29
  }
31
30
  const placementReject = await commitPlacement(deps, runStore, taskId, args.prepared.bindingIntent);
@@ -51,6 +50,7 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
51
50
  runId: taskId,
52
51
  scope: args.fleetScope,
53
52
  rootTaskId: taskId,
53
+ sessionId,
54
54
  ...fleetRunLabels(args.prepared.spec.objective),
55
55
  });
56
56
  void runInBackground(args.prepared.runner, { ...args.prepared.spec, sessionId }, runStore, taskId, deps.metrics, args.prepared.auth?.principal, args.prepared.verify, args.prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, args.prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, args.elicitOwner, captureTurnAnchor, fleetPub, captureUserMessageAnchor, deps.question, deps.workflowCompletionInbox, deps.subagentSteerRegistry, (m, x) => deps.logger?.info?.(m, x), deps.sendUserFile, deps.promptManifests, deps.toolApproval
@@ -1,65 +1,110 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { ServiceConfig } from "../config-types.js";
1
3
  import type { WiringManifestReceipt } from "./wire-types.js";
4
+ import type { SubmitReplayFace } from "./submit-replay.js";
2
5
  /**
3
- * In-memory, TTL'd dedup cache keyed by an opaque string (an Idempotency-Key).
6
+ * 同进程的**在飞合流** —— 并发同键的提交共享同一个 promise(避免同一进程里双起)。它**不是**重放的真源。
4
7
  *
5
- * - `run(key, produce, shouldCache?)` invokes `produce` at most once per key — concurrent and retried calls
6
- * with the same key await the same promise — and retains a CACHEABLE resolution for `ttlMs` so a later retry
7
- * replays it. A rejection (a thrown error billed nothing) or a caller-marked un-cacheable resolution (e.g. a
8
- * transient 409) is evicted so a genuine retry re-runs.
9
- * - `peek(key)` returns the cached (in-flight or settled) promise without producing — letting a caller
10
- * short-circuit a retry BEFORE side gates (rate/quota), so the retry gets the same response, not a fresh 429.
11
- * An SSE stream wraps its live run in `run()` so the in-flight promise is stored too; a retry then peeks it.
8
+ * 提交幂等的律:`Idempotency-Key` 的执法点 = 资源行上的 `idem_key` UNIQUE(`task_run` / `leader_run` /
9
+ * `image_bake`);同键再投 ⇒ 读到那一行 ⇒ 唯一回执函数作答(`http/submit-replay.ts`)。本类只管「那一行还在
10
+ * 半路上」的那一刻:
12
11
  *
13
- * Per-entry TTL is enforced on access (O(1)); abandoned keys are reclaimed by a sweep that runs only once the
14
- * map grows past a threshold (no O(n) scan on the hot path). Single-threaded event-loop semantics: each HTTP
15
- * request is its own task, so there is no get→set race within a tick.
16
- *
17
- * (Caller context: the web-admin review gateway mints/echoes the Idempotency-Key and routes a given key to
18
- * the same worker, so this PER-POD cache is the worker-side backstop against a retried submission re-running —
19
- * and re-billing. Cross-pod / cross-restart exactly-once needs store-backing; the durable async /v1/runs path
20
- * is the alternative for long tasks.)
12
+ * - `run(key, produce)` 对同一个键至多调一次 `produce`;并发的同键调用拿到**同一个** promise(两份同体回执)。
13
+ * promise 一落定(成或败)条目即删 —— 之后的同键请求读行,不读这里。于是「同进程重试」与「跨副本 / 重启后
14
+ * 重试」读的是同一张账、拿到的是同一种回执,不存在「15 分钟内回缓存体、之后回行」的两个判官。
15
+ * - `peek(key)` 交出在飞的 promise(不产出)—— 让调用方在副门(rate/quota)**之前**合流,并发的那一条不吃新 429。
16
+ * SSE 腿把整条活流包在 `run()` 里,在飞期间到达的同键重试 peek 到它、等它落定。
17
+ * - TTL 只约束**条目**:一个落不了定的 promise(挂死的腿)最多被合流 `ttlMs`,过期即不再被 peek(之后的同键请求
18
+ * 去读行)。被遗弃的条目在表长过阈值后才扫(热路径无 O(n))。单线程事件循环语义:同一 tick 内没有 get→set 竞态。
21
19
  */
22
20
  export declare class IdempotencyCache<T> {
23
21
  private readonly ttlMs;
24
22
  private readonly sweepThreshold;
25
23
  private readonly entries;
26
24
  constructor(ttlMs?: number, sweepThreshold?: number);
27
- /** Live (un-expired) cached promise for a key, or undefined — without producing. */
25
+ /** 在飞(未过 TTL)的 promise,或 undefined —— 不产出。 */
28
26
  peek(key: string): Promise<T> | undefined;
29
- run(key: string | undefined, produce: () => Promise<T>, shouldCache?: (value: T) => boolean): Promise<T>;
27
+ run(key: string | undefined, produce: () => Promise<T>): Promise<T>;
30
28
  get size(): number;
31
- /** Return the cached promise if present AND within the TTL; drop it and return undefined if expired. */
29
+ /** Return the in-flight promise if present AND within the TTL; drop it and return undefined if expired. */
32
30
  private live;
33
31
  /** Reclaim expired entries only once the map is large — avoids an O(n) scan on every call (council). */
34
32
  private maybeSweep;
35
33
  }
36
34
  /**
37
- * Namespace an Idempotency-Key by the authenticated identity (BL-3). The dedup cache is one per process; keying
38
- * it on the RAW client header let a different tenant (or anonymous caller) replaying the same key receive the
39
- * FIRST caller's response and skip the rate/quota gates. Folding the credential-derived `source` + the
40
- * `principal` into the key scopes a replay to the SAME caller only — cross-tenant collisions become cache
41
- * misses (re-run, correctly attributed). `\x1f` (unit separator) can't occur in an HTTP header value, so the
42
- * three fields can't be confused. undefined raw key → undefined (no dedup, unchanged).
35
+ * `Idempotency-Key` 的**资源族**闭集 —— 键的作用域里那一位是**资源族,不是端点**。
36
+ *
37
+ * · `run` —— 一条 run(`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs` · A2A `message/send` 都建同一种行;
38
+ * 同一条 run 换腿重试仍同键,不会重复计费)。同族的两个动词口(`/v1/runs/:id/steer` · `/interrupt`)
39
+ * 也按本族取键,收成引擎的 `inputId`(它们的去重属主是引擎的输入队列,不是本仓的行)。
40
+ * · `leader` —— 一条 leader run(`leader_run.idem_key`)。
41
+ * · `bake` —— 一次镜像烘焙(`image_bake.idem_key`)。
42
+ *
43
+ * 跨族同一个原始键**互不命中**(它们是不同资源表上的不同行)。
44
+ */
45
+ export type IdemFamily = "run" | "leader" | "bake";
46
+ /**
47
+ * 一次提交的**调用方身份**(幂等的作用域就是它)—— 授权口建立的那两样:验过的 principal(`gatedPrincipal`)与凭据族
48
+ * (`source`:鉴权门由出示的服务凭据派生的系统名,`RouteRequestState.source` 是它唯一的赋值点)。由 {@link idemCallerOf}
49
+ * 从请求一处读出;键里用的那一位由 {@link idemIdentityOf} 一处定。
50
+ */
51
+ export interface IdemCaller {
52
+ readonly principal: string | null | undefined;
53
+ readonly source: string | null | undefined;
54
+ }
55
+ /** 本次请求的 {@link IdemCaller} —— 一处读:验过的 principal + 鉴权门派生的凭据族(两样都是既有的席位,这里不另判身份)。 */
56
+ export declare function idemCallerOf(req: IncomingMessage, ctx: {
57
+ readonly deps: {
58
+ readonly config: ServiceConfig;
59
+ };
60
+ readonly req: {
61
+ readonly source: string | null;
62
+ };
63
+ }): IdemCaller;
64
+ /**
65
+ * 幂等的**身份位** —— 一条规则:`principal ?? source`。有验过的 principal 用 principal;没有 principal 用凭据族(按系统令牌区分的
66
+ * 多系统部署,请求都不带 principal);两者皆无 ⇒ 空(无凭据的单租部署)。两位调用方用同一个原始键得到两把不同的键 —— 不给跨租户
67
+ * 预言机,也不让 B 的提交被 A 的行吞掉。同一次提交的两把钥匙(`idem_key` 与 A2A 派生的 taskId)都吃这一位,作用域相同。
68
+ * 编码带种类字:principal 与凭据族是两个命名空间(一个叫 `sysA` 的 principal 与凭据族 `sysA` 不共用键空间)。
69
+ */
70
+ export declare function idemIdentityOf(caller: IdemCaller): string;
71
+ /**
72
+ * 提交幂等键的**唯一**铸法:`sha256(身份位 ␟ family ␟ raw)` 的 hex(定宽 64)。
73
+ *
74
+ * - 身份位 = {@link idemIdentityOf}(`principal ?? source`)。
75
+ * - `family` = 资源族({@link IdemFamily}),不是端点。
76
+ * - 定宽 hash:落库列是 `VARCHAR(64)`,原始键与身份的长度上界都不进列宽;库里也不留调用方的原始键字面量。
77
+ * `\x1f`(单元分隔符)不可能出现在 HTTP 头值里,三段不会互相串位。
78
+ */
79
+ export declare function idemKeyOf(raw: string, caller: IdemCaller, family: IdemFamily): string;
80
+ /**
81
+ * 在飞合流的键 = 提交幂等键 × 这条腿的**回执形**(`durable` / `sync`)× 这次请求自带的 taskId。
82
+ *
83
+ * 合流交出的是**首个请求那条腿的结局**,所以只有「同一条 run、同一种回执形、同一把 taskId」的请求才许合流:durable 腿
84
+ * 合到同步腿的在飞结局上会拿到 200 引擎结果而不是 202 收据;带另一个 taskId 的请求合进来就绕过了「key 指向别的行」
85
+ * 那条 409。形不同(或 taskId 不同)的请求各自走到建行,由店的原子赢或观察 + 唯一回执函数作答 —— 仍是一条 run,
86
+ * 各拿自己腿的回执形。同步腿与流式腿同形(流式腿把同一份体放进 `done.result`),合流照旧。
43
87
  */
44
- export declare function scopedIdempotencyKey(raw: string | undefined, source: string | null, principal: string | undefined): string | undefined;
88
+ export declare function inflightKeyOf(idemKey: string, face: SubmitReplayFace, clientTaskId?: string): string;
45
89
  /**
46
- * 提交腿的**运行结局**(进幂等缓存的那一只)—— `status` + `body` 是它一直以来的两位;S-528 起多一位
90
+ * 提交腿的**运行结局**(在飞合流共享的那一只)—— `status` + `body` 是它一直以来的两位;S-528 起多一位
47
91
  * `wiringManifest`。
48
92
  *
49
- * 🔴 **为什么回执挂在 outcome 上、不铸进 `body`**(codex 对抗复审 [medium],红先复现后修):
50
- * `Idempotency-Key` 的作用域是 `(credential source, principal, raw key)`,**不含端点** —— 同一个 key 先
51
- * 走 `/v1/tasks/stream`、再走 `/v1/tasks`,命中的是前者缓存的那份体。回执若铸在同步腿的体里,这条
52
- * **确实产了帧**的 run 在非流式面上就成了「键缺席」,而 present-iff 的读法会把它读成「这台引擎不给
53
- * 回执」。挂在 outcome 上 = 它是**这条 run 的属性**(哪条腿跑的都一样),由各腿自己的应答构造器在
54
- * 送出那一刻按自己的 wire 形挂。拆缓存键也能消歧,但那会让换腿的重试**重复计费** —— 比缺一个键坏。
93
+ * 🔴 **为什么回执挂在 outcome 上、不铸进 `body`**(codex 对抗复审 [medium],红先复现后修):在飞合流按资源族
94
+ * `run` 取键(`idemKeyOf`),同一条 run 的几条提交腿同族 —— 同一个 key 先走 `/v1/tasks/stream`、在它**还在跑**时
95
+ * 再走 `/v1/tasks`,合流到的是前者的那份结局。回执若铸在同步腿的体里,这条**确实产了帧**的 run 在非流式面上
96
+ * 就成了「键缺席」,而 present-iff 的读法会把它读成「这台引擎不给回执」。挂在 outcome 上 = 它是**这条 run 的
97
+ * 属性**(哪条腿跑的都一样),由各腿自己的应答构造器在送出那一刻按自己的 wire 形挂。
98
+ * 落定之后的同键重放不走这里:它读行,体是 run 行读面 + `replay: true`(`http/submit-replay.ts`)—— 回执是起手那一帧
99
+ * 的活捕获、不落行,所以重放体上**不带** `wiringManifest`(`replay: true` 就是那一形的判别位)。
55
100
  *
56
- * 🔴 **租户面安全(别在复审里当成漏)**:缓存的那份回执是按**第一位调用方**的 `explicitOperatorOk` 投的,
57
- * 而键本身就按 `principal` 分区(`scopedIdempotencyKey`)⇒ 命中它的只可能是**同一个** principal,同一份
58
- * 部署 operator 名单下求值恒同 ⇒ operator 面不可能经重放落到租户手里。跨租户同 key 是**缓存未命中**
59
- * (各自重跑、各自归账),这一位从 BL-3 起就是这么设计的。
101
+ * 🔴 **租户面安全(别在复审里当成漏)**:合流的那份回执是按**第一位调用方**的 `explicitOperatorOk` 投的,
102
+ * 而键本身就按身份位分区(`idemKeyOf` 的 `principal ?? source`)⇒ 合流到它的只可能是**同一个** principal(无 principal 时
103
+ * 是同一个凭据族,两边的 `explicitOperatorOk` 都是假),同一份部署 operator 名单下求值恒同 ⇒ operator 面不可能经合流落到
104
+ * 租户手里。跨租户 / 跨系统同 key 是**不同的键**(各自重跑、各自归账),这一位从 BL-3 起就是这么设计的。
60
105
  *
61
106
  * 对照:同一条 200 体上的 `notice` 仍铸在 `body` 里,因为它说的是**本次请求**带没带 verify/cascade
62
- * (重放腿在 `prepareSpec` 之前就答了,结构上不知道这件事);回执说的是**那条 run**。两者的归属不同,
107
+ * (合流腿在 `prepareSpec` 之前就答了,结构上不知道这件事);回执说的是**那条 run**。两者的归属不同,
63
108
  * 不是可以统一的两处写法。
64
109
  */
65
110
  export interface SubmitOutcome {