@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.
- package/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- 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");
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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);
|