@sema-agent/server 7.43.0 → 7.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -1,40 +1,3 @@
1
- /**
2
- * Sandbox-image-pool BAKE store — SINGLE-FILE DUAL-DIALECT (design/158 A12 定型半场; the 4th collapse
3
- * after background-agent / mailbox / task-list / roster). ONE implementation, TWO dialects; the
4
- * historical `TiDBImageBake` / `PgImageBake` class names survive as thin ctor subclasses so every
5
- * consumer (store-backend.ts, pg-pool.ts, the unit + real-DB suites) is untouched.
6
- *
7
- * A bake is the async, resumable, auto-registering execution of `build.sh` on the privileged build-host
8
- * bake-runner. image-api (this, the unprivileged controller) owns the durable job row + the per-bake event
9
- * log + the single-flight build lease; the runner CLAIMS a queued bake, runs build.sh, and POSTs each
10
- * build.sh JSON line back to the ingest endpoint, which appends to the event log the SSE reader serves
11
- * (run-trace mechanics verbatim: `(bakeId,seq)` PK, ++seq, getEvents(after), maxSeq, retainedFrom→416,
12
- * heartbeat-not-persisted).
13
- *
14
- * This is a DEDICATED table set — NOT a reuse of task_run/task_event (FK-coupled to task_active + the run
15
- * reaper + the /v1/runs list; co-mingling bakes — no session, no spend, no checkpoint — would leak non-task
16
- * rows into reapStale/listRuns, a category error). The shape mirrors TiDBRunStore: durable idempotency via a
17
- * unique-key CAS (like createRun), a conditional-UPDATE single-flight claim (like requestCancel), and a
18
- * reaper twin.
19
- *
20
- * The split that makes the SSE reader correct (P2.3 MUST-FIX): the COARSE 4-value `status`
21
- * (queued|running|done|failed) is what the reader polls for terminality; build.sh's 8-value `state`
22
- * (PENDING→…→COMPLETE|FAILED|CANCELLED) is denormalized for the UI progress bar ONLY and never drives stream
23
- * termination (a non-terminal PUSHING/VERIFYING state must NOT truncate the stream before `done`).
24
- *
25
- * ── Dialect deltas, kept EXPLICIT (never hidden behind an abstraction) ────────────────────────────────
26
- * - `?` placeholders vs `$n` - `CAST(? AS JSON)` vs `$n::jsonb` (argv equality)
27
- * - `INSERT IGNORE` vs `ON CONFLICT DO NOTHING` - affectedRows vs rowCount (via SqlDriver)
28
- * - dup-key `ER_DUP_ENTRY` vs SQLSTATE `23505` - which UNIQUE collided: mysql2 `sqlMessage` text
29
- * vs pg `err.constraint` (#192 A5 归一后两条方言同名 uq_image_bake_event_line)
30
- * - `BEGIN PESSIMISTIC` (TiDB current read) vs `BEGIN` (PG READ COMMITTED under the sentinel lock)
31
- * - the ADMISSION statement itself differs (see createBakeIfIdle) — both are correct under the
32
- * sentinel lock, and both are preserved verbatim because the real-DB suites are the oracle
33
- * - PG-ONLY protocol-byte defenses: `pgSafeJsonStringify` on event payloads, and the setTerminal
34
- * identity-fold (`invalidIdentity`) + `pgSanitizeText` on error/errorCode/tag. TiDB stores verbatim.
35
- * - schema ownership: the TiDB DDL lives in tidb-pool.ts SCHEMA_STATEMENTS; the PG DDL is exported
36
- * from HERE (PG_IMAGE_BAKE_SCHEMA + ensurePgImageBakeSchema) for pg-pool.ts + the integration test.
37
- */
38
1
  import { uuidv7 } from "@sema-agent/core";
39
2
  import { randomBytes } from "node:crypto";
40
3
  import { pgSanitizeText, pgHasUnstorable } from "./pg-safe-json.js";
@@ -42,12 +5,6 @@ import { parseJsonOr as parseJson, toIso as iso } from "./sql-row-helpers.js";
42
5
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
43
6
  import { mapBakeRow as mapRow, BAKE_SELECT_COLS as SELECT_COLS, } from "./store-contracts.js";
44
7
  import { isDupKeyError } from "./sql-errors.js";
45
- /** PG translation of the image_bake / image_bake_event / image_bake_lease DDL in tidb-pool.ts SCHEMA_STATEMENTS
46
- * (JSON→JSONB, TINYINT→SMALLINT, DATETIME(3)→TIMESTAMPTZ(3), inline UNIQUE/KEY→named CONSTRAINT + CREATE INDEX).
47
- * TINYINT(1) 归一(clay 批 2026-07-26):布尔列的 PG 映射统一 SMALLINT 0/1(此前本家族用 BOOLEAN)——
48
- * 两方言驱动读回同为 number ⇒ 不再存在「忘了配的适配器」这个病族;写入参与 SQL 字面量一律 0/1,
49
- * 读侧 `!!`/`Boolean()` 保持,store 出口 JS 类型逐字节不变(真双库套件的严格值断言为证)。
50
- * The line UNIQUE is given the explicit name uq_image_bake_event_line so a 23505 can be attributed to it via err.constraint. */
51
8
  export const PG_IMAGE_BAKE_SCHEMA = [
52
9
  `CREATE TABLE IF NOT EXISTS image_bake (
53
10
  bake_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -100,32 +57,17 @@ export const PG_IMAGE_BAKE_SCHEMA = [
100
57
  expires_at TIMESTAMPTZ(3) NOT NULL,
101
58
  PRIMARY KEY (pool)
102
59
  )`,
103
- // Admission sentinel: ONE always-present row per builder pool, `SELECT … FOR UPDATE`-locked to SERIALIZE the
104
- // createBakeIfIdle busy-check+insert (so the M4 single-flight admission is genuinely atomic, not a snapshot
105
- // NOT EXISTS that two concurrent READ COMMITTED txns both pass — see createBakeIfIdle). Dialect-uniform with
106
- // the TiDB twin so a future divergence FAILS LOUD in the shared real-DB suite.
107
60
  `CREATE TABLE IF NOT EXISTS image_bake_admit (
108
61
  pool VARCHAR(32) COLLATE "C" NOT NULL,
109
62
  PRIMARY KEY (pool)
110
63
  )`,
111
64
  ];
