@sema-agent/server 7.55.0 → 7.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +3 -3
- package/README.md +14 -7
- package/README.zh-CN.md +10 -6
- package/USAGE.md +207 -59
- package/deploy/sema-up/chart/values.yaml +1 -1
- package/dist/approval-ask-audit-store.d.ts +100 -1
- package/dist/approval-ask-audit-store.js +103 -2
- package/dist/approval-card.d.ts +148 -13
- package/dist/approval-card.js +82 -13
- package/dist/approval-content-kind.d.ts +22 -0
- package/dist/approval-content-kind.js +5 -0
- package/dist/approval.d.ts +31 -0
- package/dist/approval.js +18 -0
- package/dist/auto-mode-face.d.ts +111 -0
- package/dist/auto-mode-face.js +99 -0
- package/dist/bench/s1/arms.js +5 -5
- package/dist/bench/s1/live-deps.js +11 -11
- package/dist/bench/s1/run-firm.js +3 -0
- package/dist/bench/s1/runner-ctx.d.ts +4 -0
- package/dist/bench/s1/runner-ctx.js +7 -0
- package/dist/boot/config-center.js +23 -1
- package/dist/boot/coordinators.js +13 -1
- package/dist/boot/leader.d.ts +21 -0
- package/dist/boot/leader.js +6 -0
- package/dist/boot/parked-revive-gate.d.ts +7 -2
- package/dist/boot/parked-revive-gate.js +12 -1
- package/dist/boot/resolve-spec.d.ts +3 -0
- package/dist/boot/resolve-spec.js +3 -1
- package/dist/boot/runner-deps.d.ts +12 -0
- package/dist/boot/runner-deps.js +28 -3
- package/dist/boot/runtime-caps.d.ts +19 -8
- package/dist/boot/runtime-caps.js +50 -20
- package/dist/boot/session-shell-gate-registry.d.ts +39 -0
- package/dist/boot/session-shell-gate-registry.js +21 -0
- package/dist/config-catalog.js +14 -5
- package/dist/config-center/types.d.ts +2 -1
- package/dist/config-provider.js +1 -0
- package/dist/config-types.d.ts +41 -10
- package/dist/config.d.ts +21 -0
- package/dist/config.js +40 -5
- package/dist/http/route-ctx.d.ts +90 -3
- package/dist/http/routes/a2a-serve.js +5 -3
- package/dist/http/routes/approvals-assistant.js +21 -8
- package/dist/http/routes/capabilities.js +21 -4
- package/dist/http/routes/leader.js +2 -2
- package/dist/http/routes/memory-origin.d.ts +2 -2
- package/dist/http/routes/rules.js +3 -2
- package/dist/http/routes/runs.d.ts +0 -14
- package/dist/http/routes/runs.js +20 -8
- package/dist/http/routes/tasks.js +34 -8
- package/dist/http/routes/workflows.js +20 -5
- package/dist/http/server.d.ts +11 -1
- package/dist/http/server.js +220 -34
- package/dist/http/wire-types.d.ts +9 -4
- package/dist/leader/diffout.js +2 -1
- package/dist/leader/endpoint.js +49 -15
- package/dist/leader/fanout.js +6 -5
- package/dist/leader/leader.js +17 -14
- package/dist/leader/merge.js +17 -12
- package/dist/leader/planner.js +3 -2
- package/dist/leader/repair-oracle.js +6 -4
- package/dist/leader/repair-wire.js +5 -3
- package/dist/leader/wire.d.ts +80 -1
- package/dist/leader/wire.js +55 -26
- package/dist/main.js +44 -8
- package/dist/observability/err-text.d.ts +6 -0
- package/dist/observability/err-text.js +10 -0
- package/dist/observability/fail-open.d.ts +40 -0
- package/dist/observability/fail-open.js +19 -0
- package/dist/observability/metrics.js +1 -1
- package/dist/observability/run-terminal-log.d.ts +120 -0
- package/dist/observability/run-terminal-log.js +360 -0
- package/dist/orchestration/workflow-completion-inbox.d.ts +9 -0
- package/dist/orchestration/workflow-completion-inbox.js +8 -0
- package/dist/permission-rule-vocab.d.ts +40 -0
- package/dist/permission-rule-vocab.js +17 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +26 -0
- package/dist/plugins/checkpoint-store-sql.js +23 -4
- package/dist/plugins/file-run-store.d.ts +3 -34
- package/dist/plugins/file-run-store.js +21 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +5 -0
- package/dist/plugins/local-session-store.d.ts +9 -9
- package/dist/plugins/local-session-store.js +5 -3
- package/dist/plugins/memory-run-store.d.ts +3 -15
- package/dist/plugins/memory-run-store.js +21 -0
- package/dist/plugins/permission-rule-store-file.d.ts +22 -4
- package/dist/plugins/permission-rule-store-file.js +25 -11
- package/dist/plugins/permission-rule-store-sql.d.ts +60 -30
- package/dist/plugins/permission-rule-store-sql.js +39 -23
- package/dist/plugins/pg-session-storage.js +17 -13
- package/dist/plugins/remote-scratchpad.js +3 -2
- package/dist/plugins/run-store-sql.d.ts +3 -42
- package/dist/plugins/run-store-sql.js +36 -5
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/store-contracts.d.ts +70 -1
- package/dist/plugins/tidb-session-store.js +18 -14
- package/dist/plugins/workflow-run-store-sql.d.ts +5 -0
- package/dist/plugins/workflow-run-store-sql.js +10 -2
- package/dist/rules-consent.d.ts +5 -4
- package/dist/rules-consent.js +25 -46
- package/dist/run-cancel-context.d.ts +13 -0
- package/dist/run-cancel-context.js +15 -0
- package/dist/run-local.js +2 -2
- package/dist/runs.d.ts +4 -1
- package/dist/runs.js +41 -10
- package/dist/runtime-caps-resolver.d.ts +133 -17
- package/dist/runtime-caps-resolver.js +44 -4
- package/dist/security.d.ts +7 -0
- package/dist/task-settings.d.ts +57 -3
- package/dist/task-settings.js +78 -5
- package/dist/task-workflow.d.ts +32 -2
- package/dist/task-workflow.js +8 -3
- package/dist/tool-approval.d.ts +38 -63
- package/dist/tool-approval.js +102 -74
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.d.ts +13 -4
- package/dist/trace/ledger-sink.js +14 -6
- package/dist/trace/project.d.ts +13 -0
- package/dist/trace/project.js +10 -1
- package/dist/trace/redact.d.ts +22 -11
- package/dist/trace/redact.js +655 -20
- package/dist/trace/sema-provenance.d.ts +26 -0
- package/dist/trace/sema-provenance.js +11 -0
- package/dist/turn-activity.d.ts +32 -2
- package/dist/turn-activity.js +29 -4
- package/package.json +3 -3
- package/skills/find-skills.md +1 -1
- package/skills/loop.md +1 -1
|
@@ -195,16 +195,6 @@ export type RuleTicketRedeemResult = {
|
|
|
195
195
|
ok: false;
|
|
196
196
|
reason: "unknown" | "wrong-principal" | "wrong-purpose" | "expired" | "consumed";
|
|
197
197
|
};
|
|
198
|
-
/**
|
|
199
|
-
* [ref]③/[ref]③ —— 票的**判定钟**覆写(记账钟永远不覆写,见 `consume` 内注)。
|
|
200
|
-
*
|
|
201
|
-
* 缺席 = 墙钟,既有行为逐字不变。在场 = 调用方(同意车道)按**单调锚**投影出来的那个时刻:
|
|
202
|
-
* 铸票时刻的墙钟 + 自铸票起单调钟走过的量。它只能让判定更贴近**真实经过的时间**,给不了票额外寿命
|
|
203
|
-
* (投影按单调钟前进,走满 TTL 就必然越过 `expires_at_ms`)。
|
|
204
|
-
*/
|
|
205
|
-
export interface RuleTicketDecisionClock {
|
|
206
|
-
decisionNowMs?: number;
|
|
207
|
-
}
|
|
208
198
|
/**
|
|
209
199
|
* [ref] A7 —— 一张票的**三态快照**(MINTED → CLAIMED → SETTLED)。
|
|
210
200
|
*
|
|
@@ -243,15 +233,18 @@ export type RuleTicketReclaimResult = {
|
|
|
243
233
|
ok: false;
|
|
244
234
|
};
|
|
245
235
|
/**
|
|
246
|
-
* [ref]③ 同族的 **GC 宽限**:`reapExpired`
|
|
236
|
+
* [ref]③ 同族的 **GC 宽限**:`reapExpired` 只收「按**数据库的钟**至少已死一个完整 TTL」的票行。
|
|
247
237
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
238
|
+
* 🔴 立论已换([ref] / [ref]):它原本是给单调锚兜底的 —— 一张「按墙钟已过期、按单调钟还在窗内」的
|
|
239
|
+
* 票当年是可兑付的,GC 不能抢在判定前面把行收走。TTL 轴整条搬到数据库的钟之后,那一族**从根上没有了**
|
|
240
|
+
* (判定与清扫从此同一口钟、同一个不等号,不可能一个说活一个说死)。留着它的现由是另外两条,都成立:
|
|
241
|
+
* · **归因质量**:行还在 ⇒ 拒绝是 `expired`(服务端日志说得清「票过期了」);行被收走 ⇒ 塌成 `unknown`。
|
|
242
|
+
* 死票在库里多躺一个宽限,换来运维排查时一个准确的归因,而票表按设计是短命小表,代价可忽略。
|
|
243
|
+
* · **数据库自己的钟也会被拨**(DB 主机的 NTP 纠正、或一次主从切换换了台机器)。宽限一个 TTL 覆盖住
|
|
244
|
+
* 「DB 钟被往前拨了不到一个 TTL」这一族:那种时刻 `consume` 会暂时拒,但行还在,钟走回来票就活。
|
|
251
245
|
*
|
|
252
|
-
* 残余(如实登记,不假称已解)
|
|
253
|
-
*
|
|
254
|
-
* ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
|
|
246
|
+
* 残余(如实登记,不假称已解):DB 钟被往前拨**超过**一个 TTL 时,行仍会被收。补偿是现成的:prepare
|
|
247
|
+
* 廉价,重铸一张即可(core [ref] 建议的姿态)。
|
|
255
248
|
*/
|
|
256
249
|
export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
|
|
257
250
|
/**
|
|
@@ -262,11 +255,17 @@ export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
|
|
|
262
255
|
* 生出来的权威回执下一拍就被删,而"落定之后必有一个完整的回放窗"是本批新许下的承诺 —— 它不能建立在
|
|
263
256
|
* 「两台机器的钟差不多」这条没人声明过的假设上。数据库的钟是这两台副本**唯一**共享的那口钟。
|
|
264
257
|
*
|
|
265
|
-
* 🔴
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* `
|
|
269
|
-
*
|
|
258
|
+
* 🔴 射程收官([ref] / [ref],2026-09-02):**这张票上的每一个时刻量都走本钟**,一个不剩 ——
|
|
259
|
+
* `consumed_at_ms` / `settled_at_ms`(R8-F2 起)、`expires_at_ms` / `created_at_ms`(本批)。
|
|
260
|
+
*
|
|
261
|
+
* 本批补上的是 **TTL 轴**,也是最后一条:铸票 `expires_at_ms = <本钟> + ttl`、判定
|
|
262
|
+
* `expires_at_ms > <本钟>`、清扫 `expires_at_ms <= <本钟> − 宽限`。修前那一形是**两台副本各出一半**的
|
|
263
|
+
* 比较(铸票副本的墙钟定到期时刻,兑付副本的墙钟做判定),于是 TTL 在集群上**不单调**:同一张票在钟慢
|
|
264
|
+
* 的那台上还活着、在钟快的那台上已经死了;极端形是「出生即过期」(铸票副本慢过一个 TTL ⇒ 票铸出来就
|
|
265
|
+
* 没人兑得动)。这不需要故障就能发作 —— 负载均衡把 prepare 与 redeem 分到两台副本是**常态**。
|
|
266
|
+
* 上一批的进程内单调锚只覆盖「同一副本铸、同一副本兑」那一格,连同它的台账在本批一并删除。
|
|
267
|
+
*
|
|
268
|
+
* 判定读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置(fail-closed:绝不退回本地墙钟放行)。
|
|
270
269
|
*/
|
|
271
270
|
export declare const SQL_DB_NOW_MS: {
|
|
272
271
|
readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
|
|
@@ -277,11 +276,37 @@ export declare function buildRulePayloadHash(candidates: ReadonlyArray<{
|
|
|
277
276
|
rule: string;
|
|
278
277
|
scope: RuleScope;
|
|
279
278
|
}>): string;
|
|
279
|
+
/**
|
|
280
|
+
* 票面三态协议的 SQL 实现。
|
|
281
|
+
*
|
|
282
|
+
* 🔴 **这只店没有本地钟 seam**([ref]):它此前收一个 `now: () => number`,而票上的每一个时刻量现在都由
|
|
283
|
+
* 数据库自己出({@link SQL_DB_NOW_MS})。留着那只形参会让人以为「注一只假钟就能改票的判决」——
|
|
284
|
+
* 那正是本批要消灭的那条依赖。测试要制造过期,改行(把 `expires_at_ms` 相对 `<db now>` 挪)即可,
|
|
285
|
+
* 而那恰恰也是生产上唯一能让票过期的方式。
|
|
286
|
+
*/
|
|
280
287
|
export declare class SqlRuleImportTicketStore {
|
|
281
288
|
private readonly db;
|
|
282
|
-
|
|
283
|
-
constructor(db: SqlDriver, now?: () => number);
|
|
289
|
+
constructor(db: SqlDriver);
|
|
284
290
|
private q;
|
|
291
|
+
/**
|
|
292
|
+
* 问一次**数据库自己的钟**(毫秒)。铸票用它当基准,于是到期时刻与后续每一次判定同域。
|
|
293
|
+
*
|
|
294
|
+
* 读不出数 ⇒ **响亮抛**([ref] fail-closed):静默兜个本地 `Date.now()` 正是本批要删掉的那条腿,
|
|
295
|
+
* 而一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值。
|
|
296
|
+
*/
|
|
297
|
+
private dbNowMs;
|
|
298
|
+
/**
|
|
299
|
+
* 铸一张票。
|
|
300
|
+
*
|
|
301
|
+
* 🔴 到期时刻 = **数据库的钟** + TTL([ref] / [ref]),不是这台副本的墙钟 + TTL。理由逐字见
|
|
302
|
+
* {@link SQL_DB_NOW_MS} 的头注:铸造点与判定点必须同一口钟,否则 TTL 在集群上不单调。
|
|
303
|
+
*
|
|
304
|
+
* 为什么是「先问一次钟、再把绝对值写进去」而不是「INSERT 里直接写 `<db now> + ?`」:`expiresAtMs`
|
|
305
|
+
* 要**原样上 wire**(prepare 的响应体里有它,客户端拿它显示倒计时),而 MySQL 的 INSERT 交不回
|
|
306
|
+
* 计算列 —— 那一形要么再补一条 SELECT 读回来(失败时留一行孤儿),要么两方言各写一套(PG 有
|
|
307
|
+
* `RETURNING`、MySQL 没有,两份会各自漂)。先问钟的形只有一个失败方向:钟没问到 ⇒ 一行都没落,
|
|
308
|
+
* 调用方原样收错。两跳之间流逝的那点时间只会让票**略短命**,方向保守。
|
|
309
|
+
*/
|
|
285
310
|
mint(input: {
|
|
286
311
|
ticketId: string;
|
|
287
312
|
principal: string;
|
|
@@ -308,7 +333,7 @@ export declare class SqlRuleImportTicketStore {
|
|
|
308
333
|
* wrong-purpose/expired/consumed 五类给服务端日志;wire 面把五类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
|
|
309
334
|
* 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
|
|
310
335
|
*/
|
|
311
|
-
consume(ticketId: string, principal: string, purpose: RuleTicketPurpose
|
|
336
|
+
consume(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise<RuleTicketRedeemResult>;
|
|
312
337
|
/**
|
|
313
338
|
* **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
|
|
314
339
|
*
|
|
@@ -325,7 +350,7 @@ export declare class SqlRuleImportTicketStore {
|
|
|
325
350
|
* 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
|
|
326
351
|
* 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
|
|
327
352
|
*/
|
|
328
|
-
release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number
|
|
353
|
+
release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
329
354
|
/**
|
|
330
355
|
* [ref] A7 —— CLAIMED → **SETTLED**:把这一次兑付的终局写进行里。
|
|
331
356
|
*
|
|
@@ -427,11 +452,16 @@ export interface PermissionRuleStores {
|
|
|
427
452
|
* 「超帽当场拒」那一条路,不收「人改主意了」)。
|
|
428
453
|
*
|
|
429
454
|
* 两张表的谓词都只删**可证已死**的行,所以本腿不需要旋钮(没有可调的语义):
|
|
430
|
-
* · 票(三支 OR,A7 交叉复审定形,逐支论证见 reapExpired 实现内注)
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* · 记录:`state = 'pending' AND created_at_ms <
|
|
455
|
+
* · 票(三支 OR,A7 交叉复审定形,逐支论证见 reapExpired 实现内注)**三支全走 DB 钟**([ref]):
|
|
456
|
+
* 甲=从未认领且过期已满 GC 宽限;乙=已落定且落定后过 GC 宽限(按 `settled_at_ms` 判);
|
|
457
|
+
* 丙=CLAIMED-未结且认领戳老过废弃期(按 `consumed_at_ms` 判);
|
|
458
|
+
* · 记录:`state = 'pending' AND created_at_ms < nowMs − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`,
|
|
434
459
|
* 再加 `NOT EXISTS`(载荷必须陪着把手活,R6-F1:仍有票引用的 pending 记录不删)。
|
|
460
|
+
*
|
|
461
|
+
* 🔴 `nowMs` 形参**只剩记录那一支在用**,如实登记:`permission_rule_approval.created_at_ms` 是这台
|
|
462
|
+
* 副本自己写下的(见 {@link SqlRuleApprovalRecordStore}),按同一台副本的墙钟判"落盘满 24 小时了吗"
|
|
463
|
+
* 是**同域比较** —— 把它单独搬到 DB 钟反而会造出一次新的跨钟比较。那张表的钟域是另一条轴上的事,
|
|
464
|
+
* 不在本批射程(登记为后续件)。票那一支已经不看它了。
|
|
435
465
|
*/
|
|
436
466
|
reapExpired(nowMs: number): Promise<number>;
|
|
437
467
|
}
|
|
@@ -4,6 +4,7 @@ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuar
|
|
|
4
4
|
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
5
5
|
import { isMissingColumnError } from "./sql-errors.js";
|
|
6
6
|
import { canonicalJson } from "../canonical-json.js";
|
|
7
|
+
import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, PERSISTED_RULE_TOOLS, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
7
8
|
export function writerOfSqlRuleStore(store) {
|
|
8
9
|
const w = Reflect.get(store, PERMISSION_RULE_WRITER);
|
|
9
10
|
if (w === null || typeof w !== "object")
|
|
@@ -26,8 +27,8 @@ const RuleAddSchema = z
|
|
|
26
27
|
const PersistedAllowRuleSchema = z
|
|
27
28
|
.object({
|
|
28
29
|
rule: z.string(),
|
|
29
|
-
tool: z.
|
|
30
|
-
match: z.enum(
|
|
30
|
+
tool: z.enum(PERSISTED_RULE_TOOLS),
|
|
31
|
+
match: z.enum(PERSISTED_RULE_MATCHES),
|
|
31
32
|
command: z.string(),
|
|
32
33
|
scope: DurableRuleScopeSchema,
|
|
33
34
|
adds: z.array(RuleAddSchema),
|
|
@@ -52,7 +53,9 @@ const QuarantinedRuleAddSchema = z
|
|
|
52
53
|
const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
|
|
53
54
|
const RuleCandidateSchema = z.object({ rule: z.string(), scope: DurableRuleScopeSchema }).strict();
|
|
54
55
|
const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
|
|
55
|
-
const RuleEditedSchema = z
|
|
56
|
+
const RuleEditedSchema = z
|
|
57
|
+
.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string(), warnings: z.array(z.enum(EDITED_RULE_BREADTH_WARNING_CODES)).optional() })
|
|
58
|
+
.strict();
|
|
56
59
|
const RuleOffer2Schema = z.union([
|
|
57
60
|
z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
|
|
58
61
|
z
|
|
@@ -61,6 +64,7 @@ const RuleOffer2Schema = z.union([
|
|
|
61
64
|
candidates: z.array(z.number().int().nonnegative().safe()),
|
|
62
65
|
segments: z.array(z.string()).optional(),
|
|
63
66
|
uncoveredSegments: z.number().int().nonnegative().safe().optional(),
|
|
67
|
+
uncoveredDetail: z.array(z.object({ segment: z.string(), reason: z.enum(UNCOVERED_SEGMENT_REASONS) }).strict()).optional(),
|
|
64
68
|
})
|
|
65
69
|
.strict(),
|
|
66
70
|
]);
|
|
@@ -104,8 +108,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
104
108
|
state VARCHAR(32) NOT NULL,
|
|
105
109
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
106
110
|
candidates_json LONGTEXT NOT NULL,
|
|
107
|
-
-- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 2
|
|
108
|
-
--
|
|
111
|
+
-- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 3 —— design/382 §3.5 由 2 升,
|
|
112
|
+
-- 代码里的单点是 RULE_APPROVAL_RECORD_SCHEMA;OCC 守卫另在 rev,两轴禁混,schema-naming 门③ 白名单
|
|
113
|
+
-- 登记)。读侧对非 3 的行(含上一形的 2)交还 design/375 §4.5 的
|
|
109
114
|
-- StaleRuleApprovalRecord 信封(identity 键 only)—— 本仓 SCHEMA POLICY 是删表重建,重建后的表
|
|
110
115
|
-- 不该有这种行,这一臂是「未来再换形」的防御位,绝不静默 widen 成一条本形担保不了的记录。
|
|
111
116
|
version INT NOT NULL,
|
|
@@ -258,7 +263,8 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
258
263
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
259
264
|
candidates_json TEXT COLLATE "C" NOT NULL,
|
|
260
265
|
-- version / offers_json / selected_offer:design/375 换形三列(MySQL 孪生的行内注写了理由:
|
|
261
|
-
-- version=数据格式版本恒
|
|
266
|
+
-- version=数据格式版本恒 3(design/382 §3.5 由 2 升;单点 RULE_APPROVAL_RECORD_SCHEMA),非 3 的行(含 2)
|
|
267
|
+
-- 读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
|
|
262
268
|
-- 禁存文本副本;selected_offer=确认的 OFFER index,与 ticket/redeemed_dots 的 CANDIDATE index
|
|
263
269
|
-- 两空间分离)。
|
|
264
270
|
version INT NOT NULL,
|
|
@@ -525,7 +531,7 @@ export class SqlRuleApprovalRecordStore {
|
|
|
525
531
|
const ownerKind = String(row.owner_kind);
|
|
526
532
|
const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
|
|
527
533
|
const identity = ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } };
|
|
528
|
-
if (Number(row.version) !==
|
|
534
|
+
if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
|
|
529
535
|
return { staleSchema: true, id: String(row.record_id), ...identity };
|
|
530
536
|
}
|
|
531
537
|
const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
|
|
@@ -541,7 +547,7 @@ export class SqlRuleApprovalRecordStore {
|
|
|
541
547
|
return {
|
|
542
548
|
id: String(row.record_id),
|
|
543
549
|
...identity,
|
|
544
|
-
schema:
|
|
550
|
+
schema: RULE_APPROVAL_RECORD_SCHEMA,
|
|
545
551
|
kind: kind.data,
|
|
546
552
|
state: state.data,
|
|
547
553
|
candidates,
|
|
@@ -584,14 +590,14 @@ export class SqlRuleApprovalRecordStore {
|
|
|
584
590
|
]);
|
|
585
591
|
}
|
|
586
592
|
async discardPendingRecord(recordId) {
|
|
587
|
-
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version =
|
|
593
|
+
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [recordId]);
|
|
588
594
|
return affected === 1;
|
|
589
595
|
}
|
|
590
596
|
async cas(id, expectRev, next) {
|
|
591
597
|
if (next.rev !== expectRev + 1) {
|
|
592
598
|
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
593
599
|
}
|
|
594
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version =
|
|
600
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [
|
|
595
601
|
next.state,
|
|
596
602
|
next.rev,
|
|
597
603
|
this.enc(next.candidates, "permission_rule_approval.candidates_json"),
|
|
@@ -616,16 +622,21 @@ export function buildRulePayloadHash(candidates) {
|
|
|
616
622
|
}
|
|
617
623
|
export class SqlRuleImportTicketStore {
|
|
618
624
|
db;
|
|
619
|
-
|
|
620
|
-
constructor(db, now = Date.now) {
|
|
625
|
+
constructor(db) {
|
|
621
626
|
this.db = db;
|
|
622
|
-
this.now = now;
|
|
623
627
|
}
|
|
624
628
|
q(tidbSql, pgSql) {
|
|
625
629
|
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
626
630
|
}
|
|
631
|
+
async dbNowMs() {
|
|
632
|
+
const { rows } = await this.db.query(this.q(`SELECT ${SQL_DB_NOW_MS.tidb} AS db_now_ms`, `SELECT ${SQL_DB_NOW_MS.pg} AS db_now_ms`), []);
|
|
633
|
+
const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
|
|
634
|
+
if (!Number.isFinite(n))
|
|
635
|
+
throw new Error(`${PERMISSION_RULE_TICKET_TABLE}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
|
|
636
|
+
return n;
|
|
637
|
+
}
|
|
627
638
|
async mint(input) {
|
|
628
|
-
const nowMs = this.
|
|
639
|
+
const nowMs = await this.dbNowMs();
|
|
629
640
|
const expiresAtMs = nowMs + input.ttlMs;
|
|
630
641
|
const payloadHash = buildRulePayloadHash(input.candidates);
|
|
631
642
|
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, claim_id, purpose, created_at_ms)
|
|
@@ -643,8 +654,7 @@ export class SqlRuleImportTicketStore {
|
|
|
643
654
|
]);
|
|
644
655
|
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
645
656
|
}
|
|
646
|
-
async consume(ticketId, principal, purpose
|
|
647
|
-
const decisionNowMs = opts?.decisionNowMs ?? this.now();
|
|
657
|
+
async consume(ticketId, principal, purpose) {
|
|
648
658
|
const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
|
|
649
659
|
const row = await this.readRow(ticketId);
|
|
650
660
|
if (row === undefined)
|
|
@@ -655,17 +665,23 @@ export class SqlRuleImportTicketStore {
|
|
|
655
665
|
return { ok: false, reason: "wrong-purpose" };
|
|
656
666
|
if (row.consumedAtMs !== undefined)
|
|
657
667
|
return { ok: false, reason: "consumed" };
|
|
658
|
-
if (row.expiresAtMs <=
|
|
668
|
+
if (row.expiresAtMs <= row.dbNowMs)
|
|
659
669
|
return { ok: false, reason: "expired" };
|
|
660
670
|
const claimId = randomUUID();
|
|
661
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND consumed_at_ms IS NULL AND expires_at_ms >
|
|
671
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND consumed_at_ms IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.tidb}`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.pg}, claim_id = $1 WHERE ticket_id = $2 AND owner_key = $3 AND purpose = $4 AND consumed_at_ms IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.pg}`), [claimId, ticketId, ownerKey, purpose]);
|
|
662
672
|
if (affected === 1)
|
|
663
673
|
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId };
|
|
674
|
+
const after = await this.readRow(ticketId);
|
|
675
|
+
if (after === undefined)
|
|
676
|
+
return { ok: false, reason: "unknown" };
|
|
677
|
+
if (after.consumedAtMs !== undefined)
|
|
678
|
+
return { ok: false, reason: "consumed" };
|
|
679
|
+
if (after.expiresAtMs <= after.dbNowMs)
|
|
680
|
+
return { ok: false, reason: "expired" };
|
|
664
681
|
return { ok: false, reason: "consumed" };
|
|
665
682
|
}
|
|
666
|
-
async release(ticketId, principal, claimId, mustRemainValidMs = 0
|
|
667
|
-
const
|
|
668
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = $1 AND owner_key = $2 AND claim_id = $3 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > $4`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), claimId, nowMs + mustRemainValidMs]);
|
|
683
|
+
async release(ticketId, principal, claimId, mustRemainValidMs = 0) {
|
|
684
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.tidb} + ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = $1 AND owner_key = $2 AND claim_id = $3 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.pg} + $4`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), claimId, mustRemainValidMs]);
|
|
669
685
|
return affected === 1;
|
|
670
686
|
}
|
|
671
687
|
async settle(ticketId, principal, claimId, outcome) {
|
|
@@ -730,9 +746,9 @@ export function createSqlPermissionRuleStores(db, now) {
|
|
|
730
746
|
return {
|
|
731
747
|
provider: new SqlPermissionRuleStoreProvider(db, now),
|
|
732
748
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
733
|
-
tickets: new SqlRuleImportTicketStore(db
|
|
749
|
+
tickets: new SqlRuleImportTicketStore(db),
|
|
734
750
|
reapExpired: async (nowMs) => {
|
|
735
|
-
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <=
|
|
751
|
+
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.tidb} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.pg} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH})`), []);
|
|
736
752
|
const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = ${PERMISSION_RULE_APPROVAL_TABLE}.record_id) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT a.ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} a WHERE a.state = 'pending' AND a.created_at_ms < $1 AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = a.record_id) LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
|
|
737
753
|
return deadTickets.affected + orphanPending.affected;
|
|
738
754
|
},
|
|
@@ -310,25 +310,29 @@ export class PgSessionStore {
|
|
|
310
310
|
where.push(`(sm.updated_at < ${a} OR (sm.updated_at = ${b} AND sm.session_id < ${sid}))`);
|
|
311
311
|
}
|
|
312
312
|
const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, " +
|
|
313
|
-
" COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id " +
|
|
313
|
+
" COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id, latest.last_task_run_id " +
|
|
314
314
|
"FROM session_meta sm " +
|
|
315
315
|
"LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
|
|
316
|
-
"LEFT JOIN (SELECT DISTINCT ON (session_id) session_id, objective_preview, status, task_id FROM task_run " +
|
|
316
|
+
"LEFT JOIN (SELECT DISTINCT ON (session_id) session_id, objective_preview, status, task_id, result ->> 'runId' AS last_task_run_id FROM task_run " +
|
|
317
317
|
" ORDER BY session_id, created_at DESC, task_id DESC) latest ON latest.session_id = sm.session_id " +
|
|
318
318
|
(where.length ? "WHERE " + where.join(" AND ") + " " : "") +
|
|
319
319
|
`ORDER BY sm.updated_at DESC, sm.session_id DESC LIMIT ${ph(opts.limit)}`;
|
|
320
320
|
const res = await this.pool.query(sql, params);
|
|
321
|
-
return res.rows.map((r) =>
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
321
|
+
return res.rows.map((r) => {
|
|
322
|
+
const runId = r.last_task_run_id;
|
|
323
|
+
return {
|
|
324
|
+
sessionId: String(r.session_id),
|
|
325
|
+
owner: r.owner ?? null,
|
|
326
|
+
lastActivityAt: r.last_activity instanceof Date ? r.last_activity.toISOString() : String(r.last_activity),
|
|
327
|
+
firstActivityAt: r.first_activity instanceof Date ? r.first_activity.toISOString() : String(r.first_activity),
|
|
328
|
+
runCount: Number(r.run_count),
|
|
329
|
+
objectivePreview: r.objective_preview ?? null,
|
|
330
|
+
lastStatus: r.status == null ? "" : String(r.status),
|
|
331
|
+
lastRunId: r.last_run_id ?? null,
|
|
332
|
+
...(typeof runId === "string" && runId !== "" ? { lastTaskRunId: runId } : {}),
|
|
333
|
+
title: r.title ?? null,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
332
336
|
}
|
|
333
337
|
async probeTitle(sessionId) {
|
|
334
338
|
const res = await this.pool.query("SELECT title FROM session_meta WHERE session_id = $1", [sessionId]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactHead } from "../observability/run-terminal-log.js";
|
|
1
2
|
const REMOTE_SCRATCHPAD_LANES = new Set(["e2b", "k8s", "local-docker", "ssh", "device"]);
|
|
2
3
|
export function isRemoteScratchpadLane(provider) {
|
|
3
4
|
return provider !== undefined && REMOTE_SCRATCHPAD_LANES.has(provider);
|
|
@@ -28,12 +29,12 @@ export function withRemoteScratchpad(factory, logger) {
|
|
|
28
29
|
if (!r.ok || r.value.exitCode !== 0) {
|
|
29
30
|
logger?.warn?.("remote_scratchpad_mkdir_failed", {
|
|
30
31
|
dir,
|
|
31
|
-
...(r.ok ? { exitCode: r.value.exitCode, stderr: r.value.stderr
|
|
32
|
+
...(r.ok ? { exitCode: r.value.exitCode, stderr: redactHead(r.value.stderr, 256) } : { error: redactHead(r.error.message, 512) }),
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
catch (err) {
|
|
36
|
-
logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: err instanceof Error ? err.message : String(err) });
|
|
37
|
+
logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: redactHead(err instanceof Error ? err.message : String(err), 512) });
|
|
37
38
|
}
|
|
38
39
|
})());
|
|
39
40
|
e.exec = async (command, options) => {
|
|
@@ -1,47 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
* Async run registry (S1) + replayable event log (S2) — SINGLE-FILE DUAL-DIALECT ([ref] A12 定型半场).
|
|
3
|
-
* ONE implementation, TWO dialects; the historical `TiDBRunStore` / `PgRunStore` class names survive as thin
|
|
4
|
-
* ctor subclasses so every consumer (store-backend.ts, the run/reaper/session/usage suites) is untouched.
|
|
5
|
-
*
|
|
6
|
-
* A run is the background execution of one task. `POST /v1/runs` records it `running` and returns
|
|
7
|
-
* immediately; the background driver (`src/runs.ts`) appends events here as they happen and marks the
|
|
8
|
-
* run terminal when done. Because runs + events are durable and shared, any stateless instance can
|
|
9
|
-
* report status or replay the event stream — a client can reconnect (even to a different instance)
|
|
10
|
-
* with `Last-Event-ID` and resume. The two dialects are kept in lock-step by ONE shared integration suite
|
|
11
|
-
* (test/run-store-reaper-integration.test.ts) that runs the SAME park→resume→claim scenario against BOTH
|
|
12
|
-
* real engines — the anti-drift guard.
|
|
13
|
-
*
|
|
14
|
-
* ── Dialect deltas, kept EXPLICIT (never hidden behind an abstraction) ────────────────────────────────
|
|
15
|
-
* - `?` placeholders vs `$n` (transaction control itself is normalized by `SqlDriver`/`SqlTxConn` —
|
|
16
|
-
* mysql2's native begin/commit/rollback vs PG's statement form; see sql-driver.ts)
|
|
17
|
-
* - dup-key classification (createRun's task_active claim race) — the PREDICATE has a single owner
|
|
18
|
-
* (`sql-errors.ts`, [ref] P1-①); only the dialect DISPATCH is visible here
|
|
19
|
-
*
|
|
20
|
-
* ⚠️ The run ACTIVE-status word list written out in the `status IN (…)` / `NOT IN (…)` fragments below
|
|
21
|
-
* is the closed set owned by `store-contracts.ts` (`isTerminalRunStatus` / `isParkedRunStatus`). The SQL
|
|
22
|
-
* text stays literal ON PURPOSE (A12: both dialects readable side by side), so the tie is a PARITY GATE:
|
|
23
|
-
* `test/source-hygiene-gate.test.ts` → "[ref] P1-② run 活状态 SQL 字面 ↔ store-contracts 闭集属主 parity".
|
|
24
|
-
* core adding a TaskStatus member turns that gate red on every fragment here — update them in the same batch.
|
|
25
|
-
* - null-safe owner compare: `<=>` (TiDB) vs `IS NOT DISTINCT FROM` (PG) — a null-owner run still matches
|
|
26
|
-
* `owner=null` on both, single-DB-fleet defense-in-depth (see {@link requestCancel})
|
|
27
|
-
* - multi-table DELETE: `DELETE t FROM t JOIN j …` (TiDB) vs `DELETE FROM t USING j …` (PG)
|
|
28
|
-
* - `UPDATE t AS a SET col=…` — PG's SET columns are NOT alias-qualified (PG rejects `SET a.col`), while
|
|
29
|
-
* TiDB's SET clause DOES qualify (`SET tr.col = …`) — see reapSuspended / failSuspendedWithExpiredCheckpoint
|
|
30
|
-
* - JSON binding: TiDB `JSON.stringify` verbatim vs PG `pgSafeJsonStringify` + `$n::jsonb` casts
|
|
31
|
-
* - PG-only protocol-byte defenses on plain-TEXT columns: `pgSanitizeText` on objective_preview (createRun)
|
|
32
|
-
* and error/error_code (setTerminal) — a raw NUL would throw 22P05 and roll back the whole terminal write,
|
|
33
|
-
* stranding the run in a live status forever (codex R12); TiDB stores these verbatim
|
|
34
|
-
* - dynamic multi-clause builders (listRuns/listSessions): TiDB's `?` is position-agnostic so the WHERE
|
|
35
|
-
* array is built with plain string pushes; PG's `$n` needs numbered placeholders, so those two methods
|
|
36
|
-
* keep an EXPLICIT per-dialect branch (a shared positional-counter abstraction would hide the very
|
|
37
|
-
* placeholder-numbering divergence this file exists to keep visible)
|
|
38
|
-
* - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
|
|
39
|
-
*/
|
|
40
|
-
import type { TaskResult, TaskStatus } from "@sema-agent/core";
|
|
1
|
+
import type { TaskStatus } from "@sema-agent/core";
|
|
41
2
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
42
3
|
import type { Pool as PgPool } from "pg";
|
|
43
4
|
import { type UsageRow } from "../usage-analytics.js";
|
|
44
|
-
import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
|
|
5
|
+
import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
|
|
45
6
|
import { type SqlDriver } from "./sql-driver.js";
|
|
46
7
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
47
8
|
export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
|
|
@@ -119,7 +80,7 @@ export declare class SqlRunStore {
|
|
|
119
80
|
* abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the
|
|
120
81
|
* blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
|
|
121
82
|
* unconditional (idempotent; the first writer already released it). */
|
|
122
|
-
setTerminal(taskId: string, status: TaskStatus, result:
|
|
83
|
+
setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
|
|
123
84
|
/**
|
|
124
85
|
* Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
|
|
125
86
|
* KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
|
|
1
2
|
import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
|
|
2
3
|
import { escapeLike } from "./sql-escape.js";
|
|
3
4
|
import { pgSanitizeText } from "./pg-safe-json.js";
|
|
4
5
|
import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
|
|
6
|
+
import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal-log.js";
|
|
5
7
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
6
8
|
import { isDupKeyError } from "./sql-errors.js";
|
|
7
9
|
export class SqlRunStore {
|
|
@@ -105,6 +107,7 @@ export class SqlRunStore {
|
|
|
105
107
|
await this.db.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2 AND owner IS NOT DISTINCT FROM $3 AND status = 'running'"), [new Date(), taskId, owner]);
|
|
106
108
|
}
|
|
107
109
|
async appendEvent(taskId, seq, type, data) {
|
|
110
|
+
data = redactLedgerEventData(type, data);
|
|
108
111
|
await this.db.query(this.q("INSERT INTO task_event (task_id, seq, type, data, ts) VALUES (?,?,?,?,?)", "INSERT INTO task_event (task_id, seq, type, data, ts) VALUES ($1,$2,$3,$4::jsonb,$5)"), [taskId, seq, type, data == null ? null : this.json(data), new Date()]);
|
|
109
112
|
}
|
|
110
113
|
async maxSeq(taskId) {
|
|
@@ -113,9 +116,12 @@ export class SqlRunStore {
|
|
|
113
116
|
return m == null ? 0 : Number(m);
|
|
114
117
|
}
|
|
115
118
|
async setTerminal(taskId, status, result, error) {
|
|
119
|
+
result = redactTerminalResult(result);
|
|
120
|
+
error = error === null ? null : (redactErrorMessage(error) ?? null);
|
|
116
121
|
const errorCode = result?.errorCode ?? null;
|
|
122
|
+
let observed;
|
|
117
123
|
await this.tx(async (conn) => {
|
|
118
|
-
await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
|
|
124
|
+
const w = await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
|
|
119
125
|
status,
|
|
120
126
|
result == null ? null : this.json(result),
|
|
121
127
|
this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
|
|
@@ -123,8 +129,31 @@ export class SqlRunStore {
|
|
|
123
129
|
new Date(),
|
|
124
130
|
taskId,
|
|
125
131
|
]);
|
|
132
|
+
if (w.affected > 0 && runTerminalLogLevel(status, errorCode ?? undefined) !== undefined) {
|
|
133
|
+
const { rows } = await conn.query(this.q("SELECT owner, session_id, created_at FROM task_run WHERE task_id = ?", "SELECT owner, session_id, created_at FROM task_run WHERE task_id = $1"), [taskId]);
|
|
134
|
+
const row = rows[0];
|
|
135
|
+
const createdAt = row ? Date.parse(iso(row.created_at)) : NaN;
|
|
136
|
+
observed = {
|
|
137
|
+
owner: row?.owner ?? null,
|
|
138
|
+
sessionId: String(row?.session_id ?? result?.sessionId ?? ""),
|
|
139
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
126
142
|
await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
|
|
127
143
|
});
|
|
144
|
+
if (observed) {
|
|
145
|
+
notifyRunTerminal({
|
|
146
|
+
runId: taskId,
|
|
147
|
+
sessionId: observed.sessionId,
|
|
148
|
+
owner: observed.owner,
|
|
149
|
+
status,
|
|
150
|
+
resultTaskId: result?.taskId,
|
|
151
|
+
model: result?.model,
|
|
152
|
+
errorCode: errorCode ?? undefined,
|
|
153
|
+
errorMessage: result?.errorMessage ?? error ?? undefined,
|
|
154
|
+
elapsedMs: observed.createdAt === undefined ? undefined : Math.max(0, Date.now() - observed.createdAt),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
128
157
|
}
|
|
129
158
|
async setSuspended(taskId) {
|
|
130
159
|
await this.db.query(this.q("UPDATE task_run SET status = 'suspended', updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', updated_at = $1 WHERE task_id = $2 AND status IN ('running','suspended')"), [new Date(), taskId]);
|
|
@@ -237,8 +266,8 @@ export class SqlRunStore {
|
|
|
237
266
|
outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))");
|
|
238
267
|
params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
|
|
239
268
|
}
|
|
240
|
-
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
|
|
241
|
-
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
269
|
+
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status, last_task_run_id FROM (" +
|
|
270
|
+
"SELECT session_id, owner, task_id, objective_preview, status, JSON_UNQUOTE(JSON_EXTRACT(result, '$.runId')) AS last_task_run_id," +
|
|
242
271
|
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
|
|
243
272
|
" MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
|
|
244
273
|
" MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
|
|
@@ -264,8 +293,8 @@ export class SqlRunStore {
|
|
|
264
293
|
const sid = p(opts.cursor.sessionId);
|
|
265
294
|
outer.push(`(last_activity < ${a} OR (last_activity = ${b} AND session_id < ${sid}))`);
|
|
266
295
|
}
|
|
267
|
-
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
|
|
268
|
-
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
296
|
+
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status, last_task_run_id FROM (" +
|
|
297
|
+
"SELECT session_id, owner, task_id, objective_preview, status, result ->> 'runId' AS last_task_run_id," +
|
|
269
298
|
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
|
|
270
299
|
" MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
|
|
271
300
|
" MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
|
|
@@ -276,6 +305,7 @@ export class SqlRunStore {
|
|
|
276
305
|
return rows.map((r) => this.rowToSession(r));
|
|
277
306
|
}
|
|
278
307
|
rowToSession(r) {
|
|
308
|
+
const lastTaskRunId = r.last_task_run_id;
|
|
279
309
|
return {
|
|
280
310
|
sessionId: String(r.session_id),
|
|
281
311
|
owner: r.owner ?? null,
|
|
@@ -285,6 +315,7 @@ export class SqlRunStore {
|
|
|
285
315
|
objectivePreview: r.objective_preview ?? null,
|
|
286
316
|
lastStatus: String(r.status),
|
|
287
317
|
lastRunId: r.task_id ?? null,
|
|
318
|
+
...(typeof lastTaskRunId === "string" && lastTaskRunId !== "" ? { lastTaskRunId } : {}),
|
|
288
319
|
title: null,
|
|
289
320
|
};
|
|
290
321
|
}
|
|
@@ -95,7 +95,7 @@ export type ImageBake = TiDBImageBake | PgImageBake;
|
|
|
95
95
|
/** [ref] 车4 件1:durable leader-run 登记表的双方言孪生(union 同族——TS 私有字段让具体类名义上不同)。 */
|
|
96
96
|
export type LeaderRunStore = TiDBLeaderRunStore | PgLeaderRunStore;
|
|
97
97
|
/** The FULL checkpoint store (core CheckpointStore + the service operator-queue/ctx methods listPending/
|
|
98
|
-
* listByScope/findPendingTokenBySession/peekPendingScope/putCtx/getCtx/reapCtx) — the SQL twins carry them,
|
|
98
|
+
* listByScope/findPendingTokenBySession/peekPendingScope/peekScopeByToken/putCtx/getCtx/reapCtx) — the SQL twins carry them,
|
|
99
99
|
* and the LOCAL lane now does too (core FileCheckpointStore + the service half — TOC plan-mode /
|
|
100
100
|
* durable HITL work on one box). */
|
|
101
101
|
export type CheckpointStoreFull = TiDBCheckpointStore | PgCheckpointStore | LocalCheckpointStore;
|