@sema-agent/server 7.47.0 → 7.49.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 (71) hide show
  1. package/USAGE.md +10 -0
  2. package/dist/approval-card.d.ts +1 -1
  3. package/dist/auth-bridge.d.ts +2 -2
  4. package/dist/auth-bridge.js +1 -1
  5. package/dist/boot/config-center.js +2 -2
  6. package/dist/boot/runner-deps.d.ts +4 -2
  7. package/dist/boot/stores.js +13 -0
  8. package/dist/config-center/apply-effective.js +1 -1
  9. package/dist/config-center/http-client.d.ts +1 -1
  10. package/dist/config-center/http-client.js +1 -1
  11. package/dist/config-center/restart-signal.js +1 -1
  12. package/dist/config-center/skills-mcp.js +1 -1
  13. package/dist/config-provider.d.ts +2 -2
  14. package/dist/config-provider.js +2 -2
  15. package/dist/config-types.d.ts +1 -1
  16. package/dist/config.d.ts +4 -1
  17. package/dist/config.js +16 -2
  18. package/dist/fleet/fleet-bus.d.ts +93 -6
  19. package/dist/fleet/fleet-bus.js +40 -1
  20. package/dist/fleet-client.d.ts +1 -1
  21. package/dist/fleet-client.js +1 -1
  22. package/dist/fleet-lease.d.ts +1 -1
  23. package/dist/fleet-lease.js +1 -1
  24. package/dist/hooks/hook-runner.d.ts +11 -11
  25. package/dist/hooks/hook-runner.js +143 -24
  26. package/dist/http/route-ctx.d.ts +24 -0
  27. package/dist/http/route-ctx.js +8 -0
  28. package/dist/http/routes/approvals-assistant.js +67 -9
  29. package/dist/http/routes/capabilities.js +1 -1
  30. package/dist/http/routes/fleet.js +2 -2
  31. package/dist/http/routes/notify-wake.js +3 -4
  32. package/dist/http/routes/runs.d.ts +1 -1
  33. package/dist/http/routes/runs.js +27 -3
  34. package/dist/http/routes/tasks.js +8 -3
  35. package/dist/http/server.js +11 -7
  36. package/dist/observability/fail-open.d.ts +8 -0
  37. package/dist/observability/fail-open.js +8 -0
  38. package/dist/orchestration/workflow-completion-inbox.d.ts +12 -1
  39. package/dist/orchestration/workflow-completion-inbox.js +6 -1
  40. package/dist/org-memory-admission.js +1 -1
  41. package/dist/plugins/approval-ask-store-memory.js +2 -0
  42. package/dist/plugins/approval-ask-store-sql.d.ts +50 -1
  43. package/dist/plugins/approval-ask-store-sql.js +40 -5
  44. package/dist/plugins/checkpoint-store-sql.d.ts +108 -1
  45. package/dist/plugins/checkpoint-store-sql.js +65 -0
  46. package/dist/plugins/local-checkpoint-store.d.ts +14 -1
  47. package/dist/plugins/local-checkpoint-store.js +22 -1
  48. package/dist/plugins/permission-rule-store-sql.d.ts +33 -0
  49. package/dist/plugins/permission-rule-store-sql.js +1 -8
  50. package/dist/plugins/scheduler-support.d.ts +1 -1
  51. package/dist/plugins/scheduler-support.js +1 -1
  52. package/dist/plugins/sql-errors.d.ts +18 -0
  53. package/dist/plugins/sql-errors.js +10 -0
  54. package/dist/resource-window.d.ts +111 -0
  55. package/dist/resource-window.js +80 -0
  56. package/dist/rules-consent.d.ts +29 -1
  57. package/dist/rules-consent.js +22 -1
  58. package/dist/run-local.js +1 -1
  59. package/dist/runs.js +3 -3
  60. package/dist/runtime-caps-resolver.d.ts +1 -1
  61. package/dist/runtime-governance.d.ts +1 -1
  62. package/dist/task-settings.d.ts +1 -1
  63. package/dist/tool-approval.d.ts +47 -1
  64. package/dist/tool-approval.js +131 -11
  65. package/dist/trace/engine-notice-wire.d.ts +13 -3
  66. package/dist/trace/engine-notice-wire.js +3 -0
  67. package/dist/trace/injection-tier.d.ts +33 -0
  68. package/dist/trace/injection-tier.js +7 -0
  69. package/dist/trace/project.d.ts +5 -1
  70. package/dist/trace/project.js +5 -0
  71. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
3
+ import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
4
4
  import { type RuleOfferProjection } from "../approval-card.js";
5
5
  import { type SqlDriver } from "./sql-driver.js";
6
6
  /**
@@ -175,6 +175,80 @@ export interface CheckpointAskCandidate {
175
175
  /** 在场 ⇒ 这行读不出(blob 版本超前 / JSON 坏)。收敛器视同不匹配;**从不是** false,缺席即可读。 */
176
176
  unparseable?: true;
177
177
  }
