@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
|
@@ -8,14 +8,8 @@ import { sendJson, sendError } from "../send.js";
|
|
|
8
8
|
import { headerStr, safeEqual, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
9
9
|
import { scopedIdempotencyKey } from "../idempotency.js";
|
|
10
10
|
export function createImagesLocal(deps) {
|
|
11
|
-
// Per-principal bake-submission rate guard (IMAGE-API-DESIGN.md §P2.4c — fills the register DoS gap the images
|
|
12
|
-
// block never guarded). Separate from the global rateLimiter (which may be off): an unbounded POST /bakes flood
|
|
13
|
-
// is real resource-exhaustion churn even though the single-flight lease caps concurrent BUILDS at 1. Off when
|
|
14
|
-
// submitRateMax<=0. Swept lazily by `check`'s window roll-over (no timer needed for a small operator set).
|
|
15
11
|
return { bakeSubmitLimiter: new RateLimiter(deps.config.imageBakes?.submitRateMax ?? 0, (deps.config.imageBakes?.submitRateWindowSec ?? 60) * 1000) };
|
|
16
12
|
}
|
|
17
|
-
/** build.sh's 8-value `state` → the COARSE 4-value lifecycle the SSE reader polls for terminality (§P2.3). Every
|
|
18
|
-
* NON-terminal state maps to `running` (a PUSHING/VERIFYING state must NOT truncate the stream before `done`). */
|
|
19
13
|
export function coarseStatusForState(state) {
|
|
20
14
|
if (state === "COMPLETE")
|
|
21
15
|
return "done";
|
|
@@ -23,46 +17,29 @@ export function coarseStatusForState(state) {
|
|
|
23
17
|
return "failed";
|
|
24
18
|
return "running";
|
|
25
19
|
}
|
|
26
|
-
/** [2373]C-7:202 受理体的**唯一铸点**——此前四份逐字构造散在 attach/竞态重读/新建/dryRun 四臂,
|
|
27
|
-
* 下一个字段必然只加到其中两处(:213/:217 与 :193/:206 已有分叉风险)。status/state 双发保留
|
|
28
|
-
* (status=state 的粗化像,省客户端抄映射表)。 */
|
|
29
20
|
function bakeAcceptedBody(b) {
|
|
30
21
|
return { bakeId: b.bakeId, eventsUrl: `/v1/images/bakes/${b.bakeId}/events`, status: b.status, state: b.state };
|
|
31
22
|
}
|
|
32
|
-
/** build.sh's structured exit code → the center band-composer UI's terminal `errorCode` (§P2.14 #2). null for an
|
|
33
|
-
* uncategorized failure (then `error` free-text + `exitCode` drive the UI). 10=dup-tag, 124=timeout, 507=disk. */
|
|
34
23
|
export function errorCodeForExit(exitCode, cancelled) {
|
|
35
24
|
if (cancelled)
|
|
36
25
|
return "cancelled";
|
|
37
26
|
switch (exitCode) {
|
|
38
27
|
case 9:
|
|
39
|
-
return "band-conflict";
|
|
28
|
+
return "band-conflict";
|
|
40
29
|
case 10:
|
|
41
30
|
return "duplicate";
|
|
42
31
|
case 124:
|
|
43
32
|
return "timeout";
|
|
44
33
|
case 507:
|
|
45
|
-
return "disk";
|
|
46
|
-
// structured line.errorCode were ever absent (M1: line.errorCode is the primary source).
|
|
34
|
+
return "disk";
|
|
47
35
|
default:
|
|
48
36
|
return null;
|
|
49
37
|
}
|
|
50
38
|
}
|
|
51
|
-
/** The closed set of structured terminal `errorCode`s a runner/build.sh frame may carry (§P2.14 #2). A
|
|
52
|
-
* runner-supplied code is a UI hint only, but it CANNOT reconstruct from the exit code alone (e.g. the disk
|
|
53
|
-
* guard's 507, or a future band-conflict 6/7/8), so ingestBakeLine PREFERS a validated `line.errorCode` over
|
|
54
|
-
* the exit-code recompute. Set-membership keeps it inside the BakeErrorCode union (never trust a free-text). */
|
|
55
39
|
const RUNNER_ERROR_CODES = new Set(["band-conflict", "duplicate", "disk", "timeout", "cancelled"]);
|
|
56
|
-
/** The index `profile` under which the FROM base (`sandbox-base`) is published — the ONLY profile a bake's
|
|
57
|
-
* `--base-ref` may resolve to (a published code-* profile digest is a built layer, never a valid FROM base).
|
|
58
|
-
* Both the caller-supplied-baseRef allow-list AND the default-base fallback resolve against this profile. */
|
|
59
40
|
const SANDBOX_BASE_PROFILE = "sandbox-base";
|
|
60
|
-
// sandbox-image-pool query routes (IMAGE-API-DESIGN.md §2). `/digests/:digest` is checked BEFORE `/:profile`
|
|
61
|
-
// (the single-segment profile RE would otherwise swallow "digests"); both are GET-only + visibility-scoped.
|
|
62
41
|
export const IMAGE_DIGEST_RE = /^\/v1\/images\/digests\/([^/]+)$/;
|
|
63
42
|
export const IMAGE_PROFILE_RE = /^\/v1\/images\/([^/]+)$/;
|
|
64
|
-
// sandbox-image-pool BAKE routes (IMAGE-API-DESIGN.md §P2.1). No trailing-slash variant (`/v1/images/bakes/` →
|
|
65
|
-
// 404, matching the live `/v1/images/` behavior). The `:bakeId` group + sub-action are both-or-neither.
|
|
66
43
|
export const BAKE_ID_RE = /^\/v1\/images\/bakes\/([^/]+)$/;
|
|
67
44
|
export const BAKE_EVENTS_RE = /^\/v1\/images\/bakes\/([^/]+)\/events$/;
|
|
68
45
|
export const BAKE_CANCEL_RE = /^\/v1\/images\/bakes\/([^/]+)\/cancel$/;
|
|
@@ -73,40 +50,24 @@ export async function handleImages(req, res, url, ctx) {
|
|
|
73
50
|
await handleImagesBody(req, res, url, ctx, miss);
|
|
74
51
|
return !miss.fell;
|
|
75
52
|
}
|
|
76
|
-
/** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。裸 `return;`
|
|
77
|
-
* 的语义原样 =「本域已应答」;走到函数尾才是「没匹配上」,置 `miss.fell` 交回装配器继续下一个域。
|
|
78
|
-
* 跨域的东西(deps / 助手 / 单域状态 / 每请求量)一律在首行解构出来,于是下面每一行都逐字不变。 */
|
|
79
53
|
async function handleImagesBody(req, res, url, ctx, miss) {
|
|
80
54
|
const { deps } = ctx;
|
|
81
55
|
const { readJson } = ctx.helpers;
|
|
82
56
|
const { source } = ctx.req;
|
|
83
57
|
const { bakeSubmitLimiter } = ctx.local.images;
|
|
84
|
-
// ── sandbox-image-pool BAKE control plane (IMAGE-API-DESIGN.md §P2). MUST precede the P1 images block: the P1
|
|
85
|
-
// profile RE `/^\/v1\/images\/([^/]+)$/` would otherwise swallow `/v1/images/bakes` as profile="bakes" (404),
|
|
86
|
-
// and a two-segment `/v1/images/bakes/:id` would fall through P1 with NO response (hung socket). image-api is
|
|
87
|
-
// the UNPRIVILEGED controller: it never bakes — it owns the durable job row/event log/lease, the operator gate,
|
|
88
|
-
// and the input whitelist; the privileged build runs on the bake-runner (build-host) over the internal claim/ingest.
|
|
89
58
|
if (deps.imageBakes && url.startsWith("/v1/images/bakes")) {
|
|
90
59
|
const bakes = deps.imageBakes;
|
|
91
|
-
const idx = deps.imageIndex;
|
|
92
|
-
|
|
93
|
-
const principal = gatedPrincipal(req, deps.config); // direct-door safe — identity-only proof; single source of truth (see gatedPrincipal)
|
|
60
|
+
const idx = deps.imageIndex;
|
|
61
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
94
62
|
const operators = deps.config.operatorPrincipals;
|
|
95
63
|
const isExplicitOperator = explicitOperatorOk(principal, operators);
|
|
96
64
|
const cfg = deps.config.imageBakes;
|
|
97
|
-
// 🔴 H1: the bake-runner identity is DERIVED from its bearer CREDENTIAL (the token-derived `source`,
|
|
98
|
-
// resolved above from SERVICE_AUTH_TOKENS), NOT a self-asserted x-agent-principal/JWT the runner never
|
|
99
|
-
// sends. `source` is independent of the directDoorActive principal ternary, so this works identically in
|
|
100
|
-
// direct-door AND plain mode. The config boot invariant guarantees a runner-credential→runnerPrincipal
|
|
101
|
-
// mapping exists when bakes are enabled.
|
|
102
65
|
const isRunner = source !== undefined && source !== null && source === cfg.runnerPrincipal;
|
|
103
|
-
// POST /v1/images/bakes — operator-only submit. EXPLICIT operator gate (NOT the bare isOperator; §P2.4a).
|
|
104
66
|
if (req.method === "POST" && url === "/v1/images/bakes") {
|
|
105
67
|
if (!isExplicitOperator) {
|
|
106
68
|
sendError(res, 403, "auth.operator_only", "operator only");
|
|
107
69
|
return;
|
|
108
70
|
}
|
|
109
|
-
// Per-principal submission rate guard (§P2.4c) — caps queue-churn even though the lease caps live builds at 1.
|
|
110
71
|
const rl = bakeSubmitLimiter.check(`bake:${principal}`);
|
|
111
72
|
if (!rl.allowed) {
|
|
112
73
|
res.setHeader("retry-after", String(rl.retryAfterSec));
|
|
@@ -114,22 +75,11 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
114
75
|
return;
|
|
115
76
|
}
|
|
116
77
|
const body = (await readJson(req));
|
|
117
|
-
// Input WHITELIST — set-membership BEFORE any argv is built (§P2.4d). Off-list → 400/501, never coerced.
|
|
118
78
|
const v = validateBake(body);
|
|
119
|
-
// B7:errorCode 恒在——band 冲突保留既有 `band-conflict` 机器码(1.x 契约,勿改);其余 400=字段白名单
|
|
120
|
-
// 拒收、501=该形 bake 尚未开面(custom/tenant P4)。`error` 文案与键序逐字不变。
|
|
121
79
|
if (!v.ok) {
|
|
122
80
|
sendError(res, v.status, v.conflictBands ? "band-conflict" : v.status === 501 ? "capability.unavailable" : "request.field_invalid", v.error, { ...(v.conflictBands ? { conflictBands: v.conflictBands } : {}) });
|
|
123
81
|
return;
|
|
124
82
|
}
|
|
125
|
-
// baseRef allow-list (§P2.4c): a caller CANNOT pass an arbitrary FROM. A present baseRef must be a KNOWN
|
|
126
|
-
// PUBLISHED `sandbox-base` digest the index has seen (a syntactically-valid attacker digest is still
|
|
127
|
-
// arbitrary; a published code-* PROFILE digest is also not a valid FROM base — the profile must be
|
|
128
|
-
// sandbox-base). Absent ⇒ the pinned default base = `SANDBOX_BASE_REF` OR the index's latest published
|
|
129
|
-
// sandbox-base (design §P2.4c "SANDBOX_BASE_REF / the index's latest published base"). The shape was
|
|
130
|
-
// already checked in validateBake. The digest the server resolves is the SAME for push:true and
|
|
131
|
-
// push:false — a node-local (no-push) build's host docker resolves a sandbox-base@sha256 FROM against
|
|
132
|
-
// the locally-present base image by its config digest (verified live), so one digest serves both paths.
|
|
133
83
|
let effectiveBaseRef;
|
|
134
84
|
if (v.baseRef) {
|
|
135
85
|
const baseEntry = idx ? await idx.getByDigest(v.baseRef) : null;
|
|
@@ -140,25 +90,15 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
140
90
|
effectiveBaseRef = v.baseRef;
|
|
141
91
|
}
|
|
142
92
|
else {
|
|
143
|
-
// SANDBOX_BASE_REF pin first; else the index's latest published sandbox-base (operator viewer — the base
|
|
144
|
-
// pool is server-internal, never tenant-scoped). The server still WHOLLY chooses the base (the caller
|
|
145
|
-
// never influences it), so the anti-RCE property holds.
|
|
146
93
|
effectiveBaseRef = cfg.defaultBaseRef ?? (idx ? (await idx.latestPublished(SANDBOX_BASE_PROFILE, { operator: true }))?.digest ?? null : null);
|
|
147
94
|
}
|
|
148
|
-
// A real (non-dryRun) build MUST FROM a concrete base — build.sh dies (exit 3) on a missing --base-ref.
|
|
149
|
-
// Fail CLOSED here with an honest config error rather than queue an argv that can never build (a dryRun is
|
|
150
|
-
// resolve-only and legitimately previews with build.sh's floating base, so it's exempt).
|
|
151
95
|
if (!v.dryRun && !effectiveBaseRef) {
|
|
152
96
|
sendError(res, 503, "state.no_sandbox_base", "no sandbox-base available to build from (set SANDBOX_BASE_REF or publish a sandbox-base image)");
|
|
153
97
|
return;
|
|
154
98
|
}
|
|
155
99
|
const argv = buildBakeArgv(v, { baseRef: effectiveBaseRef, cacheBase: cfg.cacheBase });
|
|
156
100
|
const rawIdem = headerStr(req.headers["idempotency-key"]) ?? (typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined);
|
|
157
|
-
// Durable submit-idempotency: scope the key to the submitting principal (BL-3 parity) so a replay is the
|
|
158
|
-
// SAME caller only; the store's `idem_key` UNIQUE is the durable dedupe.
|
|
159
101
|
const idemKey = rawIdem ? scopedIdempotencyKey(rawIdem, source, principal) ?? null : null;
|
|
160
|
-
// §P2.1/§P2.7 single-flight at SUBMIT (concurrency 1; no auto-queue). A `dryRun` is resolve-only — it never
|
|
161
|
-
// takes the lease, so it bypasses the busy guard entirely (parallel-safe) and uses the plain createBake.
|
|
162
102
|
const createInput = {
|
|
163
103
|
profile: v.profile,
|
|
164
104
|
bands: v.bands,
|
|
@@ -171,18 +111,11 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
171
111
|
requestedBy: principal ?? null,
|
|
172
112
|
};
|
|
173
113
|
if (!v.dryRun) {
|
|
174
|
-
// (a) an IDENTICAL-argv in-flight bake ⇒ attach (idempotent re-submit, 202) — no second build.
|
|
175
114
|
const sameArgv = await bakes.findActiveByArgv(argv);
|
|
176
115
|
if (sameArgv) {
|
|
177
116
|
sendJson(res, 202, bakeAcceptedBody(sameArgv));
|
|
178
117
|
return;
|
|
179
118
|
}
|
|
180
|
-
// (b) M4: ATOMIC admission — the busy-check and the create are ONE DB statement (`INSERT … SELECT …
|
|
181
|
-
// WHERE NOT EXISTS (a non-dryRun in-flight bake)`), so two concurrent keyless POSTs can never both queue
|
|
182
|
-
// (the old findActiveAny()→createBake() check-then-act interleaved). On `created:false` a DIFFERENT bake
|
|
183
|
-
// holds (or will hold) the single-flight lease ⇒ re-read the active bake. Do NOT silently queue a 45-min
|
|
184
|
-
// build; surface the active bake + its events URL so the UI attaches to the live one (or attach if a
|
|
185
|
-
// concurrent identical-argv submit won the race in the gap).
|
|
186
119
|
const r = await bakes.createBakeIfIdle(createInput);
|
|
187
120
|
if (!r.created) {
|
|
188
121
|
const sameNow = await bakes.findActiveByArgv(argv);
|
|
@@ -201,7 +134,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
201
134
|
sendJson(res, 202, bakeAcceptedBody(bake));
|
|
202
135
|
return;
|
|
203
136
|
}
|
|
204
|
-
// GET /v1/images/bakes/:bakeId/events — resumable SSE (the live "baking…" view), run-trace-SSE-shaped (§P2.2).
|
|
205
137
|
const evMatch = req.method === "GET" ? BAKE_EVENTS_RE.exec(url) : null;
|
|
206
138
|
if (evMatch) {
|
|
207
139
|
if (!isExplicitOperator) {
|
|
@@ -217,7 +149,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
217
149
|
await streamBakeEvents(req, res, bakes, bakeId, cfg.staleMs);
|
|
218
150
|
return;
|
|
219
151
|
}
|
|
220
|
-
// POST /v1/images/bakes/:bakeId/cancel — cooperative cancel (durable flag; the runner kills the pgid, §P2.11).
|
|
221
152
|
const cancelMatch = req.method === "POST" ? BAKE_CANCEL_RE.exec(url) : null;
|
|
222
153
|
if (cancelMatch) {
|
|
223
154
|
if (!isExplicitOperator) {
|
|
@@ -230,39 +161,31 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
230
161
|
sendError(res, 404, "not_found.bake", "bake not found");
|
|
231
162
|
return;
|
|
232
163
|
}
|
|
233
|
-
const flagged = await bakes.requestCancel(bakeId);
|
|
164
|
+
const flagged = await bakes.requestCancel(bakeId);
|
|
234
165
|
sendJson(res, 202, { bakeId, cancelRequested: true, note: flagged ? "cancel requested — the runner kills the build at the next heartbeat" : "bake already terminal — cancel is a no-op" });
|
|
235
166
|
return;
|
|
236
167
|
}
|
|
237
|
-
// POST /v1/images/bakes/claim — RUNNER-ONLY: long-poll claim (§P2.12). Find the oldest queued bake and claim
|
|
238
|
-
// it in one call (the discovery + the single-flight CAS). Checked BEFORE the per-bake `…/:id/claim` so the
|
|
239
|
-
// bare `claim` is not swallowed as `:bakeId="claim"`. 204 when the queue is empty (the runner idle-polls).
|
|
240
168
|
if (req.method === "POST" && url === "/v1/images/bakes/claim") {
|
|
241
169
|
if (!isRunner) {
|
|
242
170
|
sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
|
|
243
171
|
return;
|
|
244
172
|
}
|
|
245
173
|
const body = (await readJson(req));
|
|
246
|
-
// H1: the runner is credential-authed (no x-agent-principal), so `principal` may be undefined — fall back
|
|
247
|
-
// to the credential-derived `source` (== runnerPrincipal, isRunner guaranteed it) as the runner identity.
|
|
248
174
|
const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
|
|
249
|
-
// Bounded find-then-CAS: another runner racing us may win the id we just read, so retry the next queued id
|
|
250
|
-
// a few times before giving up (the queue is normally short + replicas=1, so 1 pass is the common case).
|
|
251
175
|
let claimed = null;
|
|
252
176
|
for (let attempt = 0; attempt < 4 && !claimed; attempt++) {
|
|
253
177
|
const nextId = await bakes.findNextQueuedId();
|
|
254
178
|
if (!nextId)
|
|
255
|
-
break;
|
|
179
|
+
break;
|
|
256
180
|
claimed = await bakes.claimBake(nextId, runnerId, cfg.leaseMs);
|
|
257
181
|
}
|
|
258
182
|
if (!claimed) {
|
|
259
183
|
res.writeHead(204).end();
|
|
260
184
|
return;
|
|
261
|
-
}
|
|
185
|
+
}
|
|
262
186
|
sendJson(res, 200, claimResponse(claimed));
|
|
263
187
|
return;
|
|
264
188
|
}
|
|
265
|
-
// POST /v1/images/bakes/:bakeId/claim — RUNNER-ONLY: single-flight lease of THIS queued bake (§P2.7).
|
|
266
189
|
const claimMatch = req.method === "POST" ? BAKE_CLAIM_RE.exec(url) : null;
|
|
267
190
|
if (claimMatch) {
|
|
268
191
|
if (!isRunner) {
|
|
@@ -274,16 +197,12 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
274
197
|
const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
|
|
275
198
|
const claimed = await bakes.claimBake(bakeId, runnerId, cfg.leaseMs);
|
|
276
199
|
if (!claimed) {
|
|
277
|
-
// Lost the single-flight (already claimed, or the pool lease is held by a live build) → 409.
|
|
278
200
|
sendError(res, 409, "conflict.bake_not_claimable", "bake not claimable (already claimed or the build lease is held)");
|
|
279
201
|
return;
|
|
280
202
|
}
|
|
281
|
-
// Return the VETTED argv + the per-bake ingest secret (minted at claim) the runner echoes on ingest.
|
|
282
203
|
sendJson(res, 200, claimResponse(claimed));
|
|
283
204
|
return;
|
|
284
205
|
}
|
|
285
|
-
// POST /v1/images/bakes/:bakeId/ingest — RUNNER-ONLY: append one build.sh line + heartbeat the lease.
|
|
286
|
-
// Maps build.sh `{event}` → an SSE kind + the coarse status; on a COMPLETE `done` triggers auto-register.
|
|
287
206
|
const ingestMatch = req.method === "POST" ? BAKE_INGEST_RE.exec(url) : null;
|
|
288
207
|
if (ingestMatch) {
|
|
289
208
|
if (!isRunner) {
|
|
@@ -296,16 +215,12 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
296
215
|
sendError(res, 404, "not_found.bake", "bake not found");
|
|
297
216
|
return;
|
|
298
217
|
}
|
|
299
|
-
// Per-bake ingest secret (minted at claim) — so a stale/rogue runner can't inject frames into another bake.
|
|
300
218
|
const presented = headerStr(req.headers["x-bake-ingest-secret"]);
|
|
301
219
|
if (!bake.ingestSecret || !presented || !safeEqual(presented, bake.ingestSecret)) {
|
|
302
220
|
sendError(res, 403, "auth.ingest_secret_invalid", "invalid ingest secret");
|
|
303
221
|
return;
|
|
304
222
|
}
|
|
305
223
|
const line = (await readJson(req));
|
|
306
|
-
// A `heartbeat` frame (§P2.7/§P2.11): advance the lease, append NO event, and echo the durable cancel flag +
|
|
307
|
-
// lease validity so the runner kills the build.sh process group at the next tick. A lost lease (the reaper
|
|
308
|
-
// stole it as stale, or a terminal flip released it) → 409 so the runner aborts (it no longer owns the build).
|
|
309
224
|
if (line.event === "heartbeat") {
|
|
310
225
|
const fresh = await bakes.getBake(bakeId);
|
|
311
226
|
if (!fresh || fresh.status !== "running") {
|
|
@@ -320,17 +235,11 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
320
235
|
sendJson(res, 200, { accepted: true, leaseValid: true, cancelRequested: fresh.cancelRequested });
|
|
321
236
|
return;
|
|
322
237
|
}
|
|
323
|
-
// 🔴 H3 (zombie/reaped runner) — PRIMARY guard: a terminal/reaped bake accepts NO frames. Re-read the row
|
|
324
|
-
// (the `bake` fetched above may be stale) and reject 409 for ALL non-heartbeat frames when it is no longer
|
|
325
|
-
// running, mirroring the heartbeat branch. Otherwise a zombie/duplicate runner that still holds a valid
|
|
326
|
-
// ingest_secret could inject `building` index rows + out-of-band SSE frames into a dead bake's stream.
|
|
327
238
|
const fresh = await bakes.getBake(bakeId);
|
|
328
239
|
if (!fresh || fresh.status !== "running") {
|
|
329
240
|
sendJson(res, 409, { accepted: false, leaseValid: false, cancelRequested: fresh?.cancelRequested ?? false, errorCode: "conflict.bake_not_running" });
|
|
330
241
|
return;
|
|
331
242
|
}
|
|
332
|
-
// A real build.sh frame: also advance the lease (the runner heartbeats via its frame cadence too) and echo
|
|
333
|
-
// the cancel flag so a cancel observed between heartbeats still reaches the runner promptly.
|
|
334
243
|
if (fresh.runnerId) {
|
|
335
244
|
await bakes.heartbeatLease(bakeId, fresh.runnerId, cfg.leaseMs).catch(() => undefined);
|
|
336
245
|
}
|
|
@@ -339,8 +248,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
339
248
|
sendJson(res, result.status, { ...result.body, cancelRequested: after?.cancelRequested ?? false, leaseValid: true });
|
|
340
249
|
return;
|
|
341
250
|
}
|
|
342
|
-
// GET /v1/images/bakes/:bakeId — poll fallback, `{bake:}` envelope, null-not-omitted (§P2.1). Checked LAST so
|
|
343
|
-
// the `/events|/cancel|/claim|/ingest` sub-actions (two-segment) win first.
|
|
344
251
|
const idMatch = req.method === "GET" ? BAKE_ID_RE.exec(url) : null;
|
|
345
252
|
if (idMatch) {
|
|
346
253
|
if (!isExplicitOperator) {
|
|
@@ -356,25 +263,15 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
356
263
|
sendJson(res, 200, { bake: bakeView(bake) });
|
|
357
264
|
return;
|
|
358
265
|
}
|
|
359
|
-
// No trailing-slash variant, no other bake route → 404 (parity with `/v1/images/` live behavior).
|
|
360
266
|
sendError(res, 404, "not_found.route", "not found");
|
|
361
267
|
return;
|
|
362
268
|
}
|
|
363
|
-
// ── sandbox-image-pool control API (IMAGE-API-DESIGN.md §2, pool design §8) — P1: query + select. The image
|
|
364
|
-
// INDEX is queried/selected here (bake/publish = P2/P3, later). Visibility is viewer-scoped (Codex A1): a
|
|
365
|
-
// tenant principal sees `public` + its OWN tenant images; an operator sees all. Identity is resolved the same
|
|
366
|
-
// way as the approval reads (direct-door JWT-without-cnf.bnd, else principalFrom); an absent principal still
|
|
367
|
-
// sees the public catalog (anonymous public-pull parity — pool = docker.io/claybobby; SWR deprecated) but no tenant rows.
|
|
368
269
|
if (deps.imageIndex && url.startsWith("/v1/images")) {
|
|
369
270
|
const idx = deps.imageIndex;
|
|
370
|
-
const principal = gatedPrincipal(req, deps.config);
|
|
371
|
-
// explicitOperatorOk (NOT isOperator): for image VISIBILITY, an empty OPERATOR_PRINCIPALS must NOT make every
|
|
372
|
-
// caller an operator (that would leak tenant-scoped images). Adversarial-review HIGH — same fix as the P0.5
|
|
373
|
-
// per-task resolve in main.ts. (Approval operator gates below keep isOperator's legacy empty=all boundary.)
|
|
271
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
374
272
|
const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
375
273
|
const viewer = { operator, tenantId: principal ?? null };
|
|
376
274
|
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
377
|
-
// GET /v1/images — the paginated catalog. ?profile=&status=&capability=&latest=true&limit=&cursor=
|
|
378
275
|
if (req.method === "GET" && url === "/v1/images") {
|
|
379
276
|
const limRaw = q.get("limit");
|
|
380
277
|
const { entries, nextCursor } = await idx.list({
|
|
@@ -389,9 +286,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
389
286
|
sendJson(res, 200, { images: entries, nextCursor });
|
|
390
287
|
return;
|
|
391
288
|
}
|
|
392
|
-
// POST /v1/images/select — the §7 per-sandbox resolution: profile → newest published visible digest +
|
|
393
|
-
// podContract, with capability admission (Codex B3). Pure resolution (no build); the caller threads the
|
|
394
|
-
// returned `ref` (repo@digest) into the kata adapter's per-sandbox image (today a worker-global env).
|
|
395
289
|
if (req.method === "POST" && url === "/v1/images/select") {
|
|
396
290
|
const body = (await readJson(req));
|
|
397
291
|
const profile = typeof body.profile === "string" ? body.profile : "";
|
|
@@ -424,12 +318,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
424
318
|
});
|
|
425
319
|
return;
|
|
426
320
|
}
|
|
427
|
-
// POST /v1/images/register — operator-only ingest (P1: seed from the live bake manifests; P3 atomic-publish
|
|
428
|
-
// drives it for real). Minimal validation here; the publish pipeline supplies a full manifest projection.
|
|
429
321
|
if (req.method === "POST" && url === "/v1/images/register") {
|
|
430
|
-
// EXPLICIT operator gate (§P2.4a MUST-FIX, the P1 follow-up): NOT the bare `operator`/isOperator, whose
|
|
431
|
-
// empty-OPERATOR_PRINCIPALS "true-for-all" makes this index-mutating door world-writable. (The block-level
|
|
432
|
-
// `operator` stays for read visibility; the MUTATION must require a concrete configured operator.)
|
|
433
322
|
if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
|
|
434
323
|
sendError(res, 403, "auth.operator_only", "operator only");
|
|
435
324
|
return;
|
|
@@ -439,11 +328,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
439
328
|
sendError(res, 400, "request.field_invalid", "profile, repo, digest are required");
|
|
440
329
|
return;
|
|
441
330
|
}
|
|
442
|
-
// 🔴 STATUS-MONOTONIC register (adversarial-review HIGH finding): the unconditional `upsert` let an
|
|
443
|
-
// operator POST status:'building' for an already-`published` digest and SILENTLY DE-PUBLISH a live image
|
|
444
|
-
// (latestPublished then 404s → sandbox-create fails — the P2.10 bug class, LIVE on this door). registerBuilding
|
|
445
|
-
// never downgrades a published row (building→published still works; published→building is a no-op on the
|
|
446
|
-
// lifecycle). A deliberate take-down is the explicit deprecate path (P3), never a side effect of register.
|
|
447
331
|
const id = await idx.registerBuilding({
|
|
448
332
|
profile: b.profile,
|
|
449
333
|
bands: Array.isArray(b.bands) ? b.bands : [],
|
|
@@ -467,7 +351,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
467
351
|
sendJson(res, 200, { id });
|
|
468
352
|
return;
|
|
469
353
|
}
|
|
470
|
-
// GET /v1/images/digests/:digest — one entry by immutable digest (what a running sandbox was built from).
|
|
471
354
|
const digMatch = req.method === "GET" ? IMAGE_DIGEST_RE.exec(url) : null;
|
|
472
355
|
if (digMatch) {
|
|
473
356
|
const entry = await idx.getByDigest(decodeURIComponent(digMatch[1]));
|
|
@@ -478,7 +361,6 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
478
361
|
sendJson(res, 200, { image: entry });
|
|
479
362
|
return;
|
|
480
363
|
}
|
|
481
|
-
// GET /v1/images/:profile — the newest published visible entry for a profile (latest tag + versions).
|
|
482
364
|
const profMatch = req.method === "GET" ? IMAGE_PROFILE_RE.exec(url) : null;
|
|
483
365
|
if (profMatch) {
|
|
484
366
|
const entry = await idx.latestPublished(decodeURIComponent(profMatch[1]), viewer);
|
|
@@ -491,28 +373,11 @@ async function handleImagesBody(req, res, url, ctx, miss) {
|
|
|
491
373
|
}
|
|
492
374
|
}
|
|
493
375
|
else if (url === "/v1/images" || url.startsWith("/v1/images/")) {
|
|
494
|
-
// 🔴 #F1([3397]-5 同族缺口):索引缺席时**整个域不挂载**,请求此前一路落到全局路由表尾的
|
|
495
|
-
// `404 not_found.route` —— 与「路由拼错了」同一个码,消费端结构上分不出「这个部署没接这个面」
|
|
496
|
-
// 与「我 URL 写错了」(trial-by-404 的最坏形:两种成因的处置完全相反)。改成诚实的能力拒。
|
|
497
|
-
//
|
|
498
|
-
// 谓词与上面那条挂载合取式**同一个符号**(`deps.imageIndex`),所以 `/v1/capabilities` 的 `images`
|
|
499
|
-
// 位(同一谓词)说 yes ⟺ 这条臂不触发 ⟺ 目录读面真能用。
|
|
500
|
-
//
|
|
501
|
-
// ⚠️ 判据是**按段**的(`=== "/v1/images"` 或 `"/v1/images/"` 前缀),不是上面挂载式那个裸前缀:
|
|
502
|
-
// `/v1/imagesfoo` 是**拼错的路由**,不是本域的路径,它该继续拿 404(索引在场的部署上它本来就落 404
|
|
503
|
-
// —— 两种部署形对同一个手滑给出不同的码,那正是本臂要消灭的那种含糊)。
|
|
504
|
-
//
|
|
505
|
-
// ⚠️ bake 子面**不误伤**:`deps.imageBakes` 在场时 `/v1/images/bakes*` 已在本函数最上面的域里应答
|
|
506
|
-
// 并 `return`,走不到这里;两者都缺席时(local 后端的真实形态——两个工厂它都不实现)本臂如实
|
|
507
|
-
// 回答「这个部署没有 sandbox-image-pool」,那对 bakes 路径也是真话。
|
|
508
376
|
sendError(res, 501, "capability.image_index_required", "the sandbox-image-pool requires a SQL store backend (DB_BACKEND=mysql|pg) — the local backend wires no image index");
|
|
509
377
|
return;
|
|
510
378
|
}
|
|
511
379
|
miss.fell = true;
|
|
512
380
|
}
|
|
513
|
-
/** The image_bake SSE (P2.2/P2.8) — the SAME pump, with the run-trace-SSE-shaped bake frame
|
|
514
|
-
* (`data: {kind, seq, ts, ...kindFields}`) + a synthetic `done{failed}` stale frame so a reader settles
|
|
515
|
-
* (never hangs) when the runner is lost before the reaper's terminal event lands. */
|
|
516
381
|
async function streamBakeEvents(req, res, bakes, bakeId, staleMs) {
|
|
517
382
|
await streamSseLog(req, res, {
|
|
518
383
|
statusOf: async (id) => {
|
|
@@ -521,8 +386,6 @@ async function streamBakeEvents(req, res, bakes, bakeId, staleMs) {
|
|
|
521
386
|
},
|
|
522
387
|
getEvents: (id, after) => bakes.getEvents(id, after),
|
|
523
388
|
retainedFrom: (id) => bakes.retainedFrom(id),
|
|
524
|
-
// The bake frame carries kind+seq+ts INSIDE data (center locks its relay to this), distinct from the
|
|
525
|
-
// task_run `{type, ...data}` shape. `level` rides along so an opt-in `raw` log frame is distinguishable.
|
|
526
389
|
formatEvent: (ev) => ({
|
|
527
390
|
id: ev.seq,
|
|
528
391
|
event: ev.kind,
|
|
@@ -531,9 +394,6 @@ async function streamBakeEvents(req, res, bakes, bakeId, staleMs) {
|
|
|
531
394
|
staleFrame: () => ({ event: "done", data: { kind: "done", status: "FAILED", error: "bake-runner lost (no heartbeat)", digest: null, indexId: null, repo: null, ref: null } }),
|
|
532
395
|
}, bakeId, staleMs);
|
|
533
396
|
}
|
|
534
|
-
/** The operator poll view of a bake (§P2.1 `{bake:}` envelope, null-not-omitted). Projects the durable row MINUS
|
|
535
|
-
* the runner-internal secret/lease fields (`ingestSecret`/`runnerId`/`leaseUntil`/`cancelRequested`/`argv`) — an
|
|
536
|
-
* ops surface, but the per-bake ingest secret is a credential and the argv is build-host detail. */
|
|
537
397
|
function bakeView(b) {
|
|
538
398
|
return {
|
|
539
399
|
bakeId: b.bakeId,
|
|
@@ -559,9 +419,6 @@ function bakeView(b) {
|
|
|
559
419
|
updatedAt: b.updatedAt,
|
|
560
420
|
};
|
|
561
421
|
}
|
|
562
|
-
/** The RUNNER claim response (§P2.7/§P2.12): the VETTED argv + the per-bake ingest secret (minted at claim, echoed
|
|
563
|
-
* on every ingest) + the profile/bands the runner needs for its profile-aware hard deadline. This is the ONLY
|
|
564
|
-
* place the ingest secret leaves image-api — to the runner that just won the lease, over the internal claim. */
|
|
565
422
|
function claimResponse(claimed) {
|
|
566
423
|
return {
|
|
567
424
|
bakeId: claimed.bakeId,
|
|
@@ -575,143 +432,76 @@ function claimResponse(claimed) {
|
|
|
575
432
|
leaseUntil: claimed.leaseUntil,
|
|
576
433
|
};
|
|
577
434
|
}
|
|
578
|
-
/** Map ONE build.sh JSON line (runner-forwarded, with `lineOrd`) → the durable bake event log + the coarse status
|
|
579
|
-
* transition, and on a `done:COMPLETE` trigger the status-monotonic auto-register (§P2.3/§P2.10).
|
|
580
|
-
*
|
|
581
|
-
* build.sh emits `resolved | state | step | image | manifest | done` (read, not guessed). The runner forwards each
|
|
582
|
-
* verbatim plus a per-line `lineOrd` (at-least-once dedupe, uq_line). `done`/`manifest` carry a build-host FS path, not
|
|
583
|
-
* the body, so the runner reads the manifest body off the host and forwards it as `manifestBody` on the `done`.
|
|
584
|
-
*
|
|
585
|
-
* The COARSE status (the reader's terminality column) is driven ONLY by `state`/`done` (§P2.3 MUST-FIX): every
|
|
586
|
-
* non-terminal state → `running`; COMPLETE→done, FAILED/CANCELLED→failed. A `state` line that is non-terminal only
|
|
587
|
-
* denormalizes the UI progress bar (never the coarse status). The terminal `done` is appended BEFORE the coarse
|
|
588
|
-
* row flips (event-before-row latch) so the SSE reader always observes it.
|
|
589
|
-
*/
|
|
590
435
|
async function ingestBakeLine(bakes, idx, bake, line, registry) {
|
|
591
436
|
const bakeId = bake.bakeId;
|
|
592
437
|
const event = typeof line.event === "string" ? line.event : "";
|
|
593
438
|
const lineOrd = typeof line.lineOrd === "number" ? line.lineOrd : typeof line.line_ord === "number" ? line.line_ord : null;
|
|
594
|
-
// A non-`done` build.sh line: append it as the matching kind + denormalize `state` for the UI progress bar.
|
|
595
|
-
// (`resolved | state | step | image | manifest | log` — `manifest` is folded; the body rides the `done`.)
|
|
596
439
|
if (event !== "done") {
|
|
597
440
|
if (event === "state") {
|
|
598
441
|
const state = typeof line.state === "string" ? line.state : null;
|
|
599
442
|
if (state)
|
|
600
|
-
await bakes.setState(bakeId, state);
|
|
443
|
+
await bakes.setState(bakeId, state);
|
|
601
444
|
const seq = await bakes.appendEvent(bakeId, "state", { state }, { lineOrd });
|
|
602
445
|
return { status: 200, body: { accepted: true, seq } };
|
|
603
446
|
}
|
|
604
|
-
// resolved | step | image | log | manifest → store under the build.sh event name as the SSE kind (the runner
|
|
605
|
-
// already shapes the data fields; manifest is kept as a `step`-adjacent frame for the trace, body on `done`).
|
|
606
447
|
const kind = event === "manifest" ? "manifest" : event;
|
|
607
448
|
const level = event === "log" && line.level === "raw" ? "raw" : null;
|
|
608
449
|
const { event: _e, lineOrd: _lo, line_ord: _li, ...data } = line;
|
|
609
|
-
// L3 (defense-in-depth): redactDeep the ingested non-terminal frame data before it's stored/streamed, mirroring
|
|
610
|
-
// streamTaskTrace's tool-arg redaction. build.sh keeps raw cred output off stdout (only resolved.mergedEnv —
|
|
611
|
-
// frozen server recipes — is unredacted today), but this closes the resolved.mergedEnv tail + any future
|
|
612
|
-
// `?logs=1` raw-tail before it's wired. (redactSecrets is non-exhaustive — hardening, not a guarantee.)
|
|
613
450
|
const seq = await bakes.appendEvent(bakeId, kind, redactDeep(data), { lineOrd, level });
|
|
614
451
|
return { status: 200, body: { accepted: true, seq } };
|
|
615
452
|
}
|
|
616
|
-
|
|
617
|
-
const doneStatus = typeof line.status === "string" ? line.status : "FAILED"; // COMPLETE | FAILED | CANCELLED
|
|
453
|
+
const doneStatus = typeof line.status === "string" ? line.status : "FAILED";
|
|
618
454
|
const exitCode = typeof line.exitCode === "number" ? line.exitCode : doneStatus === "COMPLETE" ? 0 : null;
|
|
619
455
|
const cancelled = doneStatus === "CANCELLED";
|
|
620
456
|
const errorText = typeof line.error === "string" ? redactSecrets(line.error) : null;
|
|
621
457
|
const tag = typeof line.tag === "string" ? line.tag : bake.tag;
|
|
622
|
-
let digest = typeof line.digest === "string" && line.digest.length > 0 ? line.digest : null;
|
|
458
|
+
let digest = typeof line.digest === "string" && line.digest.length > 0 ? line.digest : null;
|
|
623
459
|
let indexId = bake.indexId;
|
|
624
460
|
let repo = bake.repo;
|
|
625
461
|
let ref = bake.ref;
|
|
626
462
|
let manifestSha = bake.manifestSha;
|
|
627
|
-
// The done STATUS/error can be DOWNGRADED to FAILED on a register-after-build failure (the partial-publish
|
|
628
|
-
// contract below) — start from build.sh's reported status and mutate iff the register throws.
|
|
629
463
|
let finalStatus = doneStatus;
|
|
630
464
|
let finalError = errorText;
|
|
631
465
|
let finalExit = exitCode;
|
|
632
|
-
// M1: a register-after-build downgrade (COMPLETE→FAILED) must OWN the terminal errorCode — a stale COMPLETE-time
|
|
633
|
-
// runner code (e.g. none) must not mask the partial-publish failure. Tracked here, consulted at the errorCode pick.
|
|
634
466
|
let registerDowngraded = false;
|
|
635
|
-
// Auto-register (only a COMPLETE with a manifest body + a digest is registerable). EVENT-BEFORE-ROW: we register
|
|
636
|
-
// (idx write) and append the `done` SSE event BEFORE the coarse terminal flip, so a `done` is always observable
|
|
637
|
-
// even if the terminal write retries.
|
|
638
|
-
//
|
|
639
|
-
// PARTIAL-PUBLISH CONTRACT (§P2.2/§P2.10): a register-after-build failure (a TiDB blip AFTER build.sh COMPLETE)
|
|
640
|
-
// is surfaced as `digest` present + `indexId` null + `status:"FAILED"` — a built-but-unregistered partial, NOT a
|
|
641
|
-
// successful COMPLETE and NOT a re-bake trigger (a P3 reconciliation tick re-registers it). So the done status is
|
|
642
|
-
// downgraded COMPLETE→FAILED on the catch, with the digest/repo/ref still carried so the artifact is findable.
|
|
643
|
-
// M3: the `register` step frames are EVENT-LOG writes that must follow the SAME terminality gate as the done
|
|
644
|
-
// event — buffer them here and replay only when THIS call wins the setTerminal CAS (affectedRows>=1). The idx
|
|
645
|
-
// writes (registerBuilding/setIndexId) are status-monotonic and idempotent, so they may run before the CAS.
|
|
646
467
|
const registerSteps = [];
|
|
647
468
|
if (doneStatus === "COMPLETE" && digest && idx) {
|
|
648
469
|
const manifestBody = (line.manifestBody ?? line.manifest);
|
|
649
|
-
// 🔴 H3 (zombie/reaped runner): re-read the bake row HERE (the passed `bake` may be stale) and ONLY register
|
|
650
|
-
// when it is still non-terminal. A reaped/terminal row must never inject a fresh `building` index row.
|
|
651
470
|
const fresh = await bakes.getBake(bakeId);
|
|
652
471
|
const nonTerminal = !!fresh && (fresh.status === "queued" || fresh.status === "running");
|
|
653
472
|
if (manifestBody && typeof manifestBody === "object" && nonTerminal) {
|
|
654
|
-
// 🔴 H3/H5: derive repo/profile from the VETTED control-plane bake row (+ the manifest's profile only as a
|
|
655
|
-
// fallback for a --bands "custom" bake) — NEVER trust the runner-supplied manifestBody for the server-fixed
|
|
656
|
-
// repo identity. H5: prefix the configured registry host (BAKE_IMAGE_REGISTRY — historically SWR, now
|
|
657
|
-
// docker.io/claybobby; SWR deprecated) so the bake row's repo/ref/index-id match the P1 seed
|
|
658
|
-
// convention (`${registry}/${profile}`); null registry ⇒ the host-less node-local name (documented
|
|
659
|
-
// non-pullable). For a no-push bake the digest is the build.sh LOCAL_ID (node-local, NOT selectable —
|
|
660
|
-
// registerBuilding writes status:'building', and latestPublished filters status='published').
|
|
661
473
|
const profile = bake.profile && bake.profile !== "custom" ? bake.profile : (manifestBody.profile && manifestBody.profile !== "custom" ? manifestBody.profile : "custom");
|
|
662
474
|
repo = registry ? `${registry}/${profile}` : `sema-images/${profile}`;
|
|
663
475
|
manifestSha = typeof line.manifestSha === "string" ? line.manifestSha : (typeof line.manifest?.sha256 === "string" ? String(line.manifest.sha256) : bake.manifestSha);
|
|
664
|
-
// codex R23:身份候选(digest/repo/manifestSha)先验**后写**——node-postgres 的 UTF-8 编码会把
|
|
665
|
-
// lone surrogate 折成 U+FFFD,registerBuilding 可能**成功**落一条身份已变形的 index 行,随后
|
|
666
|
-
// 帧级 identityInvalid 折叠才拒帧 → index 已被污染且与拒帧后的 null 身份终态互相矛盾。脏身份
|
|
667
|
-
// ⇒ 零 index 写(indexId 强制 null),repo/manifestSha 已按本帧生效值赋出,下方折叠必然命中
|
|
668
|
-
// 并独占 FAILED/null 身份终态(该臂不是 register 失败,不走 partial-publish 契约/P3 补注册)。
|
|
669
476
|
if (pgHasUnstorable(digest) || pgHasUnstorable(repo) || pgHasUnstorable(manifestSha)) {
|
|
670
477
|
indexId = null;
|
|
671
478
|
registerSteps.push({ step: "register", status: "skipped", reason: "identity fields contained unstorable bytes" });
|
|
672
479
|
}
|
|
673
480
|
else {
|
|
674
|
-
// Synthesize a `step:"register"` start around the auto-register so a resuming reader sees the register phase
|
|
675
|
-
// (§P2.2: the `register` step is image-api-synthesized — build.sh stops at COMPLETE + the manifest line).
|
|
676
481
|
registerSteps.push({ step: "register", status: "running" });
|
|
677
482
|
try {
|
|
678
483
|
const entry = manifestToIndexEntry(manifestBody, { repo, digest, status: "building", manifestSha, sizeBytes: manifestBody.sizeBytes ?? null, signed: false });
|
|
679
|
-
// 🔴 H3: FORCE visibility/tenantId from the control plane — a P2 standard-pool bake is ALWAYS a public,
|
|
680
|
-
// tenant-less row. NEVER let the runner-supplied manifestBody (owner/visibility) decide the index row's
|
|
681
|
-
// tenancy (a zombie runner could otherwise write a `tenant`-visibility row with an attacker-chosen tenant).
|
|
682
484
|
entry.visibility = "public";
|
|
683
485
|
entry.tenantId = null;
|
|
684
|
-
// STATUS-MONOTONIC register (§P2.10 MUST-FIX): registerBuilding NEVER downgrades a `published` row — a
|
|
685
|
-
// late/duplicate `done` for an already-promoted digest is a no-op on the lifecycle, never a regression.
|
|
686
486
|
indexId = await idx.registerBuilding(entry);
|
|
687
487
|
ref = `${repo}@${digest}`;
|
|
688
|
-
// codex R24:setIndexId 是 non-terminal 门写(见 store 注释)——本请求悬在 registerBuilding 期间
|
|
689
|
-
// 另一 done(如 R23 的 dirty 折叠帧)可能已赢 setTerminal;未绑定⇒indexId 不进本帧 terminal 对象
|
|
690
|
-
// (本请求随后必输 CAS,不落行不落事件,index 里的孤 building 行不 publish 不可选、由生命周期兜底)。
|
|
691
488
|
const bound = await bakes.setIndexId(bakeId, indexId);
|
|
692
489
|
if (!bound)
|
|
693
490
|
indexId = null;
|
|
694
491
|
registerSteps.push({ step: "register", status: "ok" });
|
|
695
492
|
}
|
|
696
493
|
catch (err) {
|
|
697
|
-
// built-but-unregistered partial (a TiDB blip post-build): downgrade the done to FAILED, keep digest+repo
|
|
698
|
-
// (+ ref so the artifact is findable), leave indexId null. The P3 reconciliation tick re-registers it.
|
|
699
494
|
indexId = null;
|
|
700
495
|
ref = `${repo}@${digest}`;
|
|
701
496
|
finalStatus = "FAILED";
|
|
702
|
-
registerDowngraded = true;
|
|
703
|
-
finalExit = exitCode ?? 0;
|
|
497
|
+
registerDowngraded = true;
|
|
498
|
+
finalExit = exitCode ?? 0;
|
|
704
499
|
finalError = redactSecrets(`index register failed after a COMPLETE build (built-but-unregistered partial): ${String(err?.message ?? err)}`);
|
|
705
500
|
registerSteps.push({ step: "register", status: "fail", reason: "index register failed (TiDB?)" });
|
|
706
501
|
}
|
|
707
502
|
}
|
|
708
503
|
}
|
|
709
504
|
}
|
|
710
|
-
// errorCode + coarse status are computed from the FINAL (possibly-downgraded) status, not build.sh's raw one.
|
|
711
|
-
// M1 (§P2.14 #2): PREFER a validated runner-supplied `line.errorCode` (the disk-guard's "disk", a future
|
|
712
|
-
// build-time "band-conflict" — codes the exit-code mapper cannot reconstruct) over the recompute. A `cancelled`
|
|
713
|
-
// terminal always wins (the kill is authoritative); a register-downgraded FAILED owns its own code (the runner's
|
|
714
|
-
// COMPLETE-time code is stale), so it falls back to the exit mapper.
|
|
715
505
|
const suppliedCode = typeof line.errorCode === "string" && RUNNER_ERROR_CODES.has(line.errorCode)
|
|
716
506
|
? line.errorCode
|
|
717
507
|
: undefined;
|
|
@@ -720,14 +510,9 @@ async function ingestBakeLine(bakes, idx, bake, line, registry) {
|
|
|
720
510
|
: suppliedCode !== undefined && !registerDowngraded
|
|
721
511
|
? suppliedCode
|
|
722
512
|
: errorCodeForExit(finalExit, cancelled);
|
|
723
|
-
// M1: a band-conflict terminal carries the offending band ids so the composer UI can red-highlight the chips
|
|
724
|
-
// (§P2.14 #2). Only honored when the FINAL code is band-conflict (a downgraded/other terminal carries none).
|
|
725
513
|
const conflictBands = errorCode === "band-conflict" && Array.isArray(line.conflictBands)
|
|
726
514
|
? line.conflictBands.filter((b) => typeof b === "string")
|
|
727
515
|
: null;
|
|
728
|
-
// codex R22:身份位(digest/repo/ref/manifestSha)带不可存字节=帧无效——在**构造前**统一折叠
|
|
729
|
-
// (row/SSE done/HTTP 三面同源一份 effective 对象;store 内折叠留作双保险,值恒等)。COMPLETE 折
|
|
730
|
-
// FAILED(null 身份的成功=谎),错误面补一句归因。
|
|
731
516
|
const identityInvalid = (typeof digest === "string" && pgHasUnstorable(digest)) ||
|
|
732
517
|
(typeof repo === "string" && pgHasUnstorable(repo)) ||
|
|
733
518
|
(typeof ref === "string" && pgHasUnstorable(ref)) ||
|
|
@@ -738,17 +523,11 @@ async function ingestBakeLine(bakes, idx, bake, line, registry) {
|
|
|
738
523
|
repo = null;
|
|
739
524
|
ref = null;
|
|
740
525
|
manifestSha = null;
|
|
741
|
-
indexId = null;
|
|
526
|
+
indexId = null;
|
|
742
527
|
finalError = finalError ? `${finalError}; identity fields contained unstorable bytes` : "terminal frame identity fields contained unstorable bytes";
|
|
743
528
|
}
|
|
744
529
|
const finalState = (finalStatus === "COMPLETE" ? "COMPLETE" : finalStatus === "CANCELLED" ? "CANCELLED" : "FAILED");
|
|
745
530
|
const coarse = coarseStatusForState(finalState);
|
|
746
|
-
// M3: flip the coarse terminal row FIRST and gate the terminal `done` EVENT (+ the register `step` frames) on
|
|
747
|
-
// WINNING that queued/running→terminal CAS. A late/contradictory terminal (cancel-vs-complete, reaper-vs-recovery)
|
|
748
|
-
// sees affectedRows===0 and writes NO event, so the SSE log never replays a second, conflicting terminal `done`.
|
|
749
|
-
// Invariant: at most one terminal `done` event per bake, always matching the coarse row outcome (§P2.14).
|
|
750
|
-
// L2: the flip is idempotent (`WHERE status IN ('queued','running')`), so a transient throw is safe to retry
|
|
751
|
-
// rather than leaving the row `running` with a `done` event the reaper would later contradict.
|
|
752
531
|
let affectedRows = 0;
|
|
753
532
|
const terminal = {
|
|
754
533
|
status: coarse === "done" ? "done" : "failed",
|
|
@@ -775,13 +554,9 @@ async function ingestBakeLine(bakes, idx, bake, line, registry) {
|
|
|
775
554
|
}
|
|
776
555
|
}
|
|
777
556
|
if (lastErr !== undefined) {
|
|
778
|
-
// L2: exhausted the bounded retry — DON'T 500 (safeIngest swallows it and the row would stay `running` with no
|
|
779
|
-
// terminal event the reaper later contradicts). Return a non-terminal ack so the runner re-POSTs the
|
|
780
|
-
// (lineOrd-idempotent) done to re-drive the flip.
|
|
781
557
|
return { status: 200, body: { accepted: true, terminal: false, needsFlip: true } };
|
|
782
558
|
}
|
|
783
559
|
if (affectedRows >= 1) {
|
|
784
|
-
// We won the transition: replay the buffered register `step` frames, then append the single terminal `done`.
|
|
785
560
|
for (const s of registerSteps)
|
|
786
561
|
await bakes.appendEvent(bakeId, "step", s);
|
|
787
562
|
await bakes.appendEvent(bakeId, "done", {
|