@sema-agent/server 7.9.0 → 7.11.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.
- package/USAGE.md +6 -0
- package/dist/adoption/plan.d.ts +152 -0
- package/dist/adoption/plan.js +513 -0
- package/dist/adoption/runner.d.ts +54 -0
- package/dist/adoption/runner.js +505 -0
- package/dist/adoption/sql.d.ts +76 -0
- package/dist/adoption/sql.js +106 -0
- package/dist/adoption/wire.d.ts +250 -0
- package/dist/adoption/wire.js +153 -0
- package/dist/approval-card.d.ts +24 -0
- package/dist/approval-card.js +32 -0
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval-reconciler.js +1 -1
- package/dist/boot/adoption.d.ts +30 -0
- package/dist/boot/adoption.js +57 -0
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/parked-revive-gate.d.ts +38 -5
- package/dist/boot/parked-revive-gate.js +53 -6
- package/dist/boot/reapers.js +3 -3
- package/dist/boot/runner-deps.d.ts +10 -2
- package/dist/boot/runner-deps.js +12 -1
- package/dist/capabilities/repo-tools.d.ts +36 -2
- package/dist/capabilities/repo-tools.js +125 -11
- package/dist/capabilities/scenarios.d.ts +2 -2
- package/dist/capabilities/scenarios.js +1 -1
- package/dist/config-types.d.ts +22 -2
- package/dist/config.js +44 -2
- package/dist/fleet/fleet-terminal-window.d.ts +1 -1
- package/dist/fleet/fleet-terminal-window.js +1 -1
- package/dist/git-api-kind.d.ts +7 -0
- package/dist/git-api-kind.js +8 -0
- package/dist/http/routes/adoption.d.ts +26 -0
- package/dist/http/routes/adoption.js +120 -0
- package/dist/http/routes/approvals-assistant.js +2 -2
- package/dist/http/routes/capabilities.js +12 -0
- package/dist/http/routes/rules.d.ts +23 -0
- package/dist/http/routes/rules.js +117 -0
- package/dist/http/routes/shared-memory.d.ts +31 -0
- package/dist/http/routes/shared-memory.js +181 -0
- package/dist/http/routes/trace-usage.js +139 -3
- package/dist/http/server.d.ts +19 -1
- package/dist/http/server.js +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/main.js +54 -5
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/plugins/adoption-log-sql.d.ts +191 -0
- package/dist/plugins/adoption-log-sql.js +273 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +22 -9
- package/dist/plugins/checkpoint-store-sql.js +40 -29
- package/dist/plugins/local-checkpoint-store.d.ts +11 -1
- package/dist/plugins/local-checkpoint-store.js +10 -2
- package/dist/plugins/memory-engine-pg.js +3 -3
- package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
- package/dist/plugins/permission-rule-store-sql.js +817 -0
- package/dist/plugins/pg-pool.js +44 -7
- package/dist/plugins/run-store-sql.d.ts +3 -3
- package/dist/plugins/run-store-sql.js +3 -3
- package/dist/plugins/session-policy-store-sql.d.ts +6 -0
- package/dist/plugins/session-policy-store-sql.js +7 -1
- package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
- package/dist/plugins/shared-memory-store-sql.js +516 -0
- package/dist/plugins/store-backend.d.ts +30 -0
- package/dist/plugins/store-backend.js +14 -0
- package/dist/plugins/tidb-pool.js +55 -8
- package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
- package/dist/plugins/workflow-journal-store-sql.js +6 -6
- package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
- package/dist/plugins/workflow-run-store-sql.js +12 -12
- package/dist/rules-consent.d.ts +126 -0
- package/dist/rules-consent.js +198 -0
- package/dist/run-local.js +2 -2
- package/dist/shared-memory-scope-authorizer.d.ts +29 -0
- package/dist/shared-memory-scope-authorizer.js +17 -0
- package/dist/tool-approval.d.ts +55 -0
- package/dist/tool-approval.js +124 -5
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/project.d.ts +1 -0
- package/dist/trace/project.js +1 -0
- package/package.json +3 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import mysql from "mysql2/promise";
|
|
2
2
|
import { TIDB_APPROVAL_ASK_STATEMENTS } from "./approval-ask-store-sql.js";
|
|
3
|
+
import { TIDB_ADOPTION_LOG_STATEMENTS } from "./adoption-log-sql.js";
|
|
4
|
+
import { TIDB_PERMISSION_RULE_STATEMENTS } from "./permission-rule-store-sql.js";
|
|
3
5
|
/**
|
|
4
6
|
* Shared TiDB (MySQL wire-compatible) connection pool for the durable Session center (L1).
|
|
5
7
|
* Owned by `main.ts`; closed on shutdown.
|
|
@@ -233,8 +235,8 @@ export const SCHEMA_STATEMENTS = [
|
|
|
233
235
|
checkpoint JSON NOT NULL,
|
|
234
236
|
outcome JSON NULL,
|
|
235
237
|
deadline BIGINT NULL,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
created_at_ms BIGINT NOT NULL,
|
|
239
|
+
decided_at_ms BIGINT NULL,
|
|
238
240
|
-- rev (design/80 D-1): monotonic optimistic-concurrency counter bumped on every resolve/reopen, so a
|
|
239
241
|
-- resolve(expect) requires the rev the resume observed to still be live → fail-closed on a concurrent
|
|
240
242
|
-- resolve-reopen cycle (core → checkpoint.reopened_concurrently).
|
|
@@ -244,10 +246,10 @@ export const SCHEMA_STATEMENTS = [
|
|
|
244
246
|
-- tool_unavailable (a fresh decision is allowed). Drives core's reopen-revote validation.
|
|
245
247
|
-- NULL = NEVER REOPENED (the first resume is unconstrained).
|
|
246
248
|
reopen_reason VARCHAR(32) NULL,
|
|
247
|
-
--
|
|
249
|
+
-- terminal_at_ms: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
|
|
248
250
|
-- deadline, stamped at put(). NULL on PRE-MIGRATION rows — those keep their DEADLINE-BASED expiry (the old
|
|
249
251
|
-- path); the backstop only covers rows suspended after the column existed.
|
|
250
|
-
|
|
252
|
+
terminal_at_ms BIGINT NULL,
|
|
251
253
|
-- gate_kind (design/80 D-D, SLA-timer): the CheckpointGate.kind, stamped at put(), so the SLA sweep splits by
|
|
252
254
|
-- kind WITHOUT parsing the JSON blob per row — human/irreversible_ask past deadline are resolve-DENIED (the
|
|
253
255
|
-- model continues with a denial), resource_limit/needs_review are abandonment-TTL → expire().
|
|
@@ -430,7 +432,7 @@ export const SCHEMA_STATEMENTS = [
|
|
|
430
432
|
source_run_id VARCHAR(191) NOT NULL,
|
|
431
433
|
scope VARCHAR(190) NOT NULL,
|
|
432
434
|
new_run_id VARCHAR(191) NOT NULL,
|
|
433
|
-
|
|
435
|
+
claimed_at_ms BIGINT NOT NULL,
|
|
434
436
|
PRIMARY KEY (source_run_id, scope)
|
|
435
437
|
) COLLATE utf8mb4_bin`,
|
|
436
438
|
// P1 (fleet failover, 2026-07-05): durable cross-replica WorkflowRunStore twin — one row per run, the full
|
|
@@ -443,7 +445,7 @@ export const SCHEMA_STATEMENTS = [
|
|
|
443
445
|
run MEDIUMTEXT NOT NULL,
|
|
444
446
|
rev INT NOT NULL DEFAULT 0,
|
|
445
447
|
created_at_ms BIGINT NOT NULL,
|
|
446
|
-
|
|
448
|
+
ended_at_ms BIGINT NULL,
|
|
447
449
|
PRIMARY KEY (id),
|
|
448
450
|
KEY idx_wfrun_scope_created (scope, created_at_ms),
|
|
449
451
|
KEY idx_wfrun_scope_status (scope, status)
|
|
@@ -494,8 +496,8 @@ export const SCHEMA_STATEMENTS = [
|
|
|
494
496
|
acked TINYINT(1) NOT NULL DEFAULT 0,
|
|
495
497
|
source_task_id VARCHAR(191) NULL,
|
|
496
498
|
principal VARCHAR(190) NULL,
|
|
497
|
-
|
|
498
|
-
|
|
499
|
+
created_at_ms BIGINT NOT NULL,
|
|
500
|
+
acked_at_ms BIGINT NULL,
|
|
499
501
|
PRIMARY KEY (run_id),
|
|
500
502
|
KEY idx_wfnotify_acked (acked)
|
|
501
503
|
) COLLATE utf8mb4_bin`,
|
|
@@ -648,6 +650,51 @@ export const SCHEMA_STATEMENTS = [
|
|
|
648
650
|
// 跟着中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建 —— 把 pool 级 ensure 函数塞进执行循环
|
|
649
651
|
// 会绕开 DDL 串行化。挂进来同时也是「这张表算生产 schema」的声明(基线导出按定义只收录被执行的语句)。
|
|
650
652
|
...TIDB_APPROVAL_ASK_STATEMENTS,
|
|
653
|
+
// design/177 shared-memory store(#192 单数纪律)—— org 盘的三张表。消费面 = shared-memory-store-sql.ts。
|
|
654
|
+
// 🔴 与 `agent_memory_engine_*`(design/138 本地记忆链)**零关系**:不 join、不共享 scope 登记、不互写
|
|
655
|
+
// (设计 182 §13)。两族同库不同面,合表/建视图会让 harvest 的私人笔记漏进团队库。
|
|
656
|
+
`CREATE TABLE IF NOT EXISTS shared_memory_scope (
|
|
657
|
+
-- 一个 org scope 一行。行缺席 = 该 org 从未开通 ⇒ 语义等价于 connected 且零 store(不是故障)。
|
|
658
|
+
scope_key VARCHAR(190) NOT NULL,
|
|
659
|
+
-- 闭集词:connecting|unavailable|connected。读出时走 planeStateOf 的穷举 switch,未知词整拒快照。
|
|
660
|
+
state VARCHAR(16) NOT NULL,
|
|
661
|
+
-- host 自述原因,仅 unavailable 有意义;core 折成一行喂模型面。
|
|
662
|
+
message VARCHAR(512) NULL,
|
|
663
|
+
updated_at_ms BIGINT NOT NULL,
|
|
664
|
+
PRIMARY KEY (scope_key)
|
|
665
|
+
) COLLATE utf8mb4_bin`,
|
|
666
|
+
`CREATE TABLE IF NOT EXISTS shared_memory_store (
|
|
667
|
+
-- 模型面把手,**全局唯一**:同 id 在两个 org 各一份会在跨 org 成员的快照里撞名,而 core 对撞名 id
|
|
668
|
+
-- 的处置是整行丢弃 —— 那是一次静默的库消失,所以用 PK 在库层堵死。
|
|
669
|
+
store_id VARCHAR(64) NOT NULL,
|
|
670
|
+
scope_key VARCHAR(190) NOT NULL,
|
|
671
|
+
description VARCHAR(512) NOT NULL,
|
|
672
|
+
-- 治理面结论,原样转达;core 对它零动作(模型面根本没有写通道)。
|
|
673
|
+
writable TINYINT(1) NOT NULL DEFAULT 0,
|
|
674
|
+
updated_at_ms BIGINT NOT NULL,
|
|
675
|
+
PRIMARY KEY (store_id),
|
|
676
|
+
KEY idx_shared_memory_store_scope (scope_key, store_id)
|
|
677
|
+
) COLLATE utf8mb4_bin`,
|
|
678
|
+
`CREATE TABLE IF NOT EXISTS shared_memory_entry (
|
|
679
|
+
store_id VARCHAR(64) NOT NULL,
|
|
680
|
+
-- 绝对路径。PK 即 keyset 分页索引:WHERE store_id = ? AND path > ? ORDER BY path。
|
|
681
|
+
path VARCHAR(512) NOT NULL,
|
|
682
|
+
content LONGTEXT NOT NULL,
|
|
683
|
+
-- ISO 8601 原文逐字回放(core 只渲染匹配的 YYYY-MM-DD 前缀)。数值毫秒列是下面那根,两者不同义。
|
|
684
|
+
updated_at_iso VARCHAR(64) NULL,
|
|
685
|
+
size_bytes INT NOT NULL,
|
|
686
|
+
updated_at_ms BIGINT NOT NULL,
|
|
687
|
+
PRIMARY KEY (store_id, path)
|
|
688
|
+
) COLLATE utf8mb4_bin`,
|
|
689
|
+
// design/183 form b(纯身份重绑)的 **SQL 收编日志**。真源 = adoption-log-sql.ts 的
|
|
690
|
+
// `TIDB_ADOPTION_LOG_STATEMENTS`(与 PG twin 并排);展开进来的理由与 approval-ask 同款:
|
|
691
|
+
// 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化。
|
|
692
|
+
// 🔴 它**必须**与被迁的行同库(183 I2):phase 真源住别处 ⇒ 发起 host 被摧毁时与半迁移状态分家。
|
|
693
|
+
...TIDB_ADOPTION_LOG_STATEMENTS,
|
|
694
|
+
// #154 车二(core 5.18.0 design/179 + 5.22.0 design/182)持久化权限规则店的三张表。真源 =
|
|
695
|
+
// permission-rule-store-sql.ts 的 `TIDB_PERMISSION_RULE_STATEMENTS`(与 PG twin 并排);展开进这个
|
|
696
|
+
// 数组的理由与上面那族逐字相同(named-lock 的那条 conn + 「这张表算生产 schema」的声明)。
|
|
697
|
+
...TIDB_PERMISSION_RULE_STATEMENTS,
|
|
651
698
|
];
|
|
652
699
|
/** Named MySQL/TiDB advisory lock that serializes the whole `ensureSchema` DDL run (see below). */
|
|
653
700
|
export const ENSURE_SCHEMA_LOCK = "sema_ensure_schema";
|
|
@@ -41,7 +41,7 @@ import { type SqlDriver } from "./sql-driver.js";
|
|
|
41
41
|
/** Dual-dialect durable WorkflowJournalStore. See the file header for the dialect-delta ledger. */
|
|
42
42
|
/** RB-242 租约旋钮。TTL 只是**崩溃兜底**(engine 终态 finally 显式释放;[1981]/[1984] 两层分工:
|
|
43
43
|
* engine 崩了没释放 ⇒ 陈旧 claim 可被接管)。
|
|
44
|
-
* 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(
|
|
44
|
+
* 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at_ms 在授予时刻定格,
|
|
45
45
|
* 运行期间不刷新),所以 TTL 必须盖过最长合法 run 时长:取小了(我初版 15min)会在一次 >TTL 的活跑
|
|
46
46
|
* 中把 claim 判陈旧、放另一副本进来接管——恰是本缝要防的双跑。要更短的接管等待,先给 engine 半场
|
|
47
47
|
* 加心跳刷新,再谈调小。 */
|
|
@@ -89,8 +89,8 @@ export declare class SqlWorkflowJournalStore implements WorkflowJournalStore {
|
|
|
89
89
|
* 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
|
|
90
90
|
* 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
|
|
91
91
|
* ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
|
|
92
|
-
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的
|
|
93
|
-
* ③ TTL 崩溃兜底接管:
|
|
92
|
+
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
|
|
93
|
+
* ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
|
|
94
94
|
* 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
|
|
95
95
|
* ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
|
|
96
96
|
resumeClaim(input: {
|
|
@@ -76,8 +76,8 @@ export class SqlWorkflowJournalStore {
|
|
|
76
76
|
* 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
|
|
77
77
|
* 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
|
|
78
78
|
* ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
|
|
79
|
-
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的
|
|
80
|
-
* ③ TTL 崩溃兜底接管:
|
|
79
|
+
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
|
|
80
|
+
* ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
|
|
81
81
|
* 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
|
|
82
82
|
* ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
|
|
83
83
|
async resumeClaim(input) {
|
|
@@ -85,20 +85,20 @@ export class SqlWorkflowJournalStore {
|
|
|
85
85
|
const ins = await this.db.query(this.q(
|
|
86
86
|
// INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
|
|
87
87
|
// (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
|
|
88
|
-
"INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id,
|
|
88
|
+
"INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
|
|
89
89
|
if (ins.affected === 1)
|
|
90
90
|
return { granted: true };
|
|
91
|
-
const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET
|
|
91
|
+
const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
|
|
92
92
|
if (refresh.affected >= 1)
|
|
93
93
|
return { granted: true };
|
|
94
|
-
const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?,
|
|
94
|
+
const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND claimed_at_ms < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at_ms = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at_ms < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
|
|
95
95
|
if (takeover.affected >= 1)
|
|
96
96
|
return { granted: true };
|
|
97
97
|
const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
|
|
98
98
|
// 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
|
|
99
99
|
const row = holder.rows[0];
|
|
100
100
|
const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
|
|
101
|
-
// ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(
|
|
101
|
+
// ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
|
|
102
102
|
// 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
|
|
103
103
|
if (holderId === input.newRunId)
|
|
104
104
|
return { granted: true };
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ## WorkflowRunStore twin
|
|
14
14
|
* One row per run: the full `WorkflowRun` as a JSON blob + EXTRACTED columns for everything SQL needs to
|
|
15
|
-
* index/filter (scope, status, created_at_ms,
|
|
15
|
+
* index/filter (scope, status, created_at_ms, ended_at_ms) + an authoritative `rev` column (the OCC key — the
|
|
16
16
|
* blob's own `rev` is OVERLAID from the column on every read, so writes never have to know the bumped value
|
|
17
17
|
* up front). `update` is a single-statement CAS (`WHERE id AND scope [AND rev]`, `SET rev = rev + 1`);
|
|
18
18
|
* `listByScope` projects через core's SHARED `summarizeWorkflowRun` (anti-drift — identical to InMemory/File).
|
|
@@ -46,7 +46,7 @@ export class SqlWorkflowRunStore {
|
|
|
46
46
|
async put(id, run) {
|
|
47
47
|
const stored = { ...run, id, rev: run.rev ?? 0 }; // key authoritative + observed-rev base (InMemory parity)
|
|
48
48
|
try {
|
|
49
|
-
await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms,
|
|
49
|
+
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]);
|
|
50
50
|
}
|
|
51
51
|
catch (e) {
|
|
52
52
|
// create-once error every backend throws (contract) — dup-key classification is dialect-specific:
|
|
@@ -83,7 +83,7 @@ export class SqlWorkflowRunStore {
|
|
|
83
83
|
return false; // even the skeleton is oversize — keep the prior revision
|
|
84
84
|
blob = slim;
|
|
85
85
|
}
|
|
86
|
-
const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?,
|
|
86
|
+
const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at_ms = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at_ms = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
|
|
87
87
|
? [blob, run.status, run.endedAt ?? null, id, scope, expect.rev]
|
|
88
88
|
: [blob, run.status, run.endedAt ?? null, id, scope]);
|
|
89
89
|
return res.affected === 1; // rev always bumps → affected is a faithful CAS verdict
|
|
@@ -156,12 +156,12 @@ export class SqlWorkflowRunStore {
|
|
|
156
156
|
return 0; // retention is always explicit
|
|
157
157
|
// Cheap columns only; terminal-set + keep-N semantics computed in JS to match InMemory EXACTLY
|
|
158
158
|
// (isTerminalWorkflowStatus is core's — no status list duplicated into SQL).
|
|
159
|
-
const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms,
|
|
159
|
+
const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
|
|
160
160
|
const terminal = rows.filter((r) => isTerminalWorkflowStatus(String(r.status)));
|
|
161
161
|
const doomed = [];
|
|
162
162
|
for (let i = 0; i < terminal.length; i++) {
|
|
163
163
|
const r = terminal[i];
|
|
164
|
-
const anchor = r.
|
|
164
|
+
const anchor = r.ended_at_ms != null ? Number(r.ended_at_ms) : Number(r.created_at_ms);
|
|
165
165
|
const tooOld = opts.maxAgeMs !== undefined && anchor < now - opts.maxAgeMs;
|
|
166
166
|
const overKeep = opts.keep !== undefined && i >= opts.keep;
|
|
167
167
|
if (tooOld || overKeep)
|
|
@@ -311,31 +311,31 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
311
311
|
async record(input) {
|
|
312
312
|
// Idempotent-on-runId (a second record for the same run — incl. an acked one — is a no-op): TiDB
|
|
313
313
|
// `INSERT IGNORE` vs PG `ON CONFLICT (run_id) DO NOTHING`.
|
|
314
|
-
await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal,
|
|
314
|
+
await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
|
|
315
315
|
}
|
|
316
316
|
async ack(runId, ackedAt) {
|
|
317
|
-
await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1,
|
|
317
|
+
await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = ? WHERE run_id = ? AND acked = 0", "UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = $1 WHERE run_id = $2 AND acked = 0"), [ackedAt, runId]);
|
|
318
318
|
}
|
|
319
319
|
async listPending() {
|
|
320
|
-
const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal,
|
|
320
|
+
const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal, created_at_ms FROM workflow_notify_journal WHERE acked = 0");
|
|
321
321
|
return rows.map((r) => ({
|
|
322
322
|
runId: String(r.run_id),
|
|
323
323
|
scope: String(r.scope),
|
|
324
324
|
acked: false,
|
|
325
325
|
...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
|
|
326
326
|
...(r.principal != null ? { principal: String(r.principal) } : {}),
|
|
327
|
-
createdAt: Number(r.
|
|
327
|
+
createdAt: Number(r.created_at_ms),
|
|
328
328
|
}));
|
|
329
329
|
}
|
|
330
330
|
/** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens
|
|
331
331
|
* the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they
|
|
332
332
|
* are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */
|
|
333
333
|
async reapAcked(before) {
|
|
334
|
-
const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND
|
|
334
|
+
const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < $1"), [before]);
|
|
335
335
|
return res.affected;
|
|
336
336
|
}
|
|
337
337
|
async get(runId) {
|
|
338
|
-
const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal,
|
|
338
|
+
const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = ?", "SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = $1"), [runId]);
|
|
339
339
|
const r = rows[0];
|
|
340
340
|
if (!r)
|
|
341
341
|
return null;
|
|
@@ -345,8 +345,8 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
345
345
|
acked: Number(r.acked) === 1,
|
|
346
346
|
...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
|
|
347
347
|
...(r.principal != null ? { principal: String(r.principal) } : {}),
|
|
348
|
-
createdAt: Number(r.
|
|
349
|
-
...(r.
|
|
348
|
+
createdAt: Number(r.created_at_ms),
|
|
349
|
+
...(r.acked_at_ms != null ? { ackedAt: Number(r.acked_at_ms) } : {}),
|
|
350
350
|
};
|
|
351
351
|
}
|
|
352
352
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type ImportPreview, type ImportResult, type ImportedSettingsLayer, type RuleScope } from "@sema-agent/core";
|
|
2
|
+
import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
|
|
3
|
+
import { type RuleImportTicket, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
|
|
4
|
+
/**
|
|
5
|
+
* 本车道**真正**依赖的三面(**结构**接口,不是 SQL 类)。
|
|
6
|
+
*
|
|
7
|
+
* 🔴 为什么不直接吃 `PermissionRuleStores`(SQL 束):同意车道是**纯协议逻辑**,它对「行躺在 MySQL 还是
|
|
8
|
+
* PG 还是别处」一无所知也不该知道。吃结构接口有两个真收益:①单元测试能用 core 自己的
|
|
9
|
+
* `InMemoryPermissionRuleStore` / `InMemoryRuleApprovalRecordStore` 驱动**同一段**代码(而不是为了测协议
|
|
10
|
+
* 去拉一个数据库);②将来若真出了 File 形规则店,本文件一个字不用改。SQL 束结构上满足本接口。
|
|
11
|
+
*/
|
|
12
|
+
export interface RuleConsentStores {
|
|
13
|
+
provider: PermissionRuleStoreProvider;
|
|
14
|
+
/** core 的记录店面 + 一个**可选**的加项:`discardPendingRecord`(超帽拒绝时收掉 `prepareCcImport`
|
|
15
|
+
* 已经落盘的那一条 pending 记录)。没有这个加项的后端只是留一条孤儿行,不影响任何判决
|
|
16
|
+
* (语义与「已确认的记录永不被收」的硬条款见 SQL 侧同名方法)。 */
|
|
17
|
+
approvals: RuleApprovalRecordStore & {
|
|
18
|
+
discardPendingRecord?(recordId: string): Promise<boolean>;
|
|
19
|
+
};
|
|
20
|
+
tickets: {
|
|
21
|
+
mint(input: {
|
|
22
|
+
ticketId: string;
|
|
23
|
+
principal: string;
|
|
24
|
+
approvalId: string;
|
|
25
|
+
candidates: ReadonlyArray<{
|
|
26
|
+
rule: string;
|
|
27
|
+
scope: RuleScope;
|
|
28
|
+
}>;
|
|
29
|
+
ttlMs: number;
|
|
30
|
+
}): Promise<RuleImportTicket>;
|
|
31
|
+
/** 一次**认领**(不是「已完成」)。语义与「认领之后怎么办」见 {@link RuleConsentStores.tickets.release}。 */
|
|
32
|
+
consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
|
|
33
|
+
/** 把认领**放回去**(认领之后遇到不确定/瞬时失败时)。`mustRemainValidMs` = 放回之后这张票至少还要
|
|
34
|
+
* 能用多久 —— 撑不过就**不算放回成功**(理由逐字见 store 侧 `release` 的头注)。 */
|
|
35
|
+
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
|
|
39
|
+
* 窄到一张泄漏的票不会常驻。运维旋钮暂不开(没有部署形要求它可调;要开时走 config.ts 的既有姿势)。 */
|
|
40
|
+
export declare const RULE_IMPORT_TICKET_TTL_MS: number;
|
|
41
|
+
/** 可重试那一支通告给客户端的等待秒数(`state.rule_import_retry` 的 `retryAfterSec` / `Retry-After`)。
|
|
42
|
+
* 短 —— 它对应的是一次瞬时 store 抖动,不是排队。**单一真源在本文件**:放回认领时要用它判「这张票
|
|
43
|
+
* 还能不能撑过这段窗」,HTTP 层要用它填响应,两处用同一个数才谈得上「承诺兑得出来」。 */
|
|
44
|
+
export declare const RULE_IMPORT_RETRY_AFTER_SEC = 2;
|
|
45
|
+
/**
|
|
46
|
+
* **全部层合计**的候选上限(codex 交叉复审 round7 [high] 二,验真后修)。
|
|
47
|
+
*
|
|
48
|
+
* 单层 16 KiB 只管住了**请求体**,管不住**下游工作量**:紧凑的合法规则(`Bash(ls)` 十个字符)能让三层
|
|
49
|
+
* 塞进上千条候选,而 core 的 `redeemRuleBatch` 是**逐候选**跑一遍「读店 + CAS 写」的串行循环 —— 一个
|
|
50
|
+
* 已鉴权的请求就能把连接池占满、把响应撑到兆字节。全局限流器默认是关的,拦不住它(一次准入就够了)。
|
|
51
|
+
*
|
|
52
|
+
* 帽数的是 **core 已经解析好的候选**(不是我们自己再数一遍 JSON):零重复解析,而且数的正好是那条串行
|
|
53
|
+
* 循环的**真实**长度。位置在**铸票之前** —— 超限就没有票,那条昂贵的循环一次都跑不起来(代价只剩一条
|
|
54
|
+
* 已铸的 pending 记录,与任何一次没去兑付的 prepare 留下的残余同形)。
|
|
55
|
+
* 200 的取值:一份人手写的 CC settings 极少超过几十条;200 给了一个数量级的余量,又把最坏工作量钉在
|
|
56
|
+
* 「几百次 SQL」而不是「几万次」。
|
|
57
|
+
*/
|
|
58
|
+
export declare const MAX_IMPORT_CANDIDATES = 200;
|
|
59
|
+
/** 一次导入 prepare 的产物(HTTP 200 体的素材),或**超帽**的拒绝(见 {@link MAX_IMPORT_CANDIDATES})。 */
|
|
60
|
+
export type RuleImportPrepared = {
|
|
61
|
+
ok: true;
|
|
62
|
+
preview: ImportPreview;
|
|
63
|
+
ticket: string;
|
|
64
|
+
expiresAtMs: number;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: "too-many-candidates";
|
|
68
|
+
candidates: number;
|
|
69
|
+
};
|
|
70
|
+
/** 导入 redeem 的结果。`refused` 的四个成因**在 wire 面折成同一个 404**(见顶注 ①)——这里保留分类
|
|
71
|
+
* 只为服务端日志/诊断。 */
|
|
72
|
+
export type RuleImportRedeemed = {
|
|
73
|
+
ok: true;
|
|
74
|
+
result: ImportResult;
|
|
75
|
+
}
|
|
76
|
+
/** `retryable` = 这次失败**没有裁定任何事**(store 抛错 / CAS 冲突),认领已放回,属主原样重试即可。
|
|
77
|
+
* 它与裁定性拒绝(载荷被篡改、记录不存在)分家是承重的:把两者塌成一格,一次数据库抖动就会被
|
|
78
|
+
* 客户端读成「这张票永远不能用了」。⚠️ **wire 面仍然折成同一个 404**(零存在性 oracle 不因此松动);
|
|
79
|
+
* 这一位只进服务端日志与本层的重试判据。 */
|
|
80
|
+
| {
|
|
81
|
+
ok: false;
|
|
82
|
+
reason: "ticket-unusable" | "record-unusable" | "payload-mismatch";
|
|
83
|
+
detail: string;
|
|
84
|
+
retryable?: true;
|
|
85
|
+
};
|
|
86
|
+
/** 卡道兑付的结果。 */
|
|
87
|
+
export type CardRulePersisted = {
|
|
88
|
+
ok: true;
|
|
89
|
+
rule: string;
|
|
90
|
+
rev: number;
|
|
91
|
+
alreadyRedeemed: boolean;
|
|
92
|
+
} | {
|
|
93
|
+
ok: false;
|
|
94
|
+
reason: "no-candidates" | "unknown-candidate" | "confirm-refused" | "redeem-refused";
|
|
95
|
+
detail: string;
|
|
96
|
+
};
|
|
97
|
+
/** 一层用户交上来的 settings(HTTP 载荷已 zod 校验过的形)。 */
|
|
98
|
+
export interface RuleImportLayerInput {
|
|
99
|
+
layer: ImportedSettingsLayer;
|
|
100
|
+
path: string;
|
|
101
|
+
root: string;
|
|
102
|
+
content: string;
|
|
103
|
+
}
|
|
104
|
+
export interface RuleConsentLane {
|
|
105
|
+
/** 卡道兑付口:一次 ask 决议携规则确认 ⇒ prepare→confirm→redeem 一气呵成。 */
|
|
106
|
+
persistCardRule(input: {
|
|
107
|
+
principal: string;
|
|
108
|
+
toolName: string;
|
|
109
|
+
/** 被裁决的命令**原字节**(与 ask 上的那份同源;规则文本由引擎从它铸,调用方不供文本)。 */
|
|
110
|
+
command: string;
|
|
111
|
+
/** 客户端选中的候选**文本**——用来在引擎铸出的候选表里定位下标。文本对不上 ⇒ 拒(不猜)。 */
|
|
112
|
+
ruleText: string;
|
|
113
|
+
toolCallId?: string;
|
|
114
|
+
boundInputHash?: string;
|
|
115
|
+
scope?: RuleScope;
|
|
116
|
+
}): Promise<CardRulePersisted>;
|
|
117
|
+
/** 导入道:读层 → 预览 + pending 记录 + 票。 */
|
|
118
|
+
prepareImport(principal: string, layers: readonly RuleImportLayerInput[]): Promise<RuleImportPrepared>;
|
|
119
|
+
/** 导入道:原子消费票 → confirm → redeemRuleBatch。 */
|
|
120
|
+
redeemImport(principal: string, ticket: string): Promise<RuleImportRedeemed>;
|
|
121
|
+
}
|
|
122
|
+
export declare function createRuleConsentLane(stores: RuleConsentStores, opts?: {
|
|
123
|
+
ticketTtlMs?: number;
|
|
124
|
+
newTicketId?: () => string;
|
|
125
|
+
}): RuleConsentLane;
|
|
126
|
+
//# sourceMappingURL=rules-consent.d.ts.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #154 车二 —— **同意车道的 server 半场**(core 5.18.0 design/179 `permission-rule-consent.ts` 的宿主侧)。
|
|
3
|
+
*
|
|
4
|
+
* core 把一条规则进店的路钉成三步:`durable approval record (pending)` → `authenticated confirmation
|
|
5
|
+
* transfer (approved)` → `principal-bound redemption (redeemed)`,而且**只有第三步**够得着写面。core 明写
|
|
6
|
+
* 「The confirmation transfer is an in-process call. **A deployment must reach it only from its
|
|
7
|
+
* authenticated approval channel**」—— 那句话是把一段责任**交给宿主**,不是一段已经完成的检查。本文件就是
|
|
8
|
+
* 那段责任的落点:
|
|
9
|
+
*
|
|
10
|
+
* · 卡道(兑付口):一次 ask 决议携「不再询问」时,由**这里**去 prepare→confirm→redeem。人是谁 =
|
|
11
|
+
* 回决端点已验明的 principal(不是请求体里的任何字段);规则文本 = **引擎**从被裁决的命令铸的候选
|
|
12
|
+
* (`prepareCardApproval` 不收调用方候选,core 在那条入口上已经把「写自己的规则」这件事变成不可拼写),
|
|
13
|
+
* 客户端只能说「我选第几个」。
|
|
14
|
+
*
|
|
15
|
+
* · 导入道(CC settings):`prepare` 读用户交上来的 settings 层、铸 pending 记录 + **server 自铸的票**;
|
|
16
|
+
* `redeem` 原子消费票、confirm、`redeemRuleBatch`。
|
|
17
|
+
*
|
|
18
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
* 🔴 票的信任边界四条,**全部是 server 附加的**(不是 core 的语义)
|
|
20
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
* 亲读 core 的 `mintRuleTicket`(dist,5.22.0):`return \`rt.${candidateIndex}.${recordId}\`` —— 它**没有**
|
|
22
|
+
* TTL、没有签名、没有消费位、没有载荷绑定。core 的票在**进程内**是够的(它的相邻语义是「重放同一次兑付是
|
|
23
|
+
* 幂等的」),但一条跨网络、由客户端保管、可被复制的票需要另外四样:
|
|
24
|
+
* ① **principal 绑定** —— redeem 的已验明 principal 必须 = prepare 铸票的那一位。跨 principal redeem 与
|
|
25
|
+
* unknown ticket **同形 404**:两者返回不同的码就是一台存在性 oracle(拿别人的票撞库能探到「它存在」)。
|
|
26
|
+
* ② **过期** —— `RULE_IMPORT_TICKET_TTL_MS`;过期 redeem 拒。
|
|
27
|
+
* ③ **一次性原子消费** —— 同票二次 redeem 拒,并发双 redeem **恰一成功**。判据由 SQL 的单条条件
|
|
28
|
+
* `UPDATE … WHERE consumed_at_ms IS NULL` 回答,**禁 read-then-write**(见 store 那侧的 🔴 注)。
|
|
29
|
+
* ④ **载荷绑定** —— redeem 应用的规则集 = prepare 时刻的快照。core 已经结构上保证了「规则文本来自记录」,
|
|
30
|
+
* 本层再加一道:票上钉着 prepare 时刻候选集的摘要,redeem 时与记录上的候选集重算比对,不符即拒 ——
|
|
31
|
+
* 于是「篡改记录再拿旧票兑付」这条路上多一道等式,而不是只靠「记录只有我们能写」这一条信念。
|
|
32
|
+
*
|
|
33
|
+
* 命名(CLAUDE.md 工厂律):`build*` = 纯数据;`create*` = 活对象。本文件的 {@link createRuleConsentLane}
|
|
34
|
+
* 返回一个带方法的束 ⇒ `create*`。
|
|
35
|
+
*/
|
|
36
|
+
import { randomUUID } from "node:crypto";
|
|
37
|
+
import { recordFailOpen } from "./observability/fail-open.js";
|
|
38
|
+
import { confirmRuleApproval, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, } from "@sema-agent/core";
|
|
39
|
+
import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
|
|
40
|
+
/** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
|
|
41
|
+
* 窄到一张泄漏的票不会常驻。运维旋钮暂不开(没有部署形要求它可调;要开时走 config.ts 的既有姿势)。 */
|
|
42
|
+
export const RULE_IMPORT_TICKET_TTL_MS = 10 * 60_000;
|
|
43
|
+
/** 可重试那一支通告给客户端的等待秒数(`state.rule_import_retry` 的 `retryAfterSec` / `Retry-After`)。
|
|
44
|
+
* 短 —— 它对应的是一次瞬时 store 抖动,不是排队。**单一真源在本文件**:放回认领时要用它判「这张票
|
|
45
|
+
* 还能不能撑过这段窗」,HTTP 层要用它填响应,两处用同一个数才谈得上「承诺兑得出来」。 */
|
|
46
|
+
export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
|
|
47
|
+
/**
|
|
48
|
+
* **全部层合计**的候选上限(codex 交叉复审 round7 [high] 二,验真后修)。
|
|
49
|
+
*
|
|
50
|
+
* 单层 16 KiB 只管住了**请求体**,管不住**下游工作量**:紧凑的合法规则(`Bash(ls)` 十个字符)能让三层
|
|
51
|
+
* 塞进上千条候选,而 core 的 `redeemRuleBatch` 是**逐候选**跑一遍「读店 + CAS 写」的串行循环 —— 一个
|
|
52
|
+
* 已鉴权的请求就能把连接池占满、把响应撑到兆字节。全局限流器默认是关的,拦不住它(一次准入就够了)。
|
|
53
|
+
*
|
|
54
|
+
* 帽数的是 **core 已经解析好的候选**(不是我们自己再数一遍 JSON):零重复解析,而且数的正好是那条串行
|
|
55
|
+
* 循环的**真实**长度。位置在**铸票之前** —— 超限就没有票,那条昂贵的循环一次都跑不起来(代价只剩一条
|
|
56
|
+
* 已铸的 pending 记录,与任何一次没去兑付的 prepare 留下的残余同形)。
|
|
57
|
+
* 200 的取值:一份人手写的 CC settings 极少超过几十条;200 给了一个数量级的余量,又把最坏工作量钉在
|
|
58
|
+
* 「几百次 SQL」而不是「几万次」。
|
|
59
|
+
*/
|
|
60
|
+
export const MAX_IMPORT_CANDIDATES = 200;
|
|
61
|
+
export function createRuleConsentLane(stores, opts) {
|
|
62
|
+
const deps = { provider: stores.provider, approvals: stores.approvals };
|
|
63
|
+
const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
|
|
64
|
+
const newTicketId = opts?.newTicketId ?? (() => randomUUID());
|
|
65
|
+
return {
|
|
66
|
+
async persistCardRule(input) {
|
|
67
|
+
// 🔴 候选**由引擎铸**(core `prepareCardApproval` 不收调用方候选;那条入口上「写自己的规则」
|
|
68
|
+
// 结构上不可拼写)。本层只把「人选了哪一个」翻译成下标。
|
|
69
|
+
const prepared = await prepareCardApproval({
|
|
70
|
+
principal: input.principal,
|
|
71
|
+
toolName: input.toolName,
|
|
72
|
+
command: input.command,
|
|
73
|
+
...(input.toolCallId !== undefined ? { toolCallId: input.toolCallId } : {}),
|
|
74
|
+
...(input.boundInputHash !== undefined ? { boundInputHash: input.boundInputHash } : {}),
|
|
75
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
76
|
+
deps,
|
|
77
|
+
});
|
|
78
|
+
if (prepared === undefined) {
|
|
79
|
+
return { ok: false, reason: "no-candidates", detail: "the rule lane cannot speak for this command (compound / redirection / unsupported tool)" };
|
|
80
|
+
}
|
|
81
|
+
const index = prepared.candidates.findIndex((c) => c.rule === input.ruleText);
|
|
82
|
+
const ticket = index < 0 ? undefined : prepared.tickets[index];
|
|
83
|
+
if (index < 0 || ticket === undefined) {
|
|
84
|
+
return { ok: false, reason: "unknown-candidate", detail: "the chosen rule text is not one the engine minted for this command" };
|
|
85
|
+
}
|
|
86
|
+
const confirmed = await confirmRuleApproval({ approvalId: prepared.approvalId, principal: input.principal, selectedCandidate: index, deps });
|
|
87
|
+
if (!confirmed.ok)
|
|
88
|
+
return { ok: false, reason: "confirm-refused", detail: confirmed.reason };
|
|
89
|
+
const redeemed = await redeemRuleTicket({ ticket, principal: input.principal, deps });
|
|
90
|
+
if (redeemed.status !== "redeemed")
|
|
91
|
+
return { ok: false, reason: "redeem-refused", detail: redeemed.reason };
|
|
92
|
+
return { ok: true, rule: redeemed.rule, rev: redeemed.rev, alreadyRedeemed: redeemed.alreadyRedeemed };
|
|
93
|
+
},
|
|
94
|
+
async prepareImport(principal, layers) {
|
|
95
|
+
// core 的 `CcImportLayer.readFile` 是一条**注入的**读口 —— 云端部署没有用户的文件系统,内容由
|
|
96
|
+
// 调用方交上来(这是本仓的部署形,不是绕过:读的仍然只是 allow 桶,拒/问桶是收紧方向、另有通道)。
|
|
97
|
+
const ccLayers = layers.map((l) => ({
|
|
98
|
+
layer: l.layer,
|
|
99
|
+
path: l.path,
|
|
100
|
+
root: l.root,
|
|
101
|
+
readFile: async () => l.content,
|
|
102
|
+
}));
|
|
103
|
+
const { preview, approvalId } = await prepareCcImport({ layers: ccLayers, principal, deps });
|
|
104
|
+
// 帽在**铸票之前**(见 {@link MAX_IMPORT_CANDIDATES}):没有票 ⇒ 那条逐候选的串行 SQL 循环跑不起来。
|
|
105
|
+
if (preview.candidates.length > MAX_IMPORT_CANDIDATES) {
|
|
106
|
+
// core 在**返回预览之前**就把 pending 记录落了盘 ⇒ 这次不做的导入会留一条永远没人要的行。
|
|
107
|
+
// 收掉它(codex round8 [medium]:被拒的 prepare 不该在共享库里长东西)。best-effort:收不掉
|
|
108
|
+
// 也只是留一条 pending 孤儿,不影响任何判决 —— 但**绝不**因此把 413 变成 500。
|
|
109
|
+
try {
|
|
110
|
+
await stores.approvals.discardPendingRecord?.(approvalId);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
recordFailOpen("server.rules.rejected-prepare-row-left", err instanceof Error ? err.message : String(err));
|
|
114
|
+
}
|
|
115
|
+
return { ok: false, reason: "too-many-candidates", candidates: preview.candidates.length };
|
|
116
|
+
}
|
|
117
|
+
const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, candidates: preview.candidates, ttlMs: ticketTtlMs });
|
|
118
|
+
return { ok: true, preview, ticket: minted.ticketId, expiresAtMs: minted.expiresAtMs };
|
|
119
|
+
},
|
|
120
|
+
async redeemImport(principal, ticket) {
|
|
121
|
+
// ①②③ 一次原子**认领**同时回答(principal 绑定 / 未过期 / 未认领);四类拒绝在 wire 面同形。
|
|
122
|
+
const consumed = await stores.tickets.consume(ticket, principal);
|
|
123
|
+
if (!consumed.ok)
|
|
124
|
+
return { ok: false, reason: "ticket-unusable", detail: consumed.reason };
|
|
125
|
+
// 🔴 codex round1 [high] 一(验真后修):认领**之后**的每一步都可能失败,而旧形把认领当终局 ⇒
|
|
126
|
+
// 一次 store 抖动就永久烧掉这张票,客户端拿到与「票不存在」同形的 404,重试无门。
|
|
127
|
+
// 处置:裁定性拒绝(载荷被篡改 / 记录不存在)**留着**认领(重试不改判);不确定/瞬时失败**放回**
|
|
128
|
+
// 认领并标 `retryable`。放回不打开双兑付的门(认领仍是单持有者),兑付腿本身按 core 的设计幂等。
|
|
129
|
+
// 🔴 codex 交叉复审 round3 [high] 一(验真后修):返回值是**「认领确实放回去了吗」**,不是 void。
|
|
130
|
+
// 旧形无条件回 `retryable: true`,而释放本身也可能失败(同一场 store 故障往往两跳一起挂)——
|
|
131
|
+
// 那就变成对客户端**撒谎**:它拿着一张其实已经烧掉的票去重试,只会收到 404,人的确认永久丢失。
|
|
132
|
+
// 判据必须是「释放**确认**成功」;没确认就不许承诺重试(如实按不可重试报,四类同形 404)。
|
|
133
|
+
const releaseClaim = async () => {
|
|
134
|
+
try {
|
|
135
|
+
return await stores.tickets.release(ticket, principal, RULE_IMPORT_RETRY_AFTER_SEC * 1000);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
// 放不回去 = 回到「认领即烧票」的旧形(属主这一轮不能重试,票随 TTL 消失)。**不**把一次
|
|
139
|
+
// 已经失败的请求再变成一次抛错;但也**不**静默 —— 登记的 F 类兜底,理由逐字见 tag 词表。
|
|
140
|
+
recordFailOpen("server.rules.ticket-claim-release-failed", err instanceof Error ? err.message : String(err));
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/** 「不确定」那一支的统一收口:**释放确认成功**才承诺可重试。 */
|
|
145
|
+
const indeterminate = async (detail) => {
|
|
146
|
+
// 放回之后这张票至少要能撑过我们通告的等待窗 —— 撑不过就不承诺重试(见 store 侧 `release` 头注)。
|
|
147
|
+
const released = await releaseClaim();
|
|
148
|
+
return { ok: false, reason: "record-unusable", detail, ...(released ? { retryable: true } : {}) };
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
// ④ 载荷绑定:记录上的候选集必须与铸票时刻的快照同摘要。
|
|
152
|
+
const record = await stores.approvals.get(consumed.approvalId);
|
|
153
|
+
if (record === undefined)
|
|
154
|
+
return { ok: false, reason: "record-unusable", detail: "the approval record behind this ticket is gone" };
|
|
155
|
+
if (buildRulePayloadHash(record.candidates) !== consumed.payloadHash) {
|
|
156
|
+
return { ok: false, reason: "payload-mismatch", detail: "the approval record's candidates changed after the ticket was minted" };
|
|
157
|
+
}
|
|
158
|
+
// 🔴 确认是**一次性状态跃迁**,不是每次兑付都要过的门:重试一张**放回过认领**的票时,记录
|
|
159
|
+
// 早已不是 `pending` 了(core 的崩溃序:`redeemRuleTicket` 在调用写面**之前**就把记录推到
|
|
160
|
+
// `redeemed`,所以哪怕一个候选都没落地,状态也已经变了)。对已确认的记录再调一次
|
|
161
|
+
// `confirmRuleApproval` 会拿到 `not_pending` —— 把它当拒绝就等于「放回了认领却永远兑不动」,
|
|
162
|
+
// round1/round3/round5 那一串修复到此全部白做。判据用记录**自己的状态**,不是猜错误串。
|
|
163
|
+
if (record.state === "pending") {
|
|
164
|
+
const confirmed = await confirmRuleApproval({ approvalId: consumed.approvalId, principal, deps });
|
|
165
|
+
if (!confirmed.ok) {
|
|
166
|
+
// `conflict` = CAS 输给了一个并发写者(瞬时);其余是裁定(记录不存在/状态不对)。
|
|
167
|
+
if (confirmed.reason === "conflict")
|
|
168
|
+
return await indeterminate(confirmed.reason);
|
|
169
|
+
return { ok: false, reason: "record-unusable", detail: confirmed.reason };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const result = await redeemRuleBatch({ approvalId: consumed.approvalId, principal, deps });
|
|
173
|
+
if ("status" in result) {
|
|
174
|
+
// core 的 refused 里既有裁定也有瞬时(OCC 用尽 / 店读不出来)——分不清就按**可重试**放回:
|
|
175
|
+
// 兑付是幂等的,多放一次认领最坏是多跑一遍同样的重放;烧掉一张真票是不可逆的。
|
|
176
|
+
return await indeterminate(result.reason);
|
|
177
|
+
}
|
|
178
|
+
// 🔴 codex 交叉复审 round5 [high] 二(验真后修):core 的 `redeemRuleBatch` 把**逐候选**的失败
|
|
179
|
+
// 记在 `skippedAtRedeem` 里,顶层照样返回一个 ImportResult —— 只判顶层 `status` 会把「一半没落地」
|
|
180
|
+
// 当成功回 200,而认领已经烧掉,那一半再也补不回来。
|
|
181
|
+
// 为什么这条车道可以把「非空 skippedAtRedeem」一律当**不确定**:候选在 `prepareCcImport` 那一步
|
|
182
|
+
// 就已经全过了同一个验证器(过不了的进 `preview.skipped`,压根不进记录),所以兑付期的逐候选拒绝
|
|
183
|
+
// 实际只剩「店读不出/写不进 / OCC 用尽 / 隔离栅栏」这类**瞬时或异常**成因。重放是幂等的
|
|
184
|
+
// (记录里存着每个候选已铸的 dot),所以放回认领让人重试,代价上界只是再跑一遍同样的重放。
|
|
185
|
+
// 残留(如实登记):真出现一条**永久**被拒的候选时,客户端会一直拿到 503 直到票到期(10 分钟)——
|
|
186
|
+
// 有界、且不破坏任何已落地的规则;要更好得让 core 把逐候选拒绝分成瞬时/裁定两类,已列后续件。
|
|
187
|
+
if (result.skippedAtRedeem.length > 0) {
|
|
188
|
+
return await indeterminate(`partial import: ${result.skippedAtRedeem.length} candidate(s) did not land (${result.skippedAtRedeem.map((x) => x.reason).join("; ")})`);
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, result };
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
return await indeterminate(err instanceof Error ? err.message : String(err));
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=rules-consent.js.map
|
package/dist/run-local.js
CHANGED
|
@@ -53,7 +53,7 @@ import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-cent
|
|
|
53
53
|
import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
|
|
54
54
|
import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
|
|
55
55
|
import { loadSkills } from "./capabilities/skills.js";
|
|
56
|
-
import {
|
|
56
|
+
import { createRepoClient } from "./capabilities/repo-tools.js";
|
|
57
57
|
import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
|
|
58
58
|
import { buildScenarios, selectScenario, centerScenarios } from "./capabilities/scenarios.js";
|
|
59
59
|
import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
|
|
@@ -539,7 +539,7 @@ export async function runLocal(argv, deps = {}) {
|
|
|
539
539
|
const handslessSubRunner = new Runner(withoutExecutionEnv(subRunnerDeps));
|
|
540
540
|
// Capability layer (loadSkills + buildScenarios + selectScenario) — assembled exactly like main.ts.
|
|
541
541
|
const skills = loadSkills(config.skillsDir);
|
|
542
|
-
const repoClient = config.gitApiBaseUrl ?
|
|
542
|
+
const repoClient = config.gitApiBaseUrl ? createRepoClient(config.gitApiKind, config.gitApiBaseUrl, config.gitApiToken) : undefined;
|
|
543
543
|
// systematic-audit: wire the env-configured WebSearch backend (WEB_SEARCH_PROVIDER) exactly like main.ts, so the
|
|
544
544
|
// default scenario's WebSearch tool is assembled on the CLI path too (it was silently never built before).
|
|
545
545
|
const webSearchCfg = webSearchConfigFromEnv();
|