Skip to main content

cloud/reconciler/
mesofact_static.rs

1//! [`Reconciler`] implementation for `kind = "mesofact-static"` components.
2//!
3//! Dispatches on the mirror's `providers.static` slot:
4//!
5//! - **`kind = "local-static"` (inline)** — spawn `mesofact-dev` as a child
6//!   process on `127.0.0.1:<port>`. Pointer-swap + auto-rebuild come from
7//!   the binary's built-in watcher (R255-T2); the reconciler just owns
8//!   start/stop and the dev URL.
9//! - **`use = "cloudflare"` (reference)** — not yet implemented; production
10//!   pipeline integrates `mesofact-publisher` once R157 catches up.
11//!
12//! Binary discovery for the local path: caller may pass an explicit path
13//! via [`LocalStaticOptions::binary`]; otherwise the reconciler reads the
14//! `MESOFACT_DEV_BIN` env var; otherwise it relies on PATH resolution of
15//! the bare name `mesofact-dev`.
16//!
17//! @yah:ticket(R255-F6, "Split tier-1 into native fast-path vs managed-subprocess fallback")
18//! @yah:assignee(agent:claude)
19//! @yah:at(2026-05-25T20:08:16Z)
20//! @yah:status(review)
21//! @yah:parent(R255)
22//! @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)")
23//! @yah:next("managed-subprocess fallback: generic app runs as a managed child subprocess behind axum-as-ingress")
24//! @yah:next("make reconciler dispatch select the two paths explicitly rather than branching on if-compatible inside one arm")
25//! @yah:assumes("not all projects have a valid in-process tier-1 — only the blessed mesofact stack (in-process bun, axum ingress) qualifies")
26//! @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.")
27//! @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.")
28//!
29//! @yah:relay(R320, "Cloudflare first-class Infra provider + yah.dev R2 publish")
30//! @yah:at(2026-05-26T00:19:09Z)
31//! @yah:status(open)
32//! @arch:see(.yah/docs/working/W074-cloudflare-infra-provider.md)
33//!
34//! @yah:ticket(R320-T8, "Wire cloudflare reference path into MesofactStaticReconciler")
35//! @yah:assignee(agent:claude)
36//! @yah:at(2026-05-26T00:42:37Z)
37//! @yah:status(review)
38//! @yah:phase(P2)
39//! @yah:parent(R320)
40//! @yah:depends_on(R320-F7)
41//! @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).")
42//! @yah:verify("cargo check -p cloud — clean (verified)")
43//! @yah:verify("cargo test -p cloud --lib — 175 passed (verified)")
44//! @yah:verify("yah cloud mirror up dev-yah --env prod (requires account_id in cloudflare.toml + R2 S3 keys in keystore)")
45//!
46//! @yah:relay(R327, "CF Worker provisioner: static→R2 + SSR/SPA→origin routing for mesofact sites")
47//! @yah:at(2026-05-26T07:25:51Z)
48//! @yah:status(review)
49//! @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")
50//! @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")
51//! @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")
52//! @yah:gotcha("Pure-static sites (Mode 1) still need the Worker to serve index.html for / — R2 custom domains alone don't auto-index")
53//! @yah:gotcha("Worker script must be idempotent across mirror up re-runs: re-deploy only when content hash changes")
54//! @yah:gotcha("R2 bucket binding in the Worker requires the bucket name matches the mirror config — keep them in sync")
55//! @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.")
56//! @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)")
57//! @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)")
58//! @yah:next("Consider whether to keep upsert_index_rewrite as belt-and-suspenders or drop it entirely once Worker is confirmed stable")
59//! @yah:verify("cargo test -p cloud --lib: 215 passed (verified)")
60//! @yah:verify("cargo check --workspace: clean (verify before merge)")
61//! @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)")
62//! @yah:gotcha("build_worker_multipart uses a manual multipart body (reqwest multipart feature not enabled in cloud Cargo.toml) — boundary is 'yahWorkerUpload0'")
63//! @yah:gotcha("Worker route pattern is '{zone}/*' not '*{zone}/*' — only catches apex requests, not subdomains. Add a wildcard route if subdomains need Worker routing")
64//! @yah:gotcha("CF Workers ES module format requires 'main_module' in metadata and the part name must match that filename ('worker.js')")
65//! @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.")
66//! @yah:verify("cargo test -p cloud --lib — 215 passed")
67//! @yah:verify("cargo check --workspace — clean (warnings only, no errors)")
68//! @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)")
69//!
70//! @yah:ticket(R327-F1, "Extract Worker router from Rust string literal to a typechecked TS source + miniflare test")
71//! @yah:assignee(agent:claude)
72//! @yah:at(2026-05-26T16:33:58Z)
73//! @yah:status(review)
74//! @yah:parent(R327)
75//! @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.")
76//! @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.")
77//! @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.")
78//! @yah:next("Keep the deploy hash-gate (read_worker_script_hash) working on the bundled output.")
79//! @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.")
80//! @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.")
81//! @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.")
82//! @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.")
83//! @yah:verify("cargo test -p cloud --lib — 220 passed")
84//! @yah:verify("bun test tests/ in crates/yah/cloud/worker — 11 passed")
85//! @yah:verify("cargo check --workspace — clean (warnings only)")
86//! @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")
87//!
88//! @yah:ticket(R432-B2, "Stale jit ports file: don't re-probe a dead recorded port and call it a 'dynamic fallback'")
89//! @yah:assignee(agent:claude)
90//! @yah:at(2026-06-04T01:13:39Z)
91//! @yah:status(review)
92//! @yah:parent(R432)
93//! @yah:severity(minor)
94//! @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.")
95//! @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).")
96//! @yah:next("Consider: should camp purge the entry on shutdown? Lower priority; the read-side fix above is enough to clear the misleading error.")
97//! @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.")
98//! @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).")
99//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 26 passed (includes two new R432-B2 regression tests)")
100//! @yah:verify("adopt_only_stale_jit_same_port_omits_dynamic_fallback_phrase: passes")
101//! @yah:verify("adopt_only_stale_jit_different_port_names_it: passes")
102//!
103//! @yah:ticket(R432-F3, "Split adopt_only error: distinguish 'camp not running' from 'camp running but didn't bind'")
104//! @yah:assignee(agent:claude)
105//! @yah:at(2026-06-04T01:13:52Z)
106//! @yah:status(review)
107//! @yah:parent(R432)
108//! @yah:next("Detect camp presence (e.g., socket path exists / camp_socket connectable) before composing the error.")
109//! @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.'")
110//! @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.'")
111//! @yah:next("Drop the 'or any dynamic fallback port' phrasing — it implies a probe that didn't actually happen.")
112//! @yah:verify("Both error variants surface in the desktop Up flow under the right conditions; neither mentions a probe we didn't run.")
113//! @yah:depends_on(R432-B2)
114//! @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.")
115//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 28 passed")
116//! @yah:verify("cargo check -p desktop — clean")
117//! @yah:verify("adopt_only_no_camp_socket_gives_attach_message: passes")
118//! @yah:verify("adopt_only_live_camp_socket_gives_didnt_bind_message: passes")
119//!
120//! @yah:ticket(R434-F4, "Pond reconciler: spin ssr_runtime container when service has any mode:\"ssr\" route")
121//! @yah:assignee(agent:claude)
122//! @yah:at(2026-06-04T19:12:09Z)
123//! @yah:status(review)
124//! @yah:phase(P2)
125//! @yah:parent(R434)
126//! @arch:see(.yah/docs/working/W173-mesofact-render-cube.md)
127//! @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")
128//! @yah:next("R434-F5 (open) — convert one marketing route to mode:\"ssr\" — unblocked by F4; that's the first real SSR consumer")
129//! @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)")
130//! @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.")
131//! @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.")
132//! @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.")
133//! @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.")
134//! @yah:verify("cd crates/yah/cloud/worker && bun test tests/ → 16 pass (4 new R434-F4 segment-aware tests)")
135//! @yah:verify("cargo test -p local-driver --lib → 46 pass (8 new pond_ssr_runtime tests)")
136//! @yah:verify("cargo test -p yubaba --lib pond → 9 pass (registry handles ssr_runtime supervision)")
137//! @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")
138//! @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)")
139//! @yah:verify("cargo check -p local-driver -p yubaba -p cloud -p yah → clean (warnings only, none from F4)")
140//! @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")
141//!
142//! @yah:ticket(R438-T6, "W165 wiring: lower MesofactStaticWorkload.build_mode to ForgeCommand")
143//! @yah:assignee(agent:claude)
144//! @yah:at(2026-06-04T21:07:20Z)
145//! @yah:status(review)
146//! @yah:phase(P2)
147//! @yah:parent(R438)
148//! @yah:next("Replace run_build_command shell-out with run_build(workload_dir, &BuildConfig, &BuildMode)")
149//! @yah:next("Lower BuildMode::HostSide → ForgeSpec{Subprocess, TaskRuntime::Native}; InContainer{image} → {Subprocess(image), TaskRuntime::Container}")
150//! @yah:next("Hand ForgeSpec to task::local::execute — same path QED uses for image-build steps")
151//! @yah:next("TaskLocation::Local; cwd = workload_dir bind-mounted into container")
152//! @yah:verify("BuildMode::HostSide lowers to TaskRuntime::Native; InContainer{image} lowers to TaskRuntime::Container with pinned digest")
153//! @yah:verify("In-tree workload with build_mode=in_container runs build in configured image; host_side runs on host; identical out_dir bytes")
154//! @yah:gotcha("local-static arm behavior decision deferred to F1 (W165 OQ#1) — default-skip with warning is the proposed initial behavior")
155//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
156//! @yah:depends_on(R438-T3)
157//! @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.")
158//! @yah:next("Sign off → archive R438-T6")
159//! @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")
160//! @yah:next("R438-T8 (worked examples) can now add a mesofact-static workload with build_mode=in_container as an e2e fixture")
161//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static — 35 pass; 4 R441-B4 adopt_only failures pre-existing")
162//! @yah:verify("cargo check --workspace --locked — clean (warnings only)")
163//! @yah:verify("BuildMode::HostSide → TaskRuntime::Native, image=None; InContainer{image} → TaskRuntime::Container, Some(pinned_image) (asserted by run_build_*_lowers_to_* tests)")
164//! @yah:verify("Legacy schema_version = 1 (integer) workload.tomls still parse — read_mesofact_build uses raw toml::Value subtree extraction")
165//! @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.")
166//! @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.")
167//!
168//! @yah:ticket(R438-F9, "local-static arm: respect or skip container build_mode? (W165 OQ#1)")
169//! @yah:assignee(agent:claude)
170//! @yah:at(2026-06-04T21:07:57Z)
171//! @yah:status(review)
172//! @yah:parent(R438)
173//! @yah:next("Decide: container build for CI parity vs default-skip (no docker dependency for dev)")
174//! @yah:next("Default-skip with one-line warning is the proposed initial behavior")
175//! @yah:next("If skip: ensure log line is visible in dashboard/task-pane (per long-running→yah surface rule)")
176//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
177//! @yah:depends_on(R438-T6)
178//! @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 local_static_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.")
179//! @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)")
180//! @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)")
181//! @yah:verify("cargo check -p cloud -- clean")
182//!
183//! @yah:relay(R441, "Workspace test breakage on main (surfaced via R438-T3 sweep)")
184//! @yah:at(2026-06-04T22:55:58Z)
185//! @yah:status(open)
186//! @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.")
187//! @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.")
188//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
189//!
190//! @yah:ticket(R441-B4, "mesofact_static adopt_only_* tests expect Err but get Ok(RunningWorkload)")
191//! @yah:assignee(agent:claude)
192//! @yah:at(2026-06-04T22:56:20Z)
193//! @yah:status(review)
194//! @yah:parent(R441)
195//! @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.")
196//! @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).")
197//! @yah:next("Coordinate with whoever last touched the mesofact-static reconciler before flipping the assertions.")
198//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static::tests::adopt_only_  # all 4 pass")
199//! @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 local_static_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.")
200//!
201//! R535-T1 ("Split rebuild_static: revalidate-only path... called by
202//! almanac_dispatch", W225 §3) landed here: see [`MesofactStaticReconciler::
203//! revalidate_static`] and [`MesofactStaticReconciler::rebuild_static`]'s docs
204//! for the split, and `crate::almanac_dispatch` for the caller-side switch.
205//! Ticket record lives in `.yah/docs/working/W225-mesofact-consumer-deployment-model.md`
206//! (single declaration site — not duplicated here per Rule11).
207//!
208//! @yah:ticket(R875-B1, "Adopted mesofact-dev gets a no-op shutdown and no logs — dev-tier Stop lies, log pane is empty")
209//! @yah:at(2026-09-09T01:58:02Z)
210//! @yah:status(review)
211//! @yah:assignee(agent:bundle-anthropic-ashguard)
212//! @yah:parent(R875)
213//! @yah:severity(high)
214//! @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.")
215//! @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.")
216//! @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.")
217//! @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.")
218//! @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.")
219//! @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.")
220//! @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)")
221//! @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.")
222//! @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.")
223
224/// Bundled Worker script embedded at compile time from `worker/router.bundle.js`.
225///
226/// The source of truth is `@mesofact/edge`
227/// (`oss/mesofact/packages/mesofact-edge`) — the manifest-driven serving
228/// artifact mesofact owns (W270 §3, R595-F3). Its built bundle is *vendored*
229/// into `worker/router.bundle.js` by `scripts/check-worker-bundle.sh` so this
230/// crate stays standalone-exportable across the OSS mirror boundary (a
231/// cross-boundary `include_str!` into `oss/mesofact` would break yubaba's
232/// export). Run `scripts/check-worker-bundle.sh --update` after editing the
233/// worker; do NOT hand-edit `router.bundle.js`.
234///
235/// Used both for prod deployment and as the miniflare-sim artifact in the pond
236/// tier.
237pub const WORKER_SCRIPT: &str = include_str!("../../worker/router.bundle.js");
238
239use std::net::{IpAddr, Ipv4Addr, SocketAddr};
240use std::path::{Path, PathBuf};
241use std::sync::Arc;
242use std::time::Duration;
243
244use anyhow::{Context, Result};
245use async_trait::async_trait;
246use tokio::sync::oneshot;
247use tracing::{info, warn};
248
249use kamaji::native::NativeRuntime;
250use kamaji::{Kamaji, MeshAssignment, MeshIdent};
251use velveteen::{
252    ForgeCommand, ForgeSpec, Initiator, MeshAccess, TaskLocation, TaskPlacement, TaskRuntime,
253};
254use velveteen_exec::{ExecContext, ForgeExecutor, LocalForgeDriver};
255use workload_spec::{
256    BuildConfig, BuildMode, EnvVar, ExposeSpec, ImageRef, MeshExpose, Millis, NamespaceId,
257    ResourceLimits, RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
258};
259
260use super::native_support::{
261    capture_paths, native_spec, sanitize_ident, spawn_capture_tail, spawn_native_log_supervisor,
262    stop_process_listening_on, NATIVE_IDENTITY_DIGEST,
263};
264use super::{
265    into_running, pond, slot_field_u16, wait_for_port, LogBuffer, ReconcileCtx, Reconciler,
266    RunningWorkload,
267};
268use crate::{MirrorProviderSlot, Provider};
269
270/// Workload kind this reconciler handles. Matches `ServiceComponent.kind`
271/// and the `kind = "..."` line in `workload.toml`.
272pub const WORKLOAD_KIND: &str = "mesofact-static";
273
274/// SPA sibling of [`WORKLOAD_KIND`]: mesofact emits a hydrate-bundle-loading
275/// HTML shell instead of a fully-rendered page per route. The serving path is
276/// identical (assets in a bucket behind the Worker/miniflare router); the only
277/// behavioral difference is the Worker's fallback mode, so the same reconciler
278/// handles both kinds.
279pub const WORKLOAD_KIND_SPA: &str = "mesofact-spa";
280
281/// True for the component kinds served by [`MesofactStaticReconciler`].
282pub fn is_mesofact_site_kind(kind: &str) -> bool {
283    kind == WORKLOAD_KIND || kind == WORKLOAD_KIND_SPA
284}
285
286/// Default port for the `local-static` provider slot — matches
287/// `mesofact-dev`'s `DEFAULT_PORT` and the canonical
288/// `.yah/services/dev-yah/mirrors/local.toml`.
289pub const DEFAULT_LOCAL_STATIC_PORT: u16 = 4321;
290
291/// Knobs for the `local-static` bring-up path.
292#[derive(Debug, Clone, Default)]
293pub struct LocalStaticOptions {
294    /// Explicit path to the `mesofact-dev` binary. Overrides the env-var
295    /// and PATH lookup.
296    pub binary: Option<PathBuf>,
297    /// Extra args to pass after the workload directory (e.g. `--no-watch`).
298    pub extra_args: Vec<String>,
299    /// How long to wait for the spawned process's port to start accepting
300    /// connections before declaring the up failed. Default: 10s.
301    pub ready_timeout: Option<Duration>,
302    /// When `true`, adopt an already-running server (TCP probe + jit file)
303    /// but never fall through to spawning a subprocess. Desktop sets this
304    /// because the server is embedded in yah-camp; there is no separate
305    /// binary to spawn.
306    pub adopt_only: bool,
307    /// Unix socket path of the camp daemon for this workspace. When set and
308    /// `adopt_only` is true, the error message distinguishes "camp not
309    /// running" (socket unreachable) from "camp up but server didn't bind"
310    /// (socket reachable, mesofact-dev port not bound).
311    pub camp_socket: Option<PathBuf>,
312}
313
314impl LocalStaticOptions {
315    /// Resolve the binary path: explicit > `MESOFACT_DEV_BIN` env > bare
316    /// `mesofact-dev` (will be looked up on `PATH` at spawn time).
317    pub fn resolved_binary(&self) -> PathBuf {
318        if let Some(ref p) = self.binary {
319            return p.clone();
320        }
321        if let Some(p) = std::env::var_os("MESOFACT_DEV_BIN") {
322            return PathBuf::from(p);
323        }
324        PathBuf::from("mesofact-dev")
325    }
326}
327
328/// Reconciles `kind = "mesofact-static"` components.
329///
330/// The `executor` field handles the build step (W165): `build.command` is
331/// lowered to a [`ForgeSpec`] and dispatched through
332/// [`ForgeExecutor::execute`]. Default is [`LocalForgeDriver`]; callers
333/// wanting to redirect (e.g. tests with a mock executor) use
334/// [`Self::with_executor`].
335pub struct MesofactStaticReconciler {
336    pub local_static: LocalStaticOptions,
337    pub pond: pond::PondOptions,
338    executor: Arc<dyn ForgeExecutor>,
339}
340
341impl MesofactStaticReconciler {
342    pub fn new() -> Self {
343        Self {
344            local_static: LocalStaticOptions::default(),
345            pond: pond::PondOptions::default(),
346            executor: Arc::new(LocalForgeDriver::default()),
347        }
348    }
349
350    pub fn with_local_static(mut self, opts: LocalStaticOptions) -> Self {
351        self.local_static = opts;
352        self
353    }
354
355    pub fn with_pond(mut self, opts: pond::PondOptions) -> Self {
356        self.pond = opts;
357        self
358    }
359
360    /// Swap the [`ForgeExecutor`] used to run the build step. Production
361    /// callers take the [`LocalForgeDriver`] default; tests inject a mock
362    /// to assert the lowered [`ForgeSpec`] without spawning a subprocess.
363    pub fn with_executor(mut self, executor: Arc<dyn ForgeExecutor>) -> Self {
364        self.executor = executor;
365        self
366    }
367}
368
369impl Default for MesofactStaticReconciler {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375#[async_trait]
376impl Reconciler for MesofactStaticReconciler {
377    fn kind(&self) -> &'static str {
378        WORKLOAD_KIND
379    }
380
381    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
382        // BYO git (R561-F1): if the component is git-sourced, shallow-clone it
383        // into the source cache before anything reads workload_dir(). No-op for
384        // in-tree components.
385        ctx.materialize().await?;
386
387        // Validate that the workload manifest agrees with the component's
388        // declared kind. Mismatch is an authoring error (service.toml
389        // points at a workload of the wrong shape).
390        let kind = ctx.workload_kind().context("loading workload.toml")?;
391        if kind != ctx.component.kind {
392            anyhow::bail!(
393                "component {component_id} kind=\"{component_kind}\" but {workload_dir}/workload.toml declares kind=\"{kind}\"",
394                component_id = ctx.component.id,
395                component_kind = ctx.component.kind,
396                workload_dir = ctx.workload_dir().display(),
397            );
398        }
399
400        let slot = ctx.slot("static").with_context(|| {
401            format!(
402                "mirror has no `providers.static` slot — required for kind=\"mesofact-static\" (service={}, env={})",
403                ctx.service.name, ctx.env,
404            )
405        })?;
406
407        match slot {
408            MirrorProviderSlot::Inline {
409                kind: Provider::LocalStatic,
410                fields,
411            } => {
412                let port = slot_field_u16(fields, "port").unwrap_or(DEFAULT_LOCAL_STATIC_PORT);
413                self.up_local_static(&ctx, port).await
414            }
415            MirrorProviderSlot::Inline {
416                kind: Provider::MiniflareContainer,
417                fields,
418            } => pond::up_pond(&ctx, &self.pond, fields, WORKER_SCRIPT).await,
419            MirrorProviderSlot::Inline { kind, .. } => {
420                anyhow::bail!(
421                    "providers.static.kind = \"{kind:?}\" not supported by mesofact-static reconciler (only local-static + miniflare-container for now)",
422                )
423            }
424            MirrorProviderSlot::Reference {
425                provider_id,
426                fields,
427            } => {
428                // Dispatch on the resolved provider *kind*, not the literal
429                // name, so a workspace can name several cloudflare providers
430                // (e.g. `cloudflare` + `cloudflare-scrabcake`).
431                let cf = super::cf_creds::CfProvider::resolve_scoped(
432                    ctx.workspace_root,
433                    provider_id,
434                    &ctx.scope.tenant,
435                    &ctx.scope.namespace,
436                )?;
437                anyhow::ensure!(
438                    matches!(cf.cfg.kind, Provider::Cloudflare),
439                    "providers.static.use = {provider_id:?} (kind={:?}) — only cloudflare-kind \
440                     reference providers are supported for mesofact-static",
441                    cf.cfg.kind,
442                );
443                self.up_cloudflare_r2(&ctx, cf, fields).await
444            }
445        }
446    }
447}
448
449impl MesofactStaticReconciler {
450    /// Re-sync a *running* mirror in place — re-publish the built dist without
451    /// rebuilding or restarting the serve stack. Only the pond
452    /// (miniflare-container) slot supports this: it re-publishes `dist/` into
453    /// the already-running MinIO bucket via [`pond::sync_pond`], returning the
454    /// number of assets uploaded.
455    ///
456    /// This is what the desktop's `⟳` affordance calls for local mirrors.
457    /// `up`'s desktop adopt path returns before the publish step, so a re-click
458    /// of `▶` never re-publishes; `sync` is the correct re-sync entry point.
459    /// local-static (dev) serves from disk and cloudflare goes through the
460    /// publish-assets pipeline, so neither has an in-place bucket re-sync.
461    pub async fn sync(&self, ctx: ReconcileCtx<'_>) -> Result<usize> {
462        ctx.materialize().await?;
463        let slot = ctx.slot("static").with_context(|| {
464            format!(
465                "mirror has no `providers.static` slot — required for kind=\"mesofact-static\" (service={}, env={})",
466                ctx.service.name, ctx.env,
467            )
468        })?;
469        match slot {
470            MirrorProviderSlot::Inline {
471                kind: Provider::MiniflareContainer,
472                fields,
473            } => pond::sync_pond(&ctx, &self.pond, fields).await,
474            MirrorProviderSlot::Inline { kind, .. } => anyhow::bail!(
475                "providers.static.kind = \"{kind:?}\" has no in-place re-sync — only miniflare-container (pond) supports ⟳ sync",
476            ),
477            MirrorProviderSlot::Reference { .. } => anyhow::bail!(
478                "reference (cloud) providers re-sync through the publish-assets pipeline, not the pond reconciler",
479            ),
480        }
481    }
482
483    /// Build the workload (re-running `build.command`) then publish it to its
484    /// configured provider slot.
485    ///
486    /// This is the **full rebuild** path — source/template changes, a fresh
487    /// `mirror up`, or any case where the compiled bundle itself may be
488    /// stale. It is *not* what `almanac_dispatch` calls for a data-only feed
489    /// change — see [`Self::revalidate_static`] for that (W225 §3: a data
490    /// change is "revalidate", not "build", and never needs the bundler).
491    ///
492    /// For the Cloudflare reference arm this publishes the freshly-built
493    /// `dist/` to R2 and purges the CDN cache-tag `page:releases`. For the
494    /// `local-static` arm the build still runs (useful for verifying the
495    /// output) but no publish happens — the watcher handles hot-reload.
496    pub async fn rebuild_static(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
497        let workload_dir = ctx.workload_dir();
498        if let Some((build, build_mode)) = read_mesofact_build(&workload_dir)? {
499            // For the local-static arm, container build_mode is skipped — the host
500            // watcher drives rebuilds and dev machines may not have docker (W165 OQ#1).
501            let effective_mode = match &build_mode {
502                BuildMode::InContainer { .. }
503                    if ctx.slot("static").and_then(|s| s.inline_kind())
504                        == Some(Provider::LocalStatic) =>
505                {
506                    warn!(
507                        workload = %workload_dir.display(),
508                        "build_mode = in_container ignored for local-static; running host-side"
509                    );
510                    BuildMode::HostSide
511                }
512                _ => build_mode,
513            };
514            run_build(&workload_dir, &build, &effective_mode, &*self.executor).await?;
515        }
516        self.up(ctx).await
517    }
518
519    /// Publish the workload's **already-built** artifact directory — never
520    /// runs `build.command` (W225 §3, R535-T1).
521    ///
522    /// This is the path `almanac_dispatch` calls for
523    /// `OnChangeConfig::MesofactRebuild`: an almanac feed change is *data*,
524    /// not a source/template change, so re-running the bundler is wasted
525    /// work (and, for CI-gated `in_container` builds, wasted pull/cold-start
526    /// too). Per the doc: "almanac = revalidate = data → SSG output on the
527    /// already-built bundle... no recompilation, no CI gate, because nothing
528    /// executable changed."
529    ///
530    /// When the workload declares `build.render_command` (R535-T7), the
531    /// data-only re-render runs first — `{route}` substituted with the
532    /// invalidated route pattern, executed against the **already-built**
533    /// bundle via the same [`ForgeExecutor`] lowering as the build step
534    /// (host-side or in-container per `build_mode`), but never
535    /// `build.command` itself. The canonical command is `mesofact-build
536    /// render <dir> --route {route} --all`, which re-expands the route's
537    /// prerender params fresh and rewrites `out_dir`'s HTML for that route
538    /// only. Without `render_command` this republishes whatever bytes sit in
539    /// `build.out_dir` (the pre-T7 behavior, still correct when the bundle's
540    /// HTML was refreshed by some other actor).
541    ///
542    /// The `local-static` arm skips the render entirely — the host
543    /// `mesofact-dev` watcher already re-renders on data-file changes
544    /// independently of this reconciler (see the `rebuild_static` doc on the
545    /// pre-existing "local watcher handles rebuilds" behavior it inherits).
546    pub async fn revalidate_static(
547        &self,
548        ctx: ReconcileCtx<'_>,
549        route: &str,
550    ) -> Result<RunningWorkload> {
551        let workload_dir = ctx.workload_dir();
552        let is_local_static =
553            ctx.slot("static").and_then(|s| s.inline_kind()) == Some(Provider::LocalStatic);
554        if !is_local_static {
555            if let Some((build, build_mode)) = read_mesofact_build(&workload_dir)? {
556                if let Some(render_command) = &build.render_command {
557                    let render = BuildConfig {
558                        command: Some(render_command.replace("{route}", route)),
559                        out_dir: build.out_dir.clone(),
560                        render_command: None,
561                    };
562                    run_build(&workload_dir, &render, &build_mode, &*self.executor).await?;
563                }
564            }
565        }
566        self.up(ctx).await
567    }
568
569    async fn up_local_static(&self, ctx: &ReconcileCtx<'_>, port: u16) -> Result<RunningWorkload> {
570        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
571        let svc = &ctx.service.name;
572        let comp = &ctx.component.id;
573
574        // If a mesofact-dev server is already running on the configured port
575        // (e.g. a prior `mirror up` in this session), adopt it rather than
576        // spawning a second instance — keeps re-runs idempotent. But adopt ONLY
577        // when it identifies as THIS (service, component): a bare port probe is
578        // identity-blind, so a different service's dev server (or a foreign
579        // process) on a colliding host port would be silently hijacked and
580        // serve the wrong site (R602-B4). `/__mesofact/info` is the oracle;
581        // identity mismatch is a hard error, not a fall-through to spawn (the
582        // port is taken — there is nothing safe to do but surface it).
583        if let Some(addr) = try_adopt_identified(addr, svc, comp, "configured").await? {
584            return Ok(self.adopted_workload(ctx, addr));
585        }
586
587        // Camp may have fallen back to a dynamic port (OS-assigned when configured
588        // port was taken). Check the jit ports file it writes after binding.
589        let jit_port = read_jit_port(ctx.workspace_root, &ctx.service.name, &ctx.component.id);
590        if let Some(actual_port) = jit_port {
591            if actual_port != port {
592                let actual_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), actual_port);
593                if let Some(addr) =
594                    try_adopt_identified(actual_addr, svc, comp, "dynamic").await?
595                {
596                    return Ok(self.adopted_workload(ctx, addr));
597                }
598            }
599        }
600
601        if self.local_static.adopt_only {
602            let jit_note = jit_port
603                .filter(|&p| p != port)
604                .map(|p| format!(" (jit file recorded port {p} — also not bound)"))
605                .unwrap_or_default();
606
607            // Distinguish "camp not attached" from "camp up but server didn't bind"
608            // so the operator gets an actionable message.
609            let camp_live = self
610                .local_static
611                .camp_socket
612                .as_deref()
613                .map(is_unix_socket_live)
614                .unwrap_or(false);
615
616            if camp_live {
617                anyhow::bail!(
618                    "component {}: a yah daemon is up but mesofact-dev did not bind on port {}{} \
619                     — check that the mesofact-dev workload reconciled, or inspect its logs",
620                    ctx.component.id,
621                    port,
622                    jit_note,
623                );
624            } else {
625                anyhow::bail!(
626                    "component {}: mesofact-dev not running for this workspace \
627                     — attach the workspace in the desktop or run `yah camp` from a terminal",
628                    ctx.component.id,
629                );
630            }
631        }
632
633        let binary = self.local_static.resolved_binary();
634        let workload_dir = ctx.workload_dir();
635
636        // Prefer the configured port; fall back to OS-assigned when it's taken.
637        // Web entrypoints are browser handles — the port number itself doesn't
638        // matter to the operator, so floating to any free port is fine.
639        //
640        // R844-F2: this is the LOCAL tier's half of the one allocation
641        // contract the remote (kamaji) tier answers through as well. It was
642        // hand-rolled here and nowhere else, which is exactly the shape that
643        // lets a service running both locally and remotely learn its port from
644        // two mechanisms that can disagree; `kamaji::ports::EphemeralPorts` is
645        // this logic, named, so the two tiers cannot drift. Ephemeral rather
646        // than ledger-backed on purpose: a camp port is disposable, nothing
647        // publishes it, and a fresh one each run is fine.
648        // R844-F14: ports are named now (`http` here — one listener), and the
649        // configured number rides in as a `pin`. On THIS tier a pin is a
650        // preference, not a declaration: `localhost:4321` out of a dev mirror
651        // is a browser handle the operator typed, nothing publishes it, and it
652        // floats when taken. The published (kamaji) tier is where a pin is an
653        // error instead.
654        let spawn_port = {
655            use kamaji::ports::PortAllocator;
656            kamaji::ports::EphemeralPorts
657                .resolve_one(
658                    &ctx.component.id,
659                    kamaji::ports::LOOPBACK,
660                    kamaji::ports::PortSpec {
661                        name: kamaji::ports::HTTP.to_string(),
662                        pin: (port != 0).then_some(port),
663                    },
664                )
665                .context("could not bind any port for mesofact-dev")?
666        };
667
668        // R490-F2: spawn mesofact-dev through kamaji's Native (fork+exec)
669        // backend rather than a bespoke Command::spawn. NativeRuntime owns the
670        // fork+exec, stdio capture, and SIGTERM→grace→SIGKILL teardown; the
671        // reconciler keeps only the WorkloadSpec lowering, the readiness probe,
672        // and a file-tail→LogBuffer bridge that preserves the Run-tab's live
673        // log surface (NativeRuntime captures stdio to files, not a pipe).
674        self.spawn_via_constable(ctx, &binary, &workload_dir, spawn_port)
675            .await
676    }
677
678    /// Wrap an already-running mesofact-dev (identity confirmed by
679    /// [`try_adopt_identified`]) in a handle that can actually stop it and can
680    /// show its logs.
681    ///
682    /// **R875-B1.** This used to be `RunningWorkload::adopted(...)`, whose
683    /// `shutdown()` is a documented no-op and whose `log_buffer` is `None`. On
684    /// the dev tier that made the Stop button lie and the log pane empty — and
685    /// not rarely: the desktop re-adopts on every launch, so the *only* run
686    /// that ever got a real handle was the one that first spawned the server.
687    /// Every run after a restart got the dead one. It is the same defect
688    /// R714-B1 fixed for `kind = "container"`, and that ticket's conclusion
689    /// applies here verbatim: attaching teardown to the spawn arm alone leaves
690    /// the button a no-op after any app restart, which is the bug.
691    ///
692    /// Both halves work on an *orphan* — a server whose spawning desktop is
693    /// long gone and which has reparented to init — because neither depends on
694    /// holding the child:
695    ///
696    /// * **Teardown** asks the OS who holds the port right now
697    ///   ([`stop_process_listening_on`]) rather than trusting a pid recorded at
698    ///   spawn time, which in the orphan case is exactly the stale one.
699    /// * **Logs** tail the capture files NativeRuntime left at a deterministic
700    ///   path, which the orphan is still appending to.
701    ///
702    /// The capture files may be absent, or may belong to an earlier run rather
703    /// than to the process actually holding the port (a mesofact-dev started by
704    /// hand, or one whose spawner wrote elsewhere). The tail therefore starts
705    /// at end-of-file: an empty pane that fills as the live server logs, never
706    /// a dead run's output presented as this one's.
707    fn adopted_workload(&self, ctx: &ReconcileCtx<'_>, addr: SocketAddr) -> RunningWorkload {
708        let state_dir = ctx.workspace_root.join(".yah/jit/native");
709        let ident_str = native_ident(&ctx.service.name, &ctx.component.id);
710        let (stdout_path, stderr_path) = capture_paths(&state_dir, &ident_str);
711
712        let log_buf = LogBuffer::new();
713        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
714        let supervisor =
715            spawn_capture_tail(log_buf.clone(), stdout_path, stderr_path, shutdown_rx);
716
717        into_running(
718            "mesofact-static",
719            "static",
720            Some(format!("http://{addr}")),
721            None,
722            Some(log_buf),
723            shutdown_tx,
724            supervisor,
725        )
726        .with_teardown(move || async move {
727            stop_process_listening_on(addr, Duration::from_secs(5)).await
728        })
729    }
730
731    /// Lower the mesofact-dev invocation to a [`WorkloadSpec`], deploy it on a
732    /// per-bring-up [`NativeRuntime`], wait for the port, and wrap the result
733    /// in a [`RunningWorkload`] whose shutdown tears the workload back down.
734    async fn spawn_via_constable(
735        &self,
736        ctx: &ReconcileCtx<'_>,
737        binary: &Path,
738        workload_dir: &Path,
739        spawn_port: u16,
740    ) -> Result<RunningWorkload> {
741        // NativeRuntime captures stdout/stderr under <state_dir>/<ident>/.
742        // Scope it per-workspace so concurrent camps don't collide.
743        let state_dir = ctx.workspace_root.join(".yah/jit/native");
744        let ident_str = native_ident(&ctx.service.name, &ctx.component.id);
745        let ident = MeshIdent(ident_str.clone());
746
747        let mut argv: Vec<String> = vec![
748            binary.display().to_string(),
749            workload_dir.display().to_string(),
750            "--port".to_string(),
751            spawn_port.to_string(),
752            // Stamp logical identity so the child answers /__mesofact/info and a
753            // later adopt re-run can confirm the port holds *this* server rather
754            // than a colliding foreign listener (R602-B4).
755            "--service".to_string(),
756            ctx.service.name.clone(),
757            "--component".to_string(),
758            ctx.component.id.clone(),
759        ];
760        argv.extend(self.local_static.extra_args.iter().cloned());
761        let mut spec = native_spec(&ident_str, argv, Vec::new());
762        // The port the allocator just handed us is the workload's own fact, so it
763        // rides the spec rather than only the argv. Two things follow, and both
764        // were missing before R844-T13: the native backend injects `PORT` /
765        // `PORT_HTTP` from it (one contract, whether mesofact-dev runs here or on
766        // a fleet node), and `DeployResult::ports` stops reporting this workload
767        // as portless.
768        // Named, not anonymous (R844-F17): the allocator above asked for this
769        // port under `kamaji::ports::HTTP`, so the spec states that name rather
770        // than leaving `declared_port_names` to re-derive it from the count.
771        spec.expose.mesh.ports = vec![workload_spec::MeshPort::pinned(
772            kamaji::ports::HTTP,
773            spawn_port,
774        )];
775
776        let runtime = Arc::new(NativeRuntime::new(&state_dir));
777        let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
778
779        info!(
780            binary = %binary.display(),
781            workload = %workload_dir.display(),
782            port = spawn_port,
783            ident = %ident_str,
784            "spawning mesofact-dev (kamaji native backend)",
785        );
786
787        let deployed = runtime
788            .deploy_workload(&spec, &mesh)
789            .await
790            .with_context(|| {
791                format!(
792                    "deploying mesofact-dev via kamaji native backend \
793                     — install with `cargo install --path oss/mesofact/crates/mesofact-dev` \
794                     or ensure the bundled sidecar is on the path ({})",
795                    binary.display(),
796                )
797            })?;
798
799        let (stdout_path, stderr_path) = capture_paths(&state_dir, &ident_str);
800
801        // Wait for the server to bind. If it doesn't, tear it down and return
802        // an error so the caller doesn't hand the UI a dead URL.
803        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), spawn_port);
804        let timeout = self
805            .local_static
806            .ready_timeout
807            .unwrap_or(Duration::from_secs(10));
808        if !wait_for_port(addr, timeout).await {
809            warn!(addr = %addr, "mesofact-dev did not bind within timeout; tearing down");
810            runtime.teardown_workload(&ident).await.ok();
811            anyhow::bail!("mesofact-dev failed to bind {addr} within {:?}", timeout);
812        }
813
814        let dev_url = format!("http://{addr}");
815        info!(dev_url = %dev_url, port = spawn_port, pid = deployed.task_pid, "mesofact-dev ready");
816
817        // Bridge NativeRuntime's file capture into the Run-tab LogBuffer and
818        // own teardown on shutdown.
819        let log_buf = LogBuffer::new();
820        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
821        let supervisor = spawn_native_log_supervisor(
822            runtime,
823            ident,
824            log_buf.clone(),
825            stdout_path,
826            stderr_path,
827            shutdown_rx,
828        );
829
830        Ok(into_running(
831            "mesofact-static",
832            "static",
833            Some(dev_url),
834            None,
835            Some(log_buf),
836            shutdown_tx,
837            supervisor,
838        ))
839    }
840
841    /// Cloudflare R2 publish path: upload `dist/` to R2, optionally purge CDN
842    /// cache tags, and return a `RunningWorkload` with `public_url` set.
843    async fn up_cloudflare_r2(
844        &self,
845        ctx: &ReconcileCtx<'_>,
846        cf_provider: super::cf_creds::CfProvider,
847        slot_fields: &std::collections::BTreeMap<String, toml::Value>,
848    ) -> Result<RunningWorkload> {
849        use super::r2_publish::{publish_to_r2, R2PurgeOpts};
850        use crate::provider::cloudflare::{CloudflareClient, WorkerBinding};
851
852        // account_id + credentials come from the resolved provider.
853        let account_id = cf_provider.account_id.clone();
854
855        // Extract bucket + zone from the mirror's static slot.
856        let bucket = slot_fields
857            .get("bucket")
858            .and_then(|v| v.as_str())
859            .context("providers.static missing `bucket` field for cloudflare R2 publish")?
860            .to_string();
861        let zone = slot_fields
862            .get("zone")
863            .and_then(|v| v.as_str())
864            .context("providers.static missing `zone` field for cloudflare R2 publish")?
865            .to_string();
866
867        // asset_origin is the public HTTP URL the Worker fetches assets from.
868        // publish_to_r2 lays files down under `<svc>/<env>/<key>`, so this URL
869        // must include the same prefix. Validate up front — without it the
870        // Worker would 404 every request in prod.
871        let asset_origin =
872            slot_fields
873                .get("asset_origin")
874                .and_then(|v| v.as_str())
875                .filter(|s| !s.is_empty())
876                .with_context(|| {
877                    format!(
878                "providers.static.asset_origin missing or empty (service={svc}, env={env}) — \
879                 set it to the R2 public URL with the publish prefix, e.g. \
880                 \"https://cdn.{zone}/{svc}/{env}\"",
881                svc = ctx.service.name, env = ctx.env, zone = zone,
882            )
883                })?
884                .to_string();
885
886        // R2 S3 access keys (distinct from the management API token).
887        let (access_key, secret_key) = cf_provider.r2_keys()?;
888
889        // Management API token — used for cache-tag purge and Transform Rules.
890        // Optional: publish itself only needs the R2 S3 keys.
891        let cf_api_token: Option<String> = cf_provider.api_token_opt();
892        let purge = cf_api_token.clone().map(|token| R2PurgeOpts {
893            zone_name: zone.clone(),
894            api_token: token,
895        });
896
897        // Resolve dist dir from workload.toml build.out_dir (default: "dist").
898        let workload_dir = ctx.workload_dir();
899        let out_dir = read_workload_out_dir(&workload_dir).unwrap_or_else(|| "dist".to_string());
900        let dist_dir = workload_dir.join(&out_dir);
901
902        let mirror_prefix =
903            publish_prefix(&ctx.service.name, ctx.env, ctx.component.mount.as_deref());
904        let report = publish_to_r2(
905            &dist_dir,
906            &account_id,
907            &bucket,
908            &access_key,
909            &secret_key,
910            Some(&mirror_prefix),
911            purge,
912        )
913        .await
914        .with_context(|| format!("publishing to R2 bucket {bucket:?} (account {account_id})"))?;
915
916        info!(
917            uploaded = report.uploaded.len(),
918            purged = report.purged_tags.len(),
919            bucket,
920            zone,
921            "R2 publish complete",
922        );
923
924        // Deploy CF Worker script (replaces the Transform Rule workaround).
925        // Worker serves assets via ASSET_ORIGIN with mode-aware routing:
926        // static 404-fallback, SPA index.html fallback, or SSR proxy to origin.
927        // Non-fatal: warn if token lacks Workers Scripts: Edit scope.
928        if let Some(ref token) = cf_api_token {
929            let cf = CloudflareClient::new(token.clone());
930            let mode = parse_worker_mode(&ctx.component.kind, slot_fields);
931            let worker_name = slot_fields
932                .get("worker_name")
933                .and_then(|v| v.as_str())
934                .map(|s| s.to_string())
935                .unwrap_or_else(|| format!("{}-worker", ctx.service.name));
936            let backends = BackendOrigins::from_slot_fields(slot_fields);
937            let route_headers = crate::config::route_headers_for_service(
938                ctx.workspace_root,
939                &ctx.service.name,
940            )?;
941            let bindings =
942                worker_config_bindings(&mode, &asset_origin, &backends, &route_headers);
943            let worker_bindings: Vec<WorkerBinding<'_>> = bindings
944                .iter()
945                .map(|(k, v)| WorkerBinding::PlainText {
946                    name: k.as_str(),
947                    text: v.as_str(),
948                })
949                .collect();
950            // Hash script + bindings so config changes trigger redeploy.
951            let script_hash = {
952                let mut input = WORKER_SCRIPT.as_bytes().to_vec();
953                input.push(0);
954                input.extend_from_slice(
955                    serde_json::to_string(&bindings)
956                        .unwrap_or_default()
957                        .as_bytes(),
958                );
959                sha256_hex(&input)
960            };
961
962            let worker_result = async {
963                let zone_id = cf.zone_id_for_name(&zone).await?;
964
965                // Skip redeploy when script + config are unchanged across re-runs.
966                let cached = read_worker_script_hash(ctx.workspace_root, &worker_name);
967                if cached.as_deref() != Some(&script_hash) {
968                    cf.deploy_worker_script(
969                        &account_id,
970                        &worker_name,
971                        WORKER_SCRIPT,
972                        &worker_bindings,
973                    )
974                    .await?;
975                    let _ =
976                        write_worker_script_hash(ctx.workspace_root, &worker_name, &script_hash);
977                    info!(worker_name, "CF Worker script deployed");
978                } else {
979                    info!(
980                        worker_name,
981                        "CF Worker script unchanged — skipping redeploy"
982                    );
983                }
984
985                // Upsert zone route: `{zone}/*` → worker script.
986                let route_pattern = format!("{zone}/*");
987                cf.upsert_worker_route(&zone_id, &route_pattern, &worker_name)
988                    .await?;
989                anyhow::Ok(())
990            }
991            .await;
992
993            // R703-B4 — how loudly this fails depends on whether the Worker is
994            // the door the public actually comes through.
995            //
996            // It was unconditionally non-fatal, and that is how the yah.dev
997            // Worker ended up 19 days behind the router bundle in-tree: the
998            // token lacked `Workers Scripts: Edit`, every apply warned into a
999            // logger the CLI never installed, and every apply reported ok. A
1000            // front door that cannot be updated is not a warning — it is the
1001            // failure. When the zone's manifest declares `front_door =
1002            // "worker"`, a failed deploy is fatal.
1003            //
1004            // For any other declared front door the Worker is a warm rollback
1005            // lever rather than the live door, so a warning remains right: it
1006            // should not be able to fail an apply for a surface serving no
1007            // traffic.
1008            if let Err(e) = worker_result {
1009                let is_live_front_door = matches!(
1010                    super::publish_beacon::declared_front_door(ctx.workspace_root, &zone),
1011                    Some((_, crate::config::FrontDoor::Worker))
1012                );
1013                if is_live_front_door {
1014                    return Err(e).with_context(|| {
1015                        format!(
1016                            "deploying the Cloudflare Worker for {zone} (script {worker_name}).\n\
1017                         \n\
1018                         .yah/domains/*.toml declares front_door = \"worker\" for this zone, so \
1019                         this Worker IS the public front door — it cannot be left at whatever \
1020                         version happens to be deployed. The publish above succeeded; what \
1021                         failed is updating the thing that serves it.\n\
1022                         \n\
1023                         An `Authentication error` here means the configured Cloudflare \
1024                         token cannot touch Workers. Test that DIRECTLY — a token-validity \
1025                         check will not tell you, because the token is almost certainly \
1026                         valid and merely under-scoped:\n\
1027                         \n\
1028                           curl -sS -H \"Authorization: Bearer $(yah keys get <slot>)\" \\\n\
1029                             https://api.cloudflare.com/client/v4/accounts/<acct>/workers/scripts\n\
1030                         \n\
1031                         DO NOT reach for https://api.cloudflare.com/client/v4/user/tokens/verify \
1032                         to triage this. An account-scoped token (`cfat_` prefix) is rejected \
1033                         there with a flat `code 1000, Invalid API Token`, which reads \
1034                         exactly like a revoked credential and sends you hunting for a token \
1035                         that is fine. That misdiagnosis has now happened twice. The valid \
1036                         health check for an account token is \
1037                         /accounts/<acct>/tokens/verify.\n\
1038                         \n\
1039                         THE FIX, if the workers/scripts GET is denied: mint a token that \
1040                         carries the grants, rather than editing one by hand —\n\
1041                         \n\
1042                           yah cloud cf token create --zone <zone> \\\n\
1043                             --store-slot cloudflare-mesofact-static \\\n\
1044                             --bootstrap-slot <a slot holding API Tokens: Edit>\n\
1045                         \n\
1046                         then point `credentials` in .yah/infra/providers/cloudflare.toml at \
1047                         that slot. It builds MESOFACT_STATIC_GRANTS, which includes \
1048                         `Workers Scripts: Write` (account) and `Workers Routes: Write` \
1049                         (zone). The command's own summary line prints only five scopes and \
1050                         omits both — that text is stale, the policy is not.\n\
1051                         \n\
1052                         Whatever the cause, it is silent everywhere else: the R2 publish \
1053                         uses separate S3 keys and keeps working, so the bucket stays \
1054                         current while the Worker — the thing that serves it — freezes."
1055                        )
1056                    });
1057                }
1058                warn!(
1059                    zone,
1060                    worker_name,
1061                    error = %e,
1062                    "CF Worker deploy/route failed (non-fatal — this zone's declared \
1063                     front door is not the Worker, so it serves no traffic today) — \
1064                     ensure cloudflare-api-token has Workers Scripts: Edit \
1065                     and Zone Workers Routes: Edit scope"
1066                );
1067            }
1068        }
1069
1070        // R703-B4 — the publish is not the deliverable; the served page is.
1071        self.verify_serving(ctx, slot_fields, &zone, &asset_origin, &report.beacon)
1072            .await?;
1073
1074        Ok(RunningWorkload::adopted("mesofact-static", "static", None)
1075            .with_public_url(format!("https://{zone}")))
1076    }
1077
1078    /// Fetch the just-written publish beacon back through the origin and the
1079    /// public front door, and fail the apply if the front door is serving
1080    /// anything else.
1081    ///
1082    /// R703-B4. Everything upstream of here reports success on the *write*:
1083    /// R2 accepted the objects, the Worker API accepted the script, the apply
1084    /// exits 0. None of that is evidence that the bytes reached a reader, and
1085    /// twice now they did not — once because a declared-but-unready bundle
1086    /// tier disabled this chain, once because the apex had been cut over to an
1087    /// origin nothing republishes. Both presented as HTTP 200 on a stale page.
1088    ///
1089    /// The origin probe is checked first and separately on purpose: it splits
1090    /// "the publish did not land" from "the publish landed and the front door
1091    /// is elsewhere", which are different tickets with identical symptoms.
1092    async fn verify_serving(
1093        &self,
1094        ctx: &ReconcileCtx<'_>,
1095        slot_fields: &std::collections::BTreeMap<String, toml::Value>,
1096        zone: &str,
1097        asset_origin: &str,
1098        beacon: &super::publish_beacon::PublishBeacon,
1099    ) -> Result<()> {
1100        use super::publish_beacon as pb;
1101
1102        // Opt-out for a deliberately in-flight front-door migration. Declared
1103        // in config rather than passed as a CLI flag so that turning it off is
1104        // a reviewable diff next to a comment naming the ticket, not an
1105        // invocation habit that quietly becomes permanent.
1106        let verify = slot_fields
1107            .get("verify_serving")
1108            .and_then(|v| v.as_bool())
1109            .unwrap_or(true);
1110        if !verify {
1111            warn!(
1112                zone,
1113                "verify_serving = false — publish NOT checked against the live \
1114                 front door; the site can go stale without this apply failing"
1115            );
1116            return Ok(());
1117        }
1118
1119        let verdict = pb::check_serving(
1120            ctx.workspace_root,
1121            zone,
1122            Some(asset_origin),
1123            beacon,
1124            pb::EDGE_PROBE_ATTEMPTS,
1125            pb::EDGE_PROBE_DELAY,
1126        )
1127        .await;
1128
1129        if verdict.is_ok() {
1130            info!(
1131                zone,
1132                digest = %beacon.digest,
1133                files = beacon.files,
1134                "front door is serving this publish"
1135            );
1136            return Ok(());
1137        }
1138
1139        // A stale or absent front door means the deployed Worker (if any) may
1140        // be older than the bundle this build embeds, and the local hash cache
1141        // would otherwise skip redeploying it forever — that cache is a claim
1142        // about the live Worker made from an untracked file on one laptop.
1143        // Drop the entry so the next apply cannot take the skip branch.
1144        if !verdict.front_door.is_match() {
1145            let worker_name = slot_fields
1146                .get("worker_name")
1147                .and_then(|v| v.as_str())
1148                .map(|s| s.to_string())
1149                .unwrap_or_else(|| format!("{}-worker", ctx.service.name));
1150            forget_worker_script_hash(ctx.workspace_root, &worker_name);
1151        }
1152
1153        Err(verdict.into_error(beacon))
1154    }
1155}
1156
1157/// Read the `[build]` + `[build_mode]` subtrees from
1158/// `<workload_dir>/workload.toml`. Used by [`MesofactStaticReconciler::rebuild_static`]
1159/// to drive [`run_build`] without forcing the whole workload through the
1160/// `workload_spec::Workload` envelope — many in-tree workload manifests
1161/// still carry `schema_version = 1` (integer) which the typed envelope
1162/// rejects. Parsing only the subtrees we use keeps this path tolerant of
1163/// the legacy shape while still giving us typed [`BuildConfig`] and
1164/// [`BuildMode`] values to lower.
1165///
1166/// Returns:
1167/// - `Ok(None)` when `workload.toml` is absent, isn't a `mesofact-static`
1168///   workload, or has no `[build]` table — `rebuild_static` then skips the
1169///   build step (the subsequent `up()` will surface the missing-manifest
1170///   error if relevant).
1171/// - `Ok(Some((build, build_mode)))` on success; `build_mode` defaults to
1172///   `HostSide` when the field is absent.
1173fn read_mesofact_build(workload_dir: &std::path::Path) -> Result<Option<(BuildConfig, BuildMode)>> {
1174    let path = workload_dir.join("workload.toml");
1175    let src = match std::fs::read_to_string(&path) {
1176        Ok(s) => s,
1177        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1178        Err(e) => return Err(anyhow::Error::new(e).context(format!("reading {}", path.display()))),
1179    };
1180    let value: toml::Value =
1181        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
1182
1183    if value.get("kind").and_then(|v| v.as_str()) != Some(WORKLOAD_KIND) {
1184        return Ok(None);
1185    }
1186
1187    let Some(build_value) = value.get("build") else {
1188        return Ok(None);
1189    };
1190    let build: BuildConfig = build_value
1191        .clone()
1192        .try_into()
1193        .with_context(|| format!("parsing [build] table in {}", path.display()))?;
1194
1195    let build_mode = match value.get("build_mode") {
1196        Some(v) => v
1197            .clone()
1198            .try_into()
1199            .with_context(|| format!("parsing [build_mode] table in {}", path.display()))?,
1200        None => BuildMode::default(),
1201    };
1202
1203    Ok(Some((build, build_mode)))
1204}
1205
1206/// Lower (`build`, `build_mode`) to a [`ForgeSpec`] (W165).
1207///
1208/// - [`BuildMode::HostSide`] → `TaskRuntime::Native`, `image=None` — the
1209///   build inherits the host's PATH and toolchain.
1210/// - [`BuildMode::InContainer { image }`] → `TaskRuntime::Container` with
1211///   the pinned image attached to the `Subprocess` command. The executor
1212///   bind-mounts `workload_dir` as the container's working directory via
1213///   the [`ExecContext`] passed alongside.
1214///
1215/// `None` when the manifest declares no `build.command` (R838-B1) — there is
1216/// no subprocess to lower, because the project builds through the in-process
1217/// `mesofact-dev` pipeline rather than an external bundler. Callers skip the
1218/// build step rather than lowering an empty `sh -c ""`, which would "succeed"
1219/// having produced nothing.
1220///
1221/// Pure function: no I/O, no subprocess. Exposed at `pub(crate)` for
1222/// golden-test parity with the recipe-lowering helper (R438-T7).
1223pub(crate) fn lower_build_to_forge_spec(
1224    workload_dir: &std::path::Path,
1225    build: &BuildConfig,
1226    build_mode: &BuildMode,
1227) -> Option<ForgeSpec> {
1228    let command = build.command.clone()?;
1229    let (image, runtime) = match build_mode {
1230        BuildMode::HostSide => (None, TaskRuntime::Native),
1231        BuildMode::InContainer { image } => (Some(image.clone()), TaskRuntime::Container),
1232    };
1233    Some(ForgeSpec {
1234        command: ForgeCommand::Subprocess {
1235            argv: vec!["sh".into(), "-c".into(), command],
1236            image,
1237        },
1238        where_: TaskPlacement::new(TaskLocation::Local, runtime),
1239        timeout: None,
1240        label: Some(format!("mesofact-static-build:{}", workload_dir.display())),
1241        initiator: Initiator::Gnome {
1242            camp: "mesofact-static-reconciler".into(),
1243            shift: "build".into(),
1244        },
1245        mesh_access: MeshAccess::default(),
1246        cache_key: None,
1247    })
1248}
1249
1250/// Lower (`build`, `build_mode`) to a [`ForgeSpec`] and run it through the
1251/// supplied [`ForgeExecutor`] (W165). Thin wrapper over
1252/// [`lower_build_to_forge_spec`] — separated so the lowering is testable
1253/// without spawning a subprocess.
1254///
1255/// A manifest with no `build.command` is a no-op here (R838-B1): the project
1256/// has no external bundler step, so there is nothing for this reconciler to
1257/// shell out to. Same outcome as a workload with no `workload.toml` at all,
1258/// which `rebuild_static` has always skipped.
1259async fn run_build(
1260    workload_dir: &std::path::Path,
1261    build: &BuildConfig,
1262    build_mode: &BuildMode,
1263    executor: &dyn ForgeExecutor,
1264) -> Result<()> {
1265    let mode_tag = match build_mode {
1266        BuildMode::HostSide => "host_side",
1267        BuildMode::InContainer { .. } => "in_container",
1268    };
1269    let Some(spec) = lower_build_to_forge_spec(workload_dir, build, build_mode) else {
1270        tracing::info!(
1271            workload = %workload_dir.display(),
1272            "workload.toml declares no [build] command — skipping the build step \
1273             (the project builds in-process)"
1274        );
1275        return Ok(());
1276    };
1277    let cmd_str = build
1278        .command
1279        .clone()
1280        .expect("lower_build_to_forge_spec returns Some only when command is Some");
1281    tracing::info!(
1282        workload = %workload_dir.display(),
1283        cmd = %cmd_str,
1284        mode = mode_tag,
1285        "running mesofact-static build"
1286    );
1287
1288    let exec_ctx = ExecContext::default().with_cwd(workload_dir.to_path_buf());
1289
1290    let outcome = executor
1291        .execute(spec, exec_ctx, None)
1292        .await
1293        .with_context(|| format!("executing build command: {cmd_str}"))?;
1294
1295    if !outcome.succeeded() {
1296        anyhow::bail!(
1297            "build command failed ({}): {} — {}",
1298            outcome.status.discriminant(),
1299            cmd_str,
1300            outcome.stderr_tail,
1301        );
1302    }
1303    Ok(())
1304}
1305
1306/// Read `build.out_dir` from a workload's `workload.toml`. Returns `None`
1307/// when the file is absent, unreadable, or the field is missing — callers
1308/// default to `"dist"`.
1309pub(crate) fn read_workload_out_dir(workload_dir: &std::path::Path) -> Option<String> {
1310    let path = workload_dir.join("workload.toml");
1311    let src = std::fs::read_to_string(&path).ok()?;
1312    let value: toml::Value = toml::from_str(&src).ok()?;
1313    value
1314        .get("build")?
1315        .get("out_dir")?
1316        .as_str()
1317        .map(str::to_string)
1318}
1319
1320// ---------- CF Worker script rendering ----------
1321
1322/// Routing mode baked into the Worker script at deploy time. Shared between
1323/// the Cloudflare-Worker arm (this file) and the pond arm via `pub` so camp's
1324/// `build_miniflare_deploy_spec` can derive the same mode from a mirror's
1325/// static slot when populating `local_driver::pond_miniflare::MiniflareSpec`.
1326pub enum WorkerMode {
1327    /// All routes served from R2; `/` and directory paths → `index.html`;
1328    /// unknown paths → `404.html` (if present) or a 404 response.
1329    Static,
1330    /// Unknown paths fall back to `index.html` for client-side routing.
1331    Spa,
1332    /// Paths matching `prefixes` are proxied to `origin_url`; the rest uses
1333    /// the SPA index.html fallback.
1334    Ssr {
1335        origin_url: String,
1336        prefixes: Vec<String>,
1337    },
1338}
1339
1340/// Parse the Worker routing mode from the mirror's static slot fields. Public
1341/// so camp's pond bring-up can mirror the cloudflare-arm semantics without
1342/// duplicating the field-name conventions.
1343///
1344/// When the slot declares no explicit `mode`, the default derives from the
1345/// component's kind: `mesofact-spa` → SPA fallback, everything else → static.
1346/// An explicit `mode` field always wins.
1347pub fn parse_worker_mode(
1348    component_kind: &str,
1349    fields: &std::collections::BTreeMap<String, toml::Value>,
1350) -> WorkerMode {
1351    let default_mode = if component_kind == WORKLOAD_KIND_SPA {
1352        "spa"
1353    } else {
1354        "static"
1355    };
1356    match fields
1357        .get("mode")
1358        .and_then(|v| v.as_str())
1359        .unwrap_or(default_mode)
1360    {
1361        "spa" => WorkerMode::Spa,
1362        "ssr" => {
1363            let origin_url = fields
1364                .get("origin_url")
1365                .and_then(|v| v.as_str())
1366                .unwrap_or_default()
1367                .to_string();
1368            let prefixes = fields
1369                .get("ssr_prefixes")
1370                .and_then(|v| v.as_array())
1371                .map(|a| {
1372                    a.iter()
1373                        .filter_map(|v| v.as_str().map(String::from))
1374                        .collect()
1375                })
1376                .unwrap_or_default();
1377            WorkerMode::Ssr {
1378                origin_url,
1379                prefixes,
1380            }
1381        }
1382        _ => WorkerMode::Static,
1383    }
1384}
1385
1386/// Backend origins the edge router proxies `/api/*` prefixes to (R455-T4).
1387///
1388/// Distinct from `SSR_ORIGIN`: SSR proxies *page* routes to a renderer, these
1389/// proxy *API* routes to a service that owns state. Both are read off the
1390/// mirror's static slot (`issues_origin` / `backend_origin`).
1391///
1392/// These MUST be emitted here rather than set by hand on the Worker. Every
1393/// apply re-uploads the whole binding list, so a binding this function does
1394/// not produce is *deleted* on the next `yah cloud apply` — which is how a
1395/// hand-set `ISSUES_ORIGIN` silently reverts to a 404 (R330-F13, R752-B2).
1396#[derive(Debug, Clone, Default, PartialEq, Eq)]
1397pub struct BackendOrigins {
1398    /// `ISSUES_ORIGIN` — issue-tracker surface; `/api/issues*` proxied here.
1399    pub issues: String,
1400    /// `MESOFACT_BACKEND_ORIGIN` — almanac surface; `/api/releases*`.
1401    pub releases: String,
1402}
1403
1404impl BackendOrigins {
1405    /// Read the optional backend-origin fields off a mirror's static slot.
1406    /// Absent or empty → an empty binding, and the router's `env.X &&` guard
1407    /// leaves that prefix unrouted (a real 404, never a half-configured proxy).
1408    pub fn from_slot_fields(fields: &std::collections::BTreeMap<String, toml::Value>) -> Self {
1409        let field = |name: &str| {
1410            fields
1411                .get(name)
1412                .and_then(|v| v.as_str())
1413                .unwrap_or_default()
1414                .trim_end_matches('/')
1415                .to_string()
1416        };
1417        Self {
1418            issues: field("issues_origin"),
1419            releases: field("backend_origin"),
1420        }
1421    }
1422}
1423
1424/// The R2 key prefix a static component publishes under (R746).
1425///
1426/// `<service>/<env>` for an unmounted component — every pre-R746 component, and
1427/// the only shape `asset_origin` in a mirror manifest is written against.
1428/// `mount` appends its normalized form, which is what lets two static
1429/// components of one service coexist: before it, both wrote `index.html` to the
1430/// same key and the second deploy of the day silently replaced the first site
1431/// with the other.
1432///
1433/// The front door resolves a request by its own path (`${ASSET_ORIGIN}/<path>`),
1434/// so the mount is simultaneously the storage prefix and the URL prefix — by
1435/// construction, not by two manifests agreeing.
1436fn publish_prefix(service: &str, env: &str, mount: Option<&str>) -> String {
1437    let base = format!("{service}/{env}");
1438    match mount.map(crate::config::normalize_mount) {
1439        None => base,
1440        Some(m) if m.is_empty() => base,
1441        Some(m) => format!("{base}/{m}"),
1442    }
1443}
1444
1445/// Build the plain_text Worker binding values for the given routing mode.
1446///
1447/// These are uploaded alongside [`WORKER_SCRIPT`] as `plain_text` bindings
1448/// and appear as `env.ASSET_ORIGIN`, `env.WORKER_MODE`, etc. inside the Worker.
1449fn worker_config_bindings(
1450    mode: &WorkerMode,
1451    asset_origin: &str,
1452    backends: &BackendOrigins,
1453    route_headers: &str,
1454) -> Vec<(String, String)> {
1455    let (mode_str, ssr_origin, ssr_prefixes) = match mode {
1456        WorkerMode::Static => ("static", String::new(), "[]".to_string()),
1457        WorkerMode::Spa => ("spa", String::new(), "[]".to_string()),
1458        WorkerMode::Ssr {
1459            origin_url,
1460            prefixes,
1461        } => (
1462            "ssr",
1463            origin_url.clone(),
1464            serde_json::to_string(prefixes).unwrap_or_else(|_| "[]".to_string()),
1465        ),
1466    };
1467    vec![
1468        ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
1469        // Pointer-store origin for instance-addressed routes (W270 §3): the
1470        // @mesofact/edge worker reads `p/<key>` records here. Pointers live
1471        // under the `p/` prefix in the same bucket as content, so this defaults
1472        // to ASSET_ORIGIN; it stays a distinct binding so a future consumer can
1473        // front the (uncached) pointer reads separately.
1474        ("POINTER_ORIGIN".to_string(), asset_origin.to_string()),
1475        // Reserved upload seam (R490-T8): prod has no upload origin yet, so the
1476        // binding is empty and the Worker returns 404 on /uploads/*. A future
1477        // dynamic-bucket consumer sets this to the user-writable origin.
1478        ("UPLOAD_ORIGIN".to_string(), String::new()),
1479        ("WORKER_MODE".to_string(), mode_str.to_string()),
1480        ("SSR_ORIGIN".to_string(), ssr_origin),
1481        ("SSR_PREFIXES".to_string(), ssr_prefixes),
1482        ("ISSUES_ORIGIN".to_string(), backends.issues.clone()),
1483        (
1484            "MESOFACT_BACKEND_ORIGIN".to_string(),
1485            backends.releases.clone(),
1486        ),
1487        // R746: per-route response headers, straight from the domain manifest's
1488        // route table (`DomainConfig::route_headers_json`). `"[]"` when no
1489        // domain routes this service or none of its routes declare headers —
1490        // the Worker then leaves every response untouched.
1491        ("ROUTE_HEADERS".to_string(), route_headers.to_string()),
1492    ]
1493}
1494
1495fn sha256_hex(data: &[u8]) -> String {
1496    use sha2::Digest;
1497    hex::encode(sha2::Sha256::digest(data))
1498}
1499
1500/// Read the last deployed Worker script hash from the jit cache.
1501fn read_worker_script_hash(workspace_root: &std::path::Path, worker_name: &str) -> Option<String> {
1502    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1503    let s = std::fs::read_to_string(&path).ok()?;
1504    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&s).ok()?;
1505    map.get(worker_name)
1506        .and_then(|v| v.as_str())
1507        .map(|s| s.to_string())
1508}
1509
1510/// Write the deployed Worker script hash to the jit cache.
1511fn write_worker_script_hash(
1512    workspace_root: &std::path::Path,
1513    worker_name: &str,
1514    hash: &str,
1515) -> std::io::Result<()> {
1516    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1517    let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
1518        std::fs::read_to_string(&path)
1519            .ok()
1520            .and_then(|s| serde_json::from_str(&s).ok())
1521            .unwrap_or_default()
1522    } else {
1523        serde_json::Map::new()
1524    };
1525    map.insert(
1526        worker_name.to_string(),
1527        serde_json::Value::String(hash.to_string()),
1528    );
1529    if let Some(parent) = path.parent() {
1530        std::fs::create_dir_all(parent)?;
1531    }
1532    std::fs::write(
1533        &path,
1534        serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap_or_default(),
1535    )
1536}
1537
1538/// Drop a Worker's cached script hash so the next reconcile redeploys it.
1539///
1540/// R703-B4. The cache at `.yah/jit/worker-script-hashes.json` is an untracked
1541/// local file asserting something about a *remote* Worker, so it can be right
1542/// on one machine and wrong on the next — and while it is wrong, every apply
1543/// takes the "unchanged — skipping redeploy" branch and the live Worker never
1544/// catches up. It sat 19 days behind the in-tree router bundle that way. When
1545/// the front door is demonstrably not serving the current publish, the cache
1546/// has lost the right to be believed.
1547///
1548/// Best-effort: a cache we could not clear only costs one more manual redeploy,
1549/// and the serving check that called us is already returning an error.
1550fn forget_worker_script_hash(workspace_root: &std::path::Path, worker_name: &str) {
1551    let path = workspace_root.join(".yah/jit/worker-script-hashes.json");
1552    let Ok(s) = std::fs::read_to_string(&path) else {
1553        return;
1554    };
1555    let Ok(mut map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&s) else {
1556        return;
1557    };
1558    if map.remove(worker_name).is_none() {
1559        return;
1560    }
1561    let _ = std::fs::write(
1562        &path,
1563        serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap_or_default(),
1564    );
1565    warn!(
1566        worker_name,
1567        "cleared cached Worker script hash — next apply will redeploy the script"
1568    );
1569}
1570
1571/// Return `true` when a Unix-domain socket at `path` accepts connections.
1572/// Uses a blocking connect so it can be called from sync or async context
1573/// without spawning a task. The connect attempt is instantaneous for a live
1574/// listener and fails immediately for a missing/stale socket file.
1575#[cfg(unix)]
1576fn is_unix_socket_live(path: &std::path::Path) -> bool {
1577    std::os::unix::net::UnixStream::connect(path).is_ok()
1578}
1579
1580#[cfg(not(unix))]
1581fn is_unix_socket_live(_path: &std::path::Path) -> bool {
1582    false
1583}
1584
1585/// Confirm that a mesofact-dev already listening on `addr` identifies as
1586/// `(expected_service, expected_component)` via `/__mesofact/info` (R602-B4).
1587///
1588/// Identity check only — building the [`RunningWorkload`] is the caller's job,
1589/// because an adopted workload needs a teardown hook and a log tail that both
1590/// need `ReconcileCtx` (R875-B1). Returns:
1591/// - `Ok(None)` — nothing is listening on `addr` (caller falls through to the
1592///   next candidate / spawns a fresh server).
1593/// - `Ok(Some(addr))` — a matching mesofact-dev is running; adopt it.
1594/// - `Err(_)` — a listener is present but is a *different* service/component,
1595///   or is not an identifiable mesofact-dev at all. Adoption is refused; the
1596///   port is taken by a foreign workload, so there is nothing to spawn — surface
1597///   the collision instead of silently serving the wrong site.
1598///
1599/// `label` distinguishes the "configured" vs jit "dynamic" port in logs.
1600async fn try_adopt_identified(
1601    addr: SocketAddr,
1602    expected_service: &str,
1603    expected_component: &str,
1604    label: &str,
1605) -> Result<Option<SocketAddr>> {
1606    // Liveness gate first: no listener → nothing to adopt (spawn path).
1607    if tokio::net::TcpStream::connect(addr).await.is_err() {
1608        return Ok(None);
1609    }
1610
1611    match probe_dev_identity(addr).await {
1612        Some((svc, comp)) if svc == expected_service && comp == expected_component => {
1613            info!(
1614                port = addr.port(),
1615                %label,
1616                "mesofact-dev already running; identity matches, adopting"
1617            );
1618            Ok(Some(addr))
1619        }
1620        Some((svc, comp)) => anyhow::bail!(
1621            "port {} is serving {svc}/{comp}, but component {expected_component} of service \
1622             {expected_service} expected it — refusing to adopt another workload's dev server. \
1623             This is a host-port collision; give each service a distinct port \
1624             (check `.yah/services/*/mirrors/*.toml`, or run `yah cloud validate`).",
1625            addr.port(),
1626        ),
1627        None => anyhow::bail!(
1628            "port {} is occupied by a process that is not an identifiable mesofact-dev \
1629             (no /__mesofact/info) — refusing to adopt a foreign listener for component \
1630             {expected_component} of service {expected_service}. Free the port or point this \
1631             component at an unused one.",
1632            addr.port(),
1633        ),
1634    }
1635}
1636
1637/// Query `GET http://{addr}/__mesofact/info` and return the server's logical
1638/// `(service, component)` identity. `None` when the endpoint is unreachable,
1639/// non-2xx (older/identity-less mesofact-dev, or a foreign server), or the body
1640/// is not the expected JSON shape. Short timeout — this is a loopback probe on
1641/// the reconcile hot path.
1642async fn probe_dev_identity(addr: SocketAddr) -> Option<(String, String)> {
1643    let url = format!("http://{addr}/__mesofact/info");
1644    let resp = reqwest::Client::new()
1645        .get(&url)
1646        .timeout(Duration::from_secs(2))
1647        .send()
1648        .await
1649        .ok()?;
1650    if !resp.status().is_success() {
1651        return None;
1652    }
1653    let body: serde_json::Value = resp.json().await.ok()?;
1654    let svc = body.get("service")?.as_str()?.to_string();
1655    let comp = body.get("component")?.as_str()?.to_string();
1656    Some((svc, comp))
1657}
1658
1659/// Read the actual port recorded in `.yah/jit/mesofact-dev-ports.json` after
1660/// a mesofact-dev bind. Returns `None` when the file is absent or the entry
1661/// is missing. `pub` since R490 follow-through: the desktop's dev-cell
1662/// observation probes this port when the camp's `mesofact_dev.list` has no
1663/// entry (the camp stopped spawning mesofact-dev in R490-F2, so its list no
1664/// longer sees desktop-spawned processes).
1665pub fn read_jit_port(workspace_root: &std::path::Path, svc: &str, component: &str) -> Option<u16> {
1666    let path = workspace_root
1667        .join(".yah")
1668        .join("jit")
1669        .join("mesofact-dev-ports.json");
1670    let s = std::fs::read_to_string(&path).ok()?;
1671    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&s).ok()?;
1672    map.get(&format!("{svc}/{component}"))
1673        .and_then(|v| v.as_u64())
1674        .and_then(|n| u16::try_from(n).ok())
1675}
1676
1677/// mesofact-dev's ident namespace: `mesofact-dev-<service>-<component>`,
1678/// sanitized by [`sanitize_ident`] into a slug that is both DNS-segment shaped
1679/// (kamaji's contract) and safe as a path component (NativeRuntime joins it
1680/// under its state dir).
1681fn native_ident(service: &str, component: &str) -> String {
1682    sanitize_ident(&format!("mesofact-dev-{service}-{component}"))
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687    use super::*;
1688    use crate::{MirrorConfig, MirrorShape, ServiceComponent, ServiceConfig};
1689    use std::collections::BTreeMap;
1690    use tempfile::tempdir;
1691
1692    /// Build a minimal in-memory ctx for unit tests, with the component's
1693    /// workload dir set up to look like a mesofact-static workload.
1694    struct Fixture {
1695        _workspace: tempfile::TempDir,
1696        workspace_root: PathBuf,
1697        service: ServiceConfig,
1698        component: ServiceComponent,
1699        mirror: MirrorConfig,
1700        env: String,
1701    }
1702
1703    impl Fixture {
1704        fn new(slot: MirrorProviderSlot, write_workload: bool) -> Self {
1705            let workspace = tempdir().unwrap();
1706            let workspace_root = workspace.path().to_path_buf();
1707            let workload_dir = workspace_root.join("app/web");
1708            std::fs::create_dir_all(workload_dir.join("dist/html")).unwrap();
1709            std::fs::write(workload_dir.join("dist/html/index.html"), "<h1>x</h1>").unwrap();
1710            if write_workload {
1711                std::fs::write(
1712                    workload_dir.join("workload.toml"),
1713                    r#"schema_version = 1
1714kind = "mesofact-static"
1715routes = "./routes.ts"
1716
1717[build]
1718command = "echo built"
1719out_dir = "dist"
1720"#,
1721                )
1722                .unwrap();
1723            }
1724
1725            let mut providers = BTreeMap::new();
1726            providers.insert("static".to_string(), slot);
1727            let mirror = MirrorConfig {
1728                schema_version: 1,
1729                shape: MirrorShape::Local,
1730                providers,
1731                ingress: Default::default(),
1732                ingress_machines: Vec::new(),
1733                drivers: Default::default(),
1734                asset_aliases: Default::default(),
1735            };
1736            let service = ServiceConfig {
1737                schema_version: 1,
1738                name: "test-svc".to_string(),
1739                domain: "test.local".to_string(),
1740                components: vec![],
1741                db: crate::DbCatalog::default(),
1742            };
1743            let component = ServiceComponent {
1744                mount: None,
1745                id: "site".to_string(),
1746                kind: "mesofact-static".to_string(),
1747                path: "app/web".to_string(),
1748                role: "static".to_string(),
1749                publishes: None,
1750                wave: 0,
1751                git: None,
1752            };
1753            Self {
1754                _workspace: workspace,
1755                workspace_root,
1756                service,
1757                component,
1758                mirror,
1759                env: "local".to_string(),
1760            }
1761        }
1762
1763        fn ctx(&self) -> ReconcileCtx<'_> {
1764            ReconcileCtx {
1765                workspace_root: &self.workspace_root,
1766                service: &self.service,
1767                component: &self.component,
1768                mirror: &self.mirror,
1769                env: &self.env,
1770                scope: crate::reconciler::ProviderScope::singleton(),
1771            }
1772        }
1773    }
1774
1775    fn local_static_slot(port: u16) -> MirrorProviderSlot {
1776        let mut fields = BTreeMap::new();
1777        fields.insert("port".to_string(), toml::Value::Integer(port as i64));
1778        MirrorProviderSlot::Inline {
1779            kind: Provider::LocalStatic,
1780            fields,
1781        }
1782    }
1783
1784    /// Bind to 127.0.0.1:0, read the assigned port, drop the listener.
1785    /// Used in adopt_only tests that must exercise the "port not bound" path:
1786    /// a dynamically chosen port is almost certainly free immediately after
1787    /// the listener drops, unlike the hardcoded 4321 which a running camp will
1788    /// have occupied.
1789    fn pick_unused_port() -> u16 {
1790        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1791        l.local_addr().unwrap().port()
1792    }
1793
1794    fn cloudflare_reference_slot() -> MirrorProviderSlot {
1795        MirrorProviderSlot::Reference {
1796            provider_id: "cloudflare".to_string(),
1797            fields: BTreeMap::new(),
1798        }
1799    }
1800
1801    #[test]
1802    fn resolved_binary_prefers_explicit_path() {
1803        let opts = LocalStaticOptions {
1804            binary: Some(PathBuf::from("/explicit/path")),
1805            ..Default::default()
1806        };
1807        assert_eq!(opts.resolved_binary(), PathBuf::from("/explicit/path"));
1808    }
1809
1810    #[test]
1811    fn resolved_binary_falls_back_to_bare_name() {
1812        // Clearing the env var requires unsafe in test isolation; instead
1813        // assume MESOFACT_DEV_BIN is unset (best-effort).
1814        let opts = LocalStaticOptions::default();
1815        if std::env::var_os("MESOFACT_DEV_BIN").is_none() {
1816            assert_eq!(opts.resolved_binary(), PathBuf::from("mesofact-dev"));
1817        }
1818    }
1819
1820    #[test]
1821    fn slot_field_u16_extracts_port() {
1822        let mut fields = BTreeMap::new();
1823        fields.insert("port".to_string(), toml::Value::Integer(4321));
1824        assert_eq!(slot_field_u16(&fields, "port"), Some(4321));
1825    }
1826
1827    #[test]
1828    fn slot_field_u16_returns_none_for_missing_key() {
1829        let fields = BTreeMap::new();
1830        assert_eq!(slot_field_u16(&fields, "port"), None);
1831    }
1832
1833    #[tokio::test]
1834    async fn up_bails_when_workload_toml_missing() {
1835        let fx = Fixture::new(local_static_slot(4321), /*write_workload*/ false);
1836        let reconciler = MesofactStaticReconciler::new();
1837        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1838        let msg = format!("{err:#}");
1839        assert!(msg.contains("workload.toml"), "got: {msg}");
1840    }
1841
1842    #[tokio::test]
1843    async fn up_bails_when_workload_kind_mismatches() {
1844        let fx = Fixture::new(local_static_slot(4321), /*write_workload*/ false);
1845        // Hand-write a container-kind workload at the right path. We
1846        // don't need the full container schema; the reconciler dispatches
1847        // off the `kind` field alone.
1848        std::fs::write(
1849            fx.workspace_root.join("app/web/workload.toml"),
1850            r#"schema_version = 1
1851kind = "container"
1852"#,
1853        )
1854        .unwrap();
1855        let reconciler = MesofactStaticReconciler::new();
1856        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1857        let msg = format!("{err:#}");
1858        assert!(msg.contains("kind=\"container\""), "got: {msg}");
1859    }
1860
1861    #[tokio::test]
1862    async fn up_bails_when_static_slot_missing() {
1863        let mut fx = Fixture::new(local_static_slot(4321), true);
1864        fx.mirror.providers.clear();
1865        let reconciler = MesofactStaticReconciler::new();
1866        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1867        let msg = format!("{err:#}");
1868        assert!(msg.contains("providers.static"), "got: {msg}");
1869    }
1870
1871    /// R602-B4 follow-up: a service with two static-kind components sharing
1872    /// one mirror (e.g. `site` + `app`) must be able to give them distinct
1873    /// ports. `slot()` resolves the component-qualified key
1874    /// (`"static:<id>"`) before the bare role, so `providers."static:site"`
1875    /// wins over `providers.static` for a component whose id is `site`.
1876    #[test]
1877    fn slot_prefers_component_qualified_key_over_bare_role() {
1878        let mut fx = Fixture::new(local_static_slot(4321), true);
1879        fx.mirror
1880            .providers
1881            .insert("static:site".to_string(), local_static_slot(9999));
1882        let slot = fx.ctx().slot("static").expect("slot present");
1883        let MirrorProviderSlot::Inline { fields, .. } = slot else {
1884            panic!("expected inline slot");
1885        };
1886        assert_eq!(slot_field_u16(fields, "port"), Some(9999));
1887    }
1888
1889    /// A component whose id has no qualified entry falls back to the bare
1890    /// role — the pre-existing single-slot-per-mirror behavior is unchanged
1891    /// for services that never declared a qualified key.
1892    #[test]
1893    fn slot_falls_back_to_bare_role_for_unqualified_component() {
1894        let mut fx = Fixture::new(local_static_slot(4321), true);
1895        fx.mirror
1896            .providers
1897            .insert("static:other-component".to_string(), local_static_slot(9999));
1898        let slot = fx.ctx().slot("static").expect("slot present");
1899        let MirrorProviderSlot::Inline { fields, .. } = slot else {
1900            panic!("expected inline slot");
1901        };
1902        assert_eq!(slot_field_u16(fields, "port"), Some(4321));
1903    }
1904
1905    fn miniflare_container_slot(port: u16) -> MirrorProviderSlot {
1906        let mut fields = BTreeMap::new();
1907        fields.insert("port".to_string(), toml::Value::Integer(port as i64));
1908        fields.insert(
1909            "bucket".to_string(),
1910            toml::Value::String("yah-dev".to_string()),
1911        );
1912        MirrorProviderSlot::Inline {
1913            kind: Provider::MiniflareContainer,
1914            fields,
1915        }
1916    }
1917
1918    #[tokio::test]
1919    async fn up_miniflare_container_bails_when_object_store_slot_missing() {
1920        // MiniflareContainer dispatches into pond::up_pond, which
1921        // requires a sibling providers.object_store slot. Missing → clear
1922        // error before we attempt to talk to docker.
1923        let fx = Fixture::new(miniflare_container_slot(4322), true);
1924        let reconciler = MesofactStaticReconciler::new();
1925        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1926        let msg = format!("{err:#}");
1927        assert!(
1928            msg.contains("providers.object_store"),
1929            "error must mention the missing sibling slot; got: {msg}"
1930        );
1931        assert!(
1932            msg.contains("pond"),
1933            "error must name the requesting code path; got: {msg}"
1934        );
1935    }
1936
1937    #[tokio::test]
1938    async fn up_miniflare_container_bails_when_object_store_kind_wrong() {
1939        let mut fx = Fixture::new(miniflare_container_slot(4322), true);
1940        // Drop in a non-MinIO inline slot at object_store.
1941        fx.mirror.providers.insert(
1942            "object_store".into(),
1943            MirrorProviderSlot::Inline {
1944                kind: Provider::LocalStatic,
1945                fields: BTreeMap::new(),
1946            },
1947        );
1948        let reconciler = MesofactStaticReconciler::new();
1949        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1950        let msg = format!("{err:#}");
1951        assert!(
1952            msg.contains("minio-container"),
1953            "error must name the expected kind; got: {msg}"
1954        );
1955    }
1956
1957    #[tokio::test]
1958    async fn up_bails_on_cloudflare_reference_slot() {
1959        let cloudflare = MirrorProviderSlot::Reference {
1960            provider_id: "cloudflare".to_string(),
1961            fields: BTreeMap::new(),
1962        };
1963        let fx = Fixture::new(cloudflare, true);
1964        let reconciler = MesofactStaticReconciler::new();
1965        let err = reconciler.up(fx.ctx()).await.unwrap_err();
1966        let msg = format!("{err:#}");
1967        assert!(msg.contains("cloudflare"), "got: {msg}");
1968    }
1969
1970    /// Regression for R330-B5: a cloud mirror that supplies bucket+zone but
1971    /// omits `asset_origin` must fail loudly at reconcile time. Otherwise the
1972    /// Worker silently gets `env.ASSET_ORIGIN=""` and 404s every request in
1973    /// prod (R327-F2 gotcha).
1974    #[tokio::test]
1975    async fn up_bails_on_cloudflare_reference_missing_asset_origin() {
1976        let mut fields = BTreeMap::new();
1977        fields.insert(
1978            "bucket".to_string(),
1979            toml::Value::String("yah-dev".to_string()),
1980        );
1981        fields.insert(
1982            "zone".to_string(),
1983            toml::Value::String("yah.dev".to_string()),
1984        );
1985        let cloudflare = MirrorProviderSlot::Reference {
1986            provider_id: "cloudflare".to_string(),
1987            fields,
1988        };
1989        let fx = Fixture::new(cloudflare, true);
1990        // Write a minimal cloudflare provider config so the asset_origin
1991        // check is the first thing that fails (otherwise the missing
1992        // provider file aborts the run earlier).
1993        let providers_dir = fx.workspace_root.join(".yah/infra/providers");
1994        std::fs::create_dir_all(&providers_dir).unwrap();
1995        std::fs::write(
1996            providers_dir.join("cloudflare.toml"),
1997            r#"schema_version = 1
1998id = "cloudflare"
1999kind = "cloudflare"
2000account_id = "test-account"
2001"#,
2002        )
2003        .unwrap();
2004        let reconciler = MesofactStaticReconciler::new();
2005        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2006        let msg = format!("{err:#}");
2007        assert!(
2008            msg.contains("asset_origin"),
2009            "error must name asset_origin; got: {msg}"
2010        );
2011    }
2012
2013    /// R432-B2: stale jit file claiming the configured port must not produce
2014    /// "dynamic fallback" language — no second probe was attempted.
2015    #[tokio::test]
2016    async fn adopt_only_stale_jit_same_port_omits_dynamic_fallback_phrase() {
2017        let port = pick_unused_port();
2018        let fx = Fixture::new(local_static_slot(port), true);
2019        let jit_dir = fx.workspace_root.join(".yah/jit");
2020        std::fs::create_dir_all(&jit_dir).unwrap();
2021        std::fs::write(
2022            jit_dir.join("mesofact-dev-ports.json"),
2023            format!(r#"{{"test-svc/site": {port}}}"#),
2024        )
2025        .unwrap();
2026        let reconciler = MesofactStaticReconciler::new().with_local_static(LocalStaticOptions {
2027            adopt_only: true,
2028            ..Default::default()
2029        });
2030        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2031        let msg = format!("{err:#}");
2032        assert!(
2033            !msg.contains("dynamic fallback"),
2034            "stale jit at configured port must not claim a dynamic probe; got: {msg}"
2035        );
2036        assert!(
2037            !msg.contains("jit file"),
2038            "same-port jit entry must not appear in the error; got: {msg}"
2039        );
2040    }
2041
2042    /// R432-B2: when camp is up but jit records a different dead port, name it.
2043    /// Requires a live socket so the error takes the Case-B "camp up" branch.
2044    #[cfg(unix)]
2045    #[tokio::test]
2046    async fn adopt_only_stale_jit_different_port_names_it() {
2047        use std::os::unix::net::UnixListener;
2048
2049        let port = pick_unused_port();
2050        let jit_port = pick_unused_port();
2051        let fx = Fixture::new(local_static_slot(port), true);
2052        let jit_dir = fx.workspace_root.join(".yah/jit");
2053        std::fs::create_dir_all(&jit_dir).unwrap();
2054        std::fs::write(
2055            jit_dir.join("mesofact-dev-ports.json"),
2056            format!(r#"{{"test-svc/site": {jit_port}}}"#),
2057        )
2058        .unwrap();
2059        let socket_path = fx.workspace_root.join("camp.sock");
2060        let _listener = UnixListener::bind(&socket_path).unwrap();
2061        let reconciler = MesofactStaticReconciler::new().with_local_static(LocalStaticOptions {
2062            adopt_only: true,
2063            camp_socket: Some(socket_path),
2064            ..Default::default()
2065        });
2066        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2067        let msg = format!("{err:#}");
2068        assert!(
2069            msg.contains(&jit_port.to_string()),
2070            "error must name the dead jit port; got: {msg}"
2071        );
2072        assert!(
2073            !msg.contains("dynamic fallback"),
2074            "precise port naming replaces generic 'dynamic fallback'; got: {msg}"
2075        );
2076    }
2077
2078    /// R432-F3: no camp socket → "not running" / attach message (no socket probe noise).
2079    #[tokio::test]
2080    async fn adopt_only_no_camp_socket_gives_attach_message() {
2081        let fx = Fixture::new(local_static_slot(pick_unused_port()), true);
2082        let reconciler = MesofactStaticReconciler::new().with_local_static(LocalStaticOptions {
2083            adopt_only: true,
2084            camp_socket: None, // no socket path — treated as camp not running
2085            ..Default::default()
2086        });
2087        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2088        let msg = format!("{err:#}");
2089        assert!(
2090            msg.contains("not running") || msg.contains("attach"),
2091            "no-socket path must indicate camp is not attached; got: {msg}"
2092        );
2093    }
2094
2095    /// R432-F3: live camp socket but no server → "camp is up but didn't bind".
2096    #[cfg(unix)]
2097    #[tokio::test]
2098    async fn adopt_only_live_camp_socket_gives_didnt_bind_message() {
2099        use std::os::unix::net::UnixListener;
2100
2101        let fx = Fixture::new(local_static_slot(pick_unused_port()), true);
2102        let socket_path = fx.workspace_root.join("camp.sock");
2103        let _listener = UnixListener::bind(&socket_path).unwrap();
2104
2105        let reconciler = MesofactStaticReconciler::new().with_local_static(LocalStaticOptions {
2106            adopt_only: true,
2107            camp_socket: Some(socket_path),
2108            ..Default::default()
2109        });
2110        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2111        let msg = format!("{err:#}");
2112        assert!(
2113            msg.contains("a yah daemon is up but"),
2114            "live socket must give 'daemon is up but didn't bind' message; got: {msg}"
2115        );
2116        assert!(
2117            msg.contains("did not bind"),
2118            "message must name the bind failure; got: {msg}"
2119        );
2120    }
2121
2122    #[tokio::test]
2123    async fn up_bails_when_binary_not_found() {
2124        let fx = Fixture::new(local_static_slot(0), true);
2125        let reconciler = MesofactStaticReconciler::new().with_local_static(LocalStaticOptions {
2126            binary: Some(PathBuf::from("/definitely/not/a/binary")),
2127            ready_timeout: Some(Duration::from_millis(50)),
2128            ..Default::default()
2129        });
2130        let err = reconciler.up(fx.ctx()).await.unwrap_err();
2131        let msg = format!("{err:#}");
2132        assert!(msg.contains("spawning"), "got: {msg}");
2133    }
2134
2135    /// R490-F2: native_ident produces a DNS-/path-safe slug.
2136    #[test]
2137    fn native_ident_sanitizes_to_path_safe_slug() {
2138        assert_eq!(
2139            native_ident("dev-yah", "static"),
2140            "mesofact-dev-dev-yah-static"
2141        );
2142        // Non-alphanumerics (incl. slashes, dots) collapse to '-'; lowercased.
2143        assert_eq!(native_ident("Foo.Bar", "a/b"), "mesofact-dev-foo-bar-a-b");
2144    }
2145
2146    /// R490-F2: the mesofact-dev invocation lowers into the Native backend's
2147    /// container-`command` shape with the no-pull identity digest.
2148    #[test]
2149    fn native_mesofact_spec_lowers_argv_and_identity() {
2150        let spec = native_spec(
2151            "mesofact-dev-site-static",
2152            vec![
2153                "/bin/mesofact-dev".into(),
2154                "/wd".into(),
2155                "--port".into(),
2156                "4321".into(),
2157            ],
2158            Vec::new(),
2159        );
2160        assert_eq!(spec.name, "mesofact-dev-site-static");
2161        assert_eq!(spec.entrypoint, None);
2162        assert_eq!(spec.command.as_deref().unwrap()[0], "/bin/mesofact-dev");
2163        assert_eq!(spec.expose.mesh.identity.0, "mesofact-dev-site-static");
2164        assert_eq!(spec.image.digest, NATIVE_IDENTITY_DIGEST);
2165        assert_eq!(spec.replicas, 1);
2166    }
2167
2168    /// Wait-for-port: bind a local TCP listener in-process, confirm
2169    /// `wait_for_port` returns true within timeout.
2170    #[tokio::test]
2171    async fn wait_for_port_returns_true_when_port_bound() {
2172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2173        let addr = listener.local_addr().unwrap();
2174        assert!(wait_for_port(addr, Duration::from_millis(500)).await);
2175        drop(listener);
2176    }
2177
2178    #[tokio::test]
2179    async fn wait_for_port_returns_false_when_port_idle() {
2180        // Bind + drop to get an ephemeral port that's now definitely
2181        // unbound (modulo races; ignore the rare false flake).
2182        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2183        let addr = listener.local_addr().unwrap();
2184        drop(listener);
2185        assert!(!wait_for_port(addr, Duration::from_millis(100)).await);
2186    }
2187
2188    // ---------- Worker script + config bindings ----------
2189
2190    #[test]
2191    fn worker_script_maps_root_to_index_html() {
2192        assert!(
2193            WORKER_SCRIPT.contains("index.html"),
2194            "bundled Worker must route / to index.html; got: {WORKER_SCRIPT}"
2195        );
2196    }
2197
2198    #[test]
2199    fn worker_script_has_no_r2_binding_calls() {
2200        assert!(
2201            !WORKER_SCRIPT.contains("env.ASSETS"),
2202            "bundled Worker must not reference R2 binding env.ASSETS; got: {WORKER_SCRIPT}"
2203        );
2204        assert!(
2205            !WORKER_SCRIPT.contains("writeHttpMetadata"),
2206            "bundled Worker must not use R2 writeHttpMetadata; got: {WORKER_SCRIPT}"
2207        );
2208    }
2209
2210    #[test]
2211    fn worker_script_uses_asset_origin_fetch() {
2212        assert!(
2213            WORKER_SCRIPT.contains("ASSET_ORIGIN"),
2214            "bundled Worker must fetch from ASSET_ORIGIN; got: {WORKER_SCRIPT}"
2215        );
2216    }
2217
2218    /// The vendored @mesofact/edge bundle must carry the W270 §3 serving logic:
2219    /// manifest read, pointer-store resolution for instance-addressed routes,
2220    /// and manifest error_routes. Substring markers (not behavior — behavior is
2221    /// covered by the miniflare tests in oss/mesofact/packages/mesofact-edge).
2222    #[test]
2223    fn worker_script_resolves_pointers_and_error_routes() {
2224        assert!(
2225            WORKER_SCRIPT.contains("manifest.json"),
2226            "bundled Worker must read the published manifest; got: {WORKER_SCRIPT}"
2227        );
2228        assert!(
2229            WORKER_SCRIPT.contains("POINTER_ORIGIN"),
2230            "bundled Worker must resolve pointers via POINTER_ORIGIN; got: {WORKER_SCRIPT}"
2231        );
2232        assert!(
2233            WORKER_SCRIPT.contains("error_routes"),
2234            "bundled Worker must honor manifest error_routes; got: {WORKER_SCRIPT}"
2235        );
2236    }
2237
2238    /// The binding is only useful if the vendored bundle actually reads it —
2239    /// `scripts/check-worker-bundle.sh` keeps the two in sync, and this catches
2240    /// a bundle vendored from before R746.
2241    #[test]
2242    fn bundled_worker_reads_route_headers() {
2243        assert!(
2244            WORKER_SCRIPT.contains("ROUTE_HEADERS"),
2245            "bundled Worker must apply per-route response headers; got: {WORKER_SCRIPT}"
2246        );
2247    }
2248
2249    // ── R746: component mount → publish prefix ──
2250
2251    #[test]
2252    fn unmounted_component_publishes_at_the_service_root() {
2253        assert_eq!(publish_prefix("noisetable-marketing", "cloud", None), "noisetable-marketing/cloud");
2254    }
2255
2256    #[test]
2257    fn a_mount_extends_the_prefix_and_is_slash_insensitive() {
2258        for m in ["/app", "app", "app/", "/app/"] {
2259            assert_eq!(
2260                publish_prefix("noisetable-marketing", "cloud", Some(m)),
2261                "noisetable-marketing/cloud/app",
2262                "mount {m:?}"
2263            );
2264        }
2265    }
2266
2267    /// A root mount is the same thing as no mount — not a trailing-slash key
2268    /// prefix, which would publish every asset one directory too deep.
2269    #[test]
2270    fn a_root_mount_is_the_service_root() {
2271        assert_eq!(publish_prefix("svc", "cloud", Some("/")), "svc/cloud");
2272        assert_eq!(publish_prefix("svc", "cloud", Some("")), "svc/cloud");
2273    }
2274
2275    /// The whole point: two static components of one service must not collide.
2276    #[test]
2277    fn two_components_of_one_service_get_disjoint_prefixes() {
2278        let site = publish_prefix("noisetable-marketing", "cloud", None);
2279        let app = publish_prefix("noisetable-marketing", "cloud", Some("/app"));
2280        assert_ne!(site, app);
2281        assert!(app.starts_with(&format!("{site}/")), "{app} under {site}");
2282    }
2283
2284    #[test]
2285    fn config_bindings_carry_the_route_header_table() {
2286        let table = r#"[{"path":"/app/*","headers":{"Cross-Origin-Opener-Policy":"same-origin"}}]"#;
2287        let b: std::collections::HashMap<_, _> = worker_config_bindings(
2288            &WorkerMode::Static,
2289            "https://assets.example.com",
2290            &BackendOrigins::default(),
2291            table,
2292        )
2293        .into_iter()
2294        .collect();
2295        assert_eq!(b["ROUTE_HEADERS"], table);
2296    }
2297
2298    #[test]
2299    fn config_bindings_static_mode() {
2300        let b: std::collections::HashMap<_, _> = worker_config_bindings(
2301            &WorkerMode::Static,
2302            "https://assets.example.com",
2303            &BackendOrigins::default(),
2304            "[]",
2305        )
2306        .into_iter()
2307        .collect();
2308        assert_eq!(b["WORKER_MODE"], "static");
2309        assert_eq!(b["ASSET_ORIGIN"], "https://assets.example.com");
2310        // Pointer origin defaults to the asset origin (W270 §3).
2311        assert_eq!(b["POINTER_ORIGIN"], "https://assets.example.com");
2312        assert_eq!(b["SSR_ORIGIN"], "");
2313        assert_eq!(b["SSR_PREFIXES"], "[]");
2314        // Undeclared backends are emitted EMPTY, not omitted: the binding list
2315        // is authoritative, so an omitted key would leave a stale value from an
2316        // earlier deploy in place.
2317        assert_eq!(b["ISSUES_ORIGIN"], "");
2318        assert_eq!(b["MESOFACT_BACKEND_ORIGIN"], "");
2319    }
2320
2321    #[test]
2322    fn config_bindings_spa_mode() {
2323        let b: std::collections::HashMap<_, _> = worker_config_bindings(
2324            &WorkerMode::Spa,
2325            "https://assets.example.com",
2326            &BackendOrigins::default(),
2327            "[]",
2328        )
2329        .into_iter()
2330        .collect();
2331        assert_eq!(b["WORKER_MODE"], "spa");
2332        assert_eq!(b["SSR_ORIGIN"], "");
2333    }
2334
2335    /// R330-F13: `/api/issues*` reaches the issue-tracker only when the static
2336    /// slot's `issues_origin` becomes an `ISSUES_ORIGIN` Worker binding.
2337    #[test]
2338    fn config_bindings_carry_backend_origins() {
2339        let mut fields = std::collections::BTreeMap::new();
2340        fields.insert(
2341            "issues_origin".to_string(),
2342            // Trailing slash trimmed — the router concatenates "/issues".
2343            toml::Value::String("https://issues.example.com/".to_string()),
2344        );
2345        fields.insert(
2346            "backend_origin".to_string(),
2347            toml::Value::String("https://almanac.example.com".to_string()),
2348        );
2349        let backends = BackendOrigins::from_slot_fields(&fields);
2350        assert_eq!(backends.issues, "https://issues.example.com");
2351
2352        let b: std::collections::HashMap<_, _> = worker_config_bindings(
2353            &WorkerMode::Static,
2354            "https://assets.example.com",
2355            &backends,
2356            "[]",
2357        )
2358        .into_iter()
2359        .collect();
2360        assert_eq!(b["ISSUES_ORIGIN"], "https://issues.example.com");
2361        assert_eq!(b["MESOFACT_BACKEND_ORIGIN"], "https://almanac.example.com");
2362    }
2363
2364    /// The vendored bundle must actually read the binding this reconciler now
2365    /// emits — otherwise the config lands and nothing routes.
2366    #[test]
2367    fn worker_script_reads_issues_origin() {
2368        assert!(
2369            WORKER_SCRIPT.contains("ISSUES_ORIGIN"),
2370            "bundled Worker must route /api/issues* via ISSUES_ORIGIN"
2371        );
2372    }
2373
2374    #[test]
2375    fn config_bindings_ssr_mode() {
2376        let mode = WorkerMode::Ssr {
2377            origin_url: "https://ssr.example.com".to_string(),
2378            prefixes: vec!["/api/".to_string(), "/rpc/".to_string()],
2379        };
2380        let b: std::collections::HashMap<_, _> = worker_config_bindings(
2381            &mode,
2382            "https://assets.example.com",
2383            &BackendOrigins::default(),
2384            "[]",
2385        )
2386        .into_iter()
2387        .collect();
2388        assert_eq!(b["WORKER_MODE"], "ssr");
2389        assert_eq!(b["SSR_ORIGIN"], "https://ssr.example.com");
2390        let prefixes: Vec<String> = serde_json::from_str(&b["SSR_PREFIXES"]).unwrap();
2391        assert!(prefixes.contains(&"/api/".to_string()));
2392        assert!(prefixes.contains(&"/rpc/".to_string()));
2393    }
2394
2395    #[test]
2396    fn worker_script_hash_roundtrip() {
2397        let tmp = tempdir().unwrap();
2398        let root = tmp.path();
2399        assert!(read_worker_script_hash(root, "test-worker").is_none());
2400        write_worker_script_hash(root, "test-worker", "abc123").unwrap();
2401        assert_eq!(
2402            read_worker_script_hash(root, "test-worker").as_deref(),
2403            Some("abc123")
2404        );
2405        // Writing a second worker doesn't clobber the first.
2406        write_worker_script_hash(root, "other-worker", "def456").unwrap();
2407        assert_eq!(
2408            read_worker_script_hash(root, "test-worker").as_deref(),
2409            Some("abc123")
2410        );
2411    }
2412
2413    #[test]
2414    fn parse_worker_mode_defaults_to_static() {
2415        let fields = BTreeMap::new();
2416        assert!(matches!(
2417            parse_worker_mode(WORKLOAD_KIND, &fields),
2418            WorkerMode::Static
2419        ));
2420    }
2421
2422    #[test]
2423    fn parse_worker_mode_spa_kind_defaults_to_spa() {
2424        let fields = BTreeMap::new();
2425        assert!(matches!(
2426            parse_worker_mode(WORKLOAD_KIND_SPA, &fields),
2427            WorkerMode::Spa
2428        ));
2429    }
2430
2431    #[test]
2432    fn parse_worker_mode_explicit_mode_beats_kind_default() {
2433        let mut fields = BTreeMap::new();
2434        fields.insert(
2435            "mode".to_string(),
2436            toml::Value::String("static".to_string()),
2437        );
2438        assert!(matches!(
2439            parse_worker_mode(WORKLOAD_KIND_SPA, &fields),
2440            WorkerMode::Static
2441        ));
2442    }
2443
2444    #[test]
2445    fn parse_worker_mode_spa() {
2446        let mut fields = BTreeMap::new();
2447        fields.insert("mode".to_string(), toml::Value::String("spa".to_string()));
2448        assert!(matches!(
2449            parse_worker_mode(WORKLOAD_KIND, &fields),
2450            WorkerMode::Spa
2451        ));
2452    }
2453
2454    #[test]
2455    fn parse_worker_mode_ssr_extracts_origin_and_prefixes() {
2456        let mut fields = BTreeMap::new();
2457        fields.insert("mode".to_string(), toml::Value::String("ssr".to_string()));
2458        fields.insert(
2459            "origin_url".to_string(),
2460            toml::Value::String("https://origin.example.com".to_string()),
2461        );
2462        fields.insert(
2463            "ssr_prefixes".to_string(),
2464            toml::Value::Array(vec![toml::Value::String("/api/".to_string())]),
2465        );
2466        if let WorkerMode::Ssr {
2467            origin_url,
2468            prefixes,
2469        } = parse_worker_mode(WORKLOAD_KIND, &fields)
2470        {
2471            assert_eq!(origin_url, "https://origin.example.com");
2472            assert_eq!(prefixes, vec!["/api/"]);
2473        } else {
2474            panic!("expected Ssr mode");
2475        }
2476    }
2477
2478    // ---------- W165: BuildMode → ForgeSpec lowering (R438-T6) ----------
2479
2480    use std::sync::Mutex as StdMutex;
2481    use tokio::sync::mpsc::UnboundedSender;
2482    use velveteen::ForgeStatus;
2483    use velveteen_exec::executor::{ExecEvent, ExecOutcome, ForgeExecutorError};
2484
2485    /// Captures the [`ForgeSpec`] handed to `execute(...)` and returns
2486    /// success without spawning anything.
2487    struct CaptureExecutor {
2488        captured: Arc<StdMutex<Vec<(ForgeSpec, ExecContext)>>>,
2489    }
2490
2491    impl CaptureExecutor {
2492        fn new() -> (Arc<Self>, Arc<StdMutex<Vec<(ForgeSpec, ExecContext)>>>) {
2493            let captured = Arc::new(StdMutex::new(Vec::new()));
2494            (
2495                Arc::new(Self {
2496                    captured: captured.clone(),
2497                }),
2498                captured,
2499            )
2500        }
2501    }
2502
2503    #[async_trait]
2504    impl ForgeExecutor for CaptureExecutor {
2505        async fn execute(
2506            &self,
2507            spec: ForgeSpec,
2508            ctx: ExecContext,
2509            _sink: Option<UnboundedSender<ExecEvent>>,
2510        ) -> Result<ExecOutcome, ForgeExecutorError> {
2511            self.captured.lock().unwrap().push((spec, ctx));
2512            Ok(ExecOutcome {
2513                status: ForgeStatus::Done {
2514                    exit_code: 0,
2515                    ended_at: 0,
2516                },
2517                stderr_tail: String::new(),
2518            })
2519        }
2520    }
2521
2522    /// Executor whose runs all return a non-zero exit + canned stderr —
2523    /// used to assert error-message shape from [`run_build`].
2524    struct FailingExecutor {
2525        stderr: String,
2526    }
2527
2528    #[async_trait]
2529    impl ForgeExecutor for FailingExecutor {
2530        async fn execute(
2531            &self,
2532            _spec: ForgeSpec,
2533            _ctx: ExecContext,
2534            _sink: Option<UnboundedSender<ExecEvent>>,
2535        ) -> Result<ExecOutcome, ForgeExecutorError> {
2536            Ok(ExecOutcome {
2537                status: ForgeStatus::Done {
2538                    exit_code: 2,
2539                    ended_at: 0,
2540                },
2541                stderr_tail: self.stderr.clone(),
2542            })
2543        }
2544    }
2545
2546    fn host_side_build() -> (BuildConfig, BuildMode) {
2547        (
2548            BuildConfig {
2549                command: Some("bun run build".into()),
2550                out_dir: PathBuf::from("dist"),
2551                render_command: None,
2552            },
2553            BuildMode::HostSide,
2554        )
2555    }
2556
2557    fn in_container_build() -> (BuildConfig, BuildMode) {
2558        let image = workload_spec::ImageRef {
2559            registry: "ghcr.io".into(),
2560            repository: "org/app-build".into(),
2561            tag: "v1.2".into(),
2562            digest: workload_spec::testing::test_digest(),
2563        };
2564        (
2565            BuildConfig {
2566                command: Some("bun run build".into()),
2567                out_dir: PathBuf::from("dist"),
2568                render_command: None,
2569            },
2570            BuildMode::InContainer { image },
2571        )
2572    }
2573
2574    #[tokio::test]
2575    async fn run_build_host_side_lowers_to_native_subprocess() {
2576        let (capture, captured) = CaptureExecutor::new();
2577        let tmp = tempdir().unwrap();
2578        let (build, mode) = host_side_build();
2579        run_build(tmp.path(), &build, &mode, &*capture)
2580            .await
2581            .unwrap();
2582
2583        let captured = captured.lock().unwrap();
2584        assert_eq!(captured.len(), 1, "build executed exactly once");
2585        let (spec, ctx) = &captured[0];
2586        assert_eq!(spec.where_.runtime, TaskRuntime::Native);
2587        assert_eq!(spec.where_.location, TaskLocation::Local);
2588        match &spec.command {
2589            ForgeCommand::Subprocess { argv, image } => {
2590                assert!(image.is_none(), "host_side carries no image; got {image:?}");
2591                assert_eq!(
2592                    argv,
2593                    &vec!["sh".to_string(), "-c".into(), "bun run build".into()]
2594                );
2595            }
2596            other => panic!("expected Subprocess, got {other:?}"),
2597        }
2598        assert_eq!(ctx.cwd.as_deref(), Some(tmp.path()));
2599    }
2600
2601    #[tokio::test]
2602    async fn run_build_in_container_lowers_to_container_runtime_with_pinned_digest() {
2603        let (capture, captured) = CaptureExecutor::new();
2604        let tmp = tempdir().unwrap();
2605        let (build, mode) = in_container_build();
2606        run_build(tmp.path(), &build, &mode, &*capture)
2607            .await
2608            .unwrap();
2609
2610        let captured = captured.lock().unwrap();
2611        let (spec, ctx) = &captured[0];
2612        assert_eq!(spec.where_.runtime, TaskRuntime::Container);
2613        assert_eq!(spec.where_.location, TaskLocation::Local);
2614        match &spec.command {
2615            ForgeCommand::Subprocess { argv, image } => {
2616                let image = image.as_ref().expect("in_container lowers with an image");
2617                assert_eq!(image.registry, "ghcr.io");
2618                assert_eq!(image.repository, "org/app-build");
2619                assert_eq!(image.tag, "v1.2");
2620                assert_eq!(image.digest, workload_spec::testing::test_digest());
2621                assert_eq!(
2622                    argv,
2623                    &vec!["sh".to_string(), "-c".into(), "bun run build".into()]
2624                );
2625            }
2626            other => panic!("expected Subprocess, got {other:?}"),
2627        }
2628        assert_eq!(ctx.cwd.as_deref(), Some(tmp.path()));
2629    }
2630
2631    #[tokio::test]
2632    async fn run_build_surfaces_stderr_on_nonzero_exit() {
2633        let executor = Arc::new(FailingExecutor {
2634            stderr: "TypeError: Cannot find module 'react'".into(),
2635        });
2636        let tmp = tempdir().unwrap();
2637        let (build, mode) = host_side_build();
2638        let err = run_build(tmp.path(), &build, &mode, &*executor)
2639            .await
2640            .unwrap_err();
2641        let msg = format!("{err:#}");
2642        assert!(msg.contains("Cannot find module 'react'"), "got: {msg}");
2643        assert!(msg.contains("bun run build"), "got: {msg}");
2644    }
2645
2646    #[tokio::test]
2647    async fn read_mesofact_build_extracts_host_side_default() {
2648        let tmp = tempdir().unwrap();
2649        // Use the legacy `schema_version = 1` integer shape — production
2650        // marketing/dashboard workload.tomls carry this and rebuild_static
2651        // must keep working against them. The subtree reader skips the
2652        // envelope so this round-trips.
2653        std::fs::write(
2654            tmp.path().join("workload.toml"),
2655            r#"schema_version = 1
2656kind = "mesofact-static"
2657routes = "./routes.ts"
2658
2659[build]
2660command = "bun run build"
2661out_dir = "dist"
2662"#,
2663        )
2664        .unwrap();
2665        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2666        assert_eq!(build.command.as_deref(), Some("bun run build"));
2667        assert_eq!(build.out_dir, PathBuf::from("dist"));
2668        assert!(matches!(mode, BuildMode::HostSide));
2669    }
2670
2671    #[tokio::test]
2672    async fn read_mesofact_build_extracts_in_container_with_digest() {
2673        let tmp = tempdir().unwrap();
2674        let digest = workload_spec::testing::test_digest();
2675        std::fs::write(
2676            tmp.path().join("workload.toml"),
2677            format!(
2678                r#"schema_version = 1
2679kind = "mesofact-static"
2680routes = "./routes.ts"
2681
2682[build]
2683command = "bun run build"
2684out_dir = "dist"
2685
2686[build_mode.in_container.image]
2687registry = "ghcr.io"
2688repository = "org/app-build"
2689tag = "v1.2"
2690digest = "{digest}"
2691"#
2692            ),
2693        )
2694        .unwrap();
2695        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2696        assert_eq!(build.command.as_deref(), Some("bun run build"));
2697        match mode {
2698            BuildMode::InContainer { image } => {
2699                assert_eq!(image.registry, "ghcr.io");
2700                assert_eq!(image.repository, "org/app-build");
2701                assert_eq!(image.tag, "v1.2");
2702                assert_eq!(image.digest, digest);
2703            }
2704            other => panic!("expected InContainer, got {other:?}"),
2705        }
2706    }
2707
2708    #[tokio::test]
2709    async fn read_mesofact_build_rejects_in_container_without_digest() {
2710        let tmp = tempdir().unwrap();
2711        std::fs::write(
2712            tmp.path().join("workload.toml"),
2713            r#"schema_version = 1
2714kind = "mesofact-static"
2715routes = "./routes.ts"
2716
2717[build]
2718command = "bun run build"
2719out_dir = "dist"
2720
2721[build_mode.in_container]
2722image = "ghcr.io/org/app-build:v1.2"
2723"#,
2724        )
2725        .unwrap();
2726        let err = read_mesofact_build(tmp.path()).unwrap_err();
2727        let msg = format!("{err:#}");
2728        assert!(
2729            msg.contains("digest") || msg.contains("sha256"),
2730            "in_container with bare tag must reject at parse; got: {msg}"
2731        );
2732    }
2733
2734    #[tokio::test]
2735    async fn read_mesofact_build_returns_none_for_other_kinds() {
2736        let tmp = tempdir().unwrap();
2737        std::fs::write(
2738            tmp.path().join("workload.toml"),
2739            r#"schema_version = 1
2740kind = "static-asset"
2741"#,
2742        )
2743        .unwrap();
2744        assert!(read_mesofact_build(tmp.path()).unwrap().is_none());
2745    }
2746
2747    #[tokio::test]
2748    async fn read_mesofact_build_returns_none_when_file_absent() {
2749        let tmp = tempdir().unwrap();
2750        assert!(read_mesofact_build(tmp.path()).unwrap().is_none());
2751    }
2752
2753    /// R838-B1: a `[build]` table declaring only `out_dir` is the shape
2754    /// `mesofact new` scaffolds — the project builds through the in-process
2755    /// pipeline and has no shell command to run.
2756    #[tokio::test]
2757    async fn read_mesofact_build_accepts_a_build_table_with_no_command() {
2758        let tmp = tempdir().unwrap();
2759        std::fs::write(
2760            tmp.path().join("workload.toml"),
2761            r#"schema_version = 1
2762kind = "mesofact-static"
2763routes = "./mesofact.routes.ts"
2764
2765[build]
2766out_dir = "dist"
2767"#,
2768        )
2769        .unwrap();
2770        let (build, mode) = read_mesofact_build(tmp.path()).unwrap().unwrap();
2771        assert_eq!(build.command, None);
2772        assert_eq!(build.out_dir, PathBuf::from("dist"));
2773        assert!(matches!(mode, BuildMode::HostSide));
2774    }
2775
2776    /// R838-B1: no `build.command` → no build step, not `sh -c ""`.
2777    ///
2778    /// An empty shell command exits 0 having produced nothing, so the
2779    /// reconciler would report a successful build and then publish whatever
2780    /// stale bytes were in `out_dir`. The skip has to happen at the lowering,
2781    /// which is what `lower_build_to_forge_spec` returning `None` pins.
2782    #[tokio::test]
2783    async fn rebuild_static_skips_the_build_step_when_no_command_is_declared() {
2784        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ false);
2785        let workload_dir = fx.workspace_root.join("app/web");
2786        std::fs::write(
2787            workload_dir.join("workload.toml"),
2788            r#"schema_version = 1
2789kind = "mesofact-static"
2790routes = "./mesofact.routes.ts"
2791
2792[build]
2793out_dir = "dist"
2794"#,
2795        )
2796        .unwrap();
2797
2798        let (capture, captured) = CaptureExecutor::new();
2799        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2800
2801        // As in the sibling tests, up_cloudflare_r2 fails for want of provider
2802        // config — but only AFTER the build step would have run.
2803        let _ = reconciler.rebuild_static(fx.ctx()).await;
2804
2805        assert!(
2806            captured.lock().unwrap().is_empty(),
2807            "a manifest with no build.command must dispatch nothing to the executor"
2808        );
2809    }
2810
2811    /// The lowering itself is the seam, so pin it directly too — a future
2812    /// caller that reaches `lower_build_to_forge_spec` without going through
2813    /// `run_build` inherits the same refusal.
2814    #[test]
2815    fn lowering_a_build_with_no_command_yields_no_forge_spec() {
2816        let build = BuildConfig {
2817            command: None,
2818            out_dir: PathBuf::from("dist"),
2819            render_command: None,
2820        };
2821        assert!(lower_build_to_forge_spec(
2822            std::path::Path::new("/workspace/app/web"),
2823            &build,
2824            &BuildMode::HostSide,
2825        )
2826        .is_none());
2827    }
2828
2829    #[tokio::test]
2830    async fn rebuild_static_lifts_build_mode_through_executor() {
2831        // End-to-end smoke through rebuild_static → run_build → executor for
2832        // the cloudflare publish arm. InContainer build_mode must reach the
2833        // executor as TaskRuntime::Container (the CF path does not skip container
2834        // builds — only local-static does, per W165 OQ#1, tested separately).
2835        // up_cloudflare_r2 will fail (no provider config), but the build step
2836        // runs first so the CaptureExecutor still records the lowered ForgeSpec.
2837        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ false);
2838        let workload_dir = fx.workspace_root.join("app/web");
2839        let digest = workload_spec::testing::test_digest();
2840        std::fs::write(
2841            workload_dir.join("workload.toml"),
2842            format!(
2843                r#"schema_version = 1
2844kind = "mesofact-static"
2845routes = "./routes.ts"
2846
2847[build]
2848command = "bun run build"
2849out_dir = "dist"
2850
2851[build_mode.in_container.image]
2852registry = "ghcr.io"
2853repository = "org/app-build"
2854tag = "v1.2"
2855digest = "{digest}"
2856"#
2857            ),
2858        )
2859        .unwrap();
2860
2861        let (capture, captured) = CaptureExecutor::new();
2862        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2863
2864        // up_cloudflare_r2 will fail (no provider config on disk) but only
2865        // AFTER the build step ran. We only care that the build path produced
2866        // a captured ForgeSpec with the right runtime.
2867        let _ = reconciler.rebuild_static(fx.ctx()).await;
2868
2869        let captured = captured.lock().unwrap();
2870        assert_eq!(captured.len(), 1, "build step executed exactly once");
2871        let (spec, _ctx) = &captured[0];
2872        assert_eq!(spec.where_.runtime, TaskRuntime::Container);
2873        match &spec.command {
2874            ForgeCommand::Subprocess { image, .. } => {
2875                let image = image.as_ref().unwrap();
2876                assert_eq!(image.digest, digest, "digest survives the round-trip");
2877            }
2878            other => panic!("expected Subprocess, got {other:?}"),
2879        }
2880    }
2881
2882    #[tokio::test]
2883    async fn rebuild_static_local_static_in_container_falls_back_to_host_side() {
2884        // W165 OQ#1 (R438-F9): local-static arm + in_container build_mode must
2885        // warn and fall back to host-side (TaskRuntime::Native). Dev machines
2886        // may not have docker, and the host watcher handles hot-reload.
2887        let fx = Fixture::new(local_static_slot(0), /*write_workload*/ false);
2888        let workload_dir = fx.workspace_root.join("app/web");
2889        let digest = workload_spec::testing::test_digest();
2890        std::fs::write(
2891            workload_dir.join("workload.toml"),
2892            format!(
2893                r#"schema_version = 1
2894kind = "mesofact-static"
2895routes = "./routes.ts"
2896
2897[build]
2898command = "bun run build"
2899out_dir = "dist"
2900
2901[build_mode.in_container.image]
2902registry = "ghcr.io"
2903repository = "org/app-build"
2904tag = "v1.2"
2905digest = "{digest}"
2906"#
2907            ),
2908        )
2909        .unwrap();
2910
2911        let (capture, captured) = CaptureExecutor::new();
2912        let reconciler = MesofactStaticReconciler::new()
2913            .with_executor(capture.clone())
2914            .with_local_static(LocalStaticOptions {
2915                binary: Some(PathBuf::from("/definitely/not/a/binary")),
2916                ready_timeout: Some(Duration::from_millis(50)),
2917                ..Default::default()
2918            });
2919        let _ = reconciler.rebuild_static(fx.ctx()).await;
2920
2921        let captured = captured.lock().unwrap();
2922        assert_eq!(
2923            captured.len(),
2924            1,
2925            "build step still ran (host-side fallback)"
2926        );
2927        let (spec, _) = &captured[0];
2928        assert_eq!(
2929            spec.where_.runtime,
2930            TaskRuntime::Native,
2931            "in_container overridden to Native for local-static arm"
2932        );
2933        match &spec.command {
2934            ForgeCommand::Subprocess { image, .. } => {
2935                assert!(
2936                    image.is_none(),
2937                    "image must be stripped when falling back to host-side; got {image:?}"
2938                );
2939            }
2940            other => panic!("expected Subprocess, got {other:?}"),
2941        }
2942    }
2943
2944    #[tokio::test]
2945    async fn rebuild_static_defaults_to_host_side_when_build_mode_omitted() {
2946        // Fixture writes a workload.toml without [build_mode] (the common
2947        // shape today). rebuild_static must default to HostSide.
2948        let fx = Fixture::new(local_static_slot(0), /*write_workload*/ true);
2949        let (capture, captured) = CaptureExecutor::new();
2950        let reconciler = MesofactStaticReconciler::new()
2951            .with_executor(capture.clone())
2952            .with_local_static(LocalStaticOptions {
2953                binary: Some(PathBuf::from("/definitely/not/a/binary")),
2954                ready_timeout: Some(Duration::from_millis(50)),
2955                ..Default::default()
2956            });
2957        let _ = reconciler.rebuild_static(fx.ctx()).await;
2958        let captured = captured.lock().unwrap();
2959        assert_eq!(
2960            captured.len(),
2961            1,
2962            "default build_mode still runs the build step"
2963        );
2964        assert_eq!(captured[0].0.where_.runtime, TaskRuntime::Native);
2965    }
2966
2967    #[tokio::test]
2968    async fn rebuild_static_skips_build_when_workload_toml_missing() {
2969        // No workload.toml on disk — rebuild_static must not panic in the
2970        // build step; the subsequent up() call surfaces the missing-manifest
2971        // error to the operator.
2972        let fx = Fixture::new(local_static_slot(0), /*write_workload*/ false);
2973        let (capture, captured) = CaptureExecutor::new();
2974        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2975        let err = reconciler.rebuild_static(fx.ctx()).await.unwrap_err();
2976        let msg = format!("{err:#}");
2977        assert!(msg.contains("workload.toml"), "got: {msg}");
2978        assert!(
2979            captured.lock().unwrap().is_empty(),
2980            "no build executed when manifest missing",
2981        );
2982    }
2983
2984    // ── revalidate_static (R535-T1) ──────────────────────────────────────────
2985
2986    #[tokio::test]
2987    async fn revalidate_static_without_render_command_never_touches_executor() {
2988        // W225 §3 / R535-T1: an almanac on_change is a data-only trigger — it
2989        // must never re-run build.command, regardless of provider arm or
2990        // whether the subsequent publish step succeeds. The fixture's
2991        // workload.toml carries a real [build] table (write_workload=true)
2992        // but NO render_command — so the executor must record zero calls
2993        // (R535-T7 only runs the executor for a declared render_command).
2994        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
2995        let (capture, captured) = CaptureExecutor::new();
2996        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
2997
2998        // up_cloudflare_r2 will fail (no provider config on disk) — that's
2999        // expected and irrelevant here; only the executor call count matters.
3000        let _ = reconciler.revalidate_static(fx.ctx(), "/releases").await;
3001
3002        assert!(
3003            captured.lock().unwrap().is_empty(),
3004            "revalidate_static without render_command must never invoke the executor"
3005        );
3006    }
3007
3008    #[tokio::test]
3009    async fn revalidate_static_delegates_to_up() {
3010        // Without a render_command, revalidate_static's publish behavior must
3011        // be indistinguishable from calling up() directly. Same fixture, same
3012        // reconciler, two independent ReconcileCtx borrows: both arms must
3013        // hit the identical error (missing
3014        // .yah/infra/providers/cloudflare.toml) with byte-identical text.
3015        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
3016        let reconciler = MesofactStaticReconciler::new();
3017
3018        let revalidate_err = reconciler
3019            .revalidate_static(fx.ctx(), "/releases")
3020            .await
3021            .unwrap_err();
3022        let up_err = reconciler.up(fx.ctx()).await.unwrap_err();
3023
3024        assert_eq!(
3025            format!("{revalidate_err:#}"),
3026            format!("{up_err:#}"),
3027            "revalidate_static must delegate straight to up() with no extra behavior"
3028        );
3029    }
3030
3031    #[tokio::test]
3032    async fn revalidate_static_skips_build_when_workload_toml_missing() {
3033        // Mirrors rebuild_static_skips_build_when_workload_toml_missing:
3034        // revalidate_static must not panic when workload.toml is absent (no
3035        // [build] table → no render_command → no executor call); the
3036        // missing-manifest error surfaces from deeper in up().
3037        let fx = Fixture::new(local_static_slot(0), /*write_workload*/ false);
3038        let (capture, captured) = CaptureExecutor::new();
3039        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
3040        let err = reconciler
3041            .revalidate_static(fx.ctx(), "/releases")
3042            .await
3043            .unwrap_err();
3044        let msg = format!("{err:#}");
3045        assert!(msg.contains("workload.toml"), "got: {msg}");
3046        assert!(
3047            captured.lock().unwrap().is_empty(),
3048            "no build executed by revalidate_static",
3049        );
3050    }
3051
3052    // ── revalidate_static render_command (R535-T7) ───────────────────────────
3053
3054    fn write_workload_with_render_command(fx: &Fixture) {
3055        std::fs::write(
3056            fx.workspace_root.join("app/web/workload.toml"),
3057            r#"schema_version = 1
3058kind = "mesofact-static"
3059routes = "./routes.ts"
3060
3061[build]
3062command = "echo built"
3063out_dir = "dist"
3064render_command = "echo render {route} --all"
3065"#,
3066        )
3067        .unwrap();
3068    }
3069
3070    #[tokio::test]
3071    async fn revalidate_static_runs_render_command_with_route_substituted() {
3072        // R535-T7: a declared render_command runs exactly once before the
3073        // publish step — {route} substituted, host-side lowering (Native, no
3074        // image), cwd = workload dir — and NEVER build.command.
3075        let fx = Fixture::new(cloudflare_reference_slot(), /*write_workload*/ true);
3076        write_workload_with_render_command(&fx);
3077        let (capture, captured) = CaptureExecutor::new();
3078        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
3079
3080        // up_cloudflare_r2 still fails after the render step (no provider
3081        // config on disk) — only the executor capture matters here.
3082        let _ = reconciler.revalidate_static(fx.ctx(), "/issues/:id").await;
3083
3084        let captured = captured.lock().unwrap();
3085        assert_eq!(captured.len(), 1, "render executed exactly once");
3086        let (spec, ctx) = &captured[0];
3087        assert_eq!(spec.where_.runtime, TaskRuntime::Native);
3088        match &spec.command {
3089            ForgeCommand::Subprocess { argv, image } => {
3090                assert!(image.is_none(), "host_side render carries no image");
3091                assert_eq!(
3092                    argv,
3093                    &vec![
3094                        "sh".to_string(),
3095                        "-c".into(),
3096                        "echo render /issues/:id --all".into()
3097                    ],
3098                    "route pattern substituted into {{route}}"
3099                );
3100            }
3101            other => panic!("expected Subprocess, got {other:?}"),
3102        }
3103        assert_eq!(
3104            ctx.cwd.as_deref(),
3105            Some(fx.workspace_root.join("app/web").as_path())
3106        );
3107    }
3108
3109    #[tokio::test]
3110    async fn revalidate_static_local_static_skips_render_command() {
3111        // The local-static arm never runs the render step — the host
3112        // mesofact-dev watcher re-renders on data-file changes independently.
3113        let fx = Fixture::new(local_static_slot(0), /*write_workload*/ true);
3114        write_workload_with_render_command(&fx);
3115        let (capture, captured) = CaptureExecutor::new();
3116        let reconciler = MesofactStaticReconciler::new().with_executor(capture.clone());
3117
3118        let _ = reconciler.revalidate_static(fx.ctx(), "/issues/:id").await;
3119
3120        assert!(
3121            captured.lock().unwrap().is_empty(),
3122            "local-static revalidate must not run render_command"
3123        );
3124    }
3125
3126    /// Validate the in-tree fixture at `testdata/mesofact-in-container/workload.toml`.
3127    ///
3128    /// Verifies that `read_mesofact_build` returns `BuildMode::InContainer` for an
3129    /// on-disk workload that declares `[build_mode] mode = "in_container"`.  No
3130    /// build is executed — this is a parse + lowering-shape test that runs in CI
3131    /// without docker (R438-T8 "in-tree mesofact-static workload with
3132    /// build_mode=in_container builds green in CI").
3133    #[test]
3134    fn in_container_fixture_roundtrips_as_container_build_mode() {
3135        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3136        let fixture_dir = manifest_dir.join("testdata/mesofact-in-container");
3137        let (build, build_mode) = read_mesofact_build(&fixture_dir)
3138            .expect("testdata/mesofact-in-container/workload.toml must parse cleanly")
3139            .expect("fixture must have a [build] section");
3140        assert!(
3141            matches!(build_mode, BuildMode::InContainer { .. }),
3142            "expected BuildMode::InContainer but got {build_mode:?}",
3143        );
3144        assert!(
3145            build.command.as_deref().is_some_and(|c| !c.is_empty()),
3146            "build.command must be declared and non-empty"
3147        );
3148    }
3149
3150    /// Spin up a throwaway loopback server that answers `/__mesofact/info` with
3151    /// `identity` (Some → 200 JSON `{service,component}`, None → 404), for the
3152    /// adopt identity-check tests (R602-B4). Returns the bound port.
3153    async fn spawn_info_server(identity: Option<(&str, &str)>) -> u16 {
3154        use axum::response::IntoResponse;
3155        use axum::routing::get;
3156        use axum::Router;
3157
3158        let json = identity.map(|(s, c)| format!(r#"{{"service":"{s}","component":"{c}"}}"#));
3159        let app = Router::new().route(
3160            "/__mesofact/info",
3161            get(move || {
3162                let json = json.clone();
3163                async move {
3164                    match json {
3165                        Some(b) => ([(reqwest::header::CONTENT_TYPE, "application/json")], b)
3166                            .into_response(),
3167                        None => axum::http::StatusCode::NOT_FOUND.into_response(),
3168                    }
3169                }
3170            }),
3171        );
3172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3173        let port = listener.local_addr().unwrap().port();
3174        tokio::spawn(async move {
3175            axum::serve(listener, app).await.unwrap();
3176        });
3177        // Let the accept loop come up before the probe connects.
3178        tokio::time::sleep(Duration::from_millis(50)).await;
3179        port
3180    }
3181
3182    fn loopback(port: u16) -> SocketAddr {
3183        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
3184    }
3185
3186    #[tokio::test]
3187    async fn adopt_identified_matches_and_adopts() {
3188        let port = spawn_info_server(Some(("scrabcake", "site"))).await;
3189        let got = try_adopt_identified(loopback(port), "scrabcake", "site", "configured")
3190            .await
3191            .unwrap();
3192        assert!(got.is_some(), "matching identity should adopt");
3193    }
3194
3195    #[tokio::test]
3196    async fn adopt_identified_mismatch_bails_naming_both() {
3197        // The headline repro: scrabcake dev finds yah-marketing on the port.
3198        let port = spawn_info_server(Some(("yah-marketing", "pond"))).await;
3199        let err = try_adopt_identified(loopback(port), "scrabcake", "site", "configured")
3200            .await
3201            .unwrap_err();
3202        let msg = err.to_string();
3203        assert!(msg.contains("yah-marketing/pond"), "msg was: {msg}");
3204        assert!(msg.contains("scrabcake"), "msg was: {msg}");
3205    }
3206
3207    #[tokio::test]
3208    async fn adopt_identified_foreign_listener_bails() {
3209        // Listener present but no /__mesofact/info (a foreign server, e.g.
3210        // workerd, or an identity-less mesofact-dev) → refuse to adopt.
3211        let port = spawn_info_server(None).await;
3212        let err = try_adopt_identified(loopback(port), "scrabcake", "site", "configured")
3213            .await
3214            .unwrap_err();
3215        assert!(
3216            err.to_string().contains("not an identifiable mesofact-dev"),
3217            "msg was: {err}"
3218        );
3219    }
3220
3221    #[tokio::test]
3222    async fn adopt_identified_no_listener_returns_none() {
3223        let port = pick_unused_port();
3224        let got = try_adopt_identified(loopback(port), "scrabcake", "site", "configured")
3225            .await
3226            .unwrap();
3227        assert!(got.is_none(), "no listener → nothing to adopt");
3228    }
3229}