@sema-agent/server 7.93.7 → 7.95.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 +52 -0
- package/dist/boot/resolve-spec.js +14 -1
- package/dist/boot/stage-09-leader.js +1 -1
- package/dist/boot/stores.js +6 -3
- package/dist/boot/workflow-orchestration.js +5 -1
- package/dist/config-catalog.js +5 -3
- package/dist/config-center/types.d.ts +1 -1
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +17 -0
- package/dist/config-types.d.ts +44 -3
- package/dist/config-types.js +1 -0
- package/dist/config.js +24 -4
- package/dist/hosted-posture.d.ts +176 -0
- package/dist/hosted-posture.js +76 -0
- package/dist/http/resume-ctx-read.d.ts +40 -0
- package/dist/http/resume-ctx-read.js +25 -0
- package/dist/http/resume-legs.js +62 -42
- package/dist/http/routes/capabilities.js +3 -0
- package/dist/http/routes/images.js +12 -0
- package/dist/http/routes/runs.js +18 -4
- package/dist/http/routes/workflows.js +3 -2
- package/dist/http/wire-types.d.ts +13 -4
- package/dist/observability/corrupt-read-seat.d.ts +56 -0
- package/dist/observability/corrupt-read-seat.js +16 -0
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/orchestration/workflow-journal-entry.d.ts +24 -6
- package/dist/orchestration/workflow-journal-entry.js +17 -10
- package/dist/plugins/approval-ask-store-sql.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +16 -3
- package/dist/plugins/checkpoint-store-sql.js +7 -9
- package/dist/plugins/image-bake-store-sql.js +85 -13
- package/dist/plugins/image-index-sql.js +75 -12
- package/dist/plugins/local-checkpoint-store.d.ts +23 -2
- package/dist/plugins/local-checkpoint-store.js +40 -33
- package/dist/plugins/local-session-store.d.ts +14 -0
- package/dist/plugins/local-session-store.js +12 -7
- package/dist/plugins/local-sidecar-quarantine.d.ts +10 -0
- package/dist/plugins/local-sidecar-quarantine.js +15 -0
- package/dist/plugins/local-task-attachment-store.d.ts +1 -1
- package/dist/plugins/local-task-attachment-store.js +5 -2
- package/dist/plugins/mailbox-store-sql.d.ts +6 -3
- package/dist/plugins/mailbox-store-sql.js +45 -43
- package/dist/plugins/memory-embedder-fingerprint.js +8 -12
- package/dist/plugins/memory-engine-pg.js +34 -16
- package/dist/plugins/memory-engine-tidb.js +19 -12
- package/dist/plugins/memory-engine-vector-util.d.ts +26 -0
- package/dist/plugins/memory-engine-vector-util.js +3 -0
- package/dist/plugins/memory-sync-store-pg.d.ts +6 -0
- package/dist/plugins/memory-sync-store-pg.js +14 -10
- package/dist/plugins/memory-sync-store-tidb.js +7 -11
- package/dist/plugins/pg-session-storage.js +26 -28
- package/dist/plugins/session-event-payload.d.ts +37 -0
- package/dist/plugins/session-event-payload.js +20 -0
- package/dist/plugins/sql-json-column.d.ts +124 -22
- package/dist/plugins/sql-json-column.js +22 -1
- package/dist/plugins/sql-row-helpers.d.ts +10 -16
- package/dist/plugins/sql-row-helpers.js +0 -29
- package/dist/plugins/store-backend.js +10 -12
- package/dist/plugins/store-contracts.d.ts +4 -6
- package/dist/plugins/store-contracts.js +0 -60
- package/dist/plugins/task-attachment-store.d.ts +21 -12
- package/dist/plugins/task-attachment-store.js +12 -0
- package/dist/plugins/tidb-session-storage.d.ts +2 -2
- package/dist/plugins/tidb-session-storage.js +12 -11
- package/dist/plugins/tidb-session-store.d.ts +2 -2
- package/dist/plugins/tidb-session-store.js +20 -22
- package/dist/plugins/usage-window-store-sql.d.ts +9 -1
- package/dist/plugins/usage-window-store-sql.js +23 -9
- package/dist/plugins/workflow-journal-store-sql.js +16 -4
- package/dist/run-local.js +4 -10
- package/dist/usage-analytics.d.ts +14 -1
- package/dist/usage-analytics.js +11 -17
- package/package.json +2 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 **腐读披露座的唯一属主**(S-569,core 7.28.1 提货批;#1013 F4 的 server 半场)。
|
|
3
|
+
*
|
|
4
|
+
* ## core 那边发生了什么
|
|
5
|
+
* core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,也会
|
|
6
|
+
* 自锁修复写)。7.27.0(#1013 F4)把这条 fail-open 的披露面收成**一只共用 guard**
|
|
7
|
+
* (`stores/file/corrupt-read-sink.js`:`CorruptReadInfo` + `guardCorruptSink`),并给每一只容错 File 店
|
|
8
|
+
* 都开了座 —— core 自己的原话是那只 guard「hand-written four times」,其中三份包住了 async sink 的
|
|
9
|
+
* rejection、mailbox 那份只包住同步 throw。**同一个病,server 这侧也有**:座的载荷投影当时在本仓被
|
|
10
|
+
* 手抄了四遍(roster / mailbox / run-local 与 store-backend 的两只 FileStorageBackend)。
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么是一个属主,而不是第五、六、七、八份手抄([ref])
|
|
13
|
+
* 7.27.0 之后 server 真构造的店里又多出**四只空座**(`FileCheckpointStore` / `FileWorkflowRunStore` /
|
|
14
|
+
* `FileBackgroundAgentStore` / `FileWorkflowJournalStore`)。按老形接线 = 八处各写一遍
|
|
15
|
+
* `{ path, reason, ...可选成员 }` 的转发 —— 而这八处**本来就该是同一句话**:
|
|
16
|
+
*
|
|
17
|
+
* 「一次持久读因为字节读不出来被当成了缺席。ENOENT 不触发(那是真缺席,不是腐读)。」
|
|
18
|
+
*
|
|
19
|
+
* 载荷形不是本仓铸的:`FileStorageCorruptReadInfo` 是 **core 的导出**,并且是 core 亲口说的**最宽**那一只
|
|
20
|
+
* ——"the widest shape any store this backend constructs can deliver, so ONE sink reaches all of them…
|
|
21
|
+
* the two-field store seats are satisfied by it"。于是本属主就按那只最宽形收参:两个窄座
|
|
22
|
+
* (`CorruptReadInfo = {path, reason}`)与 roster/mailbox 的内联形都因**可选成员**而逐字接受它
|
|
23
|
+
* (`{path,reason}` 可赋给最宽形 ⇒ 吃最宽形的函数可赋给窄座)。**本仓因此零镜像类型**。
|
|
24
|
+
*
|
|
25
|
+
* ## 刻意不做的两件事
|
|
26
|
+
* · **不在这里下统一结论**:后果按脸各不相同(少了会话规则这一层 / 列表静默少行 / rewind 边界读成缺席 /
|
|
27
|
+
* durable park 丢一行 / resume 读到「无事可重放」于是把活重跑),把其中一种写成「整个任务无约束」会误导
|
|
28
|
+
* 事故定级,而且对另外几张脸根本不成立(codex R2-medium 的原案)。所以 `note` 是**按脸传进来的一句话**,
|
|
29
|
+
* 不是本属主里的一张词表 —— 属主里放一张「事件名 → 后果句」的表,等于把调用点的知识搬进属主再抄回去。
|
|
30
|
+
* · **不接 `recordFailOpen`**:那条通道是给 F 类(装饰/缓存)fail-open 的计数与探针用的;这里的 fail-open
|
|
31
|
+
* 是 core 的裁定、本仓不改它,server 的职责只有一条 —— **不许无声**([ref] 安全轴纪律)。响亮 warn 就是
|
|
32
|
+
* 那条职责的兑现;要不要再进 fail-open 台账是另一件事、另一台车的设计。
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ core 那只 guard 的纪律同样适用:sink 是**无绑定**调用的(`this` 是 undefined)。本属主返回的是箭头
|
|
35
|
+
* 闭包,天然满足;但**不要**把某个对象的裸方法引用直接塞进座 —— 读 `this` 会抛,而那个抛会被 core 的
|
|
36
|
+
* guard 吞掉,于是披露**静默丢失**(比没接座更难查)。
|
|
37
|
+
*/
|
|
38
|
+
import type { FileStorageCorruptReadInfo } from "@sema-agent/core";
|
|
39
|
+
import type { Logger } from "./logger.js";
|
|
40
|
+
/**
|
|
41
|
+
* 铸一只腐读披露座。
|
|
42
|
+
*
|
|
43
|
+
* @param warn 日志座。形取 `Logger["warn"]` —— 本仓日志 sink 形的既有属主,`plugins/store-backend.ts` 的
|
|
44
|
+
* `BootLogSink` 结构上就是它,所以这里**不引入第三种拼法**。
|
|
45
|
+
* @param event warn 事件名。**留在调用点**:它是那只店的身份,接线锚(test/roster-corrupt-read.test.ts)
|
|
46
|
+
* 正是靠「构造实参里能读到这个名字」来证明座不是个空回调。
|
|
47
|
+
* @param note 这张脸的后果句(可选)。缺席 ⇒ 不写这个键。
|
|
48
|
+
*/
|
|
49
|
+
export declare function createCorruptReadSeat(warn: Logger["warn"], event: string, note?: string): (info: FileStorageCorruptReadInfo) => void;
|
|
50
|
+
/**
|
|
51
|
+
* `FileStorageBackend` 那两只座共用的后果句 —— 一个座覆盖 core 转发的三张脸,所以共同事实只有一句,
|
|
52
|
+
* 差别只在第三张脸叫什么(`run-local` 腿看到的是 file-snapshot,HTTP 服务腿看到的是 file-history)。
|
|
53
|
+
* 这一句从前在两处各抄一份、只差一个词;现在词从参数来,句子只有一份。
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildDurableAbsenceNote(thirdFace: string): string;
|
|
56
|
+
//# sourceMappingURL=corrupt-read-seat.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createCorruptReadSeat(warn, event, note) {
|
|
2
|
+
return (info) => warn(event, {
|
|
3
|
+
path: info.path,
|
|
4
|
+
reason: info.reason,
|
|
5
|
+
...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
|
|
6
|
+
...(info.principal !== undefined ? { principal: info.principal } : {}),
|
|
7
|
+
...(note !== undefined ? { note } : {}),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function buildDurableAbsenceNote(thirdFace) {
|
|
11
|
+
return ("a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
|
|
12
|
+
"Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
|
|
13
|
+
"layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
|
|
14
|
+
`rows; ${thirdFace}. Inspect the named path before deciding.`);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=corrupt-read-seat.js.map
|
|
@@ -137,6 +137,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
137
137
|
readonly cls: "F";
|
|
138
138
|
readonly note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。";
|
|
139
139
|
};
|
|
140
|
+
readonly "server.sql-json.memory-engine.embedding": {
|
|
141
|
+
readonly cls: "F";
|
|
142
|
+
readonly note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。";
|
|
143
|
+
};
|
|
140
144
|
readonly "server.retention.sweep-report-dropped": {
|
|
141
145
|
readonly cls: "F";
|
|
142
146
|
readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
|
|
@@ -128,6 +128,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
128
128
|
cls: "F",
|
|
129
129
|
note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。",
|
|
130
130
|
},
|
|
131
|
+
"server.sql-json.memory-engine.embedding": {
|
|
132
|
+
cls: "F",
|
|
133
|
+
note: "S-550 车③a(`plugins/memory-engine-pg.ts` 的 `search()` 便携档):一行 `agent_memory_engine_entry.embedding` 这一格读不出形(撕裂字节 / 手改 / 不是 JSON 数组 / `null` 字面)⇒ **这一行退到词面档打分**,行**从不消失**(这一列的成文口径逐字如此:any missing/invalid side falls open to the lexical floor for THIS row)。放行的最坏后果 = 这一行的排名按词面算、可能靠后;不放行的代价是**一行坏 cell 打掉整个 scope 的记忆检索**(`search()` 是 `rows` 的整只循环)。这一列不携任何授权 / 来源 / 曝光带事实(那些全在 `frontmatter`,它照旧**拒**)。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。⚠️ 如实交代:改调前 `not-json` 经裸 `JSON.parse` **抛**、而「解出来不是数组」只降级 —— 同一列两个方向;本车把两支收成一支(降级 + 留痕),这是 S-550 整批**唯一**一处把原本 fail-closed 的臂改成容忍。",
|
|
134
|
+
},
|
|
131
135
|
"server.retention.sweep-report-dropped": {
|
|
132
136
|
cls: "F",
|
|
133
137
|
note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 收敛到这里之后,第三臂到货 ⇒ 本文件**一处**编译红。
|
|
17
17
|
*
|
|
18
18
|
* 🔴 **park 臂的形是 fail-closed 的**:core 的类型要求 `parked.terminal.kind === "paused"`。从盘上读回来的
|
|
19
|
-
* 字节没有类型,所以 {@link
|
|
19
|
+
* 字节没有类型,所以 {@link readJournalRowBody} 在铸 park 臂时**亲验**这一位,不合就判坏——它是 LOAD-BEARING
|
|
20
20
|
* 面上的坏行(一条说自己 park 了却拿不出暂停终局的记录,既不能驱动、也不能当成跑完),吞掉它会让 resume
|
|
21
21
|
* 在一个被钉住的子代 session 旁边重新起一只活跑,正是 core 那条「一次性、响亮」的 schema 台阶要防的形。
|
|
22
22
|
*
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* 而不是把一个暂停终局当成可重放结果)。
|
|
26
26
|
*/
|
|
27
27
|
import type { TaskResult, WorkflowJournalEntry } from "@sema-agent/core";
|
|
28
|
+
import type { JsonColumnReader } from "../plugins/sql-json-column.js";
|
|
28
29
|
/** park 臂的载荷型(core 的 `WorkflowJournalEntry` park 成员逐字):一只 `terminal` 收敛到 `paused` 的 `TaskResult`。 */
|
|
29
30
|
export type ParkedTaskResult = TaskResult & {
|
|
30
31
|
terminal: Extract<TaskResult["terminal"], {
|
|
@@ -63,16 +64,33 @@ export declare function journalEntryPayload(entry: WorkflowJournalEntry): {
|
|
|
63
64
|
*/
|
|
64
65
|
export declare function journalRowPayloadOf(entry: WorkflowJournalEntry): string;
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
* 与 HTTP journal 读面共读它)。
|
|
67
|
+
* 盘上一行 payload 的**形读者**(S-550 车③a:`workflow_journal.result` 这一列的唯一判据)。
|
|
68
68
|
*
|
|
69
69
|
* 判别式 = 解出来的对象上有没有**自有** `parked` 键。它与 `TaskResult` 结构上不相交(core 的 `TaskResult`
|
|
70
70
|
* 没有这个成员),所以历史字节(裸 TaskResult)恒判 result 臂 —— 不需要任何「老字节 = result」的兼容规则,
|
|
71
71
|
* 判别式自己就说清了。
|
|
72
72
|
*
|
|
73
|
-
* park 臂验 `terminal.kind === "paused"`;不合 ⇒
|
|
73
|
+
* park 臂验 `terminal.kind === "paused"`;不合 ⇒ 判坏,由 LOAD-BEARING 的读腿把它当店故障传上去。
|
|
74
74
|
* 一条「说自己 park 了却拿不出暂停终局」的行既驱动不了、也不能当跑完,吞掉它会让 resume 在被钉住的子代
|
|
75
|
-
* session 旁边重新起一只活跑 ——
|
|
75
|
+
* session 旁边重新起一只活跑 —— 与 {@link journalRowPayloadOf} 那道信封屏障要防的是同一件事。
|
|
76
|
+
*
|
|
77
|
+
* 🔴 **「一只 JSON 对象」这一条也必须判**(S-550):此前回装点是裸 `JSON.parse` + `as TaskResult`,于是
|
|
78
|
+
* 一行 `null` 字面 / JSON 字符串标量 / 双重编码的 `result` 会被当成一条**合法的已完成记录**端给 resume
|
|
79
|
+
* (`{callKey, result: null}`)—— 那正是「一次已完成的子代被当成跑过而不再跑」与「一条坏行冒充跑完」
|
|
80
|
+
* 两种最坏形的同一个入口。
|
|
81
|
+
*/
|
|
82
|
+
export declare const readJournalRowBody: JsonColumnReader<JournalRowBody>;
|
|
83
|
+
/** {@link readJournalRowBody} 收窄出来的两臂(判别位已定,载荷已验形)。 */
|
|
84
|
+
export type JournalRowBody = {
|
|
85
|
+
parked: true;
|
|
86
|
+
payload: ParkedTaskResult;
|
|
87
|
+
} | {
|
|
88
|
+
parked: false;
|
|
89
|
+
payload: TaskResult;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* {@link journalRowPayloadOf} 的逆的**后半**:已解码、已验形的载荷 → 记录。**唯一**的铸点(SQL 店的 `load`
|
|
93
|
+
* 与 HTTP journal 读面共读它;两处各自经 `decodeJsonColumn(text, readJournalRowBody, …)` 把文本收成这只体)。
|
|
76
94
|
*/
|
|
77
|
-
export declare function
|
|
95
|
+
export declare function journalEntryOfBody(callKey: string, body: JournalRowBody): WorkflowJournalEntry;
|
|
78
96
|
//# sourceMappingURL=workflow-journal-entry.d.ts.map
|
|
@@ -5,16 +5,23 @@ export function journalRowPayloadOf(entry) {
|
|
|
5
5
|
const { payload, parked } = journalEntryPayload(entry);
|
|
6
6
|
return JSON.stringify(parked ? { parked: payload } : payload);
|
|
7
7
|
}
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const payload = raw.parked;
|
|
12
|
-
const terminal = payload.terminal;
|
|
13
|
-
if (terminal === undefined || terminal.kind !== "paused") {
|
|
14
|
-
throw new Error(`workflow journal: entry ${callKey} is recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — refusing to admit it (a park row that cannot be driven must not be replayed as a completed result)`);
|
|
15
|
-
}
|
|
16
|
-
return { callKey, parked: { ...payload, terminal } };
|
|
8
|
+
export const readJournalRowBody = (body) => {
|
|
9
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
10
|
+
return { problem: "expected a JSON object (a TaskResult, or the `{parked: …}` envelope)" };
|
|
17
11
|
}
|
|
18
|
-
|
|
12
|
+
if (!Object.hasOwn(body, "parked"))
|
|
13
|
+
return { value: { parked: false, payload: body } };
|
|
14
|
+
const payload = body.parked;
|
|
15
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
16
|
+
return { problem: "the `parked` envelope does not carry an object" };
|
|
17
|
+
}
|
|
18
|
+
const terminal = payload.terminal;
|
|
19
|
+
if (terminal === undefined || terminal.kind !== "paused") {
|
|
20
|
+
return { problem: `recorded as PARKED but its stored terminal is "${terminal === undefined ? "absent" : terminal.kind}" (expected "paused") — a park row that cannot be driven must not be replayed as a completed result` };
|
|
21
|
+
}
|
|
22
|
+
return { value: { parked: true, payload: { ...payload, terminal } } };
|
|
23
|
+
};
|
|
24
|
+
export function journalEntryOfBody(callKey, body) {
|
|
25
|
+
return body.parked ? { callKey, parked: body.payload } : { callKey, result: body.payload };
|
|
19
26
|
}
|
|
20
27
|
//# sourceMappingURL=workflow-journal-entry.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
2
|
import { canAskTransition } from "../approval-ask-machine.js";
|
|
3
3
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
4
|
-
import { decodeJsonColumn, foldJsonColumn, jsonColumnText,
|
|
4
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, readTriStateJsonValueColumn } from "./sql-json-column.js";
|
|
5
5
|
import { ensureIndexes } from "./ensure-index.js";
|
|
6
6
|
export function assertIntegralEpochMs(field, value) {
|
|
7
7
|
if (!Number.isSafeInteger(value)) {
|
|
@@ -284,7 +284,7 @@ function mapAskRowBody(r, askId) {
|
|
|
284
284
|
decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
|
|
285
285
|
decisionNote: strOrNull(r.decision_note),
|
|
286
286
|
settledBy: strOrNull(r.settled_by),
|
|
287
|
-
...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input,
|
|
287
|
+
...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readTriStateJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
|
|
288
288
|
decidedAtMs: numOrNull(r.decided_at_ms),
|
|
289
289
|
deniedReason: strOrNull(r.denied_reason),
|
|
290
290
|
gateToken: strOrNull(r.gate_token),
|
|
@@ -760,9 +760,22 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
760
760
|
peekPendingScope(sessionId: string, binding?: PendingRowBinding): Promise<string | null | undefined>;
|
|
761
761
|
/** Upsert the resume rebuild inputs for a session — written at SUBMIT (durable mode on) and, since S-433 F2,
|
|
762
762
|
* once per resume leg at its CAS win point (`DriveResumeArgs.resumeCtx`). Unconditional per `session_id`:
|
|
763
|
-
* the ordering fence lives at the CALLER's CAS win, not in a version column here.
|
|
764
|
-
|
|
765
|
-
|
|
763
|
+
* the ordering fence lives at the CALLER's CAS win, not in a version column here.
|
|
764
|
+
*
|
|
765
|
+
* 🔴 `ctx: object`, not `unknown` (S-577): the read leg judges this column as a JSON **object**
|
|
766
|
+
* ({@link decodeCtxColumn}), so a writer that took `unknown` would be the "writes fine, reads back
|
|
767
|
+
* refused" asymmetry this repo keeps paying for — and `putCtx(sessionId, null)` was exactly the
|
|
768
|
+
* reachable arm that made the JSON literal `null` (indistinguishable from "no such row" at every
|
|
769
|
+
* consumption point) a zero-corruption possibility. All six call sites already pass an object.
|
|
770
|
+
*
|
|
771
|
+
* 🔴 The TYPE is not the whole gate (codex adversarial review [medium], verified then fixed as a class):
|
|
772
|
+
* `object` admits arrays, `Date`, and objects whose `toJSON()` yields a scalar or `null` — each of those
|
|
773
|
+
* serializes to bytes THIS STORE'S READ LEG REFUSES, and the upsert is unconditional, so the write would
|
|
774
|
+
* SUCCEED and replace a readable durable row with an unreadable one (caller sees 2xx, the next resume
|
|
775
|
+
* 409s forever). So the serialized value is judged by {@link assertJsonColumnWritable} — the SAME shape
|
|
776
|
+
* reader the read leg uses — BEFORE the statement runs: refuse loudly, leave the row on disk untouched. */
|
|
777
|
+
putCtx(sessionId: string, ctx: object): Promise<void>;
|
|
778
|
+
getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
|
|
766
779
|
/**
|
|
767
780
|
* GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak):
|
|
768
781
|
* a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`.
|
|
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
|
4
4
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
|
|
5
5
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
6
6
|
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
|
|
7
|
-
import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText,
|
|
7
|
+
import { assertJsonColumnWritable, createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
|
|
8
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
9
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
10
10
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
@@ -117,9 +117,6 @@ const readCheckpointBlobBody = createObjectColumnReader();
|
|
|
117
117
|
const readResumeOutcomeBody = createObjectColumnReader();
|
|
118
118
|
const readGateOutcomeBody = createObjectColumnReader();
|
|
119
119
|
const readRiskDescriptorBody = createObjectColumnReader();
|
|
120
|
-
function readJsonArrayBody(body) {
|
|
121
|
-
return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
|
|
122
|
-
}
|
|
123
120
|
function decodeCheckpointColumn(cell, where) {
|
|
124
121
|
return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
|
|
125
122
|
}
|
|
@@ -133,8 +130,7 @@ function decodeToolInputColumn(cell) {
|
|
|
133
130
|
return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
|
|
134
131
|
}
|
|
135
132
|
function decodeCtxColumn(cell, where) {
|
|
136
|
-
|
|
137
|
-
return body;
|
|
133
|
+
return decodeJsonColumn(cell, createObjectColumnReader(), `checkpoint_ctx.ctx ${where}`);
|
|
138
134
|
}
|
|
139
135
|
function refusing(what, read) {
|
|
140
136
|
try {
|
|
@@ -149,7 +145,7 @@ function readRuleOffersCell(cell) {
|
|
|
149
145
|
return undefined;
|
|
150
146
|
let parsed;
|
|
151
147
|
try {
|
|
152
|
-
parsed = decodeJsonColumn(cell,
|
|
148
|
+
parsed = decodeJsonColumn(cell, readJsonArrayColumn, "checkpoint.rule_suggestions (operator queue row)");
|
|
153
149
|
}
|
|
154
150
|
catch (e) {
|
|
155
151
|
if (!isJsonColumnBodyProblem(e))
|
|
@@ -282,7 +278,7 @@ export function readPendingSteerQueueColumn(queueRaw) {
|
|
|
282
278
|
let lossy = false;
|
|
283
279
|
let items;
|
|
284
280
|
try {
|
|
285
|
-
items = decodeNullableJsonColumn(queueRaw,
|
|
281
|
+
items = decodeNullableJsonColumn(queueRaw, readJsonArrayColumn, "checkpoint.pending_steer_queue");
|
|
286
282
|
}
|
|
287
283
|
catch (e) {
|
|
288
284
|
if (!isJsonColumnBodyProblem(e))
|
|
@@ -655,9 +651,11 @@ export class SqlCheckpointStore {
|
|
|
655
651
|
return rows[0].scope ?? null;
|
|
656
652
|
}
|
|
657
653
|
async putCtx(sessionId, ctx) {
|
|
654
|
+
const text = this.json(ctx, "checkpoint ctx");
|
|
655
|
+
assertJsonColumnWritable(text, createObjectColumnReader(), `checkpoint_ctx.ctx for session ${JSON.stringify(sessionId)} (write leg)`);
|
|
658
656
|
await this.db.query(this.q("INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES (?,?,?) " +
|
|
659
657
|
"ON DUPLICATE KEY UPDATE ctx = VALUES(ctx), updated_at_ms = VALUES(updated_at_ms)", "INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES ($1,$2::jsonb,$3) " +
|
|
660
|
-
"ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId,
|
|
658
|
+
"ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, text, Date.now()]);
|
|
661
659
|
}
|
|
662
660
|
async getCtx(sessionId) {
|
|
663
661
|
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { uuidv7 } from "@sema-agent/core";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { pgSanitizeText, pgHasUnstorable } from "./pg-safe-json.js";
|
|
4
|
-
import {
|
|
4
|
+
import { toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
|
|
5
5
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
6
|
-
import {
|
|
6
|
+
import { decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn, readStringArrayColumn, } from "./sql-json-column.js";
|
|
7
|
+
import {} from "./store-contracts.js";
|
|
7
8
|
import { isDupKeyError } from "./sql-errors.js";
|
|
8
9
|
import { ensureIndexes } from "./ensure-index.js";
|
|
9
10
|
export const PG_IMAGE_BAKE_SCHEMA = [
|
|
@@ -61,6 +62,77 @@ export const PG_IMAGE_BAKE_SCHEMA = [
|
|
|
61
62
|
PRIMARY KEY (pool)
|
|
62
63
|
)`,
|
|
63
64
|
];
|
|
65
|
+
function bakeSelectCols(dialect) {
|
|
66
|
+
return [
|
|
67
|
+
"bake_id",
|
|
68
|
+
"profile",
|
|
69
|
+
jsonColumnText(dialect, "bands"),
|
|
70
|
+
"base_ref",
|
|
71
|
+
"push",
|
|
72
|
+
"dry_run",
|
|
73
|
+
"logs",
|
|
74
|
+
jsonColumnText(dialect, "argv"),
|
|
75
|
+
"status",
|
|
76
|
+
"state",
|
|
77
|
+
"digest",
|
|
78
|
+
"repo",
|
|
79
|
+
"ref",
|
|
80
|
+
"index_id",
|
|
81
|
+
"exit_code",
|
|
82
|
+
"error",
|
|
83
|
+
"error_code",
|
|
84
|
+
"manifest_sha",
|
|
85
|
+
"tag",
|
|
86
|
+
"idem_key",
|
|
87
|
+
"ingest_secret",
|
|
88
|
+
"runner_id",
|
|
89
|
+
"lease_until",
|
|
90
|
+
"cancel_requested",
|
|
91
|
+
"requested_by",
|
|
92
|
+
"created_at",
|
|
93
|
+
"updated_at",
|
|
94
|
+
].join(", ");
|
|
95
|
+
}
|
|
96
|
+
function mapRow(r) {
|
|
97
|
+
return {
|
|
98
|
+
bakeId: r.bake_id,
|
|
99
|
+
profile: r.profile,
|
|
100
|
+
bands: decodeNullableJsonColumn(r.bands, readStringArrayColumn, bakeWhere("bands", r.bake_id)) ?? null,
|
|
101
|
+
baseRef: r.base_ref,
|
|
102
|
+
push: !!r.push,
|
|
103
|
+
dryRun: !!r.dry_run,
|
|
104
|
+
logs: !!r.logs,
|
|
105
|
+
argv: decodeJsonColumn(r.argv, readStringArrayColumn, bakeWhere("argv", r.bake_id)),
|
|
106
|
+
status: r.status,
|
|
107
|
+
state: r.state ?? null,
|
|
108
|
+
digest: r.digest,
|
|
109
|
+
repo: r.repo,
|
|
110
|
+
ref: r.ref,
|
|
111
|
+
indexId: r.index_id,
|
|
112
|
+
exitCode: r.exit_code === null ? null : Number(r.exit_code),
|
|
113
|
+
error: r.error,
|
|
114
|
+
errorCode: r.error_code ?? null,
|
|
115
|
+
manifestSha: r.manifest_sha,
|
|
116
|
+
tag: r.tag,
|
|
117
|
+
idemKey: r.idem_key,
|
|
118
|
+
ingestSecret: r.ingest_secret,
|
|
119
|
+
runnerId: r.runner_id,
|
|
120
|
+
leaseUntil: isoOrNull(r.lease_until),
|
|
121
|
+
cancelRequested: !!r.cancel_requested,
|
|
122
|
+
requestedBy: r.requested_by,
|
|
123
|
+
createdAt: iso(r.created_at),
|
|
124
|
+
updatedAt: iso(r.updated_at),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const bakeWhere = (column, bakeId) => `image_bake.${column} for bake ${bakeId}`;
|
|
128
|
+
function refusingBakeRow(read) {
|
|
129
|
+
try {
|
|
130
|
+
return read();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
throw foldJsonColumn(e, (m) => new Error(`image-bake-store: ${m} — refusing rather than serve a bake row nobody can read`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
64
136
|
export const IMAGE_BAKE_INDEXES = [
|
|
65
137
|
{ table: "image_bake", name: "idx_image_bake_status", columns: ["status"] },
|
|
66
138
|
{ table: "image_bake", name: "idx_image_bake_created", columns: ["created_at"] },
|
|
@@ -183,23 +255,23 @@ export class SqlImageBake {
|
|
|
183
255
|
return { created: true, bake };
|
|
184
256
|
}
|
|
185
257
|
async getBake(bakeId) {
|
|
186
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
187
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
258
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE bake_id = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE bake_id = $1 LIMIT 1`), [bakeId]);
|
|
259
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
188
260
|
}
|
|
189
261
|
async getBakeByIdem(idemKey) {
|
|
190
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
191
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
262
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE idem_key = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE idem_key = $1 LIMIT 1`), [idemKey]);
|
|
263
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
192
264
|
}
|
|
193
265
|
async findActiveByArgv(argv) {
|
|
194
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
195
|
-
"ORDER BY created_at DESC LIMIT 1", `SELECT ${
|
|
266
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE status IN ('queued','running') AND argv = CAST(? AS JSON) ` +
|
|
267
|
+
"ORDER BY created_at DESC LIMIT 1", `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE status IN ('queued','running') AND argv = $1::jsonb ` +
|
|
196
268
|
"ORDER BY created_at DESC LIMIT 1"), [JSON.stringify(argv)]);
|
|
197
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
269
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
198
270
|
}
|
|
199
271
|
async findActiveAny() {
|
|
200
|
-
const { rows } = await this.db.query(`SELECT ${
|
|
272
|
+
const { rows } = await this.db.query(`SELECT ${bakeSelectCols(this.db.dialect)} FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 ` +
|
|
201
273
|
"ORDER BY created_at ASC LIMIT 1", []);
|
|
202
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
274
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
203
275
|
}
|
|
204
276
|
async findNextQueuedId() {
|
|
205
277
|
const { rows } = await this.db.query("SELECT bake_id FROM image_bake WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1", []);
|
|
@@ -313,13 +385,13 @@ export class SqlImageBake {
|
|
|
313
385
|
return m == null ? 0 : Number(m);
|
|
314
386
|
}
|
|
315
387
|
async getEvents(bakeId, afterSeq) {
|
|
316
|
-
const { rows } = await this.db.query(this.q(
|
|
388
|
+
const { rows } = await this.db.query(this.q(`SELECT seq, line_ord, kind, level, ${jsonColumnText("tidb", "data")}, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, line_ord, kind, level, ${jsonColumnText("pg", "data")}, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC`), [bakeId, afterSeq]);
|
|
317
389
|
return rows.map((r) => ({
|
|
318
390
|
seq: Number(r.seq),
|
|
319
391
|
lineOrd: r.line_ord === null ? null : Number(r.line_ord),
|
|
320
392
|
kind: String(r.kind),
|
|
321
393
|
level: r.level ?? null,
|
|
322
|
-
data:
|
|
394
|
+
data: decodeNullableJsonColumn(r.data, readNonNullJsonValueColumn, `image_bake_event.data for bake ${bakeId} seq ${String(r.seq)}`) ?? null,
|
|
323
395
|
ts: iso(r.ts),
|
|
324
396
|
}));
|
|
325
397
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { pgSafeJsonStringify, pgSanitizeText, pgHasUnstorable, PgUnstorableError } from "./pg-safe-json.js";
|
|
3
|
-
import {
|
|
3
|
+
import {} from "./store-contracts.js";
|
|
4
4
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
5
|
+
import { createObjectColumnReader, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn } from "./sql-json-column.js";
|
|
6
|
+
import { normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
|
|
5
7
|
import { ensureIndexes } from "./ensure-index.js";
|
|
6
8
|
export const PG_IMAGE_INDEX_SCHEMA = [
|
|
7
9
|
`CREATE TABLE IF NOT EXISTS sandbox_image_index (
|
|
@@ -29,6 +31,67 @@ export const PG_IMAGE_INDEX_SCHEMA = [
|
|
|
29
31
|
PRIMARY KEY (id)
|
|
30
32
|
)`,
|
|
31
33
|
];
|
|
34
|
+
function imageSelectCols(dialect) {
|
|
35
|
+
return [
|
|
36
|
+
"id",
|
|
37
|
+
"profile",
|
|
38
|
+
jsonColumnText(dialect, "bands"),
|
|
39
|
+
"repo",
|
|
40
|
+
"tag",
|
|
41
|
+
"digest",
|
|
42
|
+
jsonColumnText(dialect, "toolchain_versions"),
|
|
43
|
+
jsonColumnText(dialect, "capabilities"),
|
|
44
|
+
jsonColumnText(dialect, "pod_contract"),
|
|
45
|
+
"size_bytes",
|
|
46
|
+
"status",
|
|
47
|
+
"visibility",
|
|
48
|
+
"tenant_id",
|
|
49
|
+
"manifest_sha",
|
|
50
|
+
"recipe_git_sha",
|
|
51
|
+
"generator_version",
|
|
52
|
+
"build_date",
|
|
53
|
+
"supersedes",
|
|
54
|
+
"signed",
|
|
55
|
+
"created_at",
|
|
56
|
+
"updated_at",
|
|
57
|
+
].join(", ");
|
|
58
|
+
}
|
|
59
|
+
const readCapabilitiesColumn = createObjectColumnReader();
|
|
60
|
+
const readPodContractColumn = createObjectColumnReader();
|
|
61
|
+
function mapRow(r) {
|
|
62
|
+
const at = (column) => `sandbox_image_index.${column} for image ${r.id}`;
|
|
63
|
+
return {
|
|
64
|
+
id: r.id,
|
|
65
|
+
profile: r.profile,
|
|
66
|
+
bands: decodeNullableJsonColumn(r.bands, readJsonArrayColumn, at("bands")) ?? [],
|
|
67
|
+
repo: r.repo,
|
|
68
|
+
tag: r.tag,
|
|
69
|
+
digest: r.digest,
|
|
70
|
+
toolchainVersions: decodeNullableJsonColumn(r.toolchain_versions, readJsonObjectColumn, at("toolchain_versions")) ?? {},
|
|
71
|
+
capabilities: decodeNullableJsonColumn(r.capabilities, readCapabilitiesColumn, at("capabilities")) ?? {},
|
|
72
|
+
podContract: decodeNullableJsonColumn(r.pod_contract, readPodContractColumn, at("pod_contract")) ?? {},
|
|
73
|
+
sizeBytes: r.size_bytes === null ? null : Number(r.size_bytes),
|
|
74
|
+
status: r.status,
|
|
75
|
+
visibility: r.visibility,
|
|
76
|
+
tenantId: r.tenant_id,
|
|
77
|
+
manifestSha: r.manifest_sha,
|
|
78
|
+
recipeGitSha: r.recipe_git_sha,
|
|
79
|
+
generatorVersion: r.generator_version,
|
|
80
|
+
buildDate: isoOrNull(r.build_date),
|
|
81
|
+
supersedes: r.supersedes,
|
|
82
|
+
signed: !!r.signed,
|
|
83
|
+
createdAt: isoOrNull(r.created_at) ?? new Date(0).toISOString(),
|
|
84
|
+
updatedAt: isoOrNull(r.updated_at) ?? new Date(0).toISOString(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function refusingImageRow(read) {
|
|
88
|
+
try {
|
|
89
|
+
return read();
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
throw foldJsonColumn(e, (m) => new Error(`image-index: ${m} — refusing rather than serve an image row nobody can read`));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
32
95
|
export const IMAGE_INDEX_INDEXES = [
|
|
33
96
|
{ table: "sandbox_image_index", name: "uq_repo_digest", columns: ["repo", "digest"], unique: true },
|
|
34
97
|
{ table: "sandbox_image_index", name: "idx_sandbox_image_index_profile_status", columns: ["profile", "status", "visibility"] },
|
|
@@ -199,26 +262,26 @@ export class SqlImageIndex {
|
|
|
199
262
|
return id;
|
|
200
263
|
}
|
|
201
264
|
async getById(id) {
|
|
202
|
-
const { rows } = await this.db.query(this.q(
|
|
203
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
265
|
+
const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE id=? LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE id = $1 LIMIT 1`), [id]);
|
|
266
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
204
267
|
}
|
|
205
268
|
async getByDigest(digest) {
|
|
206
|
-
const { rows } = await this.db.query(this.q(
|
|
207
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
269
|
+
const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE digest=? ORDER BY updated_at DESC LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE digest = $1 ORDER BY updated_at DESC LIMIT 1`), [digest]);
|
|
270
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
208
271
|
}
|
|
209
272
|
async latestPublished(profile, viewer) {
|
|
210
273
|
if (this.db.dialect === "tidb") {
|
|
211
274
|
const vis = visibilityClauseTidb(viewer);
|
|
212
|
-
const { rows } = await this.db.query(`SELECT
|
|
275
|
+
const { rows } = await this.db.query(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index
|
|
213
276
|
WHERE profile=? AND status='published' AND ${vis.sql}
|
|
214
277
|
ORDER BY (build_date IS NULL), build_date DESC, created_at DESC LIMIT 1`, [profile, ...vis.params]);
|
|
215
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
278
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
216
279
|
}
|
|
217
280
|
const vis = visibilityClausePg(viewer, 2);
|
|
218
|
-
const { rows } = await this.db.query(`SELECT
|
|
281
|
+
const { rows } = await this.db.query(`SELECT ${imageSelectCols("pg")} FROM sandbox_image_index
|
|
219
282
|
WHERE profile = $1 AND status = 'published' AND ${vis.sql}
|
|
220
283
|
ORDER BY build_date DESC NULLS LAST, created_at DESC LIMIT 1`, [profile, ...vis.params]);
|
|
221
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
284
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
222
285
|
}
|
|
223
286
|
async list(filter) {
|
|
224
287
|
const limit = Math.min(Math.max(1, filter.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
@@ -246,7 +309,7 @@ export class SqlImageIndex {
|
|
|
246
309
|
params.push(new Date(c.createdAt), new Date(c.createdAt), c.id);
|
|
247
310
|
}
|
|
248
311
|
}
|
|
249
|
-
const sql = `SELECT
|
|
312
|
+
const sql = `SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
|
|
250
313
|
`ORDER BY created_at DESC, id DESC LIMIT ?`;
|
|
251
314
|
const { rows } = await this.db.query(sql, [...params, limit + 1]);
|
|
252
315
|
rawRows = rows;
|
|
@@ -276,13 +339,13 @@ export class SqlImageIndex {
|
|
|
276
339
|
idx += 3;
|
|
277
340
|
}
|
|
278
341
|
}
|
|
279
|
-
const sql = `SELECT
|
|
342
|
+
const sql = `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
|
|
280
343
|
`ORDER BY created_at DESC, id DESC LIMIT $${idx}`;
|
|
281
344
|
params.push(limit + 1);
|
|
282
345
|
const { rows } = await this.db.query(sql, params);
|
|
283
346
|
rawRows = rows;
|
|
284
347
|
}
|
|
285
|
-
let entries = rawRows.map(mapRow);
|
|
348
|
+
let entries = refusingImageRow(() => rawRows.map(mapRow));
|
|
286
349
|
let nextCursor = null;
|
|
287
350
|
if (entries.length > limit) {
|
|
288
351
|
entries = entries.slice(0, limit);
|
|
@@ -183,8 +183,29 @@ export declare class LocalCheckpointStore {
|
|
|
183
183
|
sessionId: string;
|
|
184
184
|
scope: string;
|
|
185
185
|
}>>;
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 🔴 **写腿的形闸与 SQL 孪生逐字同一条**(codex 对抗复审 r1 [medium],验真后按类修):TS 的 `object` 说不出
|
|
188
|
+
* 「序列化之后是一只 JSON 对象」—— 数组 / `Date` / `toJSON()` 返回标量或 `null` 的对象全都通得过类型面,
|
|
189
|
+
* 各自写出一份读腿判坏的字节。而这一写是**无条件覆盖**,所以它会「成功」地把一份读得回来的耐久 ctx 换成
|
|
190
|
+
* 读不回来的 —— 调用方拿 2xx,下一次续跑永久 409。⇒ 闸在落盘之前,盘上那一份原样不动。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 **闸判的必须是「将要落盘的那一串字节」本身**(codex r2 [medium],验真后修):初版判的是
|
|
193
|
+
* `JSON.stringify(ctx)`,而真正落盘的是 `JSON.stringify(rec)` —— `toJSON(key)` 拿得到**键名**,所以
|
|
194
|
+
* 一个 `toJSON(k){ return k === "ctx" ? null : {…} }` 的对象验的是 `{…}`、写的是 `{"ctx":null}`
|
|
195
|
+
* (实测两串逐字不同)。有状态的序列化器同理。⇒ 整条记录**只序列化一次**,闸判那一串,写也写那一串。
|
|
196
|
+
*/
|
|
197
|
+
putCtx(sessionId: string, ctx: object): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* 🔴 **坏体 ⇒ 拒,绝不读成缺席**(S-577,与 SQL 孪生同一条判据)。修前这里是
|
|
200
|
+
* `catch { return null }` + `(rec.ctx as T) ?? null`,于是一个撕裂 / 手改 / 权限读不动的 ctx 文件与
|
|
201
|
+
* 「这条会话根本没有 ctx」在消费点上**逐字同答**(409 `resume context missing`)—— 运维据此去「重新
|
|
202
|
+
* 提交一个任务」,而真因是盘上那一格坏了。三个店同向:不是「这一行不在」,是「这一行读不出来」。
|
|
203
|
+
*
|
|
204
|
+
* `ctx` 的形判据同 SQL 孪生:**一只 JSON 对象** —— 而且是**同一只读者**({@link createCtxRecordReader}
|
|
205
|
+
* 里合成的 {@link createObjectColumnReader}),不是「同一个意思的两份判据」。写腿 `putCtx(…, ctx: object)`
|
|
206
|
+
* 只收对象,读腿只认对象,两条腿一个类型。
|
|
207
|
+
*/
|
|
208
|
+
getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
|
|
188
209
|
/** GC ctx files whose lifecycle is over — same three guards as the SQL twins (stale ∧ no pending ∧ no active run). */
|
|
189
210
|
reapCtx(cutoff: number): Promise<number>;
|
|
190
211
|
/**
|