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