178
+ /**
179
+ * #368 件1 —— legacy `/v1/approvals/:sessionId/decide` 腿的**幂等回放**判别行(窄读,非凭据面)。
180
+ *
181
+ * 刻意**不带 token**:回放只需要「这条已决的行归谁、绑的是哪一次调用、判词是什么」三件,而 token 是
182
+ * resume 凭据(§12-C「checkpointToken 从不外发」;`CheckpointAskCandidate` 带它是因为收敛器要拿它去
183
+ * `bindBatch`,本口没有那个用途 ⇒ 不给)。
184
+ */
185
+ export interface DecidedApprovalRecord {
186
+ /** 行的属主 scope 列(`encodeCheckpointScope` 的产物:principal 或匿名哨兵 `"_"`)。回放属主门的输入。 */
187
+ scope: string;
188
+ /** 行上的 D-1 入参摘要(`bound_input_hash` 列);pre-D-1 行为 null。 */
189
+ boundInputHash: string | null;
190
+ /**
191
+ * 这条已决行的**工具审批判词**,或 `null` = **判别不出**(codex R1-[medium] 四的收敛形):
192
+ * outcome 读不出 / 没有 winner / winner 绑的是**另一个** callId / 判词是 REVIEW 门那三个词。
193
+ *
194
+ * 🔴 `null` **不是**「无所谓」:调用方必须把它当「这条命中的行我读不懂」⇒ **整次回放拒绝**。
195
+ * 一条读不懂的已决行与一条判词相反的已决行在风险上同级 —— 都意味着这只 callId 上的持久事实
196
+ * 不是本次回放能如实复述的。
197
+ */
198
+ decision: "approve" | "deny" | null;
199
+ /**
200
+ * 判词**之外**的决议载荷指纹(codex R2-[high] 一,验真后补):`updatedInput` / `answer` / `reason`
201
+ * 三件都在 core 的持久 winner 里,而**同判词不等于同决议** —— 「approve + 编辑后的实参 A」与
202
+ * 「approve + 实参 B」是两次不同的放行,只比判词就会把后者谎报成「你的请求已被处理」(而 B 从未执行)。
203
+ * 调用方按**同一份请求重发**的语义要求它与本次请求的同三件逐字相等。
204
+ * `null` = 算不出(载荷序列化不了)⇒ 与 `decision: null` 同判:整次拒绝。
205
+ * 铸法见 {@link approvalPayloadFingerprint}(两条车道共用同一只,免得指纹口径漂)。
206
+ */
207
+ payloadFp: string | null;
208
+ }
209
+ /**
210
+ * #368 件1(codex R2-[high] 一)—— 决议载荷的比对指纹:`[updatedInput, answer, reason]` 三件的规范串。
211
+ *
212
+ * 为什么是指纹而不是把三件原样交出来:`updatedInput` 可能携人写的命令原文/密钥形字节,而本读口的
213
+ * 契约是「窄读、非凭据面」—— 交指纹够比对,也不给任何调用点把它误投上 wire 的机会。
214
+ *
215
+ * 🔴 **必须是 canonical(键序无关)**,不是裸 `JSON.stringify`(codex R3-[high] 二,验真后修):行侧那份
216
+ * 载荷是从 `JSON`/`JSONB` 列**回读**的,而 **PG 的 jsonb 不保留输入键序**(它按自己的规则重排)⇒ 客户端
217
+ * 逐字重发原 body 时,请求侧按原键序、行侧按库的键序,裸 stringify 出来的两串不等 ⇒ 一次**合法重试**
218
+ * 被误拒成 404。方向虽保守(不是谎报成功),但那正是本件要修的那个场景,所以指纹按**递归键排序 +
219
+ * 数组保序**铸(见 {@link canonicalFingerprintJson})。
220
+ *
221
+ * 缺席一律折 `null` 哨兵(不是省位):`{updatedInput: undefined}` 与「没有这个键」在本比对里是同一件事。
222
+ * ⚠️ 调用方**必须**按 outcome 铸点的在场规则喂参(`updatedInput` 只在 approve 上、`reason` 走真值判)——
223
+ * 铸点丢掉的字节行上就没有,请求侧多喂一件会把一次合法重试误判成「不同的决议」。
224
+ * 序列化失败(循环引用/BigInt 等)⇒ `null` = 判别不出,调用方据此拒绝回放(fail-closed)。
225
+ */
226
+ export declare function approvalPayloadFingerprint(v: {
227
+ updatedInput?: unknown;
228
+ answer?: unknown;
229
+ reason?: string;
230
+ }): string | null;
231
+ /**
232
+ * #368 件1 —— {@link SqlCheckpointStore.findDecidedApprovalsForBinding} 的扫描上界。
233
+ *
234
+ * 🔴 它**同时是判据**,不只是一道 LIMIT:调用方按「命中集里每条判词都一致才回放」判,而一次被截断的
235
+ * 集合可能把分歧藏在第 N+1 行 ⇒ 消费点约定「**回满 = 判别不出**」,整次拒绝回放(回落 404)。现网正常形
236
+ * 恒 0 或 1 行,回满只可能是数据异常。
237
+ */
238
+ export declare const DECIDED_REPLAY_SCAN_CAP = 8;
239
+ /**
240
+ * #368 件1 —— core 的 `ResolvedOutcome` → 本腿(工具审批 decide)的二值词表。**闭集穷举**(#157):
241
+ * core 哪天给 `ResolvedOutcome.decision` 加员,下面的 `never` 当场编译期红,而不是让一个新判词被
242
+ * 静默折成 `deny` 或漏成「没有决议」。
243
+ *
244
+ * 三条判据,缺一即 `null`(= 不回放,调用方逐字回落修前 404 —— **绝不**把 not-found 洗成成功):
245
+ * ① 有 winner —— `resource_limit` / `wake` / `task_done` 的 resolve 不记 winner(core 契约),那些行
246
+ * 不是「一次人给的工具审批」,没有可回放的判词;
247
+ * ② winner 绑的正是这次请求回显的那个 `boundCallId` —— 身份第二锚(列谓词是第一锚),两锚同意才算同一件事;
248
+ * ③ 判词属于**工具审批**那两个词(`allow`/`deny`)—— `approve`/`reject`/`edit` 是 REVIEW 门
249
+ * (plan_review / dry_run_review)的判词,那些门的决议入口不是本腿,回放它们等于跨门作答。
250
+ */
251
+ export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undefined, boundCallId: string): "approve" | "deny" | null;
178
252
  /** `pending_steer` 列承载的两个 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
179
253
  * 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它们)。 */
180
254
  type SteerColumnState = Pick<CheckpointState, "pendingSteer" | "pendingSteerQueue">;
