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