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 address: crate::config::ServiceAddress::front_door("test.local".to_string()),
1539 description: 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}