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