112
- /** Idempotent self-contained PG schema apply (for the integration test; central aggregation is done separately). */
113
65
  export async function ensurePgImageBakeSchema(pool, poolName = "standard") {
114
66
  for (const stmt of PG_IMAGE_BAKE_SCHEMA)
115
67
  await pool.query(stmt);
116
- // Seed the always-present admission sentinel (idempotent) so createBakeIfIdle can FOR UPDATE-lock it.
117
68
  await pool.query("INSERT INTO image_bake_admit (pool) VALUES ($1) ON CONFLICT (pool) DO NOTHING", [poolName]);
118
69
  }
119
- /** Dup-key detection — the PREDICATE has a single owner (`sql-errors.ts`); only the ATTRIBUTION
120
- * (which UNIQUE collided — `dupKeyName` below) is store-specific. A-032 P1-①: the old local form
121
- * recognized only mysql2's string `code`, so an error carrying just `errno` fell through. */
122
70
  const isDupKey = isDupKeyError;
123
- /** Which UNIQUE key did the dup collide on? The ATTRIBUTION CHANNEL differs by engine: mysql2 names the key in
124
- * `sqlMessage` ("Duplicate entry '…' for key 'image_bake_event.uq_image_bake_event_line'" vs "…'image_bake_event.PRIMARY'");
125
- * PG names the violated constraint in `err.constraint` (the PK is auto-named `image_bake_event_pkey`, the line
126
- * UNIQUE is explicitly named `uq_image_bake_event_line` so it is attributable at all).
127
- * M2: a line-unique dup is a BENIGN at-least-once re-send (already stored → no-op); a `PRIMARY` (bake_id,seq)
128
- * dup is a same-seq RACE the caller must RETRY (a fresh maxSeq+1), never swallow. */
129
71
  function dupKeyName(dialect, err) {
130
72
  if (dialect === "tidb") {
131
73
  const msg = String(err?.sqlMessage ?? err?.message ?? "");
@@ -144,21 +86,16 @@ function dupKeyName(dialect, err) {
144
86
  }
145
87
  const INSERT_BAKE_COLS = "INSERT INTO image_bake (bake_id, profile, bands, base_ref, push, dry_run, logs, argv, status, " +
146
88
  "idem_key, cancel_requested, requested_by, created_at, updated_at) ";
147
- /** Dual-dialect BAKE store. See the file header for the dialect-delta ledger. */
148
89
  export class SqlImageBake {
149
90
  db;
150
91
  poolName;
151
- constructor(db,
152
- /** The builder pool a lease serializes on (one docker daemon ⇒ one pool today). */
153
- poolName = "standard") {
92
+ constructor(db, poolName = "standard") {
154
93
  this.db = db;
155
94
  this.poolName = poolName;
156
95
  }
157
- /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
158
96
  q(tidb, pg) {
159
97
  return this.db.dialect === "tidb" ? tidb : pg;
160
98
  }
161
- /** The 12 bound values shared by both admission INSERTs (column order === INSERT_BAKE_COLS). */
162
99
  insertParams(bakeId, input, now) {
163
100
  return [
164
101
  bakeId,
@@ -175,12 +112,6 @@ export class SqlImageBake {
175
112
  now,
176
113
  ];
177
114
  }
178
- /**
179
- * Create a bake, queued. Durable submit-idempotency mirrors createRun's CAS: `idem_key` is UNIQUE, so a
180
- * re-submit with the same key races to INSERT and the loser catches the dup-key, then re-reads + returns the
181
- * EXISTING row (the live bake) instead of a second job. A null `idemKey` always inserts (no dedupe requested).
182
- * Returns `{ created, bake }` so the handler can answer 202 either way (idempotent dup ⇒ created:false).
183
- */
184
115
  async createBake(input) {
185
116
  const bakeId = uuidv7();
186
117
  const now = new Date();
@@ -200,35 +131,6 @@ export class SqlImageBake {
200
131
  throw new Error("createBake: row vanished after insert");
201
132
  return { created: true, bake };
202
133
  }
203
- /**
204
- * M4 (§P2.1/§P2.7 — the 409-busy MUST): create a queued bake ATOMICALLY only when no other non-dryRun bake is
205
- * in flight, so two concurrent keyless POSTs can NEVER both queue (the check-then-act in the submit handler —
206
- * findActiveAny() then createBake() as two awaited round-trips — interleaves: both see null-busy, both insert).
207
- * `uq_idem` only dedupes non-null idem_keys (a keyless submit is NULL = distinct), and the build lease is taken
208
- * at CLAIM not submit, so neither backstops this — the admission itself must be serialized.
209
- *
210
- * A bare conditional INSERT (`INSERT … SELECT … WHERE NOT EXISTS(...)`) is NOT sufficient ON ITS OWN: under
211
- * TiDB's default REPEATABLE READ (and PG's READ COMMITTED) two concurrent autocommit conditional inserts each
212
- * evaluate NOT EXISTS against a snapshot that excludes the other's still-uncommitted row (PG takes no
213
- * predicate/gap lock), so BOTH can pass and double-admit. The only backstop today is the build lease taken at
214
- * CLAIM, so a double-admit degrades the 409-busy/202-attach contract rather than double-building — but the
215
- * contract is the point.
216
- *
217
- * Fix: serialize the busy-check+insert on the per-pool admission sentinel (`image_bake_admit`) with
218
- * `SELECT … FOR UPDATE` inside one transaction. The second admitter blocks on the sentinel row lock until the
219
- * first commits, then sees the committed in-flight bake and inserts 0 → `created:false`, and the caller
220
- * re-reads the active bake (findActiveByArgv for an idempotent attach, else findActiveAny) for the
221
- * 202-attach / 409 body. A dryRun bypasses this path entirely (never takes the lease — parallel-safe) and uses
222
- * plain createBake.
223
- *
224
- * 🔴 DIALECT DIVERGENCE (deliberate, both preserved verbatim — the real-DB suites are the oracle):
225
- * - TiDB: `BEGIN PESSIMISTIC` + a separate `SELECT 1 … FOR UPDATE` busy-check, then a plain INSERT. The
226
- * pessimistic txn is REQUIRED: TiDB's snapshot (REPEATABLE READ) txn would evaluate a NOT EXISTS against
227
- * the txn-start snapshot and miss a rival's just-committed row EVEN under the sentinel lock.
228
- * - PG: plain `BEGIN` + one `INSERT … SELECT … WHERE NOT EXISTS(...)`. READ COMMITTED takes a FRESH
229
- * statement snapshot, so the NOT EXISTS run after the sentinel lock is granted already sees the committed
230
- * rival — the conditional insert is atomic enough once serialized.
231
- */
232
134
  async createBakeIfIdle(input) {
233
135
  const bakeId = uuidv7();
234
136
  const now = new Date();
@@ -238,12 +140,8 @@ export class SqlImageBake {
238
140
  try {
239
141
  if (this.db.dialect === "tidb") {
240
142
  await conn.beginPessimistic();
241
- // Lock the always-present per-pool admission sentinel so concurrent admissions serialize (defensive INSERT
242
- // IGNORE first in case ensureSchema seeded a different poolName).
243
143
  await conn.query("INSERT IGNORE INTO image_bake_admit (pool) VALUES (?)", [this.poolName]);
244
144
  await conn.query("SELECT pool FROM image_bake_admit WHERE pool = ? FOR UPDATE", [this.poolName]);
245
- // Busy-check as a CURRENT read (FOR UPDATE) — under the sentinel lock this is a single serialized admitter,
246
- // so it sees the prior admitter's committed in-flight bake (no stale-snapshot phantom).
247
145
  const busy = await conn.query("SELECT 1 FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 LIMIT 1 FOR UPDATE");
248
146
  if (busy.rows.length === 0) {
249
147
  await conn.query(INSERT_BAKE_COLS + "VALUES (?,?,?,?,?,?,?,?,'queued',?,0,?,?,?)", params);
@@ -253,7 +151,6 @@ export class SqlImageBake {
253
151
  }
254
152
  else {
255
153
  await conn.begin();
256
- // Defensive upsert first in case ensureSchema seeded a different poolName; ON CONFLICT keeps it idempotent.
257
154
  await conn.query("INSERT INTO image_bake_admit (pool) VALUES ($1) ON CONFLICT (pool) DO NOTHING", [this.poolName]);
258
155
  await conn.query("SELECT pool FROM image_bake_admit WHERE pool = $1 FOR UPDATE", [this.poolName]);
259
156
  const res = await conn.query(INSERT_BAKE_COLS +
@@ -268,15 +165,12 @@ export class SqlImageBake {
268
165
  await conn.rollback();
269
166
  }
270
167
  catch {
271
- /* ignore */
272
168
  }
273
169
  throw err;
274
170
  }
275
171
  finally {
276
172
  conn.release();
277
173
  }
278
- // 0 rows inserted ⇒ a non-dryRun bake is already in flight (the busy-check/NOT EXISTS matched under the
279
- // sentinel lock) → busy.
280
174
  if (!inserted)
281
175
  return { created: false, bake: null };
282
176
  const bake = await this.getBake(bakeId);
@@ -292,55 +186,28 @@ export class SqlImageBake {
292
186
  const { rows } = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM image_bake WHERE idem_key = ? LIMIT 1`, `SELECT ${SELECT_COLS} FROM image_bake WHERE idem_key = $1 LIMIT 1`), [idemKey]);
293
187
  return rows[0] ? mapRow(rows[0]) : null;
294
188
  }
295
- /**
296
- * Find an in-flight (queued/running) bake with the identical normalized argv — so a re-submit WITHOUT an
297
- * idempotency key still returns the live bake rather than starting a duplicate build (P2.1 "an in-flight
298
- * identical-input bake returns the EXISTING row"). argv is the canonical normalized vector, so a JSON
299
- * equality is the dedupe key. Newest first (the live one).
300
- */
301
189
  async findActiveByArgv(argv) {
302
190
  const { rows } = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND argv = CAST(? AS JSON) ` +
303
191
  "ORDER BY created_at DESC LIMIT 1", `SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND argv = $1::jsonb ` +
304
192
  "ORDER BY created_at DESC LIMIT 1"), [JSON.stringify(argv)]);
305
193
  return rows[0] ? mapRow(rows[0]) : null;
306
194
  }
307
- /**
308
- * The OLDEST in-flight (queued/running) NON-dry-run bake — the single-flight at-submit guard (P2.7). Concurrency
309
- * is 1 (one docker daemon), so a new submit while ANY non-dry-run bake is in flight is `409 busy` UNLESS it is
310
- * the identical-argv one (then the caller attaches to it, idempotent). dryRun bakes never take the lease, so they
311
- * are excluded here. Oldest first = the one that holds/will-hold the lease (the build the UI should attach to).
312
- */
313
195
  async findActiveAny() {
314
196
  const { rows } = await this.db.query(`SELECT ${SELECT_COLS} FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 ` +
315
197
  "ORDER BY created_at ASC LIMIT 1", []);
316
198
  return rows[0] ? mapRow(rows[0]) : null;
317
199
  }
318
- /**
319
- * The OLDEST queued bake id — the runner's long-poll claim target (P2.12). dryRun bakes never take the lease, so
320
- * they are still discoverable here (the runner resolves them with no docker), but ordering oldest-first gives a
321
- * stable FIFO. Returns null when the queue is empty. The actual claim is a CAS (`claimBake`), so two runners
322
- * racing this both see the id but only one wins the conditional UPDATE — no double-build.
323
- */
324
200
  async findNextQueuedId() {
325
201
  const { rows } = await this.db.query("SELECT bake_id FROM image_bake WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1", []);
326
202
  return rows[0] ? String(rows[0].bake_id) : null;
327
203
  }
328
- /**
329
- * Single-flight CLAIM (P2.7) — the runner leases the next queued bake. Two CASes in one transaction:
330
- * 1. flip the bake row `queued → running` (conditional UPDATE `WHERE status='queued'`, 1 row affected wins);
331
- * 2. take the single-row `image_bake_lease` for the pool (INSERT, or steal an EXPIRED lease via CAS).
332
- * Only the runner that wins BOTH gets the bake; a loser (someone else already claimed, or the lease is held by
333
- * a live build) gets null. Mints + stores the per-bake `ingest_secret` (so a stale/rogue runner can't inject
334
- * frames into another bake) and the initial `lease_until`. Returns the claimed row (with the secret) or null.
335
- */
336
204
  async claimBake(bakeId, runnerId, leaseMs) {
337
205
  const conn = await this.db.connect();
338
206
  try {
339
207
  await conn.begin();
340
208
  const now = new Date();
341
209
  const leaseUntil = new Date(now.getTime() + leaseMs);
342
- const ingestSecret = randomBytes(24).toString("hex"); // 48 hex chars ≤ VARCHAR(64)
343
- // (1) claim the bake row: only a still-queued row can be claimed (single-flight on the row).
210
+ const ingestSecret = randomBytes(24).toString("hex");
344
211
  const bakeRes = await conn.query(this.q("UPDATE image_bake SET status='running', state='PENDING', runner_id=?, ingest_secret=?, " +
345
212
  "lease_until=?, updated_at=? WHERE bake_id=? AND status='queued'", "UPDATE image_bake SET status='running', state='PENDING', runner_id=$1, ingest_secret=$2, " +
346
213
  "lease_until=$3, updated_at=$4 WHERE bake_id=$5 AND status='queued'"), [runnerId, ingestSecret, leaseUntil, now, bakeId]);
@@ -348,24 +215,11 @@ export class SqlImageBake {
348
215
  await conn.rollback();
349
216
  return null;
350
217
  }
351
- // L1 (§P2.7): a dryRun is RESOLVE-ONLY (no docker) and MUST be parallel-safe — it NEVER takes the build lease.
352
- // Claim the row alone (the row CAS above already single-flights it) and skip the pool-lease INSERT/steal CAS,
353
- // so a dryRun can be claimed/resolved while a real build holds the lease (matching P2.1/P2.7/P2.14). The lease
354
- // release in setTerminal/reapStaleBakes is a harmless no-op for a row that never took the lease. (Read dry_run
355
- // on the same row we just claimed.)
356
218
  const dry = await conn.query(this.q("SELECT dry_run FROM image_bake WHERE bake_id=?", "SELECT dry_run FROM image_bake WHERE bake_id=$1"), [bakeId]);
357
219
  if (Boolean(dry.rows[0]?.dry_run)) {
358
220
  await conn.commit();
359
221
  return (await this.getBake(bakeId)) ?? null;
360
222
  }
361
- // (2) take the pool lease (single-flight on the build host). 🔴 TiDB rejects a correlated sub-query that
362
- // references the INSERT target inside ON DUPLICATE KEY UPDATE ("Can't find column …" — caught by the live
363
- // bake, NOT the fake-pool unit test which never executes the SQL). So steal at APP-LEVEL: read the current
364
- // lease, then INSERT (no holder) or a CAS-UPDATE (holder's lease EXPIRED **and** its bake no longer running).
365
- // 🔴 H4(a): an `expires_at < now` lease whose holder is still `running` is an expired-but-LIVE build (a
366
- // heartbeat gap inside the reap window) — stealing it would run TWO builds on one docker daemon, so the steal
367
- // is gated on the holder being non-running (steal-window == reap-window; a vanished holder is also stealable).
368
- // The CAS on (pool,bake_id,expires_at) makes the steal atomic against a concurrent refresh.
369
223
  const leaseSel = await conn.query(this.q("SELECT bake_id, expires_at FROM image_bake_lease WHERE pool=?", "SELECT bake_id, expires_at FROM image_bake_lease WHERE pool=$1"), [this.poolName]);
370
224
  const cur = leaseSel.rows[0];
371
225
  if (!cur) {
@@ -375,7 +229,7 @@ export class SqlImageBake {
375
229
  catch (err) {
376
230
  if (isDupKey(this.db.dialect, err)) {
377
231
  await conn.rollback();
378
- return null; // a concurrent claim inserted the lease first — we lose.
232
+ return null;
379
233
  }
380
234
  throw err;
381
235
  }
@@ -383,22 +237,21 @@ export class SqlImageBake {
383
237
  else {
384
238
  if (new Date(cur.expires_at).getTime() >= now.getTime()) {
385
239
  await conn.rollback();
386
- return null; // the lease is live (unexpired) — we lose.
240
+ return null;
387
241
  }
388
242
  const holderId = String(cur.bake_id ?? "");
389
243
  const holder = await conn.query(this.q("SELECT status FROM image_bake WHERE bake_id=?", "SELECT status FROM image_bake WHERE bake_id=$1"), [holderId]);
390
244
  const holderStatus = String(holder.rows[0]?.status ?? "");
391
245
  if (holderStatus === "queued" || holderStatus === "running") {
392
246
  await conn.rollback();
393
- return null; // expired-but-LIVE build — never steal (would double-bake on one daemon).
247
+ return null;
394
248
  }
395
249
  const stealRes = await conn.query(this.q("UPDATE image_bake_lease SET bake_id=?, acquired_at=?, expires_at=? WHERE pool=? AND bake_id=? AND expires_at=?", "UPDATE image_bake_lease SET bake_id=$1, acquired_at=$2, expires_at=$3 WHERE pool=$4 AND bake_id=$5 AND expires_at=$6"), [bakeId, now, leaseUntil, this.poolName, holderId, cur.expires_at]);
396
250
  if (stealRes.affected === 0) {
397
251
  await conn.rollback();
398
- return null; // a concurrent claim refreshed/stole the lease first — we lose.
252
+ return null;
399
253
  }
400
254
  }
401
- // Double-check the lease row actually points at US (defends a same-instant race) before committing the claim.
402
255
  const check = await conn.query(this.q("SELECT bake_id FROM image_bake_lease WHERE pool=?", "SELECT bake_id FROM image_bake_lease WHERE pool=$1"), [this.poolName]);
403
256
  if (String(check.rows[0]?.bake_id ?? "") !== bakeId) {
404
257
  await conn.rollback();
@@ -412,7 +265,6 @@ export class SqlImageBake {
412
265
  await conn.rollback();
413
266
  }
414
267
  catch {
415
- /* ignore */
416
268
  }
417
269
  throw err;
418
270
  }
@@ -420,31 +272,11 @@ export class SqlImageBake {
420
272
  conn.release();
421
273
  }
422
274
  }
423
- /**
424
- * Append one event. image-api is the SOLE ++seq writer: `seq = maxSeq(bakeId)+1`. The `(bakeId,line_ord)`
425
- * UNIQUE is the at-least-once ingest dedupe (P2.10) — a runner re-sending a line it already acked races to
426
- * INSERT and the loser is a NO-OP (the frame is already stored at its seq). Returns the seq written, or null
427
- * when the line was a duplicate (already stored). A null `lineOrd` (image-api-synthesized frame:
428
- * register/done/heartbeat-derived) never collides on the line-unique (NULLs are distinct in a UNIQUE index).
429
- *
430
- * seq allocation + the INSERT are NOT one atomic statement, so two concurrent appends could pick the same
431
- * seq+1; the (bakeId,seq) PK then rejects the loser with a dup-key. M2: that PK collision is DISTINGUISHED from
432
- * a benign line re-send (by the dup-key attribution channel) and RETRIED in a bounded loop with a fresh
433
- * maxSeq+1 — never silently swallowed (which would drop a frame, incl. the terminal `done`, at a clean HTTP
434
- * 200). A line dup is the at-least-once re-send no-op (returns null). The runner also serializes its per-bake
435
- * pump (runner.ts), so the same-seq race is the rare burst case, not the norm.
436
- */
437
275
  async appendEvent(bakeId, kind, data, opts = {}) {
438
276
  const lineOrd = opts.lineOrd ?? null;
439
277
  if (lineOrd !== null && (await this.lineOrdExists(bakeId, lineOrd)))
440
- return null; // fast dedupe pre-check
441
- // PG-only: codex R14 — a `done` frame carrying a NUL in its error text would raise 22P05 and be LOST FOREVER
442
- // (the terminal flip already cleared the ingest secret, so it cannot be retried). TiDB stores JSON verbatim.
278
+ return null;
443
279
  const payload = data == null ? null : dialectJsonEncoder(this.db.dialect)(data);
444
- // The PK race is a thundering-herd: K concurrent writers all read the same maxSeq, ONE wins the (bake_id,seq)
445
- // INSERT, the rest re-read and retry — so the unluckiest writer can lose up to ~K races in a row. A small fixed
446
- // cap (6) under-serves a realistic ingest burst and would THROW a frame that is merely contended, not lost.
447
- // Use a generous cap plus jittered backoff so the herd disperses instead of lock-stepping onto the same seq.
448
280
  for (let attempt = 0; attempt < 50; attempt += 1) {
449
281
  const seq = (await this.maxSeq(bakeId)) + 1;
450
282
  try {
@@ -455,12 +287,8 @@ export class SqlImageBake {
455
287
  if (!isDupKey(this.db.dialect, err))
456
288
  throw err;
457
289
  const which = dupKeyName(this.db.dialect, err);
458
- // the line-unique lost the race (the line was concurrently stored at its own seq) → a benign at-least-once
459
- // duplicate, NOT an error and NOT a frame loss. A null lineOrd CANNOT collide on the line-unique.
460
290
  if (which === "uq_line" && lineOrd !== null)
461
291
  return null;
462
- // PRIMARY/(bake_id,seq) → a real same-seq race; re-read maxSeq and retry (do NOT swallow — that would
463
- // silently drop this frame). `other` (an unnamed dup driver) is treated conservatively as retryable.
464
292
  await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 5)));
465
293
  }
466
294
  }
@@ -470,19 +298,16 @@ export class SqlImageBake {
470
298
  const { rows } = await this.db.query(this.q("SELECT 1 FROM image_bake_event WHERE bake_id = ? AND line_ord = ? LIMIT 1", "SELECT 1 FROM image_bake_event WHERE bake_id = $1 AND line_ord = $2 LIMIT 1"), [bakeId, lineOrd]);
471
299
  return rows.length > 0;
472
300
  }
473
- /** Highest event seq for a bake (0 if none) — the ++seq allocator + the resume cursor head. */
474
301
  async maxSeq(bakeId) {
475
302
  const { rows } = await this.db.query(this.q("SELECT MAX(seq) AS m FROM image_bake_event WHERE bake_id = ?", "SELECT MAX(seq) AS m FROM image_bake_event WHERE bake_id = $1"), [bakeId]);
476
303
  const m = rows[0]?.m;
477
304
  return m == null ? 0 : Number(m);
478
305
  }
479
- /** Earliest retained event seq (0 if none) → the SSE 416 boundary (a resume below this was evicted, P2.9). */
480
306
  async retainedFrom(bakeId) {
481
307
  const { rows } = await this.db.query(this.q("SELECT MIN(seq) AS m FROM image_bake_event WHERE bake_id = ?", "SELECT MIN(seq) AS m FROM image_bake_event WHERE bake_id = $1"), [bakeId]);
482
308
  const m = rows[0]?.m;
483
309
  return m == null ? 0 : Number(m);
484
310
  }
485
- /** Events with seq strictly greater than `afterSeq` (0 for the whole stream), ascending. */
486
311
  async getEvents(bakeId, afterSeq) {
487
312
  const { rows } = await this.db.query(this.q("SELECT seq, line_ord, kind, level, data, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, line_ord, kind, level, data, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC"), [bakeId, afterSeq]);
488
313
  return rows.map((r) => ({
@@ -494,32 +319,12 @@ export class SqlImageBake {
494
319
  ts: iso(r.ts),
495
320
  }));
496
321
  }
497
- /**
498
- * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality).
499
- *
500
- * @deprecated #193 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是
501
- * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态;
502
- * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删
503
- * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。
504
- */
505
322
  async setStatus(bakeId, status) {
506
323
  await this.db.query(this.q("UPDATE image_bake SET status = ?, updated_at = ? WHERE bake_id = ?", "UPDATE image_bake SET status = $1, updated_at = $2 WHERE bake_id = $3"), [status, new Date(), bakeId]);
507
324
  }
508
- /** Denormalize build.sh's 8-value `state` for the UI progress bar (NEVER drives terminality, P2.3). */
509
325
  async setState(bakeId, state) {
510
326
  await this.db.query(this.q("UPDATE image_bake SET state = ?, updated_at = ? WHERE bake_id = ?", "UPDATE image_bake SET state = $1, updated_at = $2 WHERE bake_id = $3"), [state, new Date(), bakeId]);
511
327
  }
512
- /**
513
- * Terminal write (P2.10 step 4): flip the coarse `status` + persist all the terminal facts (digest/repo/ref/
514
- * indexId/exitCode/error/errorCode/state/manifestSha/tag) the `done` frame and the poll row carry, AND release
515
- * the pool lease in one transaction (a separate-statement release that failed would wedge the build host).
516
- * Idempotent terminal write: only flips a NON-terminal row (`WHERE status IN ('queued','running')`) so a
517
- * re-delivered terminal can't clobber the first outcome. Returns the UPDATE's affected count — 1 = THIS call won
518
- * the queued/running→terminal transition; 0 = a terminal already exists (M3: the caller then SKIPS appending a
519
- * contradictory second terminal `done` event, so at most one terminal `done` per bake matches the coarse row).
520
- * 🔴 H3: null `ingest_secret` on the terminal flip so a terminal row can NEVER re-authenticate an ingest
521
- * (a lagging/duplicate runner that still holds the secret can't inject frames into a settled bake).
522
- */
523
328
  async setTerminal(bakeId, t) {
524
329
  let affectedRows = 0;
525
330
  await this.tx(async (conn) => {
@@ -531,7 +336,6 @@ export class SqlImageBake {
531
336
  });
532
337
  return affectedRows;
533
338
  }
534
- /** TiDB stores the terminal facts VERBATIM (no protocol-byte class to defend against). */
535
339
  terminalParamsTidb(bakeId, t) {
536
340
  return [
537
341
  t.status,
@@ -549,10 +353,6 @@ export class SqlImageBake {
549
353
  bakeId,
550
354
  ];
551
355
  }
552
- /** PG-ONLY protocol-byte handling. R20/R21:身份位(digest/manifestSha/repo/ref)带不可存字节=帧无效——
553
- * **单一谓词**统管状态折叠与全部身份绑位(R21:只查两位会放 dirty repo/ref 的假 COMPLETE 过——终态单赢,
554
- * 假成功不可逆)。事务必须提交(lease 释放/secret 清理=单飞池活性底线)。codex R13/R17-H2:NUL 错误文案
555
- * 或脏 tag 若让终态事务回滚,lease 保持 = bake 永占单飞池,故文本位走 sanitize 而非拒绝。 */
556
356
  terminalParamsPg(bakeId, t) {
557
357
  const invalidIdentity = (t.digest != null && pgHasUnstorable(t.digest)) ||
558
358
  (t.manifestSha != null && pgHasUnstorable(t.manifestSha)) ||
@@ -574,79 +374,41 @@ export class SqlImageBake {
574
374
  bakeId,
575
375
  ];
576
376
  }
577
- /** Set the index_id once auto-register succeeds (P2.10 — kept distinct from the terminal flip so the `done`
578
- * frame can carry indexId even when the terminal write retries). codex R24:non-terminal 门——并发 done
579
- * 竞态下(本请求悬在 registerBuilding 时另一 done 已赢 setTerminal)绝不把 indexId 挂回终态行(R23 折叠
580
- * 的 FAILED/null 身份行会被复写成「null 身份却带 index 绑定」的矛盾形)。返回是否真绑定。 */
581
377
  async setIndexId(bakeId, indexId) {
582
378
  const res = await this.db.query(this.q("UPDATE image_bake SET index_id = ?, updated_at = ? WHERE bake_id = ? AND status IN ('queued','running')", "UPDATE image_bake SET index_id = $1, updated_at = $2 WHERE bake_id = $3 AND status IN ('queued','running')"), [indexId, new Date(), bakeId]);
583
379
  return res.affected >= 1;
584
380
  }
585
- /** Heartbeat the lease forward (P2.7): the runner pushes `lease_until` (+ the lease row's `expires_at`) while
586
- * its build child runs, so the reaper doesn't steal a LIVE build's lease. CAS on the runner owning the row +
587
- * the bake still running. Returns whether it refreshed (false ⇒ lease lost/stolen — the runner should abort). */
588
381
  async heartbeatLease(bakeId, runnerId, leaseMs) {
589
382
  const now = new Date();
590
383
  const leaseUntil = new Date(now.getTime() + leaseMs);
591
384
  const res = await this.db.query(this.q("UPDATE image_bake SET lease_until = ?, updated_at = ? WHERE bake_id = ? AND runner_id = ? AND status = 'running'", "UPDATE image_bake SET lease_until = $1, updated_at = $2 WHERE bake_id = $3 AND runner_id = $4 AND status = 'running'"), [leaseUntil, now, bakeId, runnerId]);
592
385
  if (res.affected < 1)
593
386
  return false;
594
- // L1: a dryRun never took the pool lease (claimBake skips it) — so it has NO `image_bake_lease` row to refresh.
595
- // The bake-row refresh above is its only heartbeat; skip the pool-lease CAS so a healthy dryRun isn't aborted
596
- // by an (always-zero) pool-lease no-op.
597
387
  const dry = await this.db.query(this.q("SELECT dry_run FROM image_bake WHERE bake_id = ?", "SELECT dry_run FROM image_bake WHERE bake_id = $1"), [bakeId]);
598
388
  if (Boolean(dry.rows[0]?.dry_run))
599
389
  return true;
600
- // 🔴 H4(b): the pool-lease refresh is a REAL CAS — `WHERE pool=? AND bake_id=?` only matches while WE still
601
- // own the lease row. If it was stolen to another bake, 0 rows affected → return false so the displaced runner
602
- // sees leaseValid:false and aborts + kills its child (runner.ts). Without this guard the no-op UPDATE was
603
- // silently treated as success and a displaced runner kept building (two builds, one daemon).
604
390
  const leaseRes = await this.db.query(this.q("UPDATE image_bake_lease SET expires_at = ? WHERE pool = ? AND bake_id = ?", "UPDATE image_bake_lease SET expires_at = $1 WHERE pool = $2 AND bake_id = $3"), [leaseUntil, this.poolName, bakeId]);
605
391
  return leaseRes.affected >= 1;
606
392
  }
607
- /** Request cooperative cancel (P2.11) — durable + cross-replica; the runner's heartbeat tick polls it and kills
608
- * the build.sh process group. Only flags a non-terminal row (terminal → 0 affected = no-op). Returns whether a
609
- * live bake was flagged (false ⇒ already terminal → the caller answers a no-op 202). */
610
393
  async requestCancel(bakeId) {
611
394
  const res = await this.db.query(this.q("UPDATE image_bake SET cancel_requested = 1, updated_at = ? WHERE bake_id = ? AND status IN ('queued','running')", "UPDATE image_bake SET cancel_requested = 1, updated_at = $1 WHERE bake_id = $2 AND status IN ('queued','running')"), [new Date(), bakeId]);
612
395
  return res.affected > 0;
613
396
  }
614
- /** Whether a cancel was requested (polled by the runner). */
615
397
  async isCancelRequested(bakeId) {
616
398
  const { rows } = await this.db.query(this.q("SELECT cancel_requested FROM image_bake WHERE bake_id = ?", "SELECT cancel_requested FROM image_bake WHERE bake_id = $1"), [bakeId]);
617
399
  return Boolean(rows[0]?.cancel_requested);
618
400
  }
619
- /**
620
- * Reaper twin of TiDBRunStore.reapStale (P2.7/§P2.6): fail any `running` bake whose lease is STALE (a crashed
621
- * runner stopped heartbeating), append a synthetic terminal `done{failed}` so SSE readers SETTLE (never hang),
622
- * and force-release the pool lease. `olderThanMs` must exceed >2 lease-heartbeat intervals (≈3× the heartbeat)
623
- * so a healthy slow build is not wrongly reaped. Returns the count reaped.
624
- *
625
- * M3: flip the row terminal FIRST (the CAS on `status='running'`), and append the synthetic `done{failed}` ONLY
626
- * when the flip WON (1 row affected). A victim that recovered + COMPLETEd between the SELECT and the flip
627
- * no-ops the CAS, so we leak NO contradictory `done{failed}` into its settled SSE log. A reader that polled the
628
- * still-running row in the tiny pre-flip gap simply re-polls (streamSseLog re-fetches) and settles on the
629
- * eventual terminal — the winner's `done` — rather than a spurious one.
630
- */
631
401
  async reapStaleBakes(olderThanMs) {
632
402
  const cutoff = new Date(Date.now() - olderThanMs);
633
403
  const victims = await this.db.query(this.q("SELECT bake_id FROM image_bake WHERE status = 'running' AND " + "(lease_until IS NULL OR lease_until < ?) AND updated_at < ?", "SELECT bake_id FROM image_bake WHERE status = 'running' AND " + "(lease_until IS NULL OR lease_until < $1) AND updated_at < $2"), [cutoff, cutoff]);
634
404
  let reaped = 0;
635
405
  for (const v of victims.rows) {
636
406
  const bakeId = String(v.bake_id);
637
- // CAS the flip on still-running (a runner that recovered between the SELECT and here keeps its row).
638
- // 🔴 H3: also null `ingest_secret` so the reaped (terminal) row can never re-authenticate an ingest — a
639
- // lost-but-lagging runner that still holds the secret cannot inject frames into the dead bake's stream.
640
- // codex R25: also null `index_id` atomically — a victim whose ingest died AFTER setIndexId bound but
641
- // BEFORE setTerminal would otherwise keep a stale binding that contradicts the synthetic done below
642
- // (indexId:null); the orphan `building` index row is lifecycle-tolerated (never published/selectable).
643
407
  const res = await this.db.query(this.q("UPDATE image_bake SET status='failed', state='FAILED', error='bake-runner lost (lease expired)', " +
644
408
  "ingest_secret=NULL, index_id=NULL, updated_at=? WHERE bake_id=? AND status='running'", "UPDATE image_bake SET status='failed', state='FAILED', error='bake-runner lost (lease expired)', " +
645
409
  "ingest_secret=NULL, index_id=NULL, updated_at=$1 WHERE bake_id=$2 AND status='running'"), [new Date(), bakeId]);
646
410
  if (res.affected === 1) {
647
411
  reaped += 1;
648
- // M3: we WON the flip — only now append the synthetic terminal so a reader settles on a `done` that matches
649
- // the coarse row. A recovered+COMPLETEd victim never reaches here (the CAS no-op'd), so no contradiction.
650
412
  try {
651
413
  await this.appendEvent(bakeId, "done", {
652
414
  status: "FAILED",
@@ -660,14 +422,12 @@ export class SqlImageBake {
660
422
  });
661
423
  }
662
424
  catch {
663
- /* best-effort — the row is already terminal; a reader sees status!=running and re-fetches. */
664
425
  }
665
426
  await this.db.query(this.q("DELETE FROM image_bake_lease WHERE pool = ? AND bake_id = ?", "DELETE FROM image_bake_lease WHERE pool = $1 AND bake_id = $2"), [this.poolName, bakeId]);
666
427
  }
667
428
  }
668
429
  return reaped;
669
430
  }
670
- /** Run statements atomically; rolls back on failure (mirrors TiDBRunStore.tx). */
671
431
  async tx(fn) {
672
432
  const conn = await this.db.connect();
673
433
  try {
@@ -680,7 +440,6 @@ export class SqlImageBake {
680
440
  await conn.rollback();
681
441
  }
682
442
  catch {
683
- /* ignore */
684
443
  }
685
444
  throw err;
686
445
  }
@@ -689,13 +448,11 @@ export class SqlImageBake {
689
448
  }
690
449
  }
691
450
  }
692
- /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
693
451
  export class TiDBImageBake extends SqlImageBake {
694
452
  constructor(pool, poolName = "standard") {
695
453
  super(mysqlDriver(pool), poolName);
696
454
  }
697
455
  }
698
- /** PostgreSQL binding — historical class name + ctor shape preserved. */
699
456
  export class PgImageBake extends SqlImageBake {
700
457
  constructor(pool, poolName = "standard") {
701
458
  super(pgDriver(pool), poolName);