Skip to main content

cloud/reconciler/
mesofact_static.rs

1//! [`Reconciler`] implementation for `kind = "mesofact-static"` components.
2//!
3//! Dispatches on the mirror's `providers.static` slot:
4//!
5//! - **`kind = "miniflare-native"` (inline)** — the dev tier. Publish the
6//!   built `dist/` into the bucket the mirror's `[drivers.s3]` binding names,
7//!   then serve it with miniflare on `127.0.0.1:<port>`. See
8//!   [`super::dev_door`].
9//! - **`kind = "miniflare-container"` (inline)** — the pond tier. Same door,
10//!   MinIO in a container underneath. See [`super::pond`].
11//! - **`use = "cloudflare"` (reference)** — publish to R2 and deploy the same
12//!   Worker to Cloudflare.
13//!
14//! All three run the identical `worker/router.bundle.js`; the only thing that
15//! varies is which implementation of the `s3` capability sits behind it, and
16//! that is declared in the mirror rather than branched on here (W265,
17//! R584-F4). Until that ticket the dev tier instead spawned `mesofact-dev` to
18//! serve `dist/` straight off the filesystem under `kind = "local-static"` —
19//! a storage interface no other tier had, and the fork W265 exists to delete.
20//!
21//! @yah:ticket(R255-F6, "Split tier-1 into native fast-path vs managed-subprocess fallback")
22//! @yah:assignee(agent:claude)
23//! @yah:at(2026-05-25T20:08:16Z)
24//! @yah:status(review)
25//! @yah:parent(R255)
26//! @yah:next("native fast-path: blessed mesofact stack, bun in-process, axum-as-ingress, camp daemon runs the build job (current mesofact-dev watcher path)")
27//! @yah:next("managed-subprocess fallback: generic app runs as a managed child subprocess behind axum-as-ingress")
28//! @yah:next("make reconciler dispatch select the two paths explicitly rather than branching on if-compatible inside one arm")
29//! @yah:assumes("not all projects have a valid in-process tier-1 — only the blessed mesofact stack (in-process bun, axum ingress) qualifies")
30//! @yah:handoff("Two-path dispatch already landed in R274 (filed after F6 was opened). Native fast-path = spawn_mesofact_dev in-process in camp.rs:575 (R274-F1). Managed-subprocess fallback = adopt_only:false arm in MesofactStaticReconciler.up_local_static (mesofact_static.rs:168). Desktop sets adopt_only:true so it adopts the camp server; CLI/CI path sets adopt_only:false and spawns the binary. The 'generic app subprocess behind axum-as-ingress for non-mesofact workloads' vision is a future ticket, not R255 scope. No code change needed here.")
31//! @yah:verify("Verify dispatch: (1) cargo check --workspace --locked; (2) with camp running, mirror_run_up adopts the in-process server (jit port file); (3) with camp NOT running and adopt_only:false, reconciler spawns mesofact-dev binary.")
32//!
33//! @yah:relay(R320, "Cloudflare first-class Infra provider + yah.dev R2 publish")
34//! @yah:at(2026-05-26T00:19:09Z)
35//! @yah:status(open)
36//! @arch:see(.yah/docs/working/W074-cloudflare-infra-provider.md)
37//!
38//! @yah:ticket(R320-T8, "Wire cloudflare reference path into MesofactStaticReconciler")
39//! @yah:assignee(agent:claude)
40//! @yah:at(2026-05-26T00:42:37Z)
41//! @yah:status(review)
42//! @yah:phase(P2)
43//! @yah:parent(R320)
44//! @yah:depends_on(R320-F7)
45//! @yah:handoff("Cloudflare reference path wired into MesofactStaticReconciler.up(). Reference arm now dispatches to up_cloudflare_r2() for provider_id=cloudflare, bails for any other reference provider. Method loads ProviderConfig from .yah/infra/providers/cloudflare.toml (needs account_id field), resolves R2 S3 keys from keystore/env, calls publish_to_r2, returns RunningWorkload with public_url=https://<zone>. CDN purge fires if cloudflare-api-token is present in keystore. read_workload_out_dir helper reads build.out_dir from workload.toml (defaults to dist).")
46//! @yah:verify("cargo check -p cloud — clean (verified)")
47//! @yah:verify("cargo test -p cloud --lib — 175 passed (verified)")
48//! @yah:verify("yah cloud mirror up dev-yah --env prod (requires account_id in cloudflare.toml + R2 S3 keys in keystore)")
49//!
50//! @yah:relay(R327, "CF Worker provisioner: static→R2 + SSR/SPA→origin routing for mesofact sites")
51//! @yah:at(2026-05-26T07:25:51Z)
52//! @yah:status(review)
53//! @yah:next("Design the Worker script template: static routes fetch from R2 bucket binding, SSR routes proxy to origin (yubaba service URL), SPA shell falls back to R2 index.html for unmatched paths")
54//! @yah:next("Add CF Workers API calls to up_cloudflare_r2: upload Worker script, create KV/R2 bucket binding, wire Routes or Custom Domain to the Worker")
55//! @yah:next("Worker replaces the Transform Rule workaround (R320-T11) as the general solution — both static-only and SSR/SPA sites go through the Worker")
56//! @yah:gotcha("Pure-static sites (Mode 1) still need the Worker to serve index.html for / — R2 custom domains alone don't auto-index")
57//! @yah:gotcha("Worker script must be idempotent across mirror up re-runs: re-deploy only when content hash changes")
58//! @yah:gotcha("R2 bucket binding in the Worker requires the bucket name matches the mirror config — keep them in sync")
59//! @yah:handoff("Worker script provisioner implemented. CloudflareClient gained deploy_worker_script (multipart PUT, ES module format, R2 ASSETS binding) and upsert_worker_route (idempotent GET+POST/PUT). MesofactStaticReconciler.up_cloudflare_r2 now: (1) parses mode/origin_url/ssr_prefixes from slot_fields; (2) renders WorkerMode-aware JS script (static/spa/ssr); (3) compares SHA256 hash against .yah/jit/worker-script-hashes.json — skips redeploy if unchanged; (4) upserts zone route {zone}/* → {service.name}-worker. Transform Rule call removed. Worker script handles / → index.html, trailing-slash directory indexes, SSR proxy to origin, SPA/SSR fallback to index.html. 21 new unit tests + 215 total passing.")
60//! @yah:next("Validate live E2E: yah cloud mirror up dev-yah --env prod — Worker script deployed to CF, route yah.dev/* → dev-yah-worker, curl https://yah.dev serves index.html via Worker (not Transform Rule)")
61//! @yah:next("Update MESOFACT_STATIC_GRANTS to add 'Workers Scripts Write' + 'Zone Workers Routes Write' permission groups (need to validate their CF permission-group UUIDs live against /accounts/{id}/tokens/permission_groups)")
62//! @yah:next("Consider whether to keep upsert_index_rewrite as belt-and-suspenders or drop it entirely once Worker is confirmed stable")
63//! @yah:verify("cargo test -p cloud --lib: 215 passed (verified)")
64//! @yah:verify("cargo check --workspace: clean (verify before merge)")
65//! @yah:gotcha("cloudflare-api-token must have Workers Scripts: Edit (account-scoped) + Zone Workers Routes: Edit (zone-scoped) — both now in MESOFACT_STATIC_GRANTS as 'Workers Scripts Write' + 'Workers Routes Write' with fallback IDs sourced from global CF catalog (2026-05-26)")
66//! @yah:gotcha("build_worker_multipart uses a manual multipart body (reqwest multipart feature not enabled in cloud Cargo.toml) — boundary is 'yahWorkerUpload0'")
67//! @yah:gotcha("Worker route pattern is '{zone}/*' not '*{zone}/*' — only catches apex requests, not subdomains. Add a wildcard route if subdomains need Worker routing")
68//! @yah:gotcha("CF Workers ES module format requires 'main_module' in metadata and the part name must match that filename ('worker.js')")
69//! @yah:handoff("Added Workers Scripts Write (account-scoped, fallback e086da7e...) + Workers Routes Write (zone-scoped, fallback 28f4b596...) to MESOFACT_STATIC_GRANTS. IDs sourced from the global CF permission-groups catalog (gist.github.com/f3l1x/13d3e43933e6d770aabee95410f8ee1d, validated against CF naming conventions). Test token_body_splits_scopes_and_resolves_ids extended to assert both new fallback IDs. Gotcha annotation updated: Workers grants are now in MESOFACT_STATIC_GRANTS. 215 tests pass, cargo check --workspace clean.")
70//! @yah:verify("cargo test -p cloud --lib — 215 passed")
71//! @yah:verify("cargo check --workspace — clean (warnings only, no errors)")
72//! @yah:verify("Live E2E (user must run): yah cloud mirror up dev-yah --env prod — Worker script deployed to CF, zone route yah.dev/* → dev-yah-worker, curl https://yah.dev returns index.html served by the Worker (not the old Transform Rule)")
73//!
74//! @yah:ticket(R327-F1, "Extract Worker router from Rust string literal to a typechecked TS source + miniflare test")
75//! @yah:assignee(agent:claude)
76//! @yah:at(2026-05-26T16:33:58Z)
77//! @yah:status(review)
78//! @yah:parent(R327)
79//! @yah:next("render_worker_script (mesofact_static.rs:536) builds the Worker as JS interpolated inside a Rust format! — untyped, validated only by string.contains() tests. Move the router to a real .ts source, typecheck + bundle (esbuild/bun), embed the bundled output.")
80//! @yah:next("Inject mode/bucket/ssr_prefixes/origin_url as a binding or generated config module rather than string interpolation, so the router source is static and unit-testable.")
81//! @yah:next("Add a miniflare test asserting routing behaviour (static index-at-root, SPA index fallback, SSR proxy to origin) against real workerd, replacing the substring assertions.")
82//! @yah:next("Keep the deploy hash-gate (read_worker_script_hash) working on the bundled output.")
83//! @yah:gotcha("The extracted TS router reads assets via fetch(ASSET_ORIGIN + key), NOT the R2 binding (see R327-F2 decision 2026-05-26) — take ASSET_ORIGIN as config/env, drop the ASSETS binding and the writeHttpMetadata/httpEtag handling. The router becomes a generic 'route + fetch from an origin' script, not CF-coupled.")
84//! @yah:gotcha("deploy_worker_script (cloudflare.rs:743) uploads a single JS main_module part; if bundling emits multiple modules, build_worker_multipart must emit each as its own multipart part.")
85//! @yah:gotcha("wasm intentionally out of scope: React-based mesofact SSR is JS, so the heavy-lifting path stays JS. wasm only enters if heavy compute becomes Rust (a Rust SSR engine / image transforms), which a React mesofact never introduces.")
86//! @yah:handoff("Router extracted from Rust format! string to crates/yah/cloud/worker/router.ts (TypeScript, typechecked). Bundle at router.bundle.js is embedded via include_str! as WORKER_SCRIPT const in mesofact_static.rs. Config injected via plain_text Worker bindings: ASSET_ORIGIN (read from slot_fields.asset_origin, defaults empty), WORKER_MODE (static/spa/ssr), SSR_ORIGIN, SSR_PREFIXES (JSON array). deploy_worker_script + build_worker_multipart in cloudflare.rs updated: R2 bucket binding dropped, plain_text bindings accepted instead. render_worker_script removed; replaced by worker_config_bindings(mode, asset_origin) returning Vec<(String,String)>. Hash-gate now covers script + bindings (sha256 of bundle bytes + NUL + JSON-encoded bindings). 11 miniflare tests in worker/tests/router.test.ts cover static index-at-root, 404.html fallback, plain 404 when absent, SPA fallback, known-asset passthrough, SSR prefix proxy, SSR non-prefix fallback. 220 cargo tests pass; cargo check --workspace clean.")
87//! @yah:verify("cargo test -p cloud --lib — 220 passed")
88//! @yah:verify("bun test tests/ in crates/yah/cloud/worker — 11 passed")
89//! @yah:verify("cargo check --workspace — clean (warnings only)")
90//! @yah:verify("Live E2E (user): set slot_fields.asset_origin to the R2 bucket public URL, run yah cloud mirror up dev-yah --env prod")
91//!
92//! @yah:ticket(R432-B2, "Stale jit ports file: don't re-probe a dead recorded port and call it a 'dynamic fallback'")
93//! @yah:assignee(agent:claude)
94//! @yah:at(2026-06-04T01:13:39Z)
95//! @yah:status(review)
96//! @yah:parent(R432)
97//! @yah:severity(minor)
98//! @yah:next("In up_local_static, after reading read_jit_port: if the recorded port == the configured port AND the first probe already failed, skip the second probe — it's the same dead address.")
99//! @yah:next("If the jit file claims a different port and that also fails to connect, treat the file as stale (don't pretend we exhaustively probed).")
100//! @yah:next("Consider: should camp purge the entry on shutdown? Lower priority; the read-side fix above is enough to clear the misleading error.")
101//! @yah:verify("With stale .yah/jit/mesofact-dev-ports.json (port not bound), the error no longer contains 'or any dynamic fallback port' — instead it says camp isn't running.")
102//! @yah:handoff("Fixed in mesofact_static.rs::up_local_static. Lifted jit_port outside the conditional block so the error arm can inspect it. Error message now: no jit note when file absent or records same port as configured; precise 'jit file recorded port N — also not bound' note when a genuinely different port was probed and also dead. Phrase 'or any dynamic fallback port' removed in all cases. Also fixed pre-existing MirrorConfig asset_aliases missing-field compile errors across cloud/config.rs, pond.rs, cloudflare_worker.rs, mesofact_static.rs, camp.rs (all tests now compile).")
103//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 26 passed (includes two new R432-B2 regression tests)")
104//! @yah:verify("adopt_only_stale_jit_same_port_omits_dynamic_fallback_phrase: passes")
105//! @yah:verify("adopt_only_stale_jit_different_port_names_it: passes")
106//!
107//! @yah:ticket(R432-F3, "Split adopt_only error: distinguish 'camp not running' from 'camp running but didn't bind'")
108//! @yah:assignee(agent:claude)
109//! @yah:at(2026-06-04T01:13:52Z)
110//! @yah:status(review)
111//! @yah:parent(R432)
112//! @yah:next("Detect camp presence (e.g., socket path exists / camp_socket connectable) before composing the error.")
113//! @yah:next("Case A — no camp: 'mesofact-dev not running for component {id}; attach this workspace in the desktop or run yah camp from a terminal.'")
114//! @yah:next("Case B — camp up, no listener: 'camp is up but mesofact-dev did not bind for component {id} (configured port {port}, jit recorded {actual?}); check spawn_mesofact_dev workspace gating.'")
115//! @yah:next("Drop the 'or any dynamic fallback port' phrasing — it implies a probe that didn't actually happen.")
116//! @yah:verify("Both error variants surface in the desktop Up flow under the right conditions; neither mentions a probe we didn't run.")
117//! @yah:depends_on(R432-B2)
118//! @yah:handoff("Added camp_socket: Option<PathBuf> to LocalStaticOptions. In up_local_static adopt_only error arm: probes the socket via is_unix_socket_live helper (sync UnixStream::connect, cfg(unix) + no-op cfg(not(unix))). Case A (socket absent/unreachable) — 'mesofact-dev not running for this workspace — attach the workspace in the desktop or run yah camp from a terminal.' Case B (socket reachable, mesofact-dev not bound) — 'camp is up but mesofact-dev did not bind on port {port}{jit_note} — check spawn_mesofact_dev workspace gating or camp logs.' Desktop mirror_run.rs now passes camp_socket: Some(rpc::camp_socket_path(&workspace_root)). Updated B2 test adopt_only_stale_jit_different_port_names_it to use a live socket so jit note appears in Case-B path. 28 tests pass, desktop check clean.")
119//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 28 passed")
120//! @yah:verify("cargo check -p desktop — clean")
121//! @yah:verify("adopt_only_no_camp_socket_gives_attach_message: passes")
122//! @yah:verify("adopt_only_live_camp_socket_gives_didnt_bind_message: passes")
123//!
124//! @yah:ticket(R434-F4, "Pond reconciler: spin ssr_runtime container when service has any mode:\"ssr\" route")
125//! @yah:assignee(agent:claude)
126//! @yah:at(2026-06-04T19:12:09Z)
127//! @yah:status(review)
128//! @yah:phase(P2)
129//! @yah:parent(R434)
130//! @arch:see(.yah/docs/working/W173-mesofact-render-cube.md)
131//! @yah:next("Live smoke: declare a workload.toml [ssr_runtime] block + a mirror.toml mode=\"ssr\" route, start camp, confirm bun container + miniflare proxy work end-to-end via YAH_LOCAL_SIM_E2E pond_smoke")
132//! @yah:next("R434-F5 (open) — convert one marketing route to mode:\"ssr\" — unblocked by F4; that's the first real SSR consumer")
133//! @yah:next("Optional: persist read_manifest_ssr_prefixes results across pond rebuilds so a stale dist/manifest.json fall-back doesn't bite (not needed today — manifest is always fresh after a mesofact-dev rebuild)")
134//! @yah:handoff("Phase A — Worker matcher + miniflare env plumbing. (1) router.ts:39 now uses segment-aware `path === p || path.startsWith(p + \"/\")`; rebuilt router.bundle.js; 4 new miniflare tests cover /api/health vs /api/healthcheck + trailing-slash boundary (16/16 pass). (2) local_driver::pond_miniflare::MiniflareSpec gained worker_mode/ssr_origin/ssr_prefixes fields; spawn_miniflare reads them from spec instead of hardcoding static. (3) cloud::reconciler::pond::spawn_miniflare_child + up_pond mirror the change; new public helpers parse_worker_mode and worker_mode_triple let camp derive the triple from mirror slot_fields. (4) camp::build_miniflare_deploy_spec calls parse_worker_mode → worker_mode_triple so flipping a mirror.toml to mode=ssr now works end-to-end.")
135//! @yah:handoff("Phase B — SSR runtime container slot in yubaba. (1) New local_driver::pond_ssr_runtime module with SsrRuntimeSpec, ensure_ssr_runtime_running, SsrRuntimeRunning, and lower_workload_spec(ws, host_port, name, label, timeout) → SsrRuntimeSpec. Lowering pulls image (with digest preference), command, literal env vars (FromSecret/FromMesh rejected with clear errors), Bind volumes, and expose.mesh.ports[0] as container_port (default 3000). 8/8 unit tests pass. (2) New yubaba::pond::ssr_runtime module with SsrRuntimeReconciler (probe + restart) and SsrRuntimeSupervision, mirroring MinioReconciler. (3) yubaba::pond::PondDeployReq gained ssr_runtime: Option<SsrRuntimeSpec>. The deploy handler brings SSR up BETWEEN MinIO and miniflare, overriding effective_miniflare.ssr_origin to point at the bound container so miniflare proxies correctly. RegistryEntry tracks ssr_runtime supervision alongside minio + miniflare; shutdown_all + mark_failed drain it.")
136//! @yah:handoff("Phase C — manifest-derived SSR_PREFIXES + camp wiring. (1) camp::build_ssr_runtime_deploy_spec reads <workload_dir>/workload.toml's MesofactStaticWorkload.ssr_runtime: Option<WorkloadSpec> and lowers it. Host port comes from static_fields.ssr_port (default 4324); collision with miniflare's port is rejected up-front. (2) camp::read_manifest_ssr_prefixes reads <workload_dir>/dist/manifest.json's top-level ssr_prefixes (R015-F2 contract). When present + non-empty, overrides miniflare's spec.ssr_prefixes (mirror.toml override path stays as fallback). (3) Camp's deploy loop now: builds miniflare → builds optional ssr_runtime → overrides miniflare.worker_mode=ssr + ssr_origin when runtime is present → overrides ssr_prefixes from manifest when available → POSTs full req. 9 new camp::r434_f4_ssr_pond_tests pass.")
137//! @yah:handoff("Verify lines satisfied: (a) Worker test /api/health vs /api/healthcheck DIRECTLY covered by tests/router.test.ts segment-aware matcher tests. (b) Pure static/spa pond mirrors still reconcile without spinning ssr_runtime — covered by build_ssr_runtime_deploy_spec_returns_none_without_ssr_runtime_field + existing 25 cloud reconciler::pond tests stay green. (c) End-to-end 'spins bun container and miniflare proxies prefix' is wired and unit-tested at the spec-build/registry layer; live smoke requires an actual workload with ssr_runtime declared + docker available (pond_smoke or YAH_LOCAL_SIM_E2E run). 5 pre-existing mesofact_static test flakes ignored — caused by a real desktop process on 127.0.0.1:4321 on the dev box, not by F4.")
138//! @yah:verify("cd crates/yah/cloud/worker && bun test tests/ → 16 pass (4 new R434-F4 segment-aware tests)")
139//! @yah:verify("cargo test -p local-driver --lib → 46 pass (8 new pond_ssr_runtime tests)")
140//! @yah:verify("cargo test -p yubaba --lib pond → 9 pass (registry handles ssr_runtime supervision)")
141//! @yah:verify("cargo test -p cloud --lib -- reconciler::pond:: reconciler::mesofact_static::tests::config_bindings reconciler::mesofact_static::tests::parse_worker_mode reconciler::mesofact_static::tests::worker_script → 21 pass")
142//! @yah:verify("cargo test -p yah --lib r434_f4_ssr_pond_tests → 9 pass (camp helpers: workload.toml subtree read, manifest ssr_prefixes read, build_ssr_runtime_deploy_spec)")
143//! @yah:verify("cargo check -p local-driver -p yubaba -p cloud -p yah → clean (warnings only, none from F4)")
144//! @yah:verify("Live smoke (user): workload.toml with [ssr_runtime] block + mirror.toml with providers.static.mode=\"ssr\" → yah camp → desktop adopts pond and the SSR prefix routes to the bun container")
145//!
146//! @yah:ticket(R438-T6, "W165 wiring: lower MesofactStaticWorkload.build_mode to ForgeCommand")
147//! @yah:assignee(agent:claude)
148//! @yah:at(2026-06-04T21:07:20Z)
149//! @yah:status(review)
150//! @yah:phase(P2)
151//! @yah:parent(R438)
152//! @yah:next("Replace run_build_command shell-out with run_build(workload_dir, &BuildConfig, &BuildMode)")
153//! @yah:next("Lower BuildMode::HostSide → ForgeSpec{Subprocess, TaskRuntime::Native}; InContainer{image} → {Subprocess(image), TaskRuntime::Container}")
154//! @yah:next("Hand ForgeSpec to task::local::execute — same path QED uses for image-build steps")
155//! @yah:next("TaskLocation::Local; cwd = workload_dir bind-mounted into container")
156//! @yah:verify("BuildMode::HostSide lowers to TaskRuntime::Native; InContainer{image} lowers to TaskRuntime::Container with pinned digest")
157//! @yah:verify("In-tree workload with build_mode=in_container runs build in configured image; host_side runs on host; identical out_dir bytes")
158//! @yah:gotcha("local-static arm behavior decision deferred to F1 (W165 OQ#1) — default-skip with warning is the proposed initial behavior")
159//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
160//! @yah:depends_on(R438-T3)
161//! @yah:handoff("T6 landed. (1) MesofactStaticReconciler gains executor: Arc<dyn ForgeExecutor> field + with_executor() setter; default Arc::new(LocalForgeDriver::default()) — mirrors T15's static_asset pattern. (2) rebuild_static now reads (BuildConfig, BuildMode) from workload.toml via new read_mesofact_build helper and hands them to run_build, which lowers to ForgeSpec{Subprocess{sh -c <cmd>, image?}, TaskPlacement{Local, runtime}} and dispatches through ForgeExecutor::execute. BuildMode::HostSide → image=None + TaskRuntime::Native; InContainer{image} → Some(image) + TaskRuntime::Container. ExecContext::default().with_cwd(workload_dir). (3) Old shell-out (sh -c with tokio::process::Command) deleted. (4) Critical: read_mesofact_build uses raw toml::Value subtree extraction (kind→build→build_mode), NOT the full workload_spec::Workload envelope — production marketing/dashboard workload.tomls carry schema_version = 1 (integer) which the typed envelope rejects; the subtree reader stays tolerant of that legacy shape while still typed-deserializing the build/build_mode subtrees to BuildConfig/BuildMode. ImageRef digest-pin enforcement inherits automatically via T3 (rejects bare-tag at deserialize). (5) 7 new tests under reconciler::mesofact_static::tests: read_mesofact_build_extracts_host_side_default, _extracts_in_container_with_digest, _rejects_in_container_without_digest, _returns_none_for_other_kinds, _returns_none_when_file_absent, rebuild_static_lifts_build_mode_through_executor (e2e: workload.toml→executor with digest round-trip), rebuild_static_defaults_to_host_side_when_build_mode_omitted, rebuild_static_skips_build_when_workload_toml_missing, plus run_build_host_side_lowers_to_native_subprocess, _in_container_lowers_to_container_runtime_with_pinned_digest, _surfaces_stderr_on_nonzero_exit (CaptureExecutor + FailingExecutor mocks). cargo test -p cloud --lib: 293 pass; 5 pre-existing failures (4 adopt_only port-4321 dev-box collision + 1 cloud_init drift — R441-B4 umbrella, unrelated). cargo check --workspace clean.")
162//! @yah:next("Sign off → archive R438-T6")
163//! @yah:next("R438-F9 (local-static arm: respect or skip container build_mode) is now unblocked — picker can decide default-skip vs honor for the local arm")
164//! @yah:next("R438-T8 (worked examples) can now add a mesofact-static workload with build_mode=in_container as an e2e fixture")
165//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 35 pass; 4 R441-B4 adopt_only failures pre-existing")
166//! @yah:verify("cargo check --workspace --locked — clean (warnings only)")
167//! @yah:verify("BuildMode::HostSide → TaskRuntime::Native, image=None; InContainer{image} → TaskRuntime::Container, Some(pinned_image) (asserted by run_build_*_lowers_to_* tests)")
168//! @yah:verify("Legacy schema_version = 1 (integer) workload.tomls still parse — read_mesofact_build uses raw toml::Value subtree extraction")
169//! @yah:gotcha("read_mesofact_build uses raw toml::Value subtree extraction rather than the workload_spec::Workload envelope — production marketing/dashboard workload.tomls carry schema_version = 1 (integer) which the typed envelope rejects (SchemaVersion is enum V1, expects \"V1\" string). Until the workspace-wide schema_version migration ships, T4-style envelope parsing is unsafe in this path. If/when that migration lands, swap read_mesofact_build for a full envelope load.")
170//! @yah:gotcha("rebuild_static is only called from almanac_dispatch (OnChange::MesofactRebuild) — local-static arm bring-up via up() does NOT run the build step (the host watcher handles rebuilds). That gates W165 OQ#1 (R438-F9): the local arm question is purely about whether OnChange feeds should honor container build_mode locally.")
171//!
172//! @yah:ticket(R438-F9, "local-static arm: respect or skip container build_mode? (W165 OQ#1)")
173//! @yah:assignee(agent:claude)
174//! @yah:at(2026-06-04T21:07:57Z)
175//! @yah:status(review)
176//! @yah:parent(R438)
177//! @yah:next("Decide: container build for CI parity vs default-skip (no docker dependency for dev)")
178//! @yah:next("Default-skip with one-line warning is the proposed initial behavior")
179//! @yah:next("If skip: ensure log line is visible in dashboard/task-pane (per long-running→yah surface rule)")
180//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
181//! @yah:depends_on(R438-T6)
182//! @yah:handoff("F9 landed. Decision: local-static arm + InContainer build_mode → warn + fall back to HostSide (W165 OQ#1). Implementation: rebuild_static gains a 3-line pattern-guard before calling run_build; if slot is local-static and build_mode is InContainer, emit warn!(\"build_mode = in_container ignored for local-static; running host-side\") and override to BuildMode::HostSide. No new fields, no new types. Two test changes: (1) rebuild_static_lifts_build_mode_through_executor switched from dev_door_slot(0) to cloudflare_reference_slot() — it now covers the CF publish arm (still asserts TaskRuntime::Container); (2) new rebuild_static_local_static_in_container_falls_back_to_host_side asserts TaskRuntime::Native + image=None when slot=local-static + build_mode=InContainer. cargo test -p cloud --lib reconciler::mesofact_static: 37 pass; 4 pre-existing R441-B4 adopt_only failures. cargo check -p cloud: clean.")
183//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static::tests::rebuild_static_local_static_in_container_falls_back_to_host_side -- passes (TaskRuntime::Native, image=None)")
184//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static::tests::rebuild_static_lifts_build_mode_through_executor -- passes (CF arm still uses TaskRuntime::Container)")
185//! @yah:verify("cargo check -p cloud -- clean")
186//!
187//! @yah:relay(R441, "Workspace test breakage on main (surfaced via R438-T3 sweep)")
188//! @yah:at(2026-06-04T22:55:58Z)
189//! @yah:status(open)
190//! @yah:next("4 independent pre-existing test failures on main, all caught while running `cargo test --workspace` during R438-T3 ImageRef tightening. Each surfaces test signal that's been silently broken; pick up the child tickets and route them to the right owner per area.")
191//! @yah:gotcha("These aren't ImageRef-related and didn't break during R438-T3 — they were broken on main before that work started. The umbrella is a discovery channel, not a regression.")
192//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
193//!
194//! @yah:ticket(R441-B4, "mesofact_static adopt_only_* tests expect Err but get Ok(RunningWorkload)")
195//! @yah:assignee(agent:claude)
196//! @yah:at(2026-06-04T22:56:20Z)
197//! @yah:status(review)
198//! @yah:parent(R441)
199//! @yah:next("Four tests panic at mesofact_static.rs:1188 with `called Result::unwrap_err() on an Ok value: RunningWorkload {...}`: adopt_only_no_camp_socket_gives_attach_message, adopt_only_live_camp_socket_gives_didnt_bind_message, adopt_only_stale_jit_same_port_omits_dynamic_fallback_phrase, adopt_only_stale_jit_different_port_names_it.")
200//! @yah:next("Reconciler changed: adopt-only paths now succeed (return RunningWorkload) where they used to error with operator-facing messages. This is a real behavior question, not a mechanical fix — either revert the reconciler change or update the four tests to assert on the new Ok-shape contract (likely the latter; check the most recent reconciler commit for intent).")
201//! @yah:next("Coordinate with whoever last touched the mesofact-static reconciler before flipping the assertions.")
202//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static::tests::adopt_only_  # all 4 pass")
203//! @yah:handoff("Root cause: all four tests used hardcoded port 4321 which a running camp's mesofact-dev occupies, causing up_local_static to adopt it as Ok instead of reaching the adopt_only error path. Fix: added pick_unused_port() helper (bind :0, read port, drop listener) and replaced dev_door_slot(4321) with pick_unused_port() in all four tests. stale_jit_different_port_names_it also replaced hardcoded 9999 with a second pick_unused_port() so the assertion checks the dynamic value. All 4 pass.")
204//!
205//! R535-T1 ("Split rebuild_static: revalidate-only path... called by
206//! almanac_dispatch", W225 §3) landed here: see [`MesofactStaticReconciler::
207//! revalidate_static`] and [`MesofactStaticReconciler::rebuild_static`]'s docs
208//! for the split, and `crate::almanac_dispatch` for the caller-side switch.
209//! Ticket record lives in `.yah/docs/working/W225-mesofact-consumer-deployment-model.md`
210//! (single declaration site — not duplicated here per Rule11).
211//!
212//! @yah:ticket(R875-B1, "Adopted mesofact-dev gets a no-op shutdown and no logs — dev-tier Stop lies, log pane is empty")
213//! @yah:at(2026-09-09T01:58:02Z)
214//! @yah:status(review)
215//! @yah:assignee(agent:bundle-anthropic-ashguard)
216//! @yah:parent(R875)
217//! @yah:severity(high)
218//! @yah:gotcha("Reproduced live on this camp 2026-09-08: three orphaned mesofact-dev processes, all PPID 1 (yah-marketing/site on 4321, scrabcake/site on 4353, and a leaked test-svc on 55506 from a 13:12 test run). The 4321 one survived both a desktop quit and a camp-daemon restart, which is the operator report that opened this relay.")
219//! @yah:gotcha("\"Stop makes it go away for a second then it comes back\" is NOT a respawn and NOT kamaji. The dev cell's running-state is a live port probe, not the registry: mirror_run_list -> observe_mirrors -> probe_dev_cell (app/yah/desktop/src/mirror_observation.rs:268), polled every 3s by MirrorPanel. Stop empties the desktop registry, the row briefly renders from the stopped snapshot, the next poll re-probes 4321, finds the server still serving, and the row returns. The UI was reporting honestly; the stop was the lie.")
220//! @yah:handoff("FIXED. Mechanism: try_adopt_identified returned RunningWorkload::adopted(), whose shutdown() is a documented no-op (shutdown/supervisor/teardown all None) and whose log_buffer is None. mirror_run_down called it, got Ok(()), set stopped=true and reported success. The spawn arm has always had a real teardown and a LogBuffer — but the desktop re-adopts on every launch, so the only run that ever got a live handle was the one that first spawned the server. This is R714-B1 one reconciler over, and that ticket's own handoff had already decided the adopt arm must carry teardown too; the decision was applied to container.rs and not here.")
221//! @yah:handoff("Landed in four parts. (1) try_adopt_identified is now identity-verification only, returning Result<Option<SocketAddr>>; the new MesofactStaticReconciler::adopted_workload builds the handle where ReconcileCtx is in scope. (2) native_support::stop_process_listening_on(addr, grace) — SIGTERM, wait for the port to go quiet, SIGKILL, then FAIL if the port is still accepting. (3) native_support::spawn_capture_tail — a tail-only supervisor for a workload this process does not hold a NativeRuntime handle for, plus FileTail::from_end. (4) RunningWorkload::owns_teardown() replaces mirror_run_down's `kind == \"container\"` test.")
222//! @yah:handoff("DESIGN CALL, and the one a reviewer should push on: teardown resolves the pid FROM THE PORT (`lsof -nP -iTCP:<port> -sTCP:LISTEN -t`) rather than from a pid recorded at spawn time. local_process.rs already has the pid-sidecar shape (owner.json, write_owner/reap_owner) and reusing it was the obvious move — rejected because a sidecar is only ever stale in exactly the orphan case this exists to fix, and a recycled pid on a long-lived mac names an innocent process. The port has no staleness window: the caller has already confirmed via /__mesofact/info that the listener IS this service+component, and success is verified by effect (the port stops accepting), so a kill that misses is reported as a failed stop instead of a silent success. Cost: a shell-out to lsof, and an honest error if lsof is absent.")
223//! @yah:handoff("BUG MY OWN TESTS CAUGHT, worth knowing before touching the log half: the adopt tail must start at end-of-file, not offset 0. An adopted server is not reliably the process that wrote the capture file — the mesofact-dev holding 4321 here started at 17:43 while .yah/jit/native/mesofact-dev-yah-marketing-site/stdout.log had not been touched since 17:39 — so draining from 0 replays a dead run's output as the live server's. FileTail::from_end seeds the offset at the current length; the spawn path keeps FileTail::new (offset 0) because NativeRuntime truncated the file for that child. Both arms pinned by tests.")
224//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib -- native_support teardown_tests adopt_identified  # 17 passed, 0 failed (2026-09-08)")
225//! @yah:verify("MANUAL, blocked on a desktop rebuild+install (app/yah/desktop/install-app.sh): with mesofact-dev orphaned on 4321 (PPID 1), press Stop on the yah-marketing dev cell — `lsof -nP -iTCP:4321 -sTCP:LISTEN` must come back empty and the cell must stay idle across the next 3s poll, not flip back to running.")
226//! @yah:verify("MANUAL: a Stop that cannot free the port must surface the error chip, never a silent success — mirror_run_down now returns Err for any workload whose owns_teardown() is true.")
227
228/// Bundled Worker script embedded at compile time from `worker/router.bundle.js`.
229///
230/// The source of truth is `@mesofact/edge`
231/// (`oss/mesofact/packages/mesofact-edge`) — the manifest-driven serving
232/// artifact mesofact owns (W270 §3, R595-F3). Its built bundle is *vendored*
233/// into `worker/router.bundle.js` by `scripts/check-worker-bundle.sh` so this
234/// crate stays standalone-exportable across the OSS mirror boundary (a
235/// cross-boundary `include_str!` into `oss/mesofact` would break yubaba's
236/// export). Run `scripts/check-worker-bundle.sh --update` after editing the
237/// worker; do NOT hand-edit `router.bundle.js`.
238///
239/// Used both for prod deployment and as the miniflare-sim artifact in the pond
240/// tier.
241pub const WORKER_SCRIPT: &str = include_str!("../../worker/router.bundle.js");
242
243use std::sync::Arc;
244
245use anyhow::{Context, Result};
246use async_trait::async_trait;
247use tracing::{info, warn};
248
249use velveteen::{
250    ForgeCommand, ForgeSpec, Initiator, MeshAccess, TaskLocation, TaskPlacement, TaskRuntime,
251};
252use velveteen_exec::{ExecContext, ForgeExecutor, LocalForgeDriver};
253use workload_spec::{BuildConfig, BuildMode};
254
255use super::{dev_door, pond, ReconcileCtx, Reconciler, RunningWorkload};
256use crate::route_table::WorkerAssets;
257use crate::{MirrorProviderSlot, Provider};
258
259/// Workload kind this reconciler handles. Matches `ServiceComponent.kind`
260/// and the `kind = "..."` line in `workload.toml`.
261pub const WORKLOAD_KIND: &str = "mesofact-static";
262
263/// SPA sibling of [`WORKLOAD_KIND`]: mesofact emits a hydrate-bundle-loading
264/// HTML shell instead of a fully-rendered page per route. The serving path is
265/// identical (assets in a bucket behind the Worker/miniflare router); the only
266/// behavioral difference is the Worker's fallback mode, so the same reconciler
267/// handles both kinds.
268pub const WORKLOAD_KIND_SPA: &str = "mesofact-spa";
269
270/// True for the component kinds served by [`MesofactStaticReconciler`].
271pub fn is_mesofact_site_kind(kind: &str) -> bool {
272    kind == WORKLOAD_KIND || kind == WORKLOAD_KIND_SPA
273}
274
275
276/// Reconciles `kind = "mesofact-static"` components.
277///
278/// The `executor` field handles the build step (W165): `build.command` is
279/// lowered to a [`ForgeSpec`] and dispatched through
280/// [`ForgeExecutor::execute`]. Default is [`LocalForgeDriver`]; callers
281/// wanting to redirect (e.g. tests with a mock executor) use
282/// [`Self::with_executor`].
283pub struct MesofactStaticReconciler {
284    /// Knobs for the miniflare door. Shared by the dev and pond arms — the
285    /// door is one piece of machinery at both tiers, and the fields that
286    /// differ (MinIO's image and credentials) are simply unread at dev.
287    pub pond: pond::PondOptions,
288    executor: Arc<dyn ForgeExecutor>,
289}
290
291impl MesofactStaticReconciler {
292    pub fn new() -> Self {
293        Self {
294            pond: pond::PondOptions::default(),
295            executor: Arc::new(LocalForgeDriver::default()),
296        }
297    }
298
299    pub fn with_pond(mut self, opts: pond::PondOptions) -> Self {
300        self.pond = opts;
301        self
302    }
303
304    /// Swap the [`ForgeExecutor`] used to run the build step. Production
305    /// callers take the [`LocalForgeDriver`] default; tests inject a mock
306    /// to assert the lowered [`ForgeSpec`] without spawning a subprocess.
307    pub fn with_executor(mut self, executor: Arc<dyn ForgeExecutor>) -> Self {
308        self.executor = executor;
309        self
310    }
311}
312
313impl Default for MesofactStaticReconciler {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319#[async_trait]
320impl Reconciler for MesofactStaticReconciler {
321    fn kind(&self) -> &'static str {
322        WORKLOAD_KIND
323    }
324
325    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
326        // BYO git (R561-F1): if the component is git-sourced, shallow-clone it
327        // into the source cache before anything reads workload_dir(). No-op for
328        // in-tree components.
329        ctx.materialize().await?;
330
331        // Validate that the workload manifest agrees with the component's
332        // declared kind. Mismatch is an authoring error (service.toml
333        // points at a workload of the wrong shape).
334        let kind = ctx.workload_kind().context("loading workload.toml")?;
335        if kind != ctx.component.kind {
336            anyhow::bail!(
337                "component {component_id} kind=\"{component_kind}\" but {workload_dir}/workload.toml declares kind=\"{kind}\"",
338                component_id = ctx.component.id,
339                component_kind = ctx.component.kind,
340                workload_dir = ctx.workload_dir().display(),
341            );
342        }
343
344        let slot = ctx.slot("static").with_context(|| {
345            format!(
346                "mirror has no `providers.static` slot — required for kind=\"mesofact-static\" (service={}, env={})",
347                ctx.service.name, ctx.env,
348            )
349        })?;
350
351        match slot {
352            // Dev: miniflare in front of whatever the mirror bound to `s3`.
353            // Both halves are load-bearing — a `miniflare-native` door with no
354            // store to read is a misconfiguration, not a tier, so the arm
355            // requires the binding rather than inventing a default.
356            MirrorProviderSlot::Inline {
357                kind: Provider::MiniflareNative,
358                fields,
359            } => {
360                anyhow::ensure!(
361                    dev_door::binds_dev_store(&ctx),
362                    "providers.static.kind = \"miniflare-native\" but the mirror binds no \
363                     dev-tier s3 driver — add `[drivers.s3]` / `kind = \"local-s3-fs\"` \
364                     (service={}, env={})",
365                    ctx.service.name,
366                    ctx.env,
367                );
368                dev_door::up_dev_door(&ctx, &self.pond, fields, WORKER_SCRIPT).await
369            }
370            // Pond: the same door, in front of a MinIO container.
371            MirrorProviderSlot::Inline {
372                kind: Provider::MiniflareContainer,
373                fields,
374            } => pond::up_pond(&ctx, &self.pond, fields, WORKER_SCRIPT).await,
375            MirrorProviderSlot::Inline { kind, .. } => {
376                anyhow::bail!(
377                    "providers.static.kind = \"{kind:?}\" not supported by mesofact-static reconciler (only miniflare-native + miniflare-container for now)",
378                )
379            }
380            MirrorProviderSlot::Reference {
381                provider_id,
382                fields,
383            } => {
384                // Dispatch on the resolved provider *kind*, not the literal
385                // name, so a workspace can name several cloudflare providers
386                // (e.g. `cloudflare` + `cloudflare-scrabcake`).
387                let cf = super::cf_creds::CfProvider::resolve_scoped(
388                    ctx.workspace_root,
389                    provider_id,
390                    &ctx.scope.tenant,
391                    &ctx.scope.namespace,
392                )?;
393                anyhow::ensure!(
394                    matches!(cf.cfg.kind, Provider::Cloudflare),
395                    "providers.static.use = {provider_id:?} (kind={:?}) — only cloudflare-kind \
396                     reference providers are supported for mesofact-static",
397                    cf.cfg.kind,
398                );
399                self.up_cloudflare_r2(&ctx, cf, fields).await
400            }
401        }
402    }
403}
404
405impl MesofactStaticReconciler {
406    /// Re-sync a *running* mirror in place — re-publish the built dist without
407    /// rebuilding or restarting the serve stack. Both miniflare doors support
408    /// it, because both serve out of a bucket: dev re-publishes into the
409    /// `yah-s3-fs` driver via [`dev_door::sync_dev_door`], pond into the
410    /// already-running MinIO via [`pond::sync_pond`]. Returns the number of
411    /// assets uploaded.
412    ///
413    /// This is what the desktop's `⟳` affordance calls for local mirrors.
414    /// Re-running `up` would collide on the already-bound door port, so `sync`
415    /// is the correct re-sync entry point. Cloudflare goes through the
416    /// publish-assets pipeline and has no in-place bucket re-sync.
417    pub async fn sync(&self, ctx: ReconcileCtx<'_>) -> Result<usize> {
418        ctx.materialize().await?;
419        let slot = ctx.slot("static").with_context(|| {
420            format!(
421                "mirror has no `providers.static` slot — required for kind=\"mesofact-static\" (service={}, env={})",
422                ctx.service.name, ctx.env,
423            )
424        })?;
425        match slot {
426            MirrorProviderSlot::Inline {
427                kind: Provider::MiniflareNative,
428                fields,
429            } => dev_door::sync_dev_door(&ctx, fields).await,
430            MirrorProviderSlot::Inline {
431                kind: Provider::MiniflareContainer,
432                fields,
433            } => pond::sync_pond(&ctx, &self.pond, fields).await,
434            MirrorProviderSlot::Inline { kind, .. } => anyhow::bail!(
435                "providers.static.kind = \"{kind:?}\" has no in-place re-sync — only the miniflare doors (dev, pond) support ⟳ sync",
436            ),
437            MirrorProviderSlot::Reference { .. } => anyhow::bail!(
438                "reference (cloud) providers re-sync through the publish-assets pipeline, not the pond reconciler",
439            ),
440        }
441    }
442
443    /// Build the workload (re-running `build.command`) then publish it to its
444    /// configured provider slot.
445    ///
446    /// This is the **full rebuild** path — source/template changes, a fresh
447    /// `mirror up`, or any case where the compiled bundle itself may be
448    /// stale. It is *not* what `almanac_dispatch` calls for a data-only feed
449    /// change — see [`Self::revalidate_static`] for that (W225 §3: a data
450    /// change is "revalidate", not "build", and never needs the bundler).
451    ///
452    /// For the Cloudflare reference arm this publishes the freshly-built
453    /// `dist/` to R2 and purges the CDN cache-tag `page:releases`; the two
454    /// miniflare arms publish into their bucket inside [`Self::up`]. Every
455    /// arm honours the workload's declared `build_mode`, including
456    /// `in_container` — the dev tier used to override that to host-side
457    /// (W165 OQ#1, R438-F9) because it had no bucket to publish into and no
458    /// docker guarantee; with dev on the same publish path as pond, the
459    /// override was a per-tier fork with nothing left to justify it.
460    pub async fn rebuild_static(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
461        let workload_dir = ctx.workload_dir();
462        let override_ = ctx.build_override();
463        if let Some((mut build, build_mode)) = read_mesofact_build(&workload_dir)? {
464            apply_build_override(&mut build, override_);
465            run_build(
466                &workload_dir,
467                &build,
468                &build_mode,
469                &build_env(override_),
470                &*self.executor,
471            )
472            .await?;
473        }
474        self.up(ctx).await
475    }
476
477    /// Publish the workload's **already-built** artifact directory — never
478    /// runs `build.command` (W225 §3, R535-T1).
479    ///
480    /// This is the path `almanac_dispatch` calls for
481    /// `OnChangeConfig::MesofactRebuild`: an almanac feed change is *data*,
482    /// not a source/template change, so re-running the bundler is wasted
483    /// work (and, for CI-gated `in_container` builds, wasted pull/cold-start
484    /// too). Per the doc: "almanac = revalidate = data → SSG output on the
485    /// already-built bundle... no recompilation, no CI gate, because nothing
486    /// executable changed."
487    ///
488    /// When the workload declares `build.render_command` (R535-T7), the
489    /// data-only re-render runs first — `{route}` substituted with the
490    /// invalidated route pattern, executed against the **already-built**
491    /// bundle via the same [`ForgeExecutor`] lowering as the build step
492    /// (host-side or in-container per `build_mode`), but never
493    /// `build.command` itself. The canonical command is `mesofact-build
494    /// render <dir> --route {route} --all`, which re-expands the route's
495    /// prerender params fresh and rewrites `out_dir`'s HTML for that route
496    /// only. Without `render_command` this republishes whatever bytes sit in
497    /// `build.out_dir` (the pre-T7 behavior, still correct when the bundle's
498    /// HTML was refreshed by some other actor).
499    ///
500    /// Every arm runs the render, dev included. The dev tier used to skip it
501    /// on the grounds that `mesofact-dev`'s own watcher re-rendered on
502    /// data-file changes; there is no such watcher behind the miniflare door,
503    /// and a tier that silently served pre-invalidation HTML while its
504    /// siblings re-rendered was exactly the kind of divergence W265 removes.
505    pub async fn revalidate_static(
506        &self,
507        ctx: ReconcileCtx<'_>,
508        route: &str,
509    ) -> Result<RunningWorkload> {
510        let workload_dir = ctx.workload_dir();
511        let override_ = ctx.build_override();
512        if let Some((mut build, build_mode)) = read_mesofact_build(&workload_dir)? {
513            apply_build_override(&mut build, override_);
514            if let Some(render_command) = &build.render_command {
515                let render = BuildConfig {
516                    command: Some(render_command.replace("{route}", route)),
517                    out_dir: build.out_dir.clone(),
518                    render_command: None,
519                };
520                run_build(
521                    &workload_dir,
522                    &render,
523                    &build_mode,
524                    &build_env(override_),
525                    &*self.executor,
526                )
527                .await?;
528            }
529        }
530        self.up(ctx).await
531    }
532
533
534    /// Cloudflare R2 publish path: upload `dist/` to R2, optionally purge CDN
535    /// cache tags, and return a `RunningWorkload` with `public_url` set.
536    async fn up_cloudflare_r2(
537        &self,
538        ctx: &ReconcileCtx<'_>,
539        cf_provider: super::cf_creds::CfProvider,
540        slot_fields: &std::collections::BTreeMap<String, toml::Value>,
541    ) -> Result<RunningWorkload> {
542        use super::r2_publish::{publish_to_r2, R2PurgeOpts};
543        use crate::provider::cloudflare::{CloudflareClient, WorkerBinding};
544
545        // account_id + credentials come from the resolved provider.
546        let account_id = cf_provider.account_id.clone();
547
548        // Extract bucket + zone from the mirror's static slot.
549        let bucket = slot_fields
550            .get("bucket")
551            .and_then(|v| v.as_str())
552            .context("providers.static missing `bucket` field for cloudflare R2 publish")?
553            .to_string();
554        let zone = slot_fields
555            .get("zone")
556            .and_then(|v| v.as_str())
557            .context("providers.static missing `zone` field for cloudflare R2 publish")?
558            .to_string();
559
560        // asset_origin is the public HTTP URL the Worker fetches assets from.
561        // publish_to_r2 lays files down under `<svc>/<env>/<key>`, so this URL
562        // must include the same prefix. Validate up front — without it the
563        // Worker would 404 every request in prod.
564        let asset_origin =
565            slot_fields
566                .get("asset_origin")
567                .and_then(|v| v.as_str())
568                .filter(|s| !s.is_empty())
569                .with_context(|| {
570                    format!(
571                "providers.static.asset_origin missing or empty (service={svc}, env={env}) — \
572                 set it to the R2 public URL with the publish prefix, e.g. \
573                 \"https://cdn.{zone}/{svc}/{env}\"",
574                svc = ctx.service.name, env = ctx.env, zone = zone,
575            )
576                })?
577                .to_string();
578
579        // R2 S3 access keys (distinct from the management API token).
580        let (access_key, secret_key) = cf_provider.r2_keys()?;
581
582        // Management API token — used for cache-tag purge and Transform Rules.
583        // Optional: publish itself only needs the R2 S3 keys.
584        let cf_api_token: Option<String> = cf_provider.api_token_opt();
585        let purge = cf_api_token.clone().map(|token| R2PurgeOpts {
586            zone_name: zone.clone(),
587            api_token: token,
588        });
589
590        // Resolve dist dir from workload.toml build.out_dir (default: "dist").
591        let workload_dir = ctx.workload_dir();
592        let out_dir = read_workload_out_dir(&workload_dir).unwrap_or_else(|| "dist".to_string());
593        let dist_dir = workload_dir.join(&out_dir);
594
595        let mirror_prefix =
596            publish_prefix(&ctx.service.name, ctx.env, ctx.component.mount.as_deref());
597        let report = publish_to_r2(
598            &dist_dir,
599            &account_id,
600            &bucket,
601            &access_key,
602            &secret_key,
603            Some(&mirror_prefix),
604            purge,
605        )
606        .await
607        .with_context(|| format!("publishing to R2 bucket {bucket:?} (account {account_id})"))?;
608
609        info!(
610            uploaded = report.uploaded.len(),
611            purged = report.purged_tags.len(),
612            bucket,
613            zone,
614            "R2 publish complete",
615        );
616
617        // Deploy CF Worker script (replaces the Transform Rule workaround).
618        // Worker serves assets via ASSET_ORIGIN with mode-aware routing:
619        // static 404-fallback, SPA index.html fallback, or SSR proxy to origin.
620        // Non-fatal: warn if token lacks Workers Scripts: Edit scope.
621        if let Some(ref token) = cf_api_token {
622            let cf = CloudflareClient::new(token.clone());
623            let mode = parse_worker_mode(&ctx.component.kind, slot_fields);
624            let worker_name = slot_fields
625                .get("worker_name")
626                .and_then(|v| v.as_str())
627                .map(|s| s.to_string())
628                .unwrap_or_else(|| format!("{}-worker", ctx.service.name));
629            let backends = BackendOrigins::from_slot_fields(slot_fields);
630            let domain =
631                crate::config::domain_for_service(ctx.workspace_root, &ctx.service.name)?;
632            let bindings =
633                worker_config_bindings(
634                    &mode,
635                    WorkerAssets::Deployed(&asset_origin),
636                    &backends,
637                    domain.as_ref(),
638                )?;
639            let worker_bindings: Vec<WorkerBinding<'_>> = bindings
640                .iter()
641                .map(ConfigBinding::as_worker_binding)
642                .collect();
643            // Hash script + bindings so config changes trigger redeploy.
644            let script_hash = {
645                let mut input = WORKER_SCRIPT.as_bytes().to_vec();
646                input.push(0);
647                input.extend_from_slice(
648                    serde_json::to_string(&bindings)
649                        .unwrap_or_default()
650                        .as_bytes(),
651                );
652                sha256_hex(&input)
653            };
654
655            let worker_result = async {
656                let zone_id = cf.zone_id_for_name(&zone).await?;
657
658                // Skip redeploy when script + config are unchanged across re-runs.
659                let cached = read_worker_script_hash(ctx.workspace_root, &worker_name);
660                if cached.as_deref() != Some(&script_hash) {
661                    cf.deploy_worker_script(
662                        &account_id,
663                        &worker_name,
664                        WORKER_SCRIPT,
665                        &worker_bindings,
666                    )
667                    .await?;
668                    let _ =
669                        write_worker_script_hash(ctx.workspace_root, &worker_name, &script_hash);
670                    info!(worker_name, "CF Worker script deployed");
671                } else {
672                    info!(
673                        worker_name,
674                        "CF Worker script unchanged — skipping redeploy"
675                    );
676                }
677
678                // Upsert zone route: `{zone}/*` → worker script.
679                let route_pattern = format!("{zone}/*");
680                cf.upsert_worker_route(&zone_id, &route_pattern, &worker_name)
681                    .await?;
682                anyhow::Ok(())
683            }
684            .await;
685
686            // R703-B4 — how loudly this fails depends on whether the Worker is
687            // the door the public actually comes through.
688            //
689            // It was unconditionally non-fatal, and that is how the yah.dev
690            // Worker ended up 19 days behind the router bundle in-tree: the
691            // token lacked `Workers Scripts: Edit`, every apply warned into a
692            // logger the CLI never installed, and every apply reported ok. A
693            // front door that cannot be updated is not a warning — it is the
694            // failure. When the zone's manifest declares `front_door =
695            // "worker"`, a failed deploy is fatal.
696            //
697            // For any other declared front door the Worker is a warm rollback
698            // lever rather than the live door, so a warning remains right: it
699            // should not be able to fail an apply for a surface serving no
700            // traffic.
701            if let Err(e) = worker_result {
702                let is_live_front_door = matches!(
703                    super::publish_beacon::declared_front_door(ctx.workspace_root, &zone),
704                    Some((_, crate::config::FrontDoor::Worker))
705                );
706                if is_live_front_door {
707                    return Err(e).with_context(|| {
708                        format!(
709                            "deploying the Cloudflare Worker for {zone} (script {worker_name}).\n\
710                         \n\
711                         .yah/domains/*.toml declares front_door = \"worker\" for this zone, so \
712                         this Worker IS the public front door — it cannot be left at whatever \
713                         version happens to be deployed. The publish above succeeded; what \
714                         failed is updating the thing that serves it.\n\
715                         \n\
716                         An `Authentication error` here means the configured Cloudflare \
717                         token cannot touch Workers. Test that DIRECTLY — a token-validity \
718                         check will not tell you, because the token is almost certainly \
719                         valid and merely under-scoped:\n\
720                         \n\
721                           curl -sS -H \"Authorization: Bearer $(yah keys get <slot>)\" \\\n\
722                             https://api.cloudflare.com/client/v4/accounts/<acct>/workers/scripts\n\
723                         \n\
724                         DO NOT reach for https://api.cloudflare.com/client/v4/user/tokens/verify \
725                         to triage this. An account-scoped token (`cfat_` prefix) is rejected \
726                         there with a flat `code 1000, Invalid API Token`, which reads \
727                         exactly like a revoked credential and sends you hunting for a token \
728                         that is fine. That misdiagnosis has now happened twice. The valid \
729                         health check for an account token is \
730                         /accounts/<acct>/tokens/verify.\n\
731                         \n\
732                         THE FIX, if the workers/scripts GET is denied: mint a token that \
733                         carries the grants, rather than editing one by hand —\n\
734                         \n\
735                           yah cloud cf token create --zone <zone> \\\n\
736                             --store-slot cloudflare-mesofact-static \\\n\
737                             --bootstrap-slot <a slot holding API Tokens: Edit>\n\
738                         \n\
739                         then point `credentials` in .yah/infra/providers/cloudflare.toml at \
740                         that slot. It builds MESOFACT_STATIC_GRANTS, which includes \
741                         `Workers Scripts: Write` (account) and `Workers Routes: Write` \
742                         (zone). The command's own summary line prints only five scopes and \
743                         omits both — that text is stale, the policy is not.\n\
744                         \n\
745                         Whatever the cause, it is silent everywhere else: the R2 publish \
746                         uses separate S3 keys and keeps working, so the bucket stays \
747                         current while the Worker — the thing that serves it — freezes."
748                        )
749                    });
750                }
751                warn!(
752                    zone,
753                    worker_name,
754                    error = %e,
755                    "CF Worker deploy/route failed (non-fatal — this zone's declared \
756                     front door is not the Worker, so it serves no traffic today) — \
757                     ensure cloudflare-api-token has Workers Scripts: Edit \
758                     and Zone Workers Routes: Edit scope"
759                );
760            }
761        }
762
763        // R703-B4 — the publish is not the deliverable; the served page is.
764        self.verify_serving(ctx, slot_fields, &zone, &asset_origin, &report.beacon)
765            .await?;
766
767        Ok(RunningWorkload::adopted("mesofact-static", "static", None)
768            .with_public_url(format!("https://{zone}")))
769    }
770
771    /// Fetch the just-written publish beacon back through the origin and the
772    /// public front door, and fail the apply if the front door is serving
773    /// anything else.
774    ///
775    /// R703-B4. Everything upstream of here reports success on the *write*:
776    /// R2 accepted the objects, the Worker API accepted the script, the apply
777    /// exits 0. None of that is evidence that the bytes reached a reader, and
778    /// twice now they did not — once because a declared-but-unready bundle
779    /// tier disabled this chain, once because the apex had been cut over to an
780    /// origin nothing republishes. Both presented as HTTP 200 on a stale page.
781    ///
782    /// The origin probe is checked first and separately on purpose: it splits
783    /// "the publish did not land" from "the publish landed and the front door
784    /// is elsewhere", which are different tickets with identical symptoms.
785    async fn verify_serving(
786        &self,
787        ctx: &ReconcileCtx<'_>,
788        slot_fields: &std::collections::BTreeMap<String, toml::Value>,
789        zone: &str,
790        asset_origin: &str,
791        beacon: &super::publish_beacon::PublishBeacon,
792    ) -> Result<()> {
793        use super::publish_beacon as pb;
794
795        // Opt-out for a deliberately in-flight front-door migration. Declared
796        // in config rather than passed as a CLI flag so that turning it off is
797        // a reviewable diff next to a comment naming the ticket, not an
798        // invocation habit that quietly becomes permanent.
799        let verify = slot_fields
800            .get("verify_serving")
801            .and_then(|v| v.as_bool())
802            .unwrap_or(true);
803        if !verify {
804            warn!(
805                zone,
806                "verify_serving = false — publish NOT checked against the live \
807                 front door; the site can go stale without this apply failing"
808            );
809            return Ok(());
810        }
811
812        let verdict = pb::check_serving(
813            ctx.workspace_root,
814            zone,
815            Some(asset_origin),
816            beacon,
817            pb::EDGE_PROBE_ATTEMPTS,
818            pb::EDGE_PROBE_DELAY,
819        )
820        .await;
821
822        if verdict.is_ok() {
823            info!(
824                zone,
825                digest = %beacon.digest,
826                files = beacon.files,
827                "front door is serving this publish"
828            );
829            return Ok(());
830        }
831
832        // A stale or absent front door means the deployed Worker (if any) may
833        // be older than the bundle this build embeds, and the local hash cache
834        // would otherwise skip redeploying it forever — that cache is a claim
835        // about the live Worker made from an untracked file on one laptop.
836        // Drop the entry so the next apply cannot take the skip branch.
837        if !verdict.front_door.is_match() {
838            let worker_name = slot_fields
839                .get("worker_name")
840                .and_then(|v| v.as_str())
841                .map(|s| s.to_string())
842                .unwrap_or_else(|| format!("{}-worker", ctx.service.name));
843            forget_worker_script_hash(ctx.workspace_root, &worker_name);
844        }
845
846        Err(verdict.into_error(beacon))
847    }
848}
849
850/// Read the `[build]` + `[build_mode]` subtrees from
851/// `<workload_dir>/workload.toml`. Used by [`MesofactStaticReconciler::rebuild_static`]
852/// to drive [`run_build`] without forcing the whole workload through the
853/// `workload_spec::Workload` envelope, while still giving us typed [`BuildConfig`] and
854/// [`BuildMode`] values to lower.
855///
856/// Returns:
857/// - `Ok(None)` when `workload.toml` is absent, isn't a `mesofact-static`
858///   workload, or has no `[build]` table — `rebuild_static` then skips the
859///   build step (the subsequent `up()` will surface the missing-manifest
860///   error if relevant).
861/// - `Ok(Some((build, build_mode)))` on success; `build_mode` defaults to
862///   `HostSide` when the field is absent.
863fn read_mesofact_build(workload_dir: &std::path::Path) -> Result<Option<(BuildConfig, BuildMode)>> {
864    let path = workload_dir.join("workload.toml");
865    let src = match std::fs::read_to_string(&path) {
866        Ok(s) => s,
867        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
868        Err(e) => return Err(anyhow::Error::new(e).context(format!("reading {}", path.display()))),
869    };
870    let value: toml::Value =
871        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
872
873    if value.get("kind").and_then(|v| v.as_str()) != Some(WORKLOAD_KIND) {
874        return Ok(None);
875    }
876
877    let Some(build_value) = value.get("build") else {
878        return Ok(None);
879    };
880    let build: BuildConfig = build_value
881        .clone()
882        .try_into()
883        .with_context(|| format!("parsing [build] table in {}", path.display()))?;
884
885    let build_mode = match value.get("build_mode") {
886        Some(v) => v
887            .clone()
888            .try_into()
889            .with_context(|| format!("parsing [build_mode] table in {}", path.display()))?,
890        None => BuildMode::default(),
891    };
892
893    Ok(Some((build, build_mode)))
894}
895
896/// Fold a mirror's `[build.<component>]` override into the `BuildConfig` read
897/// from the component's `workload.toml` (R905).
898///
899/// Field-wise replacement, not a whole-table swap: a mirror that only names an
900/// `env` keeps the workload's commands, and one that only names a `command`
901/// keeps its `render_command`. `out_dir` is never overridden — see
902/// [`MirrorBuildOverride`](crate::config::MirrorBuildOverride) for why.
903///
904/// `None` (no override, or an override that changes nothing) leaves `build`
905/// byte-identical, which is what keeps every environment that declares no
906/// override on exactly the pre-R905 path.
907pub(crate) fn apply_build_override(
908    build: &mut BuildConfig,
909    override_: Option<&crate::config::MirrorBuildOverride>,
910) {
911    let Some(o) = override_ else { return };
912    if let Some(command) = &o.command {
913        build.command = Some(command.clone());
914    }
915    if let Some(render_command) = &o.render_command {
916        build.render_command = Some(render_command.clone());
917    }
918}
919
920/// The env pairs a mirror's build override exports to the build subprocess —
921/// empty when there is no override (R905).
922pub(crate) fn build_env(
923    override_: Option<&crate::config::MirrorBuildOverride>,
924) -> Vec<(String, String)> {
925    override_.map(|o| o.env_pairs()).unwrap_or_default()
926}
927
928/// Lower (`build`, `build_mode`) to a [`ForgeSpec`] (W165).
929///
930/// - [`BuildMode::HostSide`] → `TaskRuntime::Native`, `image=None` — the
931///   build inherits the host's PATH and toolchain.
932/// - [`BuildMode::InContainer { image }`] → `TaskRuntime::Container` with
933///   the pinned image attached to the `Subprocess` command. The executor
934///   bind-mounts `workload_dir` as the container's working directory via
935///   the [`ExecContext`] passed alongside.
936///
937/// `None` when the manifest declares no `build.command` (R838-B1) — there is
938/// no subprocess to lower, because the project builds through the in-process
939/// `mesofact-dev` pipeline rather than an external bundler. Callers skip the
940/// build step rather than lowering an empty `sh -c ""`, which would "succeed"
941/// having produced nothing.
942///
943/// Pure function: no I/O, no subprocess. Exposed at `pub(crate)` for
944/// golden-test parity with the recipe-lowering helper (R438-T7).
945pub(crate) fn lower_build_to_forge_spec(
946    workload_dir: &std::path::Path,
947    build: &BuildConfig,
948    build_mode: &BuildMode,
949) -> Option<ForgeSpec> {
950    let command = build.command.clone()?;
951    let (image, runtime) = match build_mode {
952        BuildMode::HostSide => (None, TaskRuntime::Native),
953        BuildMode::InContainer { image } => (Some(image.clone()), TaskRuntime::Container),
954    };
955    Some(ForgeSpec {
956        command: ForgeCommand::Subprocess {
957            argv: vec!["sh".into(), "-c".into(), command],
958            image,
959        },
960        where_: TaskPlacement::new(TaskLocation::Local, runtime),
961        timeout: None,
962        label: Some(format!("mesofact-static-build:{}", workload_dir.display())),
963        initiator: Initiator::Gnome {
964            camp: "mesofact-static-reconciler".into(),
965            shift: "build".into(),
966        },
967        mesh_access: MeshAccess::default(),
968        cache_key: None,
969    })
970}
971
972/// Lower (`build`, `build_mode`) to a [`ForgeSpec`] and run it through the
973/// supplied [`ForgeExecutor`] (W165). Thin wrapper over
974/// [`lower_build_to_forge_spec`] — separated so the lowering is testable
975/// without spawning a subprocess.
976///
977/// A manifest with no `build.command` is a no-op here (R838-B1): the project
978/// has no external bundler step, so there is nothing for this reconciler to
979/// shell out to. Same outcome as a workload with no `workload.toml` at all,
980/// which `rebuild_static` has always skipped.
981async fn run_build(
982    workload_dir: &std::path::Path,
983    build: &BuildConfig,
984    build_mode: &BuildMode,
985    env: &[(String, String)],
986    executor: &dyn ForgeExecutor,
987) -> Result<()> {
988    let mode_tag = match build_mode {
989        BuildMode::HostSide => "host_side",
990        BuildMode::InContainer { .. } => "in_container",
991    };
992    let Some(spec) = lower_build_to_forge_spec(workload_dir, build, build_mode) else {
993        tracing::info!(
994            workload = %workload_dir.display(),
995            "workload.toml declares no [build] command — skipping the build step \
996             (the project builds in-process)"
997        );
998        return Ok(());
999    };
1000    let cmd_str = build
1001        .command
1002        .clone()
1003        .expect("lower_build_to_forge_spec returns Some only when command is Some");
1004    tracing::info!(
1005        workload = %workload_dir.display(),
1006        cmd = %cmd_str,
1007        mode = mode_tag,
1008        env_overrides = env.len(),
1009        "running mesofact-static build"
1010    );
1011
1012    let exec_ctx = ExecContext::default()
1013        .with_cwd(workload_dir.to_path_buf())
1014        .with_env(env.to_vec());
1015
1016    let outcome = executor
1017        .execute(spec, exec_ctx, None)
1018        .await
1019        .with_context(|| format!("executing build command: {cmd_str}"))?;
1020
1021    if !outcome.succeeded() {
1022        anyhow::bail!(
1023            "build command failed ({}): {} — {}",
1024            outcome.status.discriminant(),
1025            cmd_str,
1026            outcome.stderr_tail,
1027        );
1028    }
1029    Ok(())
1030}
1031
1032/// Read `build.out_dir` from a workload's `workload.toml`. Returns `None`
1033/// when the file is absent, unreadable, or the field is missing — callers
1034/// default to `"dist"`.
1035pub(crate) fn read_workload_out_dir(workload_dir: &std::path::Path) -> Option<String> {
1036    let path = workload_dir.join("workload.toml");
1037    let src = std::fs::read_to_string(&path).ok()?;
1038    let value: toml::Value = toml::from_str(&src).ok()?;
1039    value
1040        .get("build")?
1041        .get("out_dir")?
1042        .as_str()
1043        .map(str::to_string)
1044}
1045
1046// ---------- CF Worker script rendering ----------
1047
1048/// Routing mode baked into the Worker script at deploy time. Shared between
1049/// the Cloudflare-Worker arm (this file) and the pond arm via `pub` so camp's
1050/// `build_miniflare_deploy_spec` can derive the same mode from a mirror's
1051/// static slot when populating `local_driver::pond_miniflare::MiniflareSpec`.
1052pub enum WorkerMode {
1053    /// All routes served from R2; `/` and directory paths → `index.html`;
1054    /// unknown paths → `404.html` (if present) or a 404 response.
1055    Static,
1056    /// Unknown paths fall back to `index.html` for client-side routing.
1057    Spa,
1058    /// Paths matching `prefixes` are proxied to `origin_url`; the rest uses
1059    /// the SPA index.html fallback.
1060    Ssr {
1061        origin_url: String,
1062        prefixes: Vec<String>,
1063    },
1064}
1065
1066/// Parse the Worker routing mode from the mirror's static slot fields. Public
1067/// so camp's pond bring-up can mirror the cloudflare-arm semantics without
1068/// duplicating the field-name conventions.
1069///
1070/// When the slot declares no explicit `mode`, the default derives from the
1071/// component's kind: `mesofact-spa` → SPA fallback, everything else → static.
1072/// An explicit `mode` field always wins.
1073pub fn parse_worker_mode(
1074    component_kind: &str,
1075    fields: &std::collections::BTreeMap<String, toml::Value>,
1076) -> WorkerMode {
1077    let default_mode = if component_kind == WORKLOAD_KIND_SPA {
1078        "spa"
1079    } else {
1080        "static"
1081    };
1082    match fields
1083        .get("mode")
1084        .and_then(|v| v.as_str())
1085        .unwrap_or(default_mode)
1086    {
1087        "spa" => WorkerMode::Spa,
1088        "ssr" => {
1089            let origin_url = fields
1090                .get("origin_url")
1091                .and_then(|v| v.as_str())
1092                .unwrap_or_default()
1093                .to_string();
1094            let prefixes = fields
1095                .get("ssr_prefixes")
1096                .and_then(|v| v.as_array())
1097                .map(|a| {
1098                    a.iter()
1099                        .filter_map(|v| v.as_str().map(String::from))
1100                        .collect()
1101                })
1102                .unwrap_or_default();
1103            WorkerMode::Ssr {
1104                origin_url,
1105                prefixes,
1106            }
1107        }
1108        _ => WorkerMode::Static,
1109    }
1110}
1111
1112/// Backend origins the edge router proxies `/api/*` prefixes to (R455-T4).
1113///
1114/// Distinct from `SSR_ORIGIN`: SSR proxies *page* routes to a renderer, these
1115/// proxy *API* routes to a service that owns state. Both are read off the
1116/// mirror's static slot (`issues_origin` / `backend_origin`).
1117///
1118/// These MUST be emitted here rather than set by hand on the Worker. Every
1119/// apply re-uploads the whole binding list, so a binding this function does
1120/// not produce is *deleted* on the next `yah cloud apply` — which is how a
1121/// hand-set `ISSUES_ORIGIN` silently reverts to a 404 (R330-F13, R752-B2).
1122#[derive(Debug, Clone, Default, PartialEq, Eq)]
1123pub struct BackendOrigins {
1124    /// `ISSUES_ORIGIN` — issue-tracker surface; `/api/issues*` proxied here.
1125    pub issues: String,
1126    /// `MESOFACT_BACKEND_ORIGIN` — almanac surface; `/api/releases*`.
1127    pub releases: String,
1128}
1129
1130impl BackendOrigins {
1131    /// The two prefixes these origins serve, and the path each becomes at the
1132    /// origin — `(route pattern, origin, origin path)`.
1133    ///
1134    /// This mapping was two hardcoded `if` blocks in the Worker until R898-F3
1135    /// deleted them (`/api/issues` → `ISSUES_ORIGIN` + `/issues` + rest). It is
1136    /// route data, so it now travels as table entries the Worker walks like any
1137    /// other. It still originates HERE, in slot fields, rather than in the
1138    /// domain manifest's `[[routes]]` — moving the declaration is R898-T4's
1139    /// half, and it needs the manifest to be able to name an origin that is not
1140    /// a deployed mesh unit.
1141    ///
1142    /// An empty origin emits no entry at all, which is the same "leave that
1143    /// prefix unrouted" the `env.X &&` guard used to give: a real 404 from the
1144    /// static tier, never a half-configured proxy.
1145    fn table_routes(&self) -> Vec<(&'static str, &str, &'static str)> {
1146        [
1147            ("/api/issues*", self.issues.as_str(), "/issues"),
1148            ("/api/releases*", self.releases.as_str(), "/releases"),
1149        ]
1150        .into_iter()
1151        .filter(|(_, origin, _)| !origin.is_empty())
1152        .collect()
1153    }
1154
1155    /// Read the optional backend-origin fields off a mirror's static slot.
1156    /// Absent or empty → an empty binding, and the router's `env.X &&` guard
1157    /// leaves that prefix unrouted (a real 404, never a half-configured proxy).
1158    pub fn from_slot_fields(fields: &std::collections::BTreeMap<String, toml::Value>) -> Self {
1159        let field = |name: &str| {
1160            fields
1161                .get(name)
1162                .and_then(|v| v.as_str())
1163                .unwrap_or_default()
1164                .trim_end_matches('/')
1165                .to_string()
1166        };
1167        Self {
1168            issues: field("issues_origin"),
1169            releases: field("backend_origin"),
1170        }
1171    }
1172}
1173
1174/// The R2 key prefix a static component publishes under (R746).
1175///
1176/// `<service>/<env>` for an unmounted component — every pre-R746 component, and
1177/// the only shape `asset_origin` in a mirror manifest is written against.
1178/// `mount` appends its normalized form, which is what lets two static
1179/// components of one service coexist: before it, both wrote `index.html` to the
1180/// same key and the second deploy of the day silently replaced the first site
1181/// with the other.
1182///
1183/// The front door resolves a request by its own path (`${ASSET_ORIGIN}/<path>`),
1184/// so the mount is simultaneously the storage prefix and the URL prefix — by
1185/// construction, not by two manifests agreeing.
1186fn publish_prefix(service: &str, env: &str, mount: Option<&str>) -> String {
1187    let base = format!("{service}/{env}");
1188    match mount.map(crate::config::normalize_mount) {
1189        None => base,
1190        Some(m) if m.is_empty() => base,
1191        Some(m) => format!("{base}/{m}"),
1192    }
1193}
1194
1195/// The `ROUTE_TABLE` binding — the ONE ordered table the edge router walks
1196/// (R898-F3), in the order it is matched.
1197///
1198/// Until R898-F3 the Worker carried four hardcoded prefix seams
1199/// (`ISSUES_ORIGIN`, `MESOFACT_BACKEND_ORIGIN`, `SSR_PREFIXES`,
1200/// `UPLOAD_ORIGIN`) plus a separate `ROUTE_HEADERS` table, so a fifth prefix
1201/// meant a fifth binding and a fifth `if`. They are all entries here now, and
1202/// the Worker walks them first-match-wins.
1203///
1204/// **Order is the contract.** The interception seams precede the manifest's
1205/// declared routes because that is the precedence the Worker had: the two
1206/// `/api/*` backends took priority over SSR, SSR over uploads, and everything
1207/// over the static catch-all. Getting this backwards serves `/api/releases`
1208/// from the asset bucket with a 200, which is the failure W348 exists to close.
1209///
1210/// Each synthesized entry carries the headers its path would have been given by
1211/// the declared table, because the Worker now applies the headers of the ONE
1212/// entry that claimed the path — where it used to route and header
1213/// independently. Without the stamp, a domain's catch-all headers would
1214/// silently stop reaching its proxied paths.
1215pub fn worker_route_table_json(
1216    mode: &WorkerMode,
1217    assets: WorkerAssets<'_>,
1218    upload_origin: &str,
1219    backends: &BackendOrigins,
1220    domain: Option<&crate::config::DomainConfig>,
1221) -> Result<String> {
1222    let entries = worker_route_table(mode, assets, upload_origin, backends, domain)?;
1223    Ok(serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string()))
1224}
1225
1226/// The entries [`worker_route_table_json`] serializes, before serialization —
1227/// what [`worker_config_bindings`] also derives the R2 bucket bindings from, so
1228/// the table and the binding list are two views of one value (R560-F13).
1229fn worker_route_table(
1230    mode: &WorkerMode,
1231    assets: WorkerAssets<'_>,
1232    upload_origin: &str,
1233    backends: &BackendOrigins,
1234    domain: Option<&crate::config::DomainConfig>,
1235) -> Result<Vec<crate::route_table::RouteTableEntry>> {
1236    use crate::route_table::{ResolvedRouteMode, RouteAuth, RouteRewrite, RouteTableEntry};
1237
1238    // `slot:<field>` rather than a component ref: these entries are declared by
1239    // a mirror's static slot, not by the manifest, and saying so in the wire
1240    // value is what makes a deployed table traceable back to its source.
1241    let synth = |path: String, origin: &str, slot: &'static str, origin_path: Option<&str>| {
1242        let prefix = path.strip_suffix('*').unwrap_or(&path);
1243        let prefix = prefix.trim_end_matches('/');
1244        let rewrite = origin_path.filter(|to| *to != prefix).map(|to| RouteRewrite {
1245            from: prefix.to_string(),
1246            to: to.to_string(),
1247        });
1248        RouteTableEntry {
1249            headers: domain
1250                .and_then(|d| d.route_for_path(prefix))
1251                .map(|r| r.headers.clone())
1252                .unwrap_or_default(),
1253            path,
1254            mode: ResolvedRouteMode::Backend {
1255                component: format!("slot:{slot}"),
1256                origin: origin.trim_end_matches('/').to_string(),
1257                rewrite,
1258            },
1259            auth: RouteAuth::Anonymous,
1260        }
1261    };
1262
1263    let mut entries: Vec<RouteTableEntry> = Vec::new();
1264    for (path, origin, origin_path) in backends.table_routes() {
1265        entries.push(synth(
1266            path.to_string(),
1267            origin,
1268            if path.starts_with("/api/issues") {
1269                "issues_origin"
1270            } else {
1271                "backend_origin"
1272            },
1273            Some(origin_path),
1274        ));
1275    }
1276    if let WorkerMode::Ssr {
1277        origin_url,
1278        prefixes,
1279    } = mode
1280    {
1281        if !origin_url.is_empty() {
1282            for prefix in prefixes {
1283                // `<prefix>*` is the same segment-aware match the Worker's own
1284                // SSR matcher made (W173): exact prefix or descendant under it,
1285                // never a bare `starts_with`.
1286                entries.push(synth(format!("{prefix}*"), origin_url, "ssr_origin", None));
1287            }
1288        }
1289    }
1290    if !upload_origin.is_empty() {
1291        entries.push(synth(
1292            "/uploads/*".to_string(),
1293            upload_origin,
1294            "upload_origin",
1295            None,
1296        ));
1297    }
1298    if let Some(d) = domain {
1299        entries.extend(
1300            d.route_table(&crate::route_table::WorkerPlacement { assets, domain: d })?
1301                .entries,
1302        );
1303    }
1304    Ok(entries)
1305}
1306
1307/// One binding a Worker is uploaded with, owned — what
1308/// [`worker_config_bindings`] produces and both deploy arms upload.
1309///
1310/// The owned twin of [`WorkerBinding`](crate::provider::cloudflare::WorkerBinding),
1311/// which borrows: a plan has to hold its bindings past the call that computed
1312/// them. Serialized into the redeploy hash, so an added or retargeted bucket
1313/// binding redeploys like any other config change.
1314#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1315#[serde(tag = "type", rename_all = "snake_case")]
1316pub enum ConfigBinding {
1317    /// A runtime config string (`env.ASSET_ORIGIN`, `env.ROUTE_TABLE`, …).
1318    PlainText { name: String, text: String },
1319    /// An R2 bucket a `static` table entry reads through (R560-F13).
1320    R2Bucket { name: String, bucket_name: String },
1321}
1322
1323impl ConfigBinding {
1324    /// The binding's name — what the Worker reads it as on `env`.
1325    pub fn name(&self) -> &str {
1326        match self {
1327            Self::PlainText { name, .. } | Self::R2Bucket { name, .. } => name,
1328        }
1329    }
1330
1331    /// The borrowed form `CloudflareClient::deploy_worker_script` takes.
1332    pub fn as_worker_binding(&self) -> crate::provider::cloudflare::WorkerBinding<'_> {
1333        use crate::provider::cloudflare::WorkerBinding;
1334        match self {
1335            Self::PlainText { name, text } => WorkerBinding::PlainText { name, text },
1336            Self::R2Bucket { name, bucket_name } => WorkerBinding::R2Bucket { name, bucket_name },
1337        }
1338    }
1339}
1340
1341/// Build the plain_text Worker binding values for the given routing mode.
1342///
1343/// These are uploaded alongside [`WORKER_SCRIPT`] as `plain_text` bindings
1344/// and appear as `env.ASSET_ORIGIN`, `env.WORKER_MODE`, etc. inside the Worker.
1345///
1346/// R898-T4: this is the **one** producer of a Worker's binding set. The
1347/// alias-tier planner ([`crate::reconciler::domain::plan_domain_worker`]) used
1348/// to keep a `static_worker_bindings` twin "in lockstep with this by hand", and
1349/// it had already drifted — the twin was still emitting `UPLOAD_ORIGIN`,
1350/// `SSR_ORIGIN`, `SSR_PREFIXES` and `ROUTE_HEADERS` after R898-F3 deleted all
1351/// four from the Worker. A hand-synced binding set is a silent 404 waiting for
1352/// the next binding to move, so there is one, and both arms call it.
1353pub(crate) fn worker_config_bindings(
1354    mode: &WorkerMode,
1355    assets: WorkerAssets<'_>,
1356    backends: &BackendOrigins,
1357    domain: Option<&crate::config::DomainConfig>,
1358) -> Result<Vec<ConfigBinding>> {
1359    // Reserved upload seam (R490-T8): prod has no upload origin yet, so no
1360    // `/uploads/*` entry is emitted and the path falls to the static tier. A
1361    // future dynamic-bucket consumer sets this to the user-writable origin.
1362    const UPLOAD_ORIGIN: &str = "";
1363    let mode_str = match mode {
1364        WorkerMode::Static => "static",
1365        WorkerMode::Spa => "spa",
1366        WorkerMode::Ssr { .. } => "ssr",
1367    };
1368    // The fall-through origin for a path no table entry claims; every static
1369    // route's own origin rides the table instead.
1370    let asset_origin = assets.fallback_origin(domain).unwrap_or_default();
1371    let entries = worker_route_table(mode, assets, UPLOAD_ORIGIN, backends, domain)?;
1372    let plain = |name: &str, text: String| ConfigBinding::PlainText {
1373        name: name.to_string(),
1374        text,
1375    };
1376    let mut bindings = vec![
1377        plain("ASSET_ORIGIN", asset_origin.clone()),
1378        // Pointer-store origin for instance-addressed routes (W270 §3): the
1379        // @mesofact/edge worker reads `p/<key>` records here. Pointers live
1380        // under the `p/` prefix in the same bucket as content, so this defaults
1381        // to ASSET_ORIGIN; it stays a distinct binding so a future consumer can
1382        // front the (uncached) pointer reads separately.
1383        plain("POINTER_ORIGIN", asset_origin),
1384        // Still a binding after R898-F3, and deliberately: `WORKER_MODE` no
1385        // longer selects a ROUTE — it selects what a static MISS becomes (the
1386        // branded 404 of a static site, or the SPA/SSR shell). The routing half
1387        // it used to gate lives in ROUTE_TABLE.
1388        plain("WORKER_MODE", mode_str.to_string()),
1389        plain(
1390            "ROUTE_TABLE",
1391            serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string()),
1392        ),
1393    ];
1394    // R560-F13: one R2 binding per distinct bucket a table entry reads through,
1395    // derived from the same entries ROUTE_TABLE carries. A domain with no
1396    // bucket route adds nothing, so every existing Worker's set is unchanged.
1397    bindings.extend(
1398        crate::route_table::r2_bucket_bindings(&entries)
1399            .into_iter()
1400            .map(|(name, bucket_name)| ConfigBinding::R2Bucket { name, bucket_name }),
1401    );
1402    Ok(bindings)
1403}
1404
1405fn sha256_hex(data: &[u8]) -> String {
1406    use sha2::Digest;
1407    hex::encode(sha2::Sha256::digest(data))
1408}
1409
1410/// Read the last deployed Worker script hash from the jit cache.
1411fn read_worker_script_hash(workspace_root: &std::path::Path, worker_name: &str) -> Option<String> {
1412    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1413    let s = std::fs::read_to_string(&path).ok()?;
1414    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&s).ok()?;
1415    map.get(worker_name)
1416        .and_then(|v| v.as_str())
1417        .map(|s| s.to_string())
1418}
1419
1420/// Write the deployed Worker script hash to the jit cache.
1421fn write_worker_script_hash(
1422    workspace_root: &std::path::Path,
1423    worker_name: &str,
1424    hash: &str,
1425) -> std::io::Result<()> {
1426    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1427    let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
1428        std::fs::read_to_string(&path)
1429            .ok()
1430            .and_then(|s| serde_json::from_str(&s).ok())
1431            .unwrap_or_default()
1432    } else {
1433        serde_json::Map::new()
1434    };
1435    map.insert(
1436        worker_name.to_string(),
1437        serde_json::Value::String(hash.to_string()),
1438    );
1439    if let Some(parent) = path.parent() {
1440        std::fs::create_dir_all(parent)?;
1441    }
1442    std::fs::write(
1443        &path,
1444        serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap_or_default(),
1445    )
1446}
1447
1448/// Drop a Worker's cached script hash so the next reconcile redeploys it.
1449///
1450/// R703-B4. The cache at `.yah/jit/worker-script-hashes.json` is an untracked
1451/// local file asserting something about a *remote* Worker, so it can be right
1452/// on one machine and wrong on the next — and while it is wrong, every apply
1453/// takes the "unchanged — skipping redeploy" branch and the live Worker never
1454/// catches up. It sat 19 days behind the in-tree router bundle that way. When
1455/// the front door is demonstrably not serving the current publish, the cache
1456/// has lost the right to be believed.
1457///
1458/// Best-effort: a cache we could not clear only costs one more manual redeploy,
1459/// and the serving check that called us is already returning an error.
1460fn forget_worker_script_hash(workspace_root: &std::path::Path, worker_name: &str) {
1461    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1462    let Ok(s) = std::fs::read_to_string(&path) else {
1463        return;
1464    };
1465    let Ok(mut map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&s) else {
1466        return;
1467    };
1468    if map.remove(worker_name).is_none() {
1469        return;
1470    }
1471    let _ = std::fs::write(
1472        &path,
1473        serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap_or_default(),
1474    );
1475    warn!(
1476        worker_name,
1477        "cleared cached Worker script hash — next apply will redeploy the script"
1478    );
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484    use crate::reconciler::slot_field_u16;
1485    use crate::{MirrorConfig, MirrorShape, ServiceComponent, ServiceConfig};
1486    use std::collections::BTreeMap;
1487    use std::path::PathBuf;
1488    use tempfile::tempdir;
1489
1490    /// Build a minimal in-memory ctx for unit tests, with the component's
1491    /// workload dir set up to look like a mesofact-static workload.
1492    struct Fixture {
1493        _workspace: tempfile::TempDir,
1494        workspace_root: PathBuf,
1495        service: ServiceConfig,
1496        component: ServiceComponent,
1497        mirror: MirrorConfig,
1498        env: String,
1499    }
1500
1501    impl Fixture {
1502        fn new(slot: MirrorProviderSlot, write_workload: bool) -> Self {
1503            let workspace = tempdir().unwrap();
1504            let workspace_root = workspace.path().to_path_buf();
1505            let workload_dir = workspace_root.join("app/web");
1506            std::fs::create_dir_all(workload_dir.join("dist/html")).unwrap();
1507            std::fs::write(workload_dir.join("dist/html/index.html"), "<h1>x</h1>").unwrap();
1508            if write_workload {
1509                std::fs::write(
1510                    workload_dir.join("workload.toml"),
1511                    r#"schema_version = 1
1512kind = "mesofact-static"
1513routes = "./routes.ts"
1514
1515[build]
1516command = "echo built"
1517out_dir = "dist"
1518"#,
1519                )
1520                .unwrap();
1521            }
1522
1523            let mut providers = BTreeMap::new();
1524            providers.insert("static".to_string(), slot);
1525            let mirror = MirrorConfig {
1526                schema_version: 1,
1527                shape: MirrorShape::Local,
1528                providers,
1529                ingress: Default::default(),
1530                ingress_machines: Vec::new(),
1531                drivers: Default::default(),
1532                asset_aliases: Default::default(),
1533                build: Default::default(),
1534            };
1535            let service = ServiceConfig {
1536                schema_version: 1,
1537                name: "test-svc".to_string(),
1538                domain: "test.local".to_string(),
1539                health_path: None,
1540                components: vec![],
1541                db: crate::DbCatalog::default(),
1542            };
1543            let component = ServiceComponent {
1544                mount: None,
1545                id: "site".to_string(),
1546                kind: "mesofact-static".to_string(),
1547                path: "app/web".to_string(),
1548                role: "static".to_string(),
1549                publishes: None,
1550                wave: 0,
1551                git: None,
1552                deploy: Default::default(),
1553            };
1554            Self {
1555                _workspace: workspace,
1556                workspace_root,
1557                service,
1558                component,
1559                mirror,
1560                env: "local".to_string(),
1561            }
1562        }
1563
1564        fn ctx(&self) -> ReconcileCtx<'_> {
1565            ReconcileCtx {
1566                workspace_root: &self.workspace_root,
1567                service: &self.service,
1568                component: &self.component,
1569                mirror: &self.mirror,
1570                env: &self.env,
1571                scope: crate::reconciler::ProviderScope::singleton(),
1572            }
1573        }
1574    }
1575
1576    /// The dev-tier static door. Note it carries no `[drivers.s3]` binding —
1577    /// fixtures that need the arm to actually run must add one.
1578    fn dev_door_slot(port: u16) -> MirrorProviderSlot {
1579        let mut fields = BTreeMap::new();
1580        fields.insert("port".to_string(), toml::Value::Integer(port as i64));
1581        MirrorProviderSlot::Inline {
1582            kind: Provider::MiniflareNative,
1583            fields,
1584        }
1585    }
1586
1587
1588    fn cloudflare_reference_slot() -> MirrorProviderSlot {
1589        MirrorProviderSlot::Reference {
1590            provider_id: "cloudflare".to_string(),
1591            fields: BTreeMap::new(),
1592        }
1593    }
1594
1595
1596
1597    #[test]
1598    fn slot_field_u16_extracts_port() {
1599        let mut fields = BTreeMap::new();
1600        fields.insert("port".to_string(), toml::Value::Integer(4321));
1601        assert_eq!(slot_field_u16(&fields, "port"), Some(4321));
1602    }
1603
1604    #[test]
1605    fn slot_field_u16_returns_none_for_missing_key() {
1606        let fields = BTreeMap::new();
1607        assert_eq!(slot_field_u16(&fields, "port"), None);
1608    }
1609
1610    #[tokio::test]
1611    async fn up_bails_when_workload_toml_missing() {
1612        let fx = Fixture::new(dev_door_slot(4321), /*write_workload*/ false);
1613        let reconciler = MesofactStaticReconciler::new();
1614        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1615        let msg = format!("{err:#}");
1616        assert!(msg.contains("workload.toml"), "got: {msg}");
1617    }
1618
1619    #[tokio::test]
1620    async fn up_bails_when_workload_kind_mismatches() {
1621        let fx = Fixture::new(dev_door_slot(4321), /*write_workload*/ false);
1622        // Hand-write a container-kind workload at the right path. We
1623        // don't need the full container schema; the reconciler dispatches
1624        // off the `kind` field alone.
1625        std::fs::write(
1626            fx.workspace_root.join("app/web/workload.toml"),
1627            r#"schema_version = 1
1628kind = "container"
1629"#,
1630        )
1631        .unwrap();
1632        let reconciler = MesofactStaticReconciler::new();
1633        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1634        let msg = format!("{err:#}");
1635        assert!(msg.contains("kind=\"container\""), "got: {msg}");
1636    }
1637
1638    #[tokio::test]
1639    async fn up_bails_when_static_slot_missing() {
1640        let mut fx = Fixture::new(dev_door_slot(4321), true);
1641        fx.mirror.providers.clear();
1642        let reconciler = MesofactStaticReconciler::new();
1643        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1644        let msg = format!("{err:#}");
1645        assert!(msg.contains("providers.static"), "got: {msg}");
1646    }
1647
1648    /// R602-B4 follow-up: a service with two static-kind components sharing
1649    /// one mirror (e.g. `site` + `app`) must be able to give them distinct
1650    /// ports. `slot()` resolves the component-qualified key
1651    /// (`"static:<id>"`) before the bare role, so `providers."static:site"`
1652    /// wins over `providers.static` for a component whose id is `site`.
1653    #[test]
1654    fn slot_prefers_component_qualified_key_over_bare_role() {
1655        let mut fx = Fixture::new(dev_door_slot(4321), true);
1656        fx.mirror
1657            .providers
1658            .insert("static:site".to_string(), dev_door_slot(9999));
1659        let slot = fx.ctx().slot("static").expect("slot present");
1660        let MirrorProviderSlot::Inline { fields, .. } = slot else {
1661            panic!("expected inline slot");
1662        };
1663        assert_eq!(slot_field_u16(fields, "port"), Some(9999));
1664    }
1665
1666    /// A component whose id has no qualified entry falls back to the bare
1667    /// role — the pre-existing single-slot-per-mirror behavior is unchanged
1668    /// for services that never declared a qualified key.
1669    #[test]
1670    fn slot_falls_back_to_bare_role_for_unqualified_component() {
1671        let mut fx = Fixture::new(dev_door_slot(4321), true);
1672        fx.mirror
1673            .providers
1674            .insert("static:other-component".to_string(), dev_door_slot(9999));
1675        let slot = fx.ctx().slot("static").expect("slot present");
1676        let MirrorProviderSlot::Inline { fields, .. } = slot else {
1677            panic!("expected inline slot");
1678        };
1679        assert_eq!(slot_field_u16(fields, "port"), Some(4321));
1680    }
1681
1682    fn miniflare_container_slot(port: u16) -> MirrorProviderSlot {
1683        let mut fields = BTreeMap::new();
1684        fields.insert("port".to_string(), toml::Value::Integer(port as i64));
1685        fields.insert(
1686            "bucket".to_string(),
1687            toml::Value::String("yah-dev".to_string()),
1688        );
1689        MirrorProviderSlot::Inline {
1690            kind: Provider::MiniflareContainer,
1691            fields,
1692        }
1693    }
1694
1695    #[tokio::test]
1696    async fn up_miniflare_container_bails_when_object_store_slot_missing() {
1697        // MiniflareContainer dispatches into pond::up_pond, which
1698        // requires a sibling providers.object_store slot. Missing → clear
1699        // error before we attempt to talk to docker.
1700        let fx = Fixture::new(miniflare_container_slot(4322), true);
1701        let reconciler = MesofactStaticReconciler::new();
1702        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1703        let msg = format!("{err:#}");
1704        assert!(
1705            msg.contains("providers.object_store"),
1706            "error must mention the missing sibling slot; got: {msg}"
1707        );
1708        assert!(
1709            msg.contains("pond"),
1710            "error must name the requesting code path; got: {msg}"
1711        );
1712    }
1713
1714    #[tokio::test]
1715    async fn up_miniflare_container_bails_when_object_store_kind_wrong() {
1716        let mut fx = Fixture::new(miniflare_container_slot(4322), true);
1717        // Drop in a non-MinIO inline slot at object_store.
1718        fx.mirror.providers.insert(
1719            "object_store".into(),
1720            MirrorProviderSlot::Inline {
1721                kind: Provider::MiniflareNative,
1722                fields: BTreeMap::new(),
1723            },
1724        );
1725        let reconciler = MesofactStaticReconciler::new();
1726        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1727        let msg = format!("{err:#}");
1728        assert!(
1729            msg.contains("minio-container"),
1730            "error must name the expected kind; got: {msg}"
1731        );
1732    }
1733
1734    #[tokio::test]
1735    async fn up_bails_on_cloudflare_reference_slot() {
1736        let cloudflare = MirrorProviderSlot::Reference {
1737            provider_id: "cloudflare".to_string(),
1738            fields: BTreeMap::new(),
1739        };
1740        let fx = Fixture::new(cloudflare, true);
1741        let reconciler = MesofactStaticReconciler::new();
1742        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1743        let msg = format!("{err:#}");
1744        assert!(msg.contains("cloudflare"), "got: {msg}");
1745    }
1746
1747    /// Regression for R330-B5: a cloud mirror that supplies bucket+zone but
1748    /// omits `asset_origin` must fail loudly at reconcile time. Otherwise the
1749    /// Worker silently gets `env.ASSET_ORIGIN=""` and 404s every request in
1750    /// prod (R327-F2 gotcha).
1751    #[tokio::test]
1752    async fn up_bails_on_cloudflare_reference_missing_asset_origin() {
1753        let mut fields = BTreeMap::new();
1754        fields.insert(
1755            "bucket".to_string(),
1756            toml::Value::String("yah-dev".to_string()),
1757        );
1758        fields.insert(
1759            "zone".to_string(),
1760            toml::Value::String("yah.dev".to_string()),
1761        );
1762        let cloudflare = MirrorProviderSlot::Reference {
1763            provider_id: "cloudflare".to_string(),
1764            fields,
1765        };
1766        let fx = Fixture::new(cloudflare, true);
1767        // Write a minimal cloudflare provider config so the asset_origin
1768        // check is the first thing that fails (otherwise the missing
1769        // provider file aborts the run earlier).
1770        let providers_dir = fx.workspace_root.join(".yah/infra/providers");
1771        std::fs::create_dir_all(&providers_dir).unwrap();
1772        std::fs::write(
1773            providers_dir.join("cloudflare.toml"),
1774            r#"schema_version = 1
1775id = "cloudflare"
1776kind = "cloudflare"
1777account_id = "test-account"
1778"#,
1779        )
1780        .unwrap();
1781        let reconciler = MesofactStaticReconciler::new();
1782        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1783        let msg = format!("{err:#}");
1784        assert!(
1785            msg.contains("asset_origin"),
1786            "error must name asset_origin; got: {msg}"
1787        );
1788    }
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799    // ---------- Worker script + config bindings ----------
1800
1801    #[test]
1802    fn worker_script_maps_root_to_index_html() {
1803        assert!(
1804            WORKER_SCRIPT.contains("index.html"),
1805            "bundled Worker must route / to index.html; got: {WORKER_SCRIPT}"
1806        );
1807    }
1808
1809    #[test]
1810    fn worker_script_reads_r2_only_through_route_bindings() {
1811        // R560-F13: R2 reads are allowed only through a ROUTE_TABLE entry's
1812        // per-bucket binding. The Worker must never write to a bucket, and
1813        // the retired single `env.ASSETS` binding must not come back.
1814        assert!(
1815            !WORKER_SCRIPT.contains("env.ASSETS"),
1816            "bundled Worker must not reference retired binding env.ASSETS"
1817        );
1818        assert!(
1819            WORKER_SCRIPT.contains("env[entry.binding]"),
1820            "bundled Worker must read buckets through the matched entry's binding"
1821        );
1822        assert!(!WORKER_SCRIPT.contains(".put("), "bundled Worker must not write to R2 (put)");
1823        assert!(
1824            !WORKER_SCRIPT.contains(".delete("),
1825            "bundled Worker must not write to R2 (delete)"
1826        );
1827        assert!(
1828            !WORKER_SCRIPT.contains("createMultipartUpload"),
1829            "bundled Worker must not write to R2 (multipart)"
1830        );
1831    }
1832
1833    #[test]
1834    fn worker_script_uses_asset_origin_fetch() {
1835        assert!(
1836            WORKER_SCRIPT.contains("ASSET_ORIGIN"),
1837            "bundled Worker must fetch from ASSET_ORIGIN; got: {WORKER_SCRIPT}"
1838        );
1839    }
1840
1841    /// The vendored @mesofact/edge bundle must carry the W270 §3 serving logic:
1842    /// manifest read, pointer-store resolution for instance-addressed routes,
1843    /// and manifest error_routes. Substring markers (not behavior — behavior is
1844    /// covered by the miniflare tests in oss/mesofact/packages/mesofact-edge).
1845    #[test]
1846    fn worker_script_resolves_pointers_and_error_routes() {
1847        assert!(
1848            WORKER_SCRIPT.contains("manifest.json"),
1849            "bundled Worker must read the published manifest; got: {WORKER_SCRIPT}"
1850        );
1851        assert!(
1852            WORKER_SCRIPT.contains("POINTER_ORIGIN"),
1853            "bundled Worker must resolve pointers via POINTER_ORIGIN; got: {WORKER_SCRIPT}"
1854        );
1855        assert!(
1856            WORKER_SCRIPT.contains("error_routes"),
1857            "bundled Worker must honor manifest error_routes; got: {WORKER_SCRIPT}"
1858        );
1859    }
1860
1861    /// The bindings are only useful if the vendored bundle actually reads
1862    /// them — `scripts/check-worker-bundle.sh` keeps source and vendored copy
1863    /// in sync, and this catches a bundle vendored from before R898-F3.
1864    ///
1865    /// The negative half is the load-bearing one: a bundle still naming
1866    /// `ISSUES_ORIGIN` is a bundle that was never rebuilt, and it would route
1867    /// `/api/issues` off a binding this reconciler no longer emits — i.e. the
1868    /// silent 404 the whole relay exists to close.
1869    #[test]
1870    fn bundled_worker_walks_the_route_table() {
1871        assert!(
1872            WORKER_SCRIPT.contains("ROUTE_TABLE"),
1873            "bundled Worker must walk the compiled route table; got: {WORKER_SCRIPT}"
1874        );
1875        for retired in [
1876            "ISSUES_ORIGIN",
1877            "MESOFACT_BACKEND_ORIGIN",
1878            "SSR_PREFIXES",
1879            "UPLOAD_ORIGIN",
1880            "ROUTE_HEADERS",
1881        ] {
1882            assert!(
1883                !WORKER_SCRIPT.contains(retired),
1884                "vendored bundle still reads the retired binding {retired} — \
1885                 it predates R898-F3; re-run scripts/check-worker-bundle.sh"
1886            );
1887        }
1888    }
1889
1890    // ── R746: component mount → publish prefix ──
1891
1892    #[test]
1893    fn unmounted_component_publishes_at_the_service_root() {
1894        assert_eq!(publish_prefix("noisetable-marketing", "prod", None), "noisetable-marketing/prod");
1895    }
1896
1897    #[test]
1898    fn a_mount_extends_the_prefix_and_is_slash_insensitive() {
1899        for m in ["/app", "app", "app/", "/app/"] {
1900            assert_eq!(
1901                publish_prefix("noisetable-marketing", "prod", Some(m)),
1902                "noisetable-marketing/prod/app",
1903                "mount {m:?}"
1904            );
1905        }
1906    }
1907
1908    /// A root mount is the same thing as no mount — not a trailing-slash key
1909    /// prefix, which would publish every asset one directory too deep.
1910    #[test]
1911    fn a_root_mount_is_the_service_root() {
1912        assert_eq!(publish_prefix("svc", "prod", Some("/")), "svc/prod");
1913        assert_eq!(publish_prefix("svc", "prod", Some("")), "svc/prod");
1914    }
1915
1916    /// The whole point: two static components of one service must not collide.
1917    #[test]
1918    fn two_components_of_one_service_get_disjoint_prefixes() {
1919        let site = publish_prefix("noisetable-marketing", "prod", None);
1920        let app = publish_prefix("noisetable-marketing", "prod", Some("/app"));
1921        assert_ne!(site, app);
1922        assert!(app.starts_with(&format!("{site}/")), "{app} under {site}");
1923    }
1924
1925    fn worker_domain(routes: Vec<crate::config::DomainRoute>) -> crate::config::DomainConfig {
1926        crate::config::DomainConfig {
1927            schema_version: 1,
1928            name: "example".into(),
1929            domain: "example.com".into(),
1930            front_door: crate::config::FrontDoor::Worker,
1931            cdn_bucket: "example".into(),
1932            worker_bundle_path: None,
1933            routes,
1934        }
1935    }
1936
1937    /// The plain-text half of a binding set, by name.
1938    fn plain_text(bindings: &[ConfigBinding]) -> std::collections::HashMap<String, String> {
1939        bindings
1940            .iter()
1941            .filter_map(|b| match b {
1942                ConfigBinding::PlainText { name, text } => Some((name.clone(), text.clone())),
1943                ConfigBinding::R2Bucket { .. } => None,
1944            })
1945            .collect()
1946    }
1947
1948    fn table_of(bindings: &[ConfigBinding]) -> Vec<serde_json::Value> {
1949        let raw = plain_text(bindings)
1950            .remove("ROUTE_TABLE")
1951            .expect("ROUTE_TABLE binding");
1952        serde_json::from_str(&raw).expect("ROUTE_TABLE parses as JSON")
1953    }
1954
1955    /// R746's header table is a COLUMN of the one table now, not a second
1956    /// binding: the entry that claims a path carries the headers for it.
1957    #[test]
1958    fn config_bindings_carry_the_declared_routes_and_their_headers() {
1959        let domain = worker_domain(vec![crate::config::DomainRoute {
1960            path: "/app/*".into(),
1961            headers: [(
1962                "Cross-Origin-Opener-Policy".to_string(),
1963                "same-origin".to_string(),
1964            )]
1965            .into_iter()
1966            .collect(),
1967            mode: crate::config::RouteMode::Static {
1968                component: "acme/app".into(),
1969            },
1970        }]);
1971        let b = worker_config_bindings(
1972            &WorkerMode::Static,
1973            WorkerAssets::Deployed("https://assets.example.com"),
1974            &BackendOrigins::default(),
1975            Some(&domain),
1976        )
1977        .unwrap();
1978        let table = table_of(&b);
1979        assert_eq!(table.len(), 1);
1980        assert_eq!(table[0]["path"], "/app/*");
1981        assert_eq!(table[0]["mode"], "static");
1982        assert_eq!(table[0]["origin"], "https://assets.example.com");
1983        assert_eq!(
1984            table[0]["headers"]["Cross-Origin-Opener-Policy"],
1985            "same-origin"
1986        );
1987    }
1988
1989    #[test]
1990    fn config_bindings_static_mode() {
1991        let b = worker_config_bindings(
1992            &WorkerMode::Static,
1993            WorkerAssets::Deployed("https://assets.example.com"),
1994            &BackendOrigins::default(),
1995            None,
1996        )
1997        .unwrap();
1998        let map = plain_text(&b);
1999        assert_eq!(map["WORKER_MODE"], "static");
2000        assert_eq!(map["ASSET_ORIGIN"], "https://assets.example.com");
2001        // Pointer origin defaults to the asset origin (W270 §3).
2002        assert_eq!(map["POINTER_ORIGIN"], "https://assets.example.com");
2003        // Nothing declared and no backends — an EMPTY table, emitted rather
2004        // than omitted: the binding list is authoritative, so a missing key
2005        // would leave a previous deploy's table in place.
2006        assert_eq!(map["ROUTE_TABLE"], "[]");
2007        assert!(table_of(&b).is_empty());
2008    }
2009
2010    #[test]
2011    fn config_bindings_spa_mode() {
2012        let b = worker_config_bindings(
2013            &WorkerMode::Spa,
2014            WorkerAssets::Deployed("https://assets.example.com"),
2015            &BackendOrigins::default(),
2016            None,
2017        )
2018        .unwrap();
2019        let map = plain_text(&b);
2020        assert_eq!(map["WORKER_MODE"], "spa");
2021    }
2022
2023    /// R330-F13, re-pinned on the table: `/api/issues*` reaches the issue
2024    /// tracker only when the static slot's `issues_origin` becomes an entry —
2025    /// and it must carry the PREFIX REWRITE, or the upstream sees
2026    /// `/api/issues` instead of `/issues` (R898-F3 decision 1).
2027    #[test]
2028    fn config_bindings_carry_backend_origins_with_their_rewrites() {
2029        let mut fields = std::collections::BTreeMap::new();
2030        fields.insert(
2031            "issues_origin".to_string(),
2032            // Trailing slash trimmed — the entry's origin is concatenated with
2033            // the rewritten path.
2034            toml::Value::String("https://issues.example.com/".to_string()),
2035        );
2036        fields.insert(
2037            "backend_origin".to_string(),
2038            toml::Value::String("https://almanac.example.com".to_string()),
2039        );
2040        let backends = BackendOrigins::from_slot_fields(&fields);
2041        assert_eq!(backends.issues, "https://issues.example.com");
2042
2043        let b = worker_config_bindings(
2044            &WorkerMode::Static,
2045            WorkerAssets::Deployed("https://assets.example.com"),
2046            &backends,
2047            None,
2048        )
2049        .unwrap();
2050        let table = table_of(&b);
2051        assert_eq!(table[0]["path"], "/api/issues*");
2052        assert_eq!(table[0]["mode"], "backend");
2053        assert_eq!(table[0]["origin"], "https://issues.example.com");
2054        assert_eq!(
2055            table[0]["rewrite"],
2056            serde_json::json!({"from": "/api/issues", "to": "/issues"})
2057        );
2058        assert_eq!(table[1]["path"], "/api/releases*");
2059        assert_eq!(table[1]["origin"], "https://almanac.example.com");
2060        assert_eq!(
2061            table[1]["rewrite"],
2062            serde_json::json!({"from": "/api/releases", "to": "/releases"})
2063        );
2064    }
2065
2066    /// An undeclared backend emits NO entry, which is the same "leave that
2067    /// prefix unrouted" the Worker's `env.X &&` guard used to give: the path
2068    /// falls to the static tier and 404s honestly instead of proxying to "".
2069    #[test]
2070    fn an_undeclared_backend_emits_no_entry() {
2071        let b = worker_config_bindings(
2072            &WorkerMode::Static,
2073            WorkerAssets::Deployed("https://assets.example.com"),
2074            &BackendOrigins::default(),
2075            None,
2076        )
2077        .unwrap();
2078        assert!(table_of(&b).is_empty());
2079    }
2080
2081    /// SSR prefixes are entries like everything else, and the interception
2082    /// entries precede the manifest's declared routes — the precedence the
2083    /// four hardcoded `if` blocks had.
2084    #[test]
2085    fn config_bindings_ssr_mode() {
2086        let mode = WorkerMode::Ssr {
2087            origin_url: "https://ssr.example.com".to_string(),
2088            prefixes: vec!["/api/".to_string(), "/rpc/".to_string()],
2089        };
2090        let domain = worker_domain(vec![crate::config::DomainRoute {
2091            path: "/*".into(),
2092            headers: Default::default(),
2093            mode: crate::config::RouteMode::Static {
2094                component: "acme/site".into(),
2095            },
2096        }]);
2097        let b = worker_config_bindings(
2098            &mode,
2099            WorkerAssets::Deployed("https://assets.example.com"),
2100            &BackendOrigins::default(),
2101            Some(&domain),
2102        )
2103        .unwrap();
2104        let map = plain_text(&b);
2105        assert_eq!(map["WORKER_MODE"], "ssr");
2106        let table = table_of(&b);
2107        assert_eq!(table[0]["path"], "/api/*");
2108        assert_eq!(table[0]["origin"], "https://ssr.example.com");
2109        assert!(
2110            table[0].get("rewrite").is_none(),
2111            "an SSR proxy is an identity proxy — the origin serves the public path"
2112        );
2113        assert_eq!(table[1]["path"], "/rpc/*");
2114        assert_eq!(
2115            table[2]["path"], "/*",
2116            "the catch-all is LAST — first match wins, so a declared route \
2117             above the SSR prefixes would swallow them"
2118        );
2119    }
2120
2121    #[test]
2122    fn worker_script_hash_roundtrip() {
2123        let tmp = tempdir().unwrap();
2124        let root = tmp.path();
2125        assert!(read_worker_script_hash(root, "test-worker").is_none());
2126        write_worker_script_hash(root, "test-worker", "abc123").unwrap();
2127        assert_eq!(
2128            read_worker_script_hash(root, "test-worker").as_deref(),
2129            Some("abc123")
2130        );
2131        // Writing a second worker doesn't clobber the first.
2132        write_worker_script_hash(root, "other-worker", "def456").unwrap();
2133        assert_eq!(
2134            read_worker_script_hash(root, "test-worker").as_deref(),
2135            Some("abc123")
2136        );
2137    }
2138
2139    #[test]
2140    fn parse_worker_mode_defaults_to_static() {
2141        let fields = BTreeMap::new();
2142        assert!(matches!(
2143            parse_worker_mode(WORKLOAD_KIND, &fields),
2144            WorkerMode::Static
2145        ));
2146    }
2147
2148    #[test]
2149    fn parse_worker_mode_spa_kind_defaults_to_spa() {
2150        let fields = BTreeMap::new();
2151        assert!(matches!(
2152            parse_worker_mode(WORKLOAD_KIND_SPA, &fields),
2153            WorkerMode::Spa
2154        ));
2155    }
2156
2157    #[test]
2158    fn parse_worker_mode_explicit_mode_beats_kind_default() {
2159        let mut fields = BTreeMap::new();
2160        fields.insert(
2161            "mode".to_string(),
2162            toml::Value::String("static".to_string()),
2163        );
2164        assert!(matches!(
2165            parse_worker_mode(WORKLOAD_KIND_SPA, &fields),
2166            WorkerMode::Static
2167        ));
2168    }
2169
2170    #[test]
2171    fn parse_worker_mode_spa() {
2172        let mut fields = BTreeMap::new();
2173        fields.insert("mode".to_string(), toml::Value::String("spa".to_string()));
2174        assert!(matches!(
2175            parse_worker_mode(WORKLOAD_KIND, &fields),
2176            WorkerMode::Spa
2177        ));
2178    }
2179
2180    #[test]
2181    fn parse_worker_mode_ssr_extracts_origin_and_prefixes() {
2182        let mut fields = BTreeMap::new();
2183        fields.insert("mode".to_string(), toml::Value::String("ssr".to_string()));
2184        fields.insert(
2185            "origin_url".to_string(),
2186            toml::Value::String("https://origin.example.com".to_string()),
2187        );
2188        fields.insert(
2189            "ssr_prefixes".to_string(),
2190            toml::Value::Array(vec![toml::Value::String("/api/".to_string())]),
2191        );
2192        if let WorkerMode::Ssr {
2193            origin_url,
2194            prefixes,
2195        } = parse_worker_mode(WORKLOAD_KIND, &fields)
2196        {
2197            assert_eq!(origin_url, "https://origin.example.com");
2198            assert_eq!(prefixes, vec!["/api/"]);
2199        } else {
2200            panic!("expected Ssr mode");
2201        }
2202    }
2203
2204    // ---------- W165: BuildMode → ForgeSpec lowering (R438-T6) ----------
2205
2206    use std::sync::Mutex as StdMutex;
2207    use tokio::sync::mpsc::UnboundedSender;
2208    use velveteen::ForgeStatus;
2209    use velveteen_exec::executor::{ExecEvent, ExecOutcome, ForgeExecutorError};
2210
2211    /// Captures the [`ForgeSpec`] handed to `execute(...)` and returns
2212    /// success without spawning anything.
2213    struct CaptureExecutor {
2214        captured: Arc<StdMutex<Vec<(ForgeSpec, ExecContext)>>>,
2215    }
2216
2217    impl CaptureExecutor {
2218        fn new() -> (Arc<Self>, Arc<StdMutex<Vec<(ForgeSpec, ExecContext)>>>) {
2219            let captured = Arc::new(StdMutex::new(Vec::new()));
2220            (
2221                Arc::new(Self {
2222                    captured: captured.clone(),
2223                }),
2224                captured,
2225            )
2226        }
2227    }
2228
2229    #[async_trait]
2230    impl ForgeExecutor for CaptureExecutor {
2231        async fn execute(
2232            &self,
2233            spec: ForgeSpec,
2234            ctx: ExecContext,
2235            _sink: Option<UnboundedSender<ExecEvent>>,
2236        ) -> Result<ExecOutcome, ForgeExecutorError> {
2237            self.captured.lock().unwrap().push((spec, ctx));
2238            Ok(ExecOutcome {
2239                status: ForgeStatus::Done {
2240                    exit_code: 0,
2241                    ended_at: 0,
2242                },
2243                stderr_tail: String::new(),
2244            })
2245        }
2246    }
2247
2248    /// Executor whose runs all return a non-zero exit + canned stderr —
2249    /// used to assert error-message shape from [`run_build`].
2250    struct FailingExecutor {
2251        stderr: String,
2252    }
2253
2254    #[async_trait]
2255    impl ForgeExecutor for FailingExecutor {
2256        async fn execute(
2257            &self,
2258            _spec: ForgeSpec,
2259            _ctx: ExecContext,
2260            _sink: Option<UnboundedSender<ExecEvent>>,
2261        ) -> Result<ExecOutcome, ForgeExecutorError> {
2262            Ok(ExecOutcome {
2263                status: ForgeStatus::Done {
2264                    exit_code: 2,
2265                    ended_at: 0,
2266                },
2267                stderr_tail: self.stderr.clone(),
2268            })
2269        }
2270    }
2271
2272    fn host_side_build() -> (BuildConfig, BuildMode) {
2273        (
2274            BuildConfig {
2275                command: Some("bun run build".into()),
2276                out_dir: PathBuf::from("dist"),
2277                render_command: None,
2278            },
2279            BuildMode::HostSide,
2280        )
2281    }
2282
2283    fn in_container_build() -> (BuildConfig, BuildMode) {
2284        let image = workload_spec::ImageRef {
2285            registry: "ghcr.io".into(),
2286            repository: "org/app-build".into(),
2287            tag: "v1.2".into(),
2288            digest: workload_spec::testing::test_digest(),
2289        };
2290        (
2291            BuildConfig {
2292                command: Some("bun run build".into()),
2293                out_dir: PathBuf::from("dist"),
2294                render_command: None,
2295            },
2296            BuildMode::InContainer { image },
2297        )
2298    }
2299
2300    #[tokio::test]
2301    async fn run_build_host_side_lowers_to_native_subprocess() {
2302        let (capture, captured) = CaptureExecutor::new();
2303        let tmp = tempdir().unwrap();
2304        let (build, mode) = host_side_build();
2305        run_build(tmp.path(), &build, &mode, &[], &*capture)
2306            .await
2307            .unwrap();
2308
2309        let captured = captured.lock().unwrap();
2310        assert_eq!(captured.len(), 1, "build executed exactly once");
2311        let (spec, ctx) = &captured[0];
2312        assert_eq!(spec.where_.runtime, TaskRuntime::Native);
2313        assert_eq!(spec.where_.location, TaskLocation::Local);
2314        match &spec.command {
2315            ForgeCommand::Subprocess { argv, image } => {
2316                assert!(image.is_none(), "host_side carries no image; got {image:?}");
2317                assert_eq!(
2318                    argv,
2319                    &vec!["sh".to_string(), "-c".into(), "bun run build".into()]
2320                );
2321            }
2322            other => panic!("expected Subprocess, got {other:?}"),
2323        }
2324        assert_eq!(ctx.cwd.as_deref(), Some(tmp.path()));
2325    }
2326
2327    #[tokio::test]
2328    async fn run_build_in_container_lowers_to_container_runtime_with_pinned_digest() {
2329        let (capture, captured) = CaptureExecutor::new();
2330        let tmp = tempdir().unwrap();
2331        let (build, mode) = in_container_build();
2332        run_build(tmp.path(), &build, &mode, &[], &*capture)
2333            .await
2334            .unwrap();
2335
2336        let captured = captured.lock().unwrap();
2337        let (spec, ctx) = &captured[0];
2338        assert_eq!(spec.where_.runtime, TaskRuntime::Container);
2339        assert_eq!(spec.where_.location, TaskLocation::Local);
2340        match &spec.command {
2341            ForgeCommand::Subprocess { argv, image } => {
2342                let image = image.as_ref().expect("in_container lowers with an image");
2343                assert_eq!(image.registry, "ghcr.io");
2344                assert_eq!(image.repository, "org/app-build");
2345                assert_eq!(image.tag, "v1.2");
2346                assert_eq!(image.digest, workload_spec::testing::test_digest());
2347                assert_eq!(
2348                    argv,
2349                    &vec!["sh".to_string(), "-c".into(), "bun run build".into()]
2350                );
2351            }
2352            other => panic!("expected Subprocess, got {other:?}"),
2353        }
2354        assert_eq!(ctx.cwd.as_deref(), Some(tmp.path()));
2355    }
2356
2357    #[tokio::test]
2358    async fn run_build_surfaces_stderr_on_nonzero_exit() {
2359        let executor = Arc::new(FailingExecutor {
2360            stderr: "TypeError: Cannot find module 'react'".into(),
2361        });
2362        let tmp = tempdir().unwrap();
2363        let (build, mode) = host_side_build();
2364        let err = run_build(tmp.path(), &build, &mode, &[], &*executor)
2365            .await
2366            .unwrap_err();
2367        let msg = format!("{err:#}");
2368        assert!(msg.contains("Cannot find module 'react'"), "got: {msg}");
2369        assert!(msg.contains("bun run build"), "got: {msg}");
2370    }
2371
2372    #[tokio::test]
2373    async fn read_mesofact_build_extracts_host_side_default() {
2374        let tmp = tempdir().unwrap();
2375        // Keeps a legacy `schema_version = 1` key — older workload.tomls
2376        // (including ones outside this repo) still carry it, and since
2377        // R896-T4 deleted the field it must be ignored, not rejected.
2378        std::fs::write(
2379            tmp.path().join("workload.toml"),
2380            r#"schema_version = 1
2381kind = "mesofact-static"
2382routes = "./routes.ts"
2383
2384[build]
2385command = "bun run build"
2386out_dir = "dist"
2387"#,
2388        )
2389        .unwrap();
2390        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2391        assert_eq!(build.command.as_deref(), Some("bun run build"));
2392        assert_eq!(build.out_dir, PathBuf::from("dist"));
2393        assert!(matches!(mode, BuildMode::HostSide));
2394    }
2395
2396    #[tokio::test]
2397    async fn read_mesofact_build_extracts_in_container_with_digest() {
2398        let tmp = tempdir().unwrap();
2399        let digest = workload_spec::testing::test_digest();
2400        std::fs::write(
2401            tmp.path().join("workload.toml"),
2402            format!(
2403                r#"schema_version = 1
2404kind = "mesofact-static"
2405routes = "./routes.ts"
2406
2407[build]
2408command = "bun run build"
2409out_dir = "dist"
2410
2411[build_mode.in_container.image]
2412registry = "ghcr.io"
2413repository = "org/app-build"
2414tag = "v1.2"
2415digest = "{digest}"
2416"#
2417            ),
2418        )
2419        .unwrap();
2420        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2421        assert_eq!(build.command.as_deref(), Some("bun run build"));
2422        match mode {
2423            BuildMode::InContainer { image } => {
2424                assert_eq!(image.registry, "ghcr.io");
2425                assert_eq!(image.repository, "org/app-build");
2426                assert_eq!(image.tag, "v1.2");
2427                assert_eq!(image.digest, digest);
2428            }
2429            other => panic!("expected InContainer, got {other:?}"),
2430        }
2431    }
2432
2433    #[tokio::test]
2434    async fn read_mesofact_build_rejects_in_container_without_digest() {
2435        let tmp = tempdir().unwrap();
2436        std::fs::write(
2437            tmp.path().join("workload.toml"),
2438            r#"schema_version = 1
2439kind = "mesofact-static"
2440routes = "./routes.ts"
2441
2442[build]
2443command = "bun run build"
2444out_dir = "dist"
2445
2446[build_mode.in_container]
2447image = "ghcr.io/org/app-build:v1.2"
2448"#,
2449        )
2450        .unwrap();
2451        let err = read_mesofact_build(tmp.path()).unwrap_err();
2452        let msg = format!("{err:#}");
2453        assert!(
2454            msg.contains("digest") || msg.contains("sha256"),
2455            "in_container with bare tag must reject at parse; got: {msg}"
2456        );
2457    }
2458
2459    #[tokio::test]
2460    async fn read_mesofact_build_returns_none_for_other_kinds() {
2461        let tmp = tempdir().unwrap();
2462        std::fs::write(
2463            tmp.path().join("workload.toml"),
2464            r#"schema_version = 1
2465kind = "static-asset"
2466"#,
2467        )
2468        .unwrap();
2469        assert!(read_mesofact_build(tmp.path()).unwrap().is_none());
2470    }
2471
2472    #[tokio::test]
2473    async fn read_mesofact_build_returns_none_when_file_absent() {
2474        let tmp = tempdir().unwrap();
2475        assert!(read_mesofact_build(tmp.path()).unwrap().is_none());
2476    }
2477
2478    /// R838-B1: a `[build]` table declaring only `out_dir` is the shape
2479    /// `mesofact new` scaffolds — the project builds through the in-process
2480    /// pipeline and has no shell command to run.
2481    #[tokio::test]
2482    async fn read_mesofact_build_accepts_a_build_table_with_no_command() {
2483        let tmp = tempdir().unwrap();
2484        std::fs::write(
2485            tmp.path().join("workload.toml"),
2486            r#"schema_version = 1
2487kind = "mesofact-static"
2488routes = "./mesofact.routes.ts"
2489
2490[build]
2491out_dir = "dist"
2492"#,
2493        )
2494        .unwrap();
2495        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2496        assert_eq!(build.command, None);
2497        assert_eq!(build.out_dir, PathBuf::from("dist"));
2498        assert!(matches!(mode, BuildMode::HostSide));
2499    }
2500
2501    /// R838-B1: no `build.command` → no build step, not `sh -c ""`.
2502    ///
2503    /// An empty shell command exits 0 having produced nothing, so the
2504    /// reconciler would report a successful build and then publish whatever
2505    /// stale bytes were in `out_dir`. The skip has to happen at the lowering,
2506    /// which is what `lower_build_to_forge_spec` returning `None` pins.
2507    #[tokio::test]
2508    async fn rebuild_static_skips_the_build_step_when_no_command_is_declared() {
2509        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ false);
2510        let workload_dir = fx.workspace_root.join("app/web");
2511        std::fs::write(
2512            workload_dir.join("workload.toml"),
2513            r#"schema_version = 1
2514kind = "mesofact-static"
2515routes = "./mesofact.routes.ts"
2516
2517[build]
2518out_dir = "dist"
2519"#,
2520        )
2521        .unwrap();
2522
2523        let (capture, captured) = CaptureExecutor::new();
2524        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2525
2526        // As in the sibling tests, up_cloudflare_r2 fails for want of provider
2527        // config — but only AFTER the build step would have run.
2528        let _ = reconciler.rebuild_static(fx.ctx()).await;
2529
2530        assert!(
2531            captured.lock().unwrap().is_empty(),
2532            "a manifest with no build.command must dispatch nothing to the executor"
2533        );
2534    }
2535
2536    /// The lowering itself is the seam, so pin it directly too — a future
2537    /// caller that reaches `lower_build_to_forge_spec` without going through
2538    /// `run_build` inherits the same refusal.
2539    #[test]
2540    fn lowering_a_build_with_no_command_yields_no_forge_spec() {
2541        let build = BuildConfig {
2542            command: None,
2543            out_dir: PathBuf::from("dist"),
2544            render_command: None,
2545        };
2546        assert!(lower_build_to_forge_spec(
2547            std::path::Path::new("/workspace/app/web"),
2548            &build,
2549            &BuildMode::HostSide,
2550        )
2551        .is_none());
2552    }
2553
2554    #[tokio::test]
2555    async fn rebuild_static_lifts_build_mode_through_executor() {
2556        // End-to-end smoke through rebuild_static → run_build → executor for
2557        // the cloudflare publish arm. InContainer build_mode must reach the
2558        // executor as TaskRuntime::Container (the CF path does not skip container
2559        // builds — every arm now honours the declared build_mode, R584-F4).
2560        // up_cloudflare_r2 will fail (no provider config), but the build step
2561        // runs first so the CaptureExecutor still records the lowered ForgeSpec.
2562        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ false);
2563        let workload_dir = fx.workspace_root.join("app/web");
2564        let digest = workload_spec::testing::test_digest();
2565        std::fs::write(
2566            workload_dir.join("workload.toml"),
2567            format!(
2568                r#"schema_version = 1
2569kind = "mesofact-static"
2570routes = "./routes.ts"
2571
2572[build]
2573command = "bun run build"
2574out_dir = "dist"
2575
2576[build_mode.in_container.image]
2577registry = "ghcr.io"
2578repository = "org/app-build"
2579tag = "v1.2"
2580digest = "{digest}"
2581"#
2582            ),
2583        )
2584        .unwrap();
2585
2586        let (capture, captured) = CaptureExecutor::new();
2587        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2588
2589        // up_cloudflare_r2 will fail (no provider config on disk) but only
2590        // AFTER the build step ran. We only care that the build path produced
2591        // a captured ForgeSpec with the right runtime.
2592        let _ = reconciler.rebuild_static(fx.ctx()).await;
2593
2594        let captured = captured.lock().unwrap();
2595        assert_eq!(captured.len(), 1, "build step executed exactly once");
2596        let (spec, _ctx) = &captured[0];
2597        assert_eq!(spec.where_.runtime, TaskRuntime::Container);
2598        match &spec.command {
2599            ForgeCommand::Subprocess { image, .. } => {
2600                let image = image.as_ref().unwrap();
2601                assert_eq!(image.digest, digest, "digest survives the round-trip");
2602            }
2603            other => panic!("expected Subprocess, got {other:?}"),
2604        }
2605    }
2606
2607
2608    #[tokio::test]
2609    async fn rebuild_static_defaults_to_host_side_when_build_mode_omitted() {
2610        // Fixture writes a workload.toml without [build_mode] (the common
2611        // shape today). rebuild_static must default to HostSide.
2612        let fx = Fixture::new(dev_door_slot(0), /*write_workload*/ true);
2613        let (capture, captured) = CaptureExecutor::new();
2614        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2615        let _ = reconciler.rebuild_static(fx.ctx()).await;
2616        let captured = captured.lock().unwrap();
2617        assert_eq!(
2618            captured.len(),
2619            1,
2620            "default build_mode still runs the build step"
2621        );
2622        assert_eq!(captured[0].0.where_.runtime, TaskRuntime::Native);
2623    }
2624
2625    // ── per-environment build overrides (R905) ───────────────────────────────
2626
2627    #[test]
2628    fn apply_build_override_replaces_only_the_fields_it_names() {
2629        let mut build = BuildConfig {
2630            command: Some("bun run build:cloud".into()),
2631            out_dir: PathBuf::from("dist"),
2632            render_command: Some("mesofact-build render . --route {route}".into()),
2633        };
2634        apply_build_override(
2635            &mut build,
2636            Some(&crate::config::MirrorBuildOverride {
2637                command: Some("bun run build:staging".into()),
2638                render_command: None,
2639                env: Default::default(),
2640            }),
2641        );
2642        assert_eq!(build.command.as_deref(), Some("bun run build:staging"));
2643        assert_eq!(
2644            build.render_command.as_deref(),
2645            Some("mesofact-build render . --route {route}"),
2646            "an override naming only `command` must not erase the renderer",
2647        );
2648        assert_eq!(
2649            build.out_dir,
2650            PathBuf::from("dist"),
2651            "out_dir is never per-environment",
2652        );
2653    }
2654
2655    #[test]
2656    fn apply_build_override_is_a_no_op_without_one() {
2657        let (mut build, _) = host_side_build();
2658        let before = build.clone();
2659        apply_build_override(&mut build, None);
2660        assert_eq!(build.command, before.command);
2661        assert_eq!(build.render_command, before.render_command);
2662        assert!(build_env(None).is_empty());
2663    }
2664
2665    #[tokio::test]
2666    async fn rebuild_static_runs_the_mirrors_overridden_command_with_its_env() {
2667        // The R905 defect in one test: the component declares one command, the
2668        // environment needs another, and before this the mirror had nowhere to
2669        // say so. Fixture's component id is "site".
2670        let mut fx = Fixture::new(dev_door_slot(0), /*write_workload*/ true);
2671        fx.mirror.build.insert(
2672            "site".to_string(),
2673            crate::config::MirrorBuildOverride {
2674                command: Some("echo staging".into()),
2675                render_command: None,
2676                env: [(
2677                    "NOISETABLE_API_ORIGIN".to_string(),
2678                    "https://api-staging.noisetable.com".to_string(),
2679                )]
2680                .into_iter()
2681                .collect(),
2682            },
2683        );
2684
2685        let (capture, captured) = CaptureExecutor::new();
2686        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2687        let _ = reconciler.rebuild_static(fx.ctx()).await;
2688
2689        let captured = captured.lock().unwrap();
2690        assert_eq!(captured.len(), 1, "build step executed exactly once");
2691        let (spec, exec_ctx) = &captured[0];
2692        match &spec.command {
2693            ForgeCommand::Subprocess { argv, .. } => assert_eq!(
2694                argv,
2695                &vec!["sh".to_string(), "-c".to_string(), "echo staging".to_string()],
2696                "the mirror's command reached the executor, not the workload's `echo built`",
2697            ),
2698            other => panic!("expected Subprocess, got {other:?}"),
2699        }
2700        assert_eq!(
2701            exec_ctx.env,
2702            vec![(
2703                "NOISETABLE_API_ORIGIN".to_string(),
2704                "https://api-staging.noisetable.com".to_string()
2705            )],
2706            "the override's env reaches the build subprocess",
2707        );
2708    }
2709
2710    #[tokio::test]
2711    async fn rebuild_static_leaves_a_sibling_component_on_its_own_command() {
2712        // The override is keyed by component id. A mirror that overrides `app`
2713        // must not change how `site` builds — otherwise a merged bundle would
2714        // build every component for whichever component the operator named.
2715        let mut fx = Fixture::new(dev_door_slot(0), /*write_workload*/ true);
2716        fx.mirror.build.insert(
2717            "app".to_string(),
2718            crate::config::MirrorBuildOverride {
2719                command: Some("echo wrong-component".into()),
2720                render_command: None,
2721                env: Default::default(),
2722            },
2723        );
2724
2725        let (capture, captured) = CaptureExecutor::new();
2726        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2727        let _ = reconciler.rebuild_static(fx.ctx()).await;
2728
2729        let captured = captured.lock().unwrap();
2730        assert_eq!(captured.len(), 1);
2731        match &captured[0].0.command {
2732            ForgeCommand::Subprocess { argv, .. } => {
2733                assert_eq!(argv[2], "echo built", "site kept its declared command")
2734            }
2735            other => panic!("expected Subprocess, got {other:?}"),
2736        }
2737        assert!(captured[0].1.env.is_empty());
2738    }
2739
2740    #[tokio::test]
2741    async fn rebuild_static_skips_build_when_workload_toml_missing() {
2742        // No workload.toml on disk — rebuild_static must not panic in the
2743        // build step; the subsequent up() call surfaces the missing-manifest
2744        // error to the operator.
2745        let fx = Fixture::new(dev_door_slot(0), /*write_workload*/ false);
2746        let (capture, captured) = CaptureExecutor::new();
2747        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2748        let err = reconciler.rebuild_static(fx.ctx()).await.unwrap_err();
2749        let msg = format!("{err:#}");
2750        assert!(msg.contains("workload.toml"), "got: {msg}");
2751        assert!(
2752            captured.lock().unwrap().is_empty(),
2753            "no build executed when manifest missing",
2754        );
2755    }
2756
2757    // ── revalidate_static (R535-T1) ──────────────────────────────────────────
2758
2759    #[tokio::test]
2760    async fn revalidate_static_without_render_command_never_touches_executor() {
2761        // W225 §3 / R535-T1: an almanac on_change is a data-only trigger — it
2762        // must never re-run build.command, regardless of provider arm or
2763        // whether the subsequent publish step succeeds. The fixture's
2764        // workload.toml carries a real [build] table (write_workload=true)
2765        // but NO render_command — so the executor must record zero calls
2766        // (R535-T7 only runs the executor for a declared render_command).
2767        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
2768        let (capture, captured) = CaptureExecutor::new();
2769        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2770
2771        // up_cloudflare_r2 will fail (no provider config on disk) — that's
2772        // expected and irrelevant here; only the executor call count matters.
2773        let _ = reconciler.revalidate_static(fx.ctx(), "/releases").await;
2774
2775        assert!(
2776            captured.lock().unwrap().is_empty(),
2777            "revalidate_static without render_command must never invoke the executor"
2778        );
2779    }
2780
2781    #[tokio::test]
2782    async fn revalidate_static_delegates_to_up() {
2783        // Without a render_command, revalidate_static's publish behavior must
2784        // be indistinguishable from calling up() directly. Same fixture, same
2785        // reconciler, two independent ReconcileCtx borrows: both arms must
2786        // hit the identical error (missing
2787        // .yah/infra/providers/cloudflare.toml) with byte-identical text.
2788        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
2789        let reconciler = MesofactStaticReconciler::new();
2790
2791        let revalidate_err = reconciler
2792            .revalidate_static(fx.ctx(), "/releases")
2793            .await
2794            .unwrap_err();
2795        let up_err = reconciler.up(fx.ctx()).await.unwrap_err();
2796
2797        assert_eq!(
2798            format!("{revalidate_err:#}"),
2799            format!("{up_err:#}"),
2800            "revalidate_static must delegate straight to up() with no extra behavior"
2801        );
2802    }
2803
2804    #[tokio::test]
2805    async fn revalidate_static_skips_build_when_workload_toml_missing() {
2806        // Mirrors rebuild_static_skips_build_when_workload_toml_missing:
2807        // revalidate_static must not panic when workload.toml is absent (no
2808        // [build] table → no render_command → no executor call); the
2809        // missing-manifest error surfaces from deeper in up().
2810        let fx = Fixture::new(dev_door_slot(0), /*write_workload*/ false);
2811        let (capture, captured) = CaptureExecutor::new();
2812        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2813        let err = reconciler
2814            .revalidate_static(fx.ctx(), "/releases")
2815            .await
2816            .unwrap_err();
2817        let msg = format!("{err:#}");
2818        assert!(msg.contains("workload.toml"), "got: {msg}");
2819        assert!(
2820            captured.lock().unwrap().is_empty(),
2821            "no build executed by revalidate_static",
2822        );
2823    }
2824
2825    // ── revalidate_static render_command (R535-T7) ───────────────────────────
2826
2827    fn write_workload_with_render_command(fx: &Fixture) {
2828        std::fs::write(
2829            fx.workspace_root.join("app/web/workload.toml"),
2830            r#"schema_version = 1
2831kind = "mesofact-static"
2832routes = "./routes.ts"
2833
2834[build]
2835command = "echo built"
2836out_dir = "dist"
2837render_command = "echo render {route} --all"
2838"#,
2839        )
2840        .unwrap();
2841    }
2842
2843    /// R905: the data-only re-render runs through the same override, so an
2844    /// environment that renders differently is not silently on production's
2845    /// renderer the moment an almanac feed changes.
2846    #[tokio::test]
2847    async fn revalidate_static_honours_the_mirrors_render_override_and_env() {
2848        let mut fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
2849        write_workload_with_render_command(&fx);
2850        fx.mirror.build.insert(
2851            "site".to_string(),
2852            crate::config::MirrorBuildOverride {
2853                command: None,
2854                render_command: Some("echo staging-render {route}".into()),
2855                env: [("API_ORIGIN".to_string(), "https://staging".to_string())]
2856                    .into_iter()
2857                    .collect(),
2858            },
2859        );
2860
2861        let (capture, captured) = CaptureExecutor::new();
2862        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2863        let _ = reconciler.revalidate_static(fx.ctx(), "/issues/:id").await;
2864
2865        let captured = captured.lock().unwrap();
2866        assert_eq!(captured.len(), 1, "render executed exactly once");
2867        let (spec, exec_ctx) = &captured[0];
2868        match &spec.command {
2869            ForgeCommand::Subprocess { argv, .. } => assert_eq!(
2870                argv[2], "echo staging-render /issues/:id",
2871                "the mirror's render_command ran, with {{route}} still substituted",
2872            ),
2873            other => panic!("expected Subprocess, got {other:?}"),
2874        }
2875        assert_eq!(
2876            exec_ctx.env,
2877            vec![("API_ORIGIN".to_string(), "https://staging".to_string())],
2878        );
2879    }
2880
2881    #[tokio::test]
2882    async fn revalidate_static_runs_render_command_with_route_substituted() {
2883        // R535-T7: a declared render_command runs exactly once before the
2884        // publish step — {route} substituted, host-side lowering (Native, no
2885        // image), cwd = workload dir — and NEVER build.command.
2886        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
2887        write_workload_with_render_command(&fx);
2888        let (capture, captured) = CaptureExecutor::new();
2889        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2890
2891        // up_cloudflare_r2 still fails after the render step (no provider
2892        // config on disk) — only the executor capture matters here.
2893        let _ = reconciler.revalidate_static(fx.ctx(), "/issues/:id").await;
2894
2895        let captured = captured.lock().unwrap();
2896        assert_eq!(captured.len(), 1, "render executed exactly once");
2897        let (spec, ctx) = &captured[0];
2898        assert_eq!(spec.where_.runtime, TaskRuntime::Native);
2899        match &spec.command {
2900            ForgeCommand::Subprocess { argv, image } => {
2901                assert!(image.is_none(), "host_side render carries no image");
2902                assert_eq!(
2903                    argv,
2904                    &vec![
2905                        "sh".to_string(),
2906                        "-c".into(),
2907                        "echo render /issues/:id --all".into()
2908                    ],
2909                    "route pattern substituted into {{route}}"
2910                );
2911            }
2912            other => panic!("expected Subprocess, got {other:?}"),
2913        }
2914        assert_eq!(
2915            ctx.cwd.as_deref(),
2916            Some(fx.workspace_root.join("app/web").as_path())
2917        );
2918    }
2919
2920
2921    /// Validate the in-tree fixture at `testdata/mesofact-in-container/workload.toml`.
2922    ///
2923    /// Verifies that `read_mesofact_build` returns `BuildMode::InContainer` for an
2924    /// on-disk workload that declares `[build_mode] mode = "in_container"`.  No
2925    /// build is executed — this is a parse + lowering-shape test that runs in CI
2926    /// without docker (R438-T8 "in-tree mesofact-static workload with
2927    /// build_mode=in_container builds green in CI").
2928    #[test]
2929    fn in_container_fixture_roundtrips_as_container_build_mode() {
2930        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2931        let fixture_dir = manifest_dir.join("testdata/mesofact-in-container");
2932        let (build, build_mode) = read_mesofact_build(&fixture_dir)
2933            .expect("testdata/mesofact-in-container/workload.toml must parse cleanly")
2934            .expect("fixture must have a [build] section");
2935        assert!(
2936            matches!(build_mode, BuildMode::InContainer { .. }),
2937            "expected BuildMode::InContainer but got {build_mode:?}",
2938        );
2939        assert!(
2940            build.command.as_deref().is_some_and(|c| !c.is_empty()),
2941            "build.command must be declared and non-empty"
2942        );
2943    }
2944
2945
2946
2947
2948
2949
2950}