@@ -431,6 +505,39 @@ export declare class SqlCheckpointStore implements CheckpointStore {
431
505
  * 维,列命中行结构上也必须解 blob 才拿得到它。
432
506
  */
433
507
  findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
508
+ /**
509
+ * #368 件1 —— 「这条会话上的这一次工具调用,**是不是已经被决过了**?」的窄读口(legacy `/decide`
510
+ * 腿的幂等回放判别器;返回形见 {@link DecidedApprovalRecord})。
511
+ *
512
+ * 为什么必须有这一口(而不是复用 `findPendingTokenBySession`):后者硬 `status='pending'`,首决之后
513
+ * 恒 null ⇒ 重试与「压根没有这条审批」在 wire 上同形(404)。而合规客户端的重试体里**没有**
514
+ * checkpointToken(它只回显二元组),所以既有的 `approval_stale` 409 那条判别路对它结构性不可达。
515
+ *
516
+ * 四条口径,逐条都是判据:
517
+ * · **谓词 = (session_id, tool_call_id, status='resolved')**:`tool_call_id` 列是 `put()` 从
518
+ * `pendingAction.toolCallId` 盖下来的权威投影,也正是 listPending 交给客户端回显的 `boundCallId`
519
+ * ⇒ 客户端手里那把与库里这一列同源。`(session_id, status)` 是既有索引 `idx_checkpoint_session_status`
520
+ * 的前缀,不新增索引面。
521
+ * · **只认 `resolved`**:`expired`(窗到期被 reaper 抹掉)**不是**一次决议 —— 把它回放成
522
+ * 「已决」等于把「没人来得及答」谎报成「有人答过」。那一形照旧落 404(诚实缺席)。
523
+ * · **`decided_at_ms IS NOT NULL`**:与 respond 腿 `decidedRowIsAuthoritative` 的合取项③同判据
524
+ * (「这条决议真的被结算腿落过」的印记);顺带消掉 MySQL/PG 对 `ORDER BY … DESC` 里 NULL 排序
525
+ * 方向不同这个方言差(MySQL NULL 最后、PG NULL 最前)—— 谓词先滤掉,排序就不含 NULL。
526
+ * · **不带 scope 谓词、由调用方判属主**:与本类 `findPendingTokenBySession` 同姿势(读口不做租户
527
+ * 判决,判决在调用点)。回放腿据返回的 `scope` 做与 pending 那道**逐字同判**的属主门,非属主
528
+ * 拿到的仍是 404(不给存在性谕示)。
529
+ *
530
+ * 🔴 **返回的是全部命中行,不是「最近那一条」**(codex R1-[medium] 四,验真后改向):本口原先按
531
+ * `decided_at_ms DESC LIMIT 1` 取「最近一次决议」,而 local 孪生手里根本没有这个时间戳(core 的
532
+ * `Checkpoint` 上没有 decidedAt 字段),它只能按 `createdAt` 排 —— 两条车道于是可能对**同一批行**给出
533
+ * **不同**的判词(创建序与决议序相反时)。判据因此改成一条与排序**无关**的:调用方要求**全部命中行
534
+ * 判词一致**才回放,分歧即拒。同毫秒并列、两方言 NULL 排序差、跨车道排序键不同 —— 三个问题一起消失,
535
+ * 而现网正常形(恒 0 或 1 行)一个字节不变。上限 8 行足够:超过它的形已经不是「重试」而是数据异常。
536
+ *
537
+ * 坏 `outcome` cell **不抛**:回放是一条锦上添花的腿,让一条读不出的 JSON 把一次 decide 重试变成 500
538
+ * 比 404 更坏。留痕后交出 `decision: null`(= 判别不出),由调用方按「整次拒绝」处置。
539
+ */
540
+ findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise<DecidedApprovalRecord[]>;
434
541
  /**
435
542
  * The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
436
543
  * BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
@@ -4,10 +4,12 @@ import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendi
4
4
  import { redactDeep, redactSecrets } from "../trace/redact.js";
5
5
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema } from "../approval-card.js";
6
6
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
7
+ import { createLogger } from "../observability/logger.js";
7
8
  import { recordFailOpen } from "../observability/fail-open.js";
8
9
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
9
10
  import { isDupKeyError } from "./sql-errors.js";
10
11
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
12
+ const fingerprintLogger = createLogger();
11
13
  const MAX_TOOL_INPUT_CHARS = 8192;
12
14
  export const TERMINAL_BACKSTOP_MS = Math.max(60_000, (Number.isFinite(Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS)) ? Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) : 0) || 30 * 86_400_000);
13
15
  export const TERMINAL_GRACE_MS = 3_600_000;
@@ -79,6 +81,48 @@ export function boundedToolInput(args) {
79
81
  return { truncated: true, bytes: json.length };
80
82
  return redacted;
81
83
  }
84
+ function canonicalFingerprintJson(v) {
85
+ if (v === null || typeof v !== "object")
86
+ return JSON.stringify(v) ?? "null";
87
+ if (Array.isArray(v))
88
+ return `[${v.map(canonicalFingerprintJson).join(",")}]`;
89
+ const entries = Object.entries(v)
90
+ .filter(([, val]) => val !== undefined)
91
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
92
+ return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalFingerprintJson(val)}`).join(",")}}`;
93
+ }
94
+ export function approvalPayloadFingerprint(v) {
95
+ try {
96
+ const fp = canonicalFingerprintJson([v.updatedInput ?? null, v.answer ?? null, v.reason ?? null]);
97
+ return fp;
98
+ }
99
+ catch (e) {
100
+ fingerprintLogger.warn("approval decision payload is not serializable — idempotent replay refused", { error: e instanceof Error ? e.message : String(e) });
101
+ return null;
102
+ }
103
+ }
104
+ export const DECIDED_REPLAY_SCAN_CAP = 8;
105
+ export function approvalDecisionOfWinner(winner, boundCallId) {
106
+ if (winner === undefined)
107
+ return null;
108
+ if (winner.boundCallId !== boundCallId)
109
+ return null;
110
+ switch (winner.decision) {
111
+ case "allow":
112
+ return "approve";
113
+ case "deny":
114
+ return "deny";
115
+ case "approve":
116
+ case "reject":
117
+ case "edit":
118
+ return null;
119
+ default: {
120
+ const unhandled = winner.decision;
121
+ void unhandled;
122
+ return null;
123
+ }
124
+ }
125
+ }
82
126
  function pendingActionToolCallId(blob) {
83
127
  let parsed;
84
128
  try {
@@ -429,6 +473,27 @@ export class SqlCheckpointStore {
429
473
  }
430
474
  return out;
431
475
  }
476
+ async findDecidedApprovalsForBinding(sessionId, boundCallId) {
477
+ const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome FROM checkpoint " +
478
+ `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 FROM checkpoint " +
479
+ `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]);
480
+ return rows.map((r) => {
481
+ let winner;
482
+ try {
483
+ const outcome = parseJson(r.outcome);
484
+ winner = outcome ? winnerFromOutcome(outcome) : undefined;
485
+ }
486
+ catch (e) {
487
+ this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
488
+ }
489
+ return {
490
+ scope: String(r.scope),
491
+ boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
492
+ decision: approvalDecisionOfWinner(winner, boundCallId),
493
+ payloadFp: winner === undefined ? null : approvalPayloadFingerprint(winner),
494
+ };
495
+ });
496
+ }
432
497
  async peekPendingScope(sessionId) {
433
498
  const { rows } = await this.db.query(this.q("SELECT scope FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT scope FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
434
499
  if (!rows[0])
@@ -1,5 +1,5 @@
1
1
  import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ReopenReason, type ResolveExpectation, type ResumeOutcome, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
2
- import { type PendingCheckpoint } from "./checkpoint-store-sql.js";
2
+ import { type DecidedApprovalRecord, type PendingCheckpoint } from "./checkpoint-store-sql.js";
3
3
  export interface LocalCheckpointStoreOptions {
4
4
  /** taskId join for the pending card (the local FileRunStore's `getActiveTaskId`). Absent ⇒ `taskId: null`. */
5
5
  getActiveTaskId?: (sessionId: string) => Promise<string | undefined>;
@@ -91,6 +91,19 @@ export declare class LocalCheckpointStore {
91
91
  * 索引里,其 expired 对本探针不可见(core File store 的 listByScope 是 pending-only,无法枚举历史行回填)。
92
92
  * 旧存量锁死的恢复把手 = cancel verb([868]①,不依赖本索引);增量行全覆盖。 */
93
93
  hasExpiredBySession(sessionId: string): Promise<boolean>;
94
+ /**
95
+ * #368 件1 —— SQL 孪生 `findDecidedApprovalsForBinding` 的本地形(判据、词表、失败方向逐条同源;
96
+ * 那处顶注是唯一真源,这里只记两处**本地形自己的**差别)。
97
+ *
98
+ * · 枚举面走 [868] 的 `sessionTokens` 索引 —— 与 `hasExpiredBySession` 同一条路(core 的 File store
99
+ * 只能 pending-only 枚举,跨态读只有 `get(token)`),所以它那条**存量限制**在这里逐字同样成立:
100
+ * 索引建立之前 park 的老行对本口不可见 ⇒ 回放判别不出 ⇒ 调用方逐字回落 404(保守方向)。
101
+ * · **没有排序**:本口交出全部命中行,由调用方按「判词一致才回放」判(理由见 SQL 孪生顶注的
102
+ * codex R1-[medium] 四段——正是为了消掉「local 只有 createdAt、SQL 有 decided_at_ms」这条排序键
103
+ * 分岔)。同理回放的 wire 形**刻意不投** `decidedAtMs`:core 的 `Checkpoint` 上没有那个字段,
104
+ * 投了就是「SQL 车道有、local 车道没有」的键集分岔。
105
+ */
106
+ findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise<DecidedApprovalRecord[]>;
94
107
  /** `undefined` = no pending row; else the owner scope (local scopes are always strings — never null). */
95
108
  peekPendingScope(sessionId: string): Promise<string | null | undefined>;
96
109
  /**
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSyn
3
3
  import { join } from "node:path";
4
4
  import { isApprovalGateKind } from "../tool-approval.js";
5
5
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
6
- import { boundedRuleOffers, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
6
+ import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
7
7
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
8
8
  function terminalAtOf(cp) {
9
9
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
@@ -195,6 +195,27 @@ export class LocalCheckpointStore {
195
195
  }
196
196
  return false;
197
197
  }
198
+ async findDecidedApprovalsForBinding(sessionId, boundCallId) {
199
+ const tokens = this.sessionTokens.get(sessionId);
200
+ if (!tokens)
201
+ return [];
202
+ const out = [];
203
+ for (const t of [...tokens]) {
204
+ const cp = await this.inner.get(t);
205
+ if (!cp || cp.status !== "resolved")
206
+ continue;
207
+ const pa = cp.pendingAction;
208
+ if (pa?.toolCallId !== boundCallId)
209
+ continue;
210
+ out.push({
211
+ scope: cp.scope,
212
+ boundInputHash: pa?.boundInputHash ?? null,
213
+ decision: approvalDecisionOfWinner(cp.resolvedOutcome, boundCallId),
214
+ payloadFp: cp.resolvedOutcome === undefined ? null : approvalPayloadFingerprint(cp.resolvedOutcome),
215
+ });
216
+ }
217
+ return out;
218
+ }
198
219
  async peekPendingScope(sessionId) {
199
220
  for (const { cp } of await this.pendings())
200
221
  if (cp.sessionId === sessionId)
@@ -34,6 +34,39 @@ export declare const TIDB_PERMISSION_RULE_STATEMENTS: readonly string[];
34
34
  /** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
35
35
  * 本函数留给只需要这三张表的集成测试)。 */
36
36
  export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise<void>;
37
+ /**
38
+ * #340 升级前置断言 —— **拒启**,不是 warn(codex 对抗复审 [high],验真后修;形照 #119 的
39
+ * `assertToolResultProvenanceSchema` 先例)。
40
+ *
41
+ * 病:本仓不发 `ALTER TABLE` 迁移(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升
42
+ * 上来的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `permission_rule_approval` 是空操作 —— 本车新加的
43
+ * `command` / `edited_json` 两列不在,而 `get` / `create` / `cas` 三条语句**无条件**引用它们。
44
+ * 后果不是「少一个新功能」,是**整条审批记录面**在那台机器上是坏的:
45
+ * · 卡道「不再询问」(**修前就有**的候选臂也一样)每次撞 unknown column,被回决腿的 catch 吞成
46
+ * `rule_store_error` —— 裁决照常 200,服务看起来完全健康;
47
+ * · CC 导入的 prepare 连 pending 记录都落不下;
48
+ * · 而 `/v1/capabilities` 的 `permissionRules` 仍然报 true ⇒ 本仓「says yes ⟺ route works」整句为假。
49
+ * 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 那正是 #157「禁静默降级」要挡的形态。
50
+ *
51
+ * 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。
52
+ * 只判「两列在不在」,不判宽度(两列都是 TEXT 族,没有截断轴)。
53
+ *
54
+ * 🔴 **只有可证的缺列才给破坏性指路**(codex 对抗复审 round2/round3 [high],两轮收窄后的终形):
55
+ * 「探针失败」**不等于**「列不在」。超时、连接被重置、取消、资源不足、列级权限被拒、表整个不存在 ——
56
+ * 每一种都会让这条读抛错,而把它们诊断成「去 DROP TABLE」是一条**会真的毁掉审计记录**的建议(比它要
57
+ * 挡的缺陷更贵)。round2 的两步探(先读一列老列)也不够:两次读之间同样可以插进一次瞬时故障。
58
+ * 终形判据 = **方言的缺列错误码**,一行一方言的闭集(见 {@link isMissingColumnError});其余错误
59
+ * **原样 rethrow**,一个字都不加工。真库两条腿各自跑过这条路径(db-integration 的存量表格),所以
60
+ * 这张码表不是抄手册抄来的,是实测钉住的。
61
+ * 🔴 位置与 #119 同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着),
62
+ * 放在 DDL 之后、任何路由装配之前 —— 还没开始服务,拒启的意义仍在;且**只在规则车道真会被装配时**跑
63
+ * (`PERMISSION_RULES_ENABLED=false` 的部署根本不碰这张表,为它拒启是纯误伤,见调用点)。
64
+ */
65
+ /**
66
+ * 「这个错误**是**『列不存在』吗」——判据属主自 #363 起收在 `sql-errors.ts`(与 `isDupKeyError` 同族、
67
+ * 同理由:识别集复制一次就会漂,而这条谓词守的是一句**破坏性**指路)。本文件此前那份逐字实现已下车,
68
+ * 语义一个字不变(闭集与 fail-safe 方向逐字见那边的头注)。
69
+ */
37
70
  export declare function assertPermissionRuleApprovalSchema(query: (sql: string) => Promise<{
38
71
  rows: Record<string, unknown>[];
39
72
  }>, dialect: "tidb" | "pg"): Promise<void>;
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
4
4
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
5
+ import { isMissingColumnError } from "./sql-errors.js";
5
6
  export function writerOfSqlRuleStore(store) {
6
7
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
7
8
  if (w === null || typeof w !== "object")
@@ -160,14 +161,6 @@ export async function ensureTiDBPermissionRuleSchema(pool) {
160
161
  for (const stmt of TIDB_PERMISSION_RULE_STATEMENTS)
161
162
  await pool.query(stmt);
162
163
  }
163
- function isMissingColumnError(err, dialect) {
164
- if (err === null || typeof err !== "object")
165
- return false;
166
- const code = Reflect.get(err, "code");
167
- if (dialect === "pg")
168
- return code === "42703";
169
- return Reflect.get(err, "errno") === 1054 || code === "ER_BAD_FIELD_ERROR";
170
- }
171
164
  export async function assertPermissionRuleApprovalSchema(query, dialect) {
172
165
  try {
173
166
  await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
@@ -1,6 +1,6 @@
1
1
  import { SchedulerError } from "@sema-agent/core";
2
2
  import type { SchedulerCapability, ScheduledIntent, SchedulerContext, ScheduledTaskId, ScheduledTaskSummary } from "@sema-agent/core";
3
- import type { SchedulerRecord } from "@sema-agent/registry-core/scheduler-store";
3
+ import type { SchedulerRecord } from "@sema-agent/settings-schema/scheduler-store";
4
4
  export type { SchedulerRecord };
5
5
  /** core `Result<T,E>` shape (harness/types) — `{ok:true,value} | {ok:false,error}`. */
6
6
  type Result<T, E> = {
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
4
  import { SchedulerError, isValidCronExpr } from "@sema-agent/core";
5
- import { loadSchedulerStore, saveSchedulerStore } from "@sema-agent/registry-core/node";
5
+ import { loadSchedulerStore, saveSchedulerStore } from "@sema-agent/settings-schema/node";
6
6
  const ok = (value) => ({ ok: true, value });
7
7
  const err = (error) => ({ ok: false, error });
8
8
  export const DEFAULT_SCHEDULER_CAPS = {
@@ -38,4 +38,22 @@ export declare function isMysqlDupKeyError(err: unknown): boolean;
38
38
  export declare function isPgUniqueViolation(err: unknown): boolean;
39
39
  /** 方言分派口——双方言店的调用形(`isDupKeyError(this.db.dialect, e)`)。 */
40
40
  export declare function isDupKeyError(dialect: SqlDialect, err: unknown): boolean;
41
+ /** PostgreSQL `undefined_column` 的 SQLSTATE。⚠️ 与 `42P01`(`undefined_table`)刻意分开。 */
42
+ export declare const PG_UNDEFINED_COLUMN_SQLSTATE = "42703";
43
+ /** MySQL/TiDB `ER_BAD_FIELD_ERROR`(未知列)的数字 errno。 */
44
+ export declare const MYSQL_ER_BAD_FIELD_ERRNO = 1054;
45
+ /** MySQL/TiDB `ER_BAD_FIELD_ERROR` 的字符串错误码。 */
46
+ export declare const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
47
+ /**
48
+ * 「这个错误**是**『列不存在』吗」——升级前置断言(`assert*Schema` 族)的**唯一**判据。
49
+ *
50
+ * 🔴 单一属主的理由与 {@link isDupKeyError} 逐字同源(本文件顶注的 single-semantic-multi-site-drift):
51
+ * 判据一旦复制,两处的识别集就会各自漂,而这条谓词的错判方向**特别贵** —— 它守的是一句**破坏性**的
52
+ * 错误指路(「去 DROP TABLE」)。超时、连接重置、取消、资源不足、列级权限被拒、表整个不存在,每一种
53
+ * 都会让探针抛错;把它们诊断成「删表重建」比它要挡的缺陷更贵(#340 codex 对抗复审 round2/round3
54
+ * [high] 两轮收窄后的终形)。认不出的一律**不是**缺列(fail-safe:宁可把一次真缺列报成原始错误)。
55
+ *
56
+ * MySQL 侧两个归因键都认(mysql2 的 `code`/`errno` 是同一件事的两种写法),与 dup-key 那条同姿势。
57
+ */
58
+ export declare function isMissingColumnError(err: unknown, dialect: SqlDialect): boolean;
41
59
  //# sourceMappingURL=sql-errors.d.ts.map
@@ -15,4 +15,14 @@ export function isPgUniqueViolation(err) {
15
15
  export function isDupKeyError(dialect, err) {
16
16
  return dialect === "tidb" ? isMysqlDupKeyError(err) : isPgUniqueViolation(err);
17
17
  }
18
+ export const PG_UNDEFINED_COLUMN_SQLSTATE = "42703";
19
+ export const MYSQL_ER_BAD_FIELD_ERRNO = 1054;
20
+ export const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
21
+ export function isMissingColumnError(err, dialect) {
22
+ if (err === null || typeof err !== "object")
23
+ return false;
24
+ if (dialect === "pg")
25
+ return keyOf(err, "code") === PG_UNDEFINED_COLUMN_SQLSTATE;
26
+ return keyOf(err, "errno") === MYSQL_ER_BAD_FIELD_ERRNO || keyOf(err, "code") === MYSQL_ER_BAD_FIELD_CODE;
27
+ }
18
28
  //# sourceMappingURL=sql-errors.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * [3833] 件 S-4 —— **running 态跨片用量投影**的数据面(`GET /v1/runs/:id` 的 `crossSliceUsage`)。
3
+ *
4
+ * 【为什么需要它】S-3 把三条跨片总额旋钮(`RESOURCE_SUSPEND_TOTAL_TOKENS` / `_BUDGET_USD` /
5
+ * `_MAX_SLICES`)供到了 `TaskSpec.resourceSuspend`,core 那本跨片账(`ResourceLedger`)也确实在记 ——
6
+ * 但**跑的过程中 wire 上一个数都读不到**。运维第一次知道「这条 run 逼近过上限」是在**爆窗那一刻**
7
+ * (行翻 `suspended` + `resource_limit` gate),想提前介入无读面。[3833] 的 75 万 token 就是这样烧完的。
8
+ *
9
+ * 【两半各自的真源(刻意不自建第三本账)】
10
+ * · **上限半**:本模块的进程内登记册 —— 记的是**这条 run 真正铸给 core 的那只 `resourceSuspend`**
11
+ * (装配点 `boot/resolve-spec.ts` 的 `resourceSuspendOptIn`),不是读时现取 env。差别是承重的:
12
+ * ① verify/cascade 腿被 `resourceSuspendOptIn` **整只排除**(opt-in 不成立)⇒ 登记册里没有它 ⇒
13
+ * 整键缺席,绝不显示一个没人执行的天花板;② 滚动改 env 之后,已在跑的 run 报的仍是它自己那份。
14
+ * · **已耗半**:run 自己的 durable 账本行 `model_usage`(`trace/project.ts` 的 `aggregateModelUsage`)——
15
+ * 与 `TaskStats.modelUsage` 回声**同一份账**,append-only、跨 slice、跨进程(每条腿只追加自己的
16
+ * delta,和跨越 park 边界)。**本模块不累加任何东西**:读时求和,不落第二本账(两本账必漂移)。
17
+ *
18
+ * 【与 core `ResourceLedger` 的关系(诚实登记,别读成「就是那本账」)】core 执法读的是挂在
19
+ * `resource_limit` checkpoint 行上的 `ResourceLedger`,它对本仓在 running 期**结构性不可达**(没有
20
+ * 「按 taskId 找 checkpoint」的面,而且跑的时候那行是 `resolved` 的)。两边量的是**同一个量**:
21
+ * core 的 `spentTokens` 轴 = 每片 `TaskStats.tokens`(= Σ 每次调用 `usage.totalTokens`,**不含**委派子代,
22
+ * 子代在 `stats.nested`),而 `model_usage` 行正是同一批 `brain.call` 的逐 turn delta、同样只收顶层 run。
23
+ * 已知差:① **滞后至多一个 turn**(delta 在 turn 边界 drain,进行中的那一 turn 还没入账);② 账本行
24
+ * 写失败(F 类)会少计。⇒ 本投影是**分诊/预警**读面,**不是**任何门/CAS/resume 判定的输入。
25
+ *
26
+ * 【登记册的语义边界(与 `turn-activity.ts` 的 `msSinceLastActivity` 逐字同族)】
27
+ * · **同副本 best-effort**:只活在本进程。跨副本 poll(run 跑在别的副本)或本副本重启后 ⇒ 读不到 ⇒
28
+ * **整键诚实缺席**。缺席 = 「本副本无法证明这条 run 有跨片窗」,不是「它没有窗」——绝不铸零上限
29
+ * (`0` 在 core 那边是**合法且极紧**的上限,与「没配」两义必须可判别,S-3 顶注同一条纪律)。
30
+ * · 不落库、不进 SQL 面。有界性由插入序近似 LRU 兜底(CAP 条,每条 ≈ 100B)。
31
+ * · **新世代必须清**:session purge 后客户端合法复用自带 taskId 重提交 —— {@link recordResourceWindow}
32
+ * 每次先删后写,于是「新腿没窗」不会读到旧腿的窗(`turn-activity` codex S1-F2 同款陷阱)。
33
+ *
34
+ * 【已声明的代价:每次 poll 一次全量账本读(codex 对抗复审 R2-[medium],如实登记、本批不修)】
35
+ * {@link buildCrossSliceUsage} 要对**全部** `model_usage` 行求和 ⇒ 调用点(`routes/runs.ts` 的 poll)在
36
+ * 窗已登记时每拍读一次 `getEvents(taskId, 0)`,工作量随「轮询次数 × 已积累事件数」增长。三条限幅是**结构性**
37
+ * 的,不是「应该很少见」:① **只有登记过窗的 run 才读**(没开三旋钮的部署、以及 verify/cascade 腿,一次都不读);
38
+ * ② 只在 `running` 且非 stale 时读;③ 同一条路上早已存在同形读(终局 + 配了 infra 价目表时的 `needCost` 臂,
39
+ * 同样是 `getEvents(taskId, 0)`)—— 本批加的是**跑动期**这一档,不是新病种。
40
+ * 🔴 真正的收口在**存储面**(不在本模块):给 run store 加一条「累计用量 + resource_limit park 计数」的
41
+ * 聚合投影/游标,让 poll 的工作量与账本长度解耦。缓存一份读时结果是**不采纳**的方向——那正是本模块顶注
42
+ * 拒绝的「第二本账」(它会与账本漂移,且缺席语义会被缓存住)。
43
+ */
44
+ import type { TaskSpec } from "@sema-agent/core";
45
+ import type { RunEvent } from "./plugins/store-contracts.js";
46
+ /** 这条 run 铸给 core 的**跨片上限**(三轴各自可缺席;至少一根在场本记录才存在)。
47
+ * $ 轴用 micro-USD —— 与 core `ResourceLedger.totalBudgetMicroUsd` 同单位、整数,避免浮点累积误差
48
+ * (core 自己的换算就是 `Math.round(totalBudgetUsd * 1e6)`,此处逐字同式)。 */
49
+ export interface ResourceWindowTotals {
50
+ totalTokens?: number;
51
+ totalBudgetMicroUsd?: number;
52
+ maxSlices?: number;
53
+ }
54
+ /** wire 上的投影体(`GET /v1/runs/:id` 顶层 `crossSliceUsage`)。**三轴各自成对**:上限键不在 ⇒ 它那根
55
+ * 的已耗键也不在(没有天花板就没有「逼近天花板」这件事可读)。`spentMicroUsd` 另有一条缺席理由 ——
56
+ * unpriced 部署下用量行不带 `costMicroUsd` = **未知**,折 0 会谎报「还没花钱」(RB-368 未知传染)。 */
57
+ export interface CrossSliceUsage {
58
+ totalTokens?: number;
59
+ spentTokens?: number;
60
+ totalBudgetMicroUsd?: number;
61
+ spentMicroUsd?: number;
62
+ maxSlices?: number;
63
+ sliceCount?: number;
64
+ }
65
+ /**
66
+ * 登记这条 run 的跨片窗(装配点 = 三个 `createRun` 成功分支,与 `clearTurnActivity` 同位同理由)。
67
+ *
68
+ * `rs` 就是 `TaskSpec.resourceSuspend` 本身 —— 缺席、或三根总额一根都没有(`resourceSuspendOptIn` 只在
69
+ * `> 0` 时才铸键,所以「有键」即「有真上限」)⇒ **不留记录**(并清掉同 taskId 的旧世代残留)。
70
+ */
71
+ export declare function recordResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void;
72
+ /**
73
+ * **续跑腿**的登记(与 {@link recordResourceWindow} 分家,codex 对抗复审 R2-[high] 验真后修)。
74
+ *
75
+ * 🔴 三根旋钮在续跑腿上的下场**不同**,所以登记法也必须不同(S-3 已成文的上游契约,此处是它的读面孪生):
76
+ * · `totalTokens` / `totalBudgetUsd` —— core **冻在账本上**(`debitLedger` 逐字 `prior ?? total`)。
77
+ * 续跑腿的 spec 里那两个值是**当期 env 现读**的,与这条链真正在被执法的值**可以不同**(运维在两片
78
+ * 之间把 500k 改成 1M:core 仍按 500k 停,而现读值会让人以为还剩一倍余量)。⇒ **一律不取**:
79
+ * 已有记录就保留(那是首片登记的、也就是被冻住的那份),没有记录就诚实缺这一轴。
80
+ * · `maxSlices` —— core **不冻结**,每片现读当期部署值去比账本上的 `sliceCount`。⇒ **取当期值**才是真的。
81
+ *
82
+ * 缺席语义不变:两条 totals 缺、`maxSlices` 也缺 ⇒ 不留记录(整键缺席)。
83
+ */
84
+ export declare function recordResumeResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void;
85
+ /**
86
+ * 本副本记得的跨片窗;无记录 ⇒ `undefined`(缺席语义见顶注:「无法证明」,不是「没有窗」)。
87
+ *
88
+ * 🔴 **读命中会刷新插入序**(codex 对抗复审 R1-[high] 的逐出半场,验真后修):终局的 run **不主动清**
89
+ * (与 `turn-activity.ts` 同理由:主动清会制造「done 已打点、行还没翻终态」窗口里的假缺席),于是纯
90
+ * 写序 FIFO 下,一条长跑的 run 会被它之后的 8192 条**早已终局**的记录挤掉 —— 恰好挤掉唯一还需要这份
91
+ * 数据的那一条。改成「按读刷新」= 真 LRU:被 poll 的(=活着且有人看的)那条永远是最年轻的。
92
+ * 代价如实:本函数因此**有副作用**(只改顺序、不改值),调用点是 poll 路径,幂等无碍。
93
+ */
94
+ export declare function readResourceWindow(taskId: string): ResourceWindowTotals | undefined;
95
+ /** 测试隔离用(生产路径不调 —— 终局不主动清,理由与 `turn-activity.ts` 同:清反而制造假缺席窗口)。 */
96
+ export declare function clearResourceWindow(taskId: string): void;
97
+ /**
98
+ * 投影体的**纯**构造:上限来自登记册,已耗来自这条 run 的 durable 账本行。
99
+ *
100
+ * 三轴的已耗读法:
101
+ * · `spentTokens` = 全账本 `model_usage` 行逐模型四分量求和(input MISS + output + cacheRead + cacheWrite
102
+ * = 与 `usage-analytics` 读侧同口径,也就是 core `stats.tokens` 量的那个总量)。零行 = 真的还没记到
103
+ * 账 ⇒ `0`(而不是缺席:token 轴没有「未知」这一格,append-only 保证它单调不减)。
104
+ * · `spentMicroUsd` = 同样逐模型求和;**任一模型的成本未知 ⇒ 整个和未知 ⇒ 键缺席**
105
+ * (`aggregateModelUsage` 已按模型做了未知传染,这里只要看键在不在)。
106
+ * · `sliceCount` = 账本上 `resource_limit` park 行的条数 —— core 的 `ResourceLedger.sliceCount` 正是在
107
+ * 每次 resource 挂起时 +1,而每一次那样的挂起在本仓都恰好落一行 `suspended{gate.kind:"resource_limit"}`
108
+ * (审批 park / plan_review park 的 gate.kind 不同,不计)。
109
+ */
110
+ export declare function buildCrossSliceUsage(totals: ResourceWindowTotals, events: readonly RunEvent[]): CrossSliceUsage | undefined;
111
+ //# sourceMappingURL=resource-window.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { aggregateModelUsage } from "./trace/project.js";
2
+ const CAP = 8192;
3
+ const windowByTask = new Map();
4
+ const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
5
+ export function recordResourceWindow(taskId, rs) {
6
+ if (!taskId)
7
+ return;
8
+ windowByTask.delete(taskId);
9
+ const totalTokens = positive(rs?.totalTokens);
10
+ const totalBudgetUsd = positive(rs?.totalBudgetUsd);
11
+ const maxSlices = positive(rs?.maxSlices);
12
+ if (totalTokens === undefined && totalBudgetUsd === undefined && maxSlices === undefined)
13
+ return;
14
+ if (windowByTask.size >= CAP) {
15
+ const oldest = windowByTask.keys().next().value;
16
+ if (oldest !== undefined)
17
+ windowByTask.delete(oldest);
18
+ }
19
+ windowByTask.set(taskId, {
20
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
21
+ ...(totalBudgetUsd !== undefined ? { totalBudgetMicroUsd: Math.round(totalBudgetUsd * 1e6) } : {}),
22
+ ...(maxSlices !== undefined ? { maxSlices } : {}),
23
+ });
24
+ }
25
+ export function recordResumeResourceWindow(taskId, rs) {
26
+ if (!taskId)
27
+ return;
28
+ const prior = windowByTask.get(taskId);
29
+ const maxSlices = positive(rs?.maxSlices);
30
+ const merged = {
31
+ ...(prior?.totalTokens !== undefined ? { totalTokens: prior.totalTokens } : {}),
32
+ ...(prior?.totalBudgetMicroUsd !== undefined ? { totalBudgetMicroUsd: prior.totalBudgetMicroUsd } : {}),
33
+ ...(maxSlices !== undefined ? { maxSlices } : {}),
34
+ };
35
+ windowByTask.delete(taskId);
36
+ if (merged.totalTokens === undefined && merged.totalBudgetMicroUsd === undefined && merged.maxSlices === undefined)
37
+ return;
38
+ if (windowByTask.size >= CAP) {
39
+ const oldest = windowByTask.keys().next().value;
40
+ if (oldest !== undefined)
41
+ windowByTask.delete(oldest);
42
+ }
43
+ windowByTask.set(taskId, merged);
44
+ }
45
+ export function readResourceWindow(taskId) {
46
+ const hit = windowByTask.get(taskId);
47
+ if (hit !== undefined) {
48
+ windowByTask.delete(taskId);
49
+ windowByTask.set(taskId, hit);
50
+ }
51
+ return hit;
52
+ }
53
+ export function clearResourceWindow(taskId) {
54
+ windowByTask.delete(taskId);
55
+ }
56
+ export function buildCrossSliceUsage(totals, events) {
57
+ if (totals.totalTokens === undefined && totals.totalBudgetMicroUsd === undefined && totals.maxSlices === undefined)
58
+ return undefined;
59
+ const perModel = totals.totalTokens !== undefined || totals.totalBudgetMicroUsd !== undefined ? aggregateModelUsage([...events]) : undefined;
60
+ let spentTokens = 0;
61
+ let spentMicroUsd = 0;
62
+ for (const d of Object.values(perModel ?? {})) {
63
+ spentTokens += d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens;
64
+ if (d.costMicroUsd === undefined)
65
+ spentMicroUsd = undefined;
66
+ else if (spentMicroUsd !== undefined)
67
+ spentMicroUsd += d.costMicroUsd;
68
+ }
69
+ const sliceCount = totals.maxSlices === undefined
70
+ ? undefined
71
+ : events.filter((e) => e.type === "suspended" && e.data?.gate?.kind === "resource_limit").length;
72
+ return {
73
+ ...(totals.totalTokens !== undefined ? { totalTokens: totals.totalTokens, spentTokens } : {}),
74
+ ...(totals.totalBudgetMicroUsd !== undefined
75
+ ? { totalBudgetMicroUsd: totals.totalBudgetMicroUsd, ...(spentMicroUsd !== undefined ? { spentMicroUsd } : {}) }
76
+ : {}),
77
+ ...(totals.maxSlices !== undefined ? { maxSlices: totals.maxSlices, sliceCount } : {}),
78
+ };
79
+ }
80
+ //# sourceMappingURL=resource-window.js.map