Skip to main content

cloud/reconciler/
local_process.rs

1//! `[providers.compute] kind = "local-process"` — run a service's own compute
2//! component as a **kamaji-supervised host process** at the dev tier.
3//!
4//! ## Why this exists
5//!
6//! A component's `kind` says what the service *is*; the mirror's provider slot
7//! says how *this tier* runs it. That split already exists for storage —
8//! `mesofact-static` runs off `miniflare-native` (a Worker over the dev S3 driver) at dev and
9//! `miniflare-container` (containers) at pond, one component kind, two
10//! runtimes — and this is the same split for compute. A `kind = "container"`
11//! component now runs natively at dev and in docker at pond without the
12//! service declaring itself twice.
13//!
14//! The alternative — a `local-process` *component kind* — was rejected: it
15//! would make the artifact shape a property of the service rather than of the
16//! tier, which is exactly the fork that W265 exists to prevent.
17//!
18//! ## Why not just run the container at dev
19//!
20//! Because a container is a different filesystem, and at the dev tier that is
21//! a lie you have to maintain. yah-cloud-admin is the worked example: it reads
22//! `.yah/infra/machines/*.toml`, so its dev container has to bind-mount
23//! `.yah/infra` read-only into `/workspace/.yah/infra` and override
24//! `YAH_CLOUD_ADMIN_WORKSPACE_ROOT` to find it (see
25//! `crates/yah/cloud-admin/workload.toml`). Get the mount wrong and the
26//! service comes up *running, healthy, and describing a fleet of zero
27//! machines*. A host process inherits the operator's actual workspace and the
28//! whole class of failure disappears — along with a docker build per edit.
29//!
30//! ## Config
31//!
32//! The mirror opts in:
33//!
34//! ```toml
35//! # .yah/services/<svc>/mirrors/dev.toml
36//! shape = "local"
37//!
38//! [providers.compute]
39//! kind = "local-process"
40//! ```
41//!
42//! and the component's `workload.toml` describes the process:
43//!
44//! ```toml
45//! [process]
46//! # Cargo package to build before spawning. Omit for a prebuilt binary.
47//! cargo_package = "yah-cloud-admin"
48//! # Binary to exec, relative to the workspace root. Defaults to
49//! # target/<profile>/<cargo_package>.
50//! bin = "target/debug/yah-cloud-admin"
51//! # Extra argv after the binary.
52//! args = []
53//! # Port the process listens on. OPTIONAL. When present it is the readiness
54//! # signal and the mirror's `dev_url`. Omit it for a process that never
55//! # listens — a native GUI, a daemon on a unix socket, a batch loop — and
56//! # readiness falls back to "still alive after a short grace window",
57//! # with no `dev_url` for the Run tab to open.
58//! port = 4325
59//!
60//! [process.env]
61//! YAH_CLOUD_ADMIN_ADDR = "127.0.0.1:4325"
62//! ```
63//!
64//! ## Per-mirror `profile` override, and non-cargo builds
65//!
66//! `workload.toml` is one file shared by every mirror bound to this slot, so
67//! `profile` can be overridden per mirror — the only field that can, since
68//! it's the only one where two tiers of the *same* component legitimately
69//! want different values (a debug dev loop next to a release build, both
70//! pointed at the same `[process]` block):
71//!
72//! ```toml
73//! # .yah/services/<svc>/mirrors/release.toml
74//! shape = "local"
75//!
76//! [providers.compute]
77//! kind = "local-process"
78//! profile = "release"
79//! ```
80//!
81//! For a component with no runnable top-level binary from a plain
82//! `cargo build -p pkg` — a native macOS/iOS app, where Rust only supplies a
83//! static lib for Xcode to link — `pre_build` replaces `cargo_package`
84//! entirely: an operator-authored argv (same trust model as `compose.rs`'s
85//! post-write shell commands, R592-T2) run in `workspace_root` before `bin`
86//! is resolved, streamed into the Run tab's log tail exactly like a cargo
87//! build:
88//!
89//! ```toml
90//! [process]
91//! pre_build = ["./build-dist.sh", "arm64", "--sign"]
92//! bin = "app/macos/dist/NoiseTable.app/Contents/MacOS/NoiseTable"
93//! ```
94//!
95//! ## Portless components
96//!
97//! A process with no TCP listener is a first-class case, not a degenerate
98//! one: noisetable's desktop dev loop is a winit window with zero network
99//! surface. Do not paper over the gap by declaring a port the process never
100//! binds — that trips the readiness timeout below and tears down a perfectly
101//! healthy child, or (worse) adopts an unrelated listener that happens to
102//! answer. Omit `port` and the Run tab renders the row as a log-tail plus
103//! stop card instead of an iframe.
104//!
105//! ## …and what a portless component SHOULD do instead
106//!
107//! Dropping the port drops the only structured thing the supervisor knew
108//! about the process, which leaves log-grepping — for an operator and, worse,
109//! for an agent. So the house default for anything long-running is the
110//! **process-control channel** ([`crate::proc_control`]): a unix socket
111//! speaking one verb, `status`, answering with a document whose `state` uses
112//! kamaji's own `WorkloadState` vocabulary.
113//!
114//! ```toml
115//! [process]
116//! cargo_package = "dev"
117//! # no `port` — a winit window has nothing to bind
118//!
119//! [process.control]
120//! # nothing to configure: the socket path arrives as $YAH_CONTROL_SOCK
121//! ```
122//!
123//! With it, readiness stops being a guess: the process says `starting` while
124//! it loads and `running` when it is up, and an agent can ask what it is
125//! doing instead of parsing sentences out of stdout. A component that already
126//! serves HTTP declares `http_path` instead and shares one endpoint with the
127//! prod tier's `Healthcheck`. The channel is optional — a process that
128//! declines it still runs, on liveness alone.
129//!
130//! @yah:relay(R715, "local-process compute provider: a dev tier that runs the binary, not a container")
131//! @yah:at(2026-08-03T22:21:13Z)
132//! @yah:status(open)
133//! @yah:assignee(agent:bundle-anthropic-ashguard)
134//! @yah:next("T1 (landed): LocalProcessReconciler + Provider::LocalProcess + native_support dedup + both dispatchers + the yah-cloud-admin dev/pond split. See the T1 handoff.")
135//! @yah:next("Follow-on: mesofact-dev's local-static arm and this reconciler now differ only in argv construction. W265 already plans to retire local-static when local-s3-fs lands - that is the moment to consider collapsing them.")
136//! @yah:next("Follow-on: the desktop mirror_run_up has no adopt-probe for local-process the way it does for mesofact-dev. Correct today because the reconciler reaps its predecessor, but an adopt path would let a re-click return the running URL without a rebuild.")
137//! @yah:gotcha("Naming rule this relay encodes, from the operator: if it runs in a container it is pond. Dev is the tier that runs against the operator's real filesystem. No service is required to have all three tiers - a service whose lowest tier is pond is fine.")
138//! @yah:gotcha("The runtime is a property of the MIRROR, not the component. A kind=container component runs natively at dev and in docker at pond, selected by the mirror's compute slot - the same split mesofact-static already had via providers.static (local-static at dev, miniflare-container at pond). Do NOT add a local-process COMPONENT kind: that makes artifact shape a property of the service and reintroduces the per-tier fork W265 exists to prevent.")
139//! @arch:see(.yah/docs/working/W265-service-capabilities-and-drivers.md)
140//!
141//! @yah:ticket(R715-T1, "LocalProcessReconciler + Provider::LocalProcess, wired into both dispatchers; yah-cloud-admin dev/pond split")
142//! @yah:status(review)
143//! @yah:at(2026-08-05T04:54:16Z)
144//! @yah:assignee(agent:bundle-anthropic-ashguard)
145//! @yah:phase(P1)
146//! @yah:parent(R715)
147//! @yah:next("R714-B1 (stop button no-ops on container mirrors) and R714-B2 (tier chip prints 'axum') were found during this work and filed separately - they predate it and are not regressions from it.")
148//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 688 passed, 0 failed")
149//! @yah:verify("cargo test -p yah --lib cloud:: - 108 passed")
150//! @yah:verify("cargo test -p xtask --test schema_drift --test workload_envelope - 4 passed")
151//! @yah:verify("MANUAL (done): yah cloud mirror up yah-cloud-admin --env dev binds 127.0.0.1:4325 with no container, and the process logs workspace=/Users/leif/ss/yah - the real checkout, not /workspace")
152//! @yah:verify("MANUAL (done): running mirror up twice in a row replaces the child (pid changes) instead of leaving a dead second instance behind an Address-already-in-use")
153//! @yah:gotcha("NativeRuntime's workload table is IN-MEMORY, so a second `mirror up` from a fresh process cannot see the first one's child. Without the owner.json sidecar + reap the new child dies on Address-already-in-use, wait_for_port sees the OLD listener answering, and the reconcile reports success while the just-edited binary is not what is running. This was observed live before the fix, not theorised. Do not remove the sidecar.")
154//! @yah:gotcha("yah-cloud-admin's pond tier moved to host port 4326 so both tiers can run at once. The container still listens on 4325 internally.")
155//! @yah:next("RESOLVED (R715-T1, 2026-08-04): the cloud.toml blocker comment was corrected by the peer whose uncommitted edits blocked it - lines 34-60 now carry the 2026-08-03 status (published image DONE, workload declaration DONE, cheers key STILL BLOCKED on both an issuer and a 0.8.21 yubaba roll). Nothing left to do there.")
156//! @yah:handoff("Re-verified independently on 2026-08-04 (session:5fe4274e, rune) rather than trusting the prior run's claims. All three automated suites green; counts are HIGHER than the ones recorded above because peers added tests since: yah-cloud lib 698 passed / 0 failed (was 688), yah lib cloud:: 111 passed (was 108), xtask schema_drift + workload_envelope 4 passed (unchanged).")
157//! @yah:verify("RE-VERIFIED 2026-08-04, sidecar survives across sessions: the owner.json left by the 2026-08-03 run ({pid:54869,port:4325}) still matched the live listener a day later - lsof -t on 4325 returned exactly 54869. That is the cross-process ownership link working in the wild, not in a test.")
158//! @yah:verify("RE-VERIFIED 2026-08-04, reap-and-replace: `cargo run -p yah -- cloud mirror up yah-cloud-admin --env dev` reaped pid 54869 (kill -0 confirms gone), spawned 71455, rewrote owner.json to {pid:71455,port:4325}, and 127.0.0.1:4325/ answers HTTP 200. No dead second instance, no Address-already-in-use.")
159//! @yah:verify("RE-VERIFIED 2026-08-04, the failure mode this tier exists to prevent: child cwd is /Users/leif/ss/yah (lsof -d cwd), `docker ps` shows NO cloud-admin container, and /partial/machines renders 9 machine rows against 9 files in .yah/infra/machines/ - us-west-001/002/003 + mac-builder among them. Not a fleet of zero.")
160//! @yah:verify("RE-VERIFIED 2026-08-04, both dispatcher arms compile: `cargo build -p yah` (app/yah/cli/src/cloud.rs:4705) and `cargo check -p desktop --lib` (app/yah/desktop/src/mirror_run.rs:624) are both clean.")
161//! @yah:gotcha("LEFT RUNNING: the re-verification above spawned yah-cloud-admin pid 71455 on 127.0.0.1:4325 and did not stop it - that matches the state found at session start (a dev-tier process was already up). Kill it with `kill $(lsof -nP -iTCP:4325 -sTCP:LISTEN -t)`, or just re-run `mirror up`, which reaps it.")
162//! @yah:gotcha("TRANSIENT, NOT THIS TICKET: `cargo run -p yah` failed once with E0599 mid-verification and compiled clean on immediate retry with no edit from this session - a peer's in-flight change on the shared tree. Same shape as the note in crates/yah/camp-service/tests/e2e.rs:35. If you see it, retry before investigating.")
163//!
164//! @yah:ticket(R715-T2, "Portless `local-process` compute slot — Run tab support for native GUI processes with no TCP listener")
165//! @yah:status(review)
166//! @yah:at(2026-08-14T21:27:45Z)
167//! @yah:assignee(agent:bundle-anthropic-ashguard)
168//! @yah:parent(R715)
169//! @yah:next("Make `[process].port` optional in `ProcessSpec` (this file). When absent, readiness = process spawned and still alive after a short grace window, instead of `wait_for_port`; the `up()` path returns `dev_url = None` to `into_running(...)` (RunningWorkload's dev_url is already `Option`, so this is a narrowing of ProcessSpec, not a new field anywhere downstream).")
170//! @yah:next("Mirror the same required->optional change for `port` on `run.spawn`'s schema and `SpawnArgs` (crates/yah/agent-tools/src/run_tools.rs) so an agent can supervise a portless process through the same tool used for app servers.")
171//! @yah:next("Run tab: a mirror/spawn with no dev_url should render as a log-tail + kill/restart card, no iframe, no URL bar — this is the `stdio` process row A046 (.yah/docs/architecture/A046-yah-run-tab.md) already sketches (`yah-rig (noise) stdio pid 4598 ... [logs] [kill]`) but neither shipped mechanism implements.")
172//! @yah:next("Real external consumer blocked on this today: noisetable's desktop dev loop (`cargo run -p dev` / `cargo run --release -p dev`, a winit native GUI window with zero network surface — no port to bind, ever). Full writeup + the service.toml/mirror.toml/workload.toml shape noisetable wants to declare once this lands: entambi repo, .yah/docs/working/W145-desktop-dev-run-tab.md.")
173//! @yah:gotcha("Do not let a consumer route around this by having the process bind a throwaway port it never uses for anything real — that just trips this reconciler's own `wait_for_port` timeout/teardown, or makes `run.spawn` poll a port that happens to be open for unrelated reasons. Both are worse than the honest gap. The fix belongs in this reconciler (and run_tools.rs), not in a consumer's workload.toml.")
174//! @yah:assumes("Readiness-as-'process alive after N ms grace window' is assumed to be an acceptable v1 fallback (matches what a bare `[kill]`-only scoreboard row needs). A richer readiness probe (e.g. an optional stdout regex match) would be nicer but isn't required to unblock the noisetable use case named above — confirm with whoever picks this up whether the simple version is sufficient, or whether R322-F4's LivePanel / RunStatePanel state model expects something richer for a 'ready' vs 'starting' distinction on portless rows.")
175//! @arch:see(.yah/docs/working/W265-service-capabilities-and-drivers.md)
176//! @arch:see(.yah/docs/architecture/A046-yah-run-tab.md)
177//! @arch:see(crates/yah/agent-tools/src/run_tools.rs)
178//! @yah:handoff("[process].port is now Option<u16> in the local-process reconciler. A portless component skips the port-contention check and wait_for_port; readiness falls back to ALIVE_GRACE (750ms) + pid_alive, dev_url is None, and OwnerRecord.port became Option<u16> with a serde default so sidecars written before this still parse (tested).")
179//! @yah:handoff("run.spawn: SpawnArgs.port is Option<u16> and the JSON schema no longer requires it; the wire still encodes portless as 0. The camp daemon's run_spawn_handler applies the same 750ms grace and fails the spawn with a log tail when a portless child exits immediately - it previously registered a healthy scoreboard row over a corpse. rpc::RunSpawnParams.port's doc claimed 0 meant OS-assigned, which the handler never did; corrected to say portless.")
180//! @yah:handoff("Run tab: MirrorPanel's running-with-no-dev_url state was the placeholder reading 'paste a local URL to preview it here' - advice that cannot be followed for a process that will never have one. Replaced with PortlessCard (host-process chip, what it is, pointer to the log panel and Stop, plus whatever the process reported). LivePanel's port column reads 'stdio' for a portless mirror or spawn and keeps the dash for task runs, via an exported transportLabel with 6 tests.")
181//! @yah:handoff("SCOPE ADDED MID-TICKET, at the operator's direction: the process-control channel. Dropping the port drops the only structured signal a supervisor had, leaving log-grepping - so portless is now paired with an opinionated default that anything long-running SHOULD expose. New module oss/yubaba/crates/cloud/src/proc_control.rs (client + wire types), new optional [process.control] in the reconciler, and W315-process-control-channel.md as the canon. Phase 2 filed as R715-F3.")
182//! @yah:handoff("The contract: one verb, `status`, over newline-JSON on a unix socket (path handed down as $YAH_CONTROL_SOCK) or GET /_yah/status for anything already serving HTTP - same document, two transports, so the cloud tier's Healthcheck{Http} probes the same endpoint the dev tier reads over a socket. `state` is the only required field and its vocabulary is EXACTLY kamaji_proto::WorkloadState, which is the whole compatibility claim: a workload's own report can be handed to the supervisor verbatim. Pinned by a test that fails if either side drifts.")
183//! @yah:handoff("Readiness in the reconciler is now a ladder: control channel > port bind > alive-after-grace. A declared channel supersedes the port because a bound port says nothing about whether the thing behind it finished booting, and a terminal state (failed/exited) short-circuits instead of burning the 20s timeout.")
184//! @yah:handoff("DISCOVERED AND FIXED, wider than the ticket: RunningWorkloadSummary dropped RunningWorkload.notes, so every operator-facing note a reconciler attached since R546-B12 was written into a struct nobody read. Added the field (+ TS type, + three desktop construction sites in mirror_observation.rs). It is what carries the process's own status line to the Run tab.")
185//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 830 passed / 0 failed (827 before the last batch). 20 in local_process, 10 in proc_control.")
186//! @yah:verify("END-TO-END, not just unit: three tests drive the real reconciler through kamaji's native backend against a temp workspace. a_portless_component_comes_up_with_no_dev_url spawns /bin/sleep with no port and asserts dev_url None, the liveness-only note, and an owner.json whose port is ABSENT rather than a placeholder zero. a_portless_component_that_exits_immediately_fails_the_reconcile runs /usr/bin/false and asserts the bring-up fails. a_control_channel_decides_readiness_when_declared holds the component at not-ready through a `starting` document and passes on `running`.")
187//! @yah:verify("cargo test -p yah-agent-tools --lib run_tools - 14 passed (was 12; +2 for the optional port).")
188//! @yah:verify("packages/yah/ui - bun run typecheck clean, bun run build clean, bun test src/components/run/ 111 passed / 0 failed.")
189//! @yah:verify("Full bun test 1717 pass / 14 fail / 8 errors. Identical failure counts to what R714-B2 recorded, and the failing names are the same pre-existing four files (StatusPill, dispatchNav, first-run, truncateArg) - no regression from this ticket.")
190//! @yah:verify("cargo check -p yah, cargo check -p desktop --lib, cargo test -p xtask --test schema_drift (3 passed) - all clean.")
191//! @yah:verify("NOT RUN: any manual desktop check. Nobody has looked at the rendered PortlessCard or the stdio transport chip in a running app - there is no portless service declared in this camp to bring up. The e2e reconciler tests cover the backend claim; the two UI changes are covered by tests and typecheck only.")
192//! @yah:gotcha("PRE-EXISTING RED GATE, not from this ticket: `cargo test -p xtask --test workload_envelope` fails on app/yah/web/chat/workload.toml and oss/mesofact/examples/hello/workload.toml, both `missing field routes`. That is R658-B1's known bug (routes written after [build], so TOML scopes it into build.routes) spread to two more files that were never added to KNOWN_GAPS. Both files are committed and untouched by this ticket. Deliberately NOT pinned into KNOWN_GAPS - silently widening the pin is what R658-B1 exists to stop; noted on that ticket instead.")
193//! @yah:gotcha("The reconciler unlinks the control socket path before spawning, because a leftover socket file makes bind fail with EADDRINUSE even with nobody listening. Consequence for anyone writing a test: a producer that binds BEFORE up() has its socket deleted out from under it. Bind after, which is the order a real child sees anyway.")
194//! @yah:gotcha("RESOLVED 2026-08-18 by R658-B1 (@Ashguard:libra): the red gate this ticket's gotcha flagged is now GREEN. app/yah/web/chat/workload.toml and oss/mesofact/examples/hello/workload.toml were migrated to put routes ABOVE [build], along with the other four files and the yah cloud site init scaffold. workload_spec::BuildConfig now carries serde(deny_unknown_fields) so the misplacement is a parse error naming routes. cargo test -p xtask --test workload_envelope passes. Nothing was pinned into KNOWN_GAPS - the four missing-field-routes entries were DELETED. No action needed here; this note exists so the gotcha is not read as still-true.")
195
196
197use std::collections::{BTreeMap, VecDeque};
198use std::net::{IpAddr, Ipv4Addr, SocketAddr};
199use std::path::PathBuf;
200use std::sync::Arc;
201use std::time::Duration;
202
203use anyhow::{bail, Context, Result};
204use async_trait::async_trait;
205use kamaji::native::NativeRuntime;
206use kamaji::{Kamaji, MeshAssignment, MeshIdent};
207use serde::Deserialize;
208use tokio::sync::{oneshot, Mutex as AsyncMutex};
209use tracing::{info, warn};
210use workload_spec::EnvVar;
211use workload_spec::MeshExpose;
212
213use super::native_support::{
214    capture_paths, native_spec, sanitize_ident, spawn_native_log_supervisor,
215};
216use super::{
217    into_running, wait_for_port, LogBuffer, PhaseCursor, ReconcileCtx, Reconciler, RunningWorkload,
218};
219use crate::proc_control::{self, ControlEndpoint, ReadyOutcome};
220use crate::{MirrorProviderSlot, MirrorShape, Provider};
221
222/// The slot role a natively-run compute component occupies on its mirror.
223const SLOT: &str = "compute";
224
225/// How long to wait for the process to bind its declared port before calling
226/// the reconcile failed. Generous because the first `cargo build` of a cold
227/// target dir is included in the caller's patience, not in this window — the
228/// build finishes before the spawn.
229const READY_TIMEOUT: Duration = Duration::from_secs(20);
230
231/// Readiness window for a component with no declared port: if the child is
232/// still alive this long after `deploy_workload` returned, call it up.
233///
234/// This is deliberately not a health check — a portless process has no
235/// surface to check. What it catches is the common failure the operator
236/// would otherwise see as a green row and an empty window: a binary that
237/// execs and dies immediately (missing dylib, bad argv, panic at startup).
238/// Anything that fails later shows up in the log tail, which is the only
239/// signal a portless process has.
240const ALIVE_GRACE: Duration = Duration::from_millis(750);
241
242/// True when `mirror` binds its compute slot to `local-process`. This is the
243/// dispatch predicate — callers use it to choose this reconciler over the
244/// container one for the same component kind.
245pub fn slot_declared(mirror: &crate::MirrorConfig) -> bool {
246    matches!(
247        mirror.providers.get(SLOT),
248        Some(MirrorProviderSlot::Inline {
249            kind: Provider::LocalProcess,
250            ..
251        })
252    )
253}
254
255/// `profile` from this mirror's `[providers.compute]` inline extra fields, if
256/// declared — the per-mirror override described on [`ProcessSpec::pre_build`]
257/// above `up()`. `None` for a `Reference` slot or an `Inline` slot with no
258/// `profile` key, both of which fall back to `workload.toml`'s own value.
259fn mirror_profile_override(mirror: &crate::MirrorConfig) -> Option<String> {
260    match mirror.providers.get(SLOT) {
261        Some(MirrorProviderSlot::Inline { fields, .. }) => fields
262            .get("profile")
263            .and_then(|v| v.as_str())
264            .map(str::to_string),
265        _ => None,
266    }
267}
268
269/// Reconciler for components bound to a `local-process` compute slot.
270#[derive(Debug, Default)]
271pub struct LocalProcessReconciler {
272    /// External sink to stream build+run output into, in place of the
273    /// private buffer `up()` would otherwise create. Lets a caller register
274    /// the buffer for polling BEFORE calling `up()`, so a slow `cargo build`
275    /// is visible to a poller while the reconcile is still in flight rather
276    /// than only after `up()` returns.
277    log_buf: Option<LogBuffer>,
278    /// Published the moment the build phase finishes (before readiness/spawn
279    /// even starts), so a caller polling an in-flight `up()` can render a
280    /// build→run transition live instead of only learning about it from the
281    /// finished [`RunningWorkload::build_log_end`].
282    build_log_end_sink: Option<PhaseCursor>,
283}
284
285impl LocalProcessReconciler {
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    /// Stream into a caller-owned [`LogBuffer`] instead of a private one.
291    pub fn with_log_buf(mut self, log_buf: LogBuffer) -> Self {
292        self.log_buf = Some(log_buf);
293        self
294    }
295
296    /// Publish the build→run boundary into a caller-owned [`PhaseCursor`] as
297    /// soon as it's known, rather than only once `up()` returns it.
298    pub fn with_build_log_end_sink(mut self, sink: PhaseCursor) -> Self {
299        self.build_log_end_sink = Some(sink);
300        self
301    }
302}
303
304/// On-disk `workload.toml` shape — only the `[process]` section is read here.
305/// Other sections (`[build]`, `[run]`) belong to the container reconciler and
306/// are ignored, so one component file can describe both tiers.
307#[derive(Debug, Default, Deserialize)]
308struct ProcessComponent {
309    #[serde(default)]
310    process: Option<ProcessSpec>,
311}
312
313#[derive(Debug, Deserialize)]
314struct ProcessSpec {
315    /// Argv of an operator-authored command to run before `cargo_package`'s
316    /// build (or standalone, when there is no `cargo_package` — e.g. a native
317    /// macOS/iOS component where the runnable artifact comes out of
318    /// `xcodebuild`, not `cargo build -p`, and Rust only supplies a static
319    /// lib for Xcode to link). Runs in `workspace_root`, streamed into
320    /// `log_buf` exactly like `cargo_build`, via the same [`run_streaming`].
321    /// Same trust model as `compose.rs`'s post-write shell commands (R592-T2:
322    /// operator-authored, not attacker input) — this is TOML the operator
323    /// wrote, not a request body.
324    #[serde(default)]
325    pre_build: Option<Vec<String>>,
326    /// Cargo package to `cargo build -p` before spawning. `None` → the binary
327    /// is expected to already exist.
328    #[serde(default)]
329    cargo_package: Option<String>,
330    /// Binary path relative to the workspace root (absolute taken as-is).
331    /// `None` → `target/<profile>/<cargo_package>`.
332    #[serde(default)]
333    bin: Option<String>,
334    /// Extra argv appended after the binary.
335    #[serde(default)]
336    args: Vec<String>,
337    /// Port the process listens on. Optional: when set it is both the
338    /// readiness signal and the `dev_url`; when absent the component is
339    /// portless (native GUI, unix socket, batch loop) and readiness falls
340    /// back to [`ALIVE_GRACE`].
341    #[serde(default)]
342    port: Option<u16>,
343    /// Environment for the child.
344    #[serde(default)]
345    env: BTreeMap<String, String>,
346    /// Cargo profile used for both the build and the default binary path.
347    #[serde(default = "default_profile")]
348    profile: String,
349    /// Opt in to the process-control channel ([`crate::proc_control`]) — the
350    /// house default for anything long-running. When present, the process's
351    /// own report decides readiness, which is strictly better than both other
352    /// signals: a bound port says nothing about whether the thing behind it
353    /// has finished booting, and a live pid says nothing at all.
354    #[serde(default)]
355    control: Option<ControlSpec>,
356}
357
358/// `[process.control]` — where to ask the process how it is doing.
359///
360/// Both fields optional and mutually exclusive in practice: give `http_path`
361/// for a process that already serves HTTP (it then shares an endpoint with
362/// the prod tier's healthcheck), otherwise the channel is a unix socket
363/// whose path this reconciler chooses and passes down as `$YAH_CONTROL_SOCK`.
364#[derive(Debug, Default, Deserialize)]
365struct ControlSpec {
366    /// Override the supervisor-chosen socket path. Rarely needed — a process
367    /// that reads `$YAH_CONTROL_SOCK` needs nothing here.
368    #[serde(default)]
369    socket: Option<String>,
370    /// Serve the status document over HTTP at this path instead, against the
371    /// component's declared `port`. Defaults to
372    /// [`proc_control::DEFAULT_HTTP_PATH`] when set to an empty string.
373    #[serde(default)]
374    http_path: Option<String>,
375}
376
377fn default_profile() -> String {
378    "debug".to_string()
379}
380
381#[async_trait]
382impl Reconciler for LocalProcessReconciler {
383    fn kind(&self) -> &'static str {
384        "local-process"
385    }
386
387    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
388        ctx.materialize().await?;
389
390        // A host process is the operator's own machine by definition. Refusing
391        // non-local shapes here keeps a `local-process` slot from silently
392        // meaning "run it on my laptop" in a mirror that describes a fleet.
393        if !matches!(ctx.mirror.shape, MirrorShape::Local) {
394            bail!(
395                "component {}: `local-process` is a dev-tier compute slot — mirror shape is \
396                 {:?}, not `local`. Deploy the prod tier via `yah cloud workload deploy`.",
397                ctx.component.id,
398                ctx.mirror.shape,
399            );
400        }
401
402        let mut spec = load_process_spec(&ctx)?;
403        // A mirror can override `profile` (and only `profile` — everything
404        // else about the process is one declaration shared by every tier)
405        // via `[providers.compute]`'s inline extra fields, same pattern the
406        // schema already documents for other inline provider kinds ("bucket,
407        // zone, dns, …"). Without this, two mirrors pointing at the same
408        // component's workload.toml (e.g. noisetable-desktop's dev + release
409        // tiers) would be unable to build different profiles from one file.
410        if let Some(profile) = mirror_profile_override(ctx.mirror) {
411            spec.profile = profile;
412        }
413
414        // Created here, before the build, rather than down where the
415        // supervisor is spawned: `cargo_build` streams into it live, and
416        // `build_log_end` below records the cursor where build output stops
417        // and the spawned process's own stdout/stderr begins. A caller-
418        // supplied buffer (`with_log_buf`) is reused as-is so it can already
419        // be registered for polling before this call started.
420        let log_buf = self.log_buf.clone().unwrap_or_default();
421
422        // Build first, if asked. Doing this before the port probe means a
423        // compile error surfaces as a compile error, rather than as a bind
424        // timeout on a binary that was never rebuilt. Streamed into
425        // `log_buf` as it runs (not buffered until exit) so a slow or
426        // failing build shows progress in the Run tab instead of going
427        // silent until it's done — and, unlike the old `.output()` capture,
428        // a *successful* build's output is no longer discarded outright.
429        if let Some(argv) = &spec.pre_build {
430            run_pre_build(ctx.workspace_root, argv, &log_buf).await?;
431        }
432
433        let mut build_log_end = None;
434        if let Some(pkg) = &spec.cargo_package {
435            cargo_build(ctx.workspace_root, pkg, &spec.profile, &log_buf).await?;
436            let cursor = log_buf.cursor().await;
437            build_log_end = Some(cursor);
438            if let Some(sink) = &self.build_log_end_sink {
439                sink.set(cursor).await;
440            }
441        }
442
443        let bin = resolve_binary(&spec, ctx.workspace_root)?;
444
445        // Kamaji's native backend captures stdio under this dir; scope it per
446        // workspace so concurrent camps don't collide on the same ident.
447        let state_dir = ctx.workspace_root.join(".yah/jit/native");
448        let ident_str = sanitize_ident(&format!(
449            "local-process-{}-{}-{}",
450            ctx.service.name, ctx.env, ctx.component.id
451        ));
452        let ident = MeshIdent(ident_str.clone());
453
454        // Replace any predecessor before spawning. `ContainerReconciler` has
455        // the same semantics ("run clears any prior container of the same name
456        // first"), and here it is load-bearing rather than tidy: NativeRuntime
457        // keeps its workload table **in memory**, so a second `mirror up` from
458        // a fresh process knows nothing about the first. Without this the new
459        // child dies on `Address already in use`, `wait_for_port` sees the
460        // *old* listener still answering, and the reconcile reports success
461        // while the binary the operator just edited is not the one running.
462        // That is the R602-B4 foreign-listener failure with a friendlier
463        // disguise, and on the dev tier it is the single most costly thing
464        // this reconciler could get wrong.
465        let owner_path = state_dir.join(&ident_str).join("owner.json");
466        reap_predecessor(&owner_path).await;
467
468        // Anything still holding the port now is not ours. Refuse rather than
469        // adopt it: a `dev_url` that answers with someone else's service is
470        // worse than a failed bring-up. A portless component has no address to
471        // contend over, so there is nothing to check.
472        let addr = spec
473            .port
474            .map(|port| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port));
475        if let Some(addr) = addr {
476            if tokio::net::TcpStream::connect(addr).await.is_ok() {
477                bail!(
478                    "component {}: {addr} is already held by a process this reconciler did not \
479                     start. Stop it, or give [process] a different `port`.",
480                    ctx.component.id,
481                );
482            }
483        }
484
485        let mut argv = vec![bin.display().to_string()];
486        argv.extend(spec.args.iter().cloned());
487
488        // Process-control channel. Resolved before the spawn because the
489        // socket variant has to reach the child in its environment — a
490        // conforming process binds `$YAH_CONTROL_SOCK` and does nothing when
491        // it is unset, so the same binary runs outside a camp untouched.
492        let control = control_endpoint(&spec, &state_dir, &ident_str);
493        if let Some(ControlEndpoint::Socket(path)) = &control {
494            // A socket file left by the predecessor makes `bind` fail with
495            // EADDRINUSE even though nothing is listening on it. Reaping the
496            // process does not remove it; unlinking here does.
497            if let Some(dir) = path.parent() {
498                tokio::fs::create_dir_all(dir).await.ok();
499            }
500            tokio::fs::remove_file(path).await.ok();
501        }
502
503        let mut env: Vec<EnvVar> = spec
504            .env
505            .iter()
506            .map(|(name, value)| EnvVar {
507                name: name.clone(),
508                value: workload_spec::EnvValue::Literal {
509                    value: value.clone(),
510                },
511            })
512            .collect();
513        if let Some(ControlEndpoint::Socket(path)) = &control {
514            // Declared `[process.env]` wins: an operator who set the variable
515            // by hand meant it.
516            if !spec.env.contains_key(proc_control::CONTROL_SOCK_ENV) {
517                env.push(EnvVar {
518                    name: proc_control::CONTROL_SOCK_ENV.to_string(),
519                    value: workload_spec::EnvValue::Literal {
520                        value: path.display().to_string(),
521                    },
522                });
523            }
524        }
525
526        let mut workload = native_spec(&ident_str, argv, env);
527        // A declared `[process] port` reaches the child as `PORT` / `PORT_HTTP`
528        // (R844-T13) by riding the spec, not by a per-caller string. Declared
529        // `[process.env]` still wins — the native backend layers spec env last.
530        // A portless component contributes nothing and gets neither variable.
531        workload.expose.mesh.ports = MeshExpose::anonymous_ports(spec.port);
532        let runtime = Arc::new(NativeRuntime::new(&state_dir));
533        let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
534
535        info!(
536            binary = %bin.display(),
537            port = ?spec.port,
538            ident = %ident_str,
539            "spawning local-process component (kamaji native backend)",
540        );
541
542        let deployed = runtime
543            .deploy_workload(&workload, &mesh)
544            .await
545            .with_context(|| {
546                format!(
547                    "deploying component {} via kamaji native backend (binary {})",
548                    ctx.component.id,
549                    bin.display(),
550                )
551            })?;
552
553        let (stdout_path, stderr_path) = capture_paths(&state_dir, &ident_str);
554
555        // Record ownership so the *next* reconcile — in a different process,
556        // with a different in-memory NativeRuntime — can find and reap this
557        // child instead of colliding with it.
558        write_owner(&owner_path, deployed.task_pid as i32, spec.port).await;
559
560        // Readiness, best signal first.
561        //
562        // 1. The control channel, when declared: the process says when it is
563        //    serving. Nothing else here can distinguish "bound" from "ready".
564        // 2. The port bind: a dead process must not hand the Run tab a URL
565        //    that will never answer.
566        // 3. Still alive after a grace window: all a portless process that
567        //    declines the channel can offer.
568        let mut notes: Vec<String> = Vec::new();
569
570        if let Some(endpoint) = &control {
571            match proc_control::wait_ready(endpoint, READY_TIMEOUT).await {
572                ReadyOutcome::Ready(status) => {
573                    info!(
574                        endpoint = %endpoint,
575                        status = %status.summary(),
576                        "local-process reported ready over its control channel",
577                    );
578                    notes.push(format!("control: {}", status.summary()));
579                }
580                ReadyOutcome::Terminal(status) => {
581                    let tail = read_capture_tail(&stdout_path, &stderr_path).await;
582                    runtime.teardown_workload(&ident).await.ok();
583                    bail!(
584                        "component {} reported `{}` on its control channel ({}){tail}",
585                        ctx.component.id,
586                        status.summary(),
587                        endpoint,
588                    );
589                }
590                ReadyOutcome::TimedOut { last } => {
591                    let tail = read_capture_tail(&stdout_path, &stderr_path).await;
592                    runtime.teardown_workload(&ident).await.ok();
593                    let seen = match last {
594                        Some(s) => format!("last reported `{}`", s.summary()),
595                        None => "never answered — is it binding $YAH_CONTROL_SOCK?".to_string(),
596                    };
597                    bail!(
598                        "component {} was not ready within {READY_TIMEOUT:?} on {endpoint} \
599                         ({seen}){tail}",
600                        ctx.component.id,
601                    );
602                }
603            }
604        }
605
606        let dev_url = match addr {
607            Some(addr) => {
608                // Already ready per the control channel? Then the bind has
609                // happened by definition, and re-waiting would only add
610                // latency to a process that already said it is serving.
611                if control.is_none() && !wait_for_port(addr, READY_TIMEOUT).await {
612                    warn!(addr = %addr, "local-process did not bind within timeout; tearing down");
613                    let tail = read_capture_tail(&stdout_path, &stderr_path).await;
614                    runtime.teardown_workload(&ident).await.ok();
615                    bail!(
616                        "component {} did not bind {addr} within {READY_TIMEOUT:?}{tail}",
617                        ctx.component.id,
618                    );
619                }
620                Some(format!("http://{addr}"))
621            }
622            None => {
623                if control.is_none() {
624                    tokio::time::sleep(ALIVE_GRACE).await;
625                    if !pid_alive(deployed.task_pid as i32) {
626                        let tail = read_capture_tail(&stdout_path, &stderr_path).await;
627                        runtime.teardown_workload(&ident).await.ok();
628                        bail!(
629                            "component {} (portless) exited within {ALIVE_GRACE:?} of \
630                             starting{tail}",
631                            ctx.component.id,
632                        );
633                    }
634                    notes.push(
635                        "portless with no [process.control] — readiness is liveness only; \
636                         see yah-cloud's proc_control module"
637                            .to_string(),
638                    );
639                }
640                None
641            }
642        };
643        info!(dev_url = ?dev_url, pid = deployed.task_pid, "local-process ready");
644
645        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
646        let supervisor = spawn_native_log_supervisor(
647            runtime,
648            ident,
649            log_buf.clone(),
650            stdout_path,
651            stderr_path,
652            shutdown_rx,
653        );
654
655        Ok(into_running(
656            "local-process",
657            SLOT,
658            dev_url,
659            None,
660            Some(log_buf),
661            shutdown_tx,
662            supervisor,
663        )
664        .with_notes(notes)
665        .with_control(control)
666        .with_build_log_end(build_log_end))
667    }
668}
669
670/// Resolve `[process.control]` into an endpoint to poll, or `None` when the
671/// component declines the channel.
672///
673/// HTTP needs a port to hang off — a component asking for `http_path` without
674/// a `port` has contradicted itself, and falling back to the socket silently
675/// would hide that. It is reported as no endpoint at all rather than guessed
676/// at; the caller then treats the process as unmonitored, which is the honest
677/// reading of a self-contradicting declaration.
678fn control_endpoint(
679    spec: &ProcessSpec,
680    state_dir: &std::path::Path,
681    ident: &str,
682) -> Option<ControlEndpoint> {
683    let control = spec.control.as_ref()?;
684    if let Some(path) = &control.http_path {
685        let port = spec.port?;
686        let path = if path.is_empty() {
687            proc_control::DEFAULT_HTTP_PATH
688        } else {
689            path
690        };
691        return Some(ControlEndpoint::Http(format!(
692            "http://127.0.0.1:{port}{path}"
693        )));
694    }
695    let sock = match &control.socket {
696        Some(s) => PathBuf::from(s),
697        None => state_dir.join(ident).join("control.sock"),
698    };
699    Some(ControlEndpoint::Socket(sock))
700}
701
702/// Read `<workload_dir>/workload.toml` and pull out its `[process]` section.
703fn load_process_spec(ctx: &ReconcileCtx<'_>) -> Result<ProcessSpec> {
704    let path = ctx.workload_dir().join("workload.toml");
705    let src =
706        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
707    let parsed: ProcessComponent =
708        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
709    parsed.process.with_context(|| {
710        format!(
711            "component {} is bound to a `local-process` compute slot but {} declares no \
712             [process] section — add one with at least `bin` or `cargo_package`",
713            ctx.component.id,
714            path.display(),
715        )
716    })
717}
718
719/// Resolve the binary to exec: explicit `bin`, else the cargo convention.
720fn resolve_binary(spec: &ProcessSpec, workspace_root: &std::path::Path) -> Result<PathBuf> {
721    let rel = match (&spec.bin, &spec.cargo_package) {
722        (Some(bin), _) => PathBuf::from(bin),
723        (None, Some(pkg)) => PathBuf::from(format!("target/{}/{pkg}", spec.profile)),
724        (None, None) => bail!(
725            "[process] declares neither `bin` nor `cargo_package` — one is needed to know \
726             what to run"
727        ),
728    };
729    let path = if rel.is_absolute() {
730        rel
731    } else {
732        workspace_root.join(rel)
733    };
734    if !path.exists() {
735        bail!(
736            "[process] binary {} does not exist — set `cargo_package` to have it built, or \
737             point `bin` at an existing file",
738            path.display(),
739        );
740    }
741    Ok(path)
742}
743
744/// Run `[process.pre_build]`'s argv to completion in `workspace_root`,
745/// streamed live into `log_buf` via the same [`run_streaming`] `cargo_build`
746/// uses. Runs before `cargo_package`'s build (if any) so a failing pre_build
747/// step surfaces as its own error rather than as a confusing downstream
748/// build/spawn failure.
749pub(super) async fn run_pre_build(
750    workspace_root: &std::path::Path,
751    argv: &[String],
752    log_buf: &LogBuffer,
753) -> Result<()> {
754    let (program, args) = argv
755        .split_first()
756        .context("[process.pre_build] argv must have at least one element")?;
757    let mut cmd = tokio::process::Command::new(program);
758    cmd.args(args);
759    cmd.current_dir(workspace_root);
760
761    info!(cmd = %argv.join(" "), "running [process.pre_build] for local-process component");
762    let (ok, stderr_tail) = run_streaming(cmd, log_buf)
763        .await
764        .with_context(|| format!("spawning pre_build command `{}`", argv.join(" ")))?;
765    if !ok {
766        bail!(
767            "[process.pre_build] `{}` failed:\n{}",
768            argv.join(" "),
769            stderr_tail.join("\n")
770        );
771    }
772    Ok(())
773}
774
775/// Lines of trailing stderr [`run_streaming`] keeps in memory to fold into a
776/// failure's error message. Compiler diagnostics are what an operator needs
777/// to act on a failed build; the full transcript is in `log_buf` regardless.
778const STDERR_TAIL_CAP: usize = 30;
779
780/// `cargo build -p <pkg>` in the workspace root, streamed live into `log_buf`
781/// (R715 follow-up — the desktop has no console to inherit stdout/stderr
782/// into, so this is the only way build progress reaches the Run tab, and
783/// unlike a buffer-then-discard capture it doesn't drop a *successful*
784/// build's output on the floor).
785async fn cargo_build(
786    workspace_root: &std::path::Path,
787    pkg: &str,
788    profile: &str,
789    log_buf: &LogBuffer,
790) -> Result<()> {
791    let mut cmd = tokio::process::Command::new("cargo");
792    cmd.arg("build").arg("-p").arg(pkg);
793    if profile == "release" {
794        cmd.arg("--release");
795    } else if profile != "debug" {
796        cmd.arg("--profile").arg(profile);
797    }
798    cmd.current_dir(workspace_root);
799
800    info!(
801        package = pkg,
802        profile, "cargo build for local-process component"
803    );
804    let (ok, stderr_tail) = run_streaming(cmd, log_buf)
805        .await
806        .with_context(|| format!("spawning `cargo build -p {pkg}`"))?;
807    if !ok {
808        bail!("cargo build -p {pkg} failed:\n{}", stderr_tail.join("\n"));
809    }
810    Ok(())
811}
812
813/// Run `cmd` to completion, streaming stdout and stderr into `log_buf`
814/// line-by-line as they're produced — not buffered until exit, so a slow
815/// command shows progress instead of going silent until it's done. Returns
816/// whether it exited successfully plus the last [`STDERR_TAIL_CAP`] stderr
817/// lines, which a caller can fold into its own error on failure without
818/// re-reading `log_buf` (a compiler's diagnostics land on stderr, so that's
819/// the stream worth quoting back).
820async fn run_streaming(mut cmd: tokio::process::Command, log_buf: &LogBuffer) -> Result<(bool, Vec<String>)> {
821    use tokio::io::{AsyncBufReadExt, BufReader};
822
823    cmd.stdout(std::process::Stdio::piped());
824    cmd.stderr(std::process::Stdio::piped());
825    let mut child = cmd.spawn().context("spawning command")?;
826    let stdout = child.stdout.take().expect("stdout piped above");
827    let stderr = child.stderr.take().expect("stderr piped above");
828
829    let stderr_tail: Arc<AsyncMutex<VecDeque<String>>> = Arc::new(AsyncMutex::new(VecDeque::new()));
830
831    let out_task = {
832        let log_buf = log_buf.clone();
833        tokio::spawn(async move {
834            let mut lines = BufReader::new(stdout).lines();
835            while let Ok(Some(line)) = lines.next_line().await {
836                log_buf.push(line).await;
837            }
838        })
839    };
840    let err_task = {
841        let log_buf = log_buf.clone();
842        let tail = stderr_tail.clone();
843        tokio::spawn(async move {
844            let mut lines = BufReader::new(stderr).lines();
845            while let Ok(Some(line)) = lines.next_line().await {
846                log_buf.push(line.clone()).await;
847                let mut t = tail.lock().await;
848                t.push_back(line);
849                if t.len() > STDERR_TAIL_CAP {
850                    t.pop_front();
851                }
852            }
853        })
854    };
855
856    let status = child
857        .wait()
858        .await
859        .context("waiting on spawned command")?;
860    // The drain tasks were spawned onto the runtime above, so they've been
861    // reading concurrently with `wait()` regardless of join order; awaiting
862    // them here just confirms both pipes hit EOF before returning the tail.
863    let _ = out_task.await;
864    let _ = err_task.await;
865
866    let tail = stderr_tail.lock().await.iter().cloned().collect();
867    Ok((status.success(), tail))
868}
869
870/// What a previous `up()` recorded about the child it left running.
871///
872/// This exists because [`NativeRuntime`]'s workload table is in-memory: a
873/// `yah cloud mirror up` from the shell and a ▶ click in the desktop are
874/// different processes, and neither can see the other's children. The sidecar
875/// is the only thing that makes "replace the predecessor" possible across them.
876#[derive(Debug, serde::Serialize, Deserialize)]
877struct OwnerRecord {
878    pid: i32,
879    /// The port the recorded child bound, when it declared one. `default` so a
880    /// sidecar written before portless components existed still parses — those
881    /// records always carry a port, and a missing one now means portless.
882    #[serde(default)]
883    port: Option<u16>,
884}
885
886/// Record the child we just spawned. Best-effort: failing to write the sidecar
887/// must not fail an otherwise-successful bring-up — the cost is a manual kill
888/// on the next re-run, not a broken mirror.
889async fn write_owner(path: &std::path::Path, pid: i32, port: Option<u16>) {
890    if let Some(dir) = path.parent() {
891        if tokio::fs::create_dir_all(dir).await.is_err() {
892            return;
893        }
894    }
895    if let Ok(json) = serde_json::to_vec(&OwnerRecord { pid, port }) {
896        if let Err(e) = tokio::fs::write(path, json).await {
897            warn!(path = %path.display(), error = %e, "could not record local-process owner");
898        }
899    }
900}
901
902/// Stop the child a previous `up()` left behind, if it is still alive.
903///
904/// SIGTERM, a short grace, then SIGKILL — the same ladder kamaji's own teardown
905/// uses. The sidecar is removed either way, including when the pid is already
906/// gone, so a stale record can't linger and make the next run think it has a
907/// predecessor to wait on.
908async fn reap_predecessor(owner_path: &std::path::Path) {
909    let Ok(bytes) = tokio::fs::read(owner_path).await else {
910        return;
911    };
912    let _ = tokio::fs::remove_file(owner_path).await;
913    let Ok(owner) = serde_json::from_slice::<OwnerRecord>(&bytes) else {
914        return;
915    };
916    if !pid_alive(owner.pid) {
917        return;
918    }
919
920    info!(
921        pid = owner.pid,
922        port = ?owner.port,
923        "replacing predecessor local-process"
924    );
925    signal_pid(owner.pid, libc::SIGTERM);
926    for _ in 0..50 {
927        if !pid_alive(owner.pid) {
928            return;
929        }
930        tokio::time::sleep(Duration::from_millis(100)).await;
931    }
932    warn!(
933        pid = owner.pid,
934        "predecessor ignored SIGTERM; sending SIGKILL"
935    );
936    signal_pid(owner.pid, libc::SIGKILL);
937    // Give the kernel a moment to release the port before we probe it.
938    tokio::time::sleep(Duration::from_millis(200)).await;
939}
940
941/// `kill(pid, 0)` — true when a process with this pid exists and we may signal
942/// it. Cannot prove the pid is still *our* child (pids are reused), which is
943/// why the sidecar is written next to this workload's capture dir and removed
944/// on every read: the window where a recycled pid could be signalled is one
945/// reconcile wide, and the alternative (never reaping) is a guaranteed
946/// collision rather than a theoretical one.
947fn pid_alive(pid: i32) -> bool {
948    // SAFETY: `kill` with signal 0 performs error checking only and never
949    // delivers a signal; any pid value is a defined input.
950    unsafe { libc::kill(pid, 0) == 0 }
951}
952
953fn signal_pid(pid: i32, sig: i32) {
954    // SAFETY: same contract as above — `kill` is defined for any pid/signal
955    // pair and reports failure through its return value, which we ignore
956    // because a vanished process is the outcome we wanted anyway.
957    unsafe {
958        libc::kill(pid, sig);
959    }
960}
961
962/// Last few lines of the capture files, formatted for an error message. A bind
963/// timeout with no output is nearly impossible to act on; the process almost
964/// always said why on the way down.
965async fn read_capture_tail(stdout_path: &std::path::Path, stderr_path: &std::path::Path) -> String {
966    let mut lines: Vec<String> = Vec::new();
967    for path in [stderr_path, stdout_path] {
968        if let Ok(s) = tokio::fs::read_to_string(path).await {
969            lines.extend(s.lines().rev().take(10).map(str::to_string));
970        }
971    }
972    if lines.is_empty() {
973        return String::new();
974    }
975    lines.reverse();
976    format!("\nlast output:\n{}", lines.join("\n"))
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    /// `run_streaming` interleaves stdout/stderr into `log_buf` as the child
984    /// produces them (not buffered until exit), reports success/failure by
985    /// exit status, and keeps a bounded stderr tail for the caller's error
986    /// message — the shape `cargo_build` builds on, exercised here without
987    /// depending on an actual `cargo` invocation.
988    #[tokio::test]
989    async fn run_streaming_captures_both_streams_and_reports_failure() {
990        let log_buf = LogBuffer::new();
991        let mut cmd = tokio::process::Command::new("sh");
992        cmd.arg("-c")
993            .arg("echo out-line; echo err-line >&2; exit 3");
994
995        let (ok, tail) = run_streaming(cmd, &log_buf).await.unwrap();
996        assert!(!ok, "exit 3 must be reported as failure");
997        assert_eq!(tail, vec!["err-line".to_string()]);
998
999        let (lines, _) = log_buf.since(0).await;
1000        assert!(lines.contains(&"out-line".to_string()), "{lines:?}");
1001        assert!(lines.contains(&"err-line".to_string()), "{lines:?}");
1002    }
1003
1004    /// The tail exists so a failure's error message doesn't have to re-read
1005    /// `log_buf` — but it must stay bounded, or a build that fails after
1006    /// paging through thousands of warnings dumps all of them into the error.
1007    #[tokio::test]
1008    async fn run_streaming_bounds_the_stderr_tail() {
1009        let log_buf = LogBuffer::new();
1010        let mut cmd = tokio::process::Command::new("sh");
1011        cmd.arg("-c").arg("for i in $(seq 1 50); do echo \"e$i\" >&2; done; exit 1");
1012
1013        let (ok, tail) = run_streaming(cmd, &log_buf).await.unwrap();
1014        assert!(!ok);
1015        assert_eq!(tail.len(), STDERR_TAIL_CAP);
1016        assert_eq!(tail.first(), Some(&"e21".to_string()));
1017        assert_eq!(tail.last(), Some(&"e50".to_string()));
1018
1019        // Every line still reached the log buffer, unbounded — only the
1020        // in-memory tail kept for the error message is capped.
1021        let (lines, _) = log_buf.since(0).await;
1022        assert_eq!(lines.len(), 50);
1023    }
1024
1025    fn spec(bin: Option<&str>, pkg: Option<&str>) -> ProcessSpec {
1026        ProcessSpec {
1027            pre_build: None,
1028            cargo_package: pkg.map(str::to_string),
1029            bin: bin.map(str::to_string),
1030            args: vec![],
1031            port: Some(4325),
1032            env: BTreeMap::new(),
1033            profile: "debug".to_string(),
1034            control: None,
1035        }
1036    }
1037
1038    #[test]
1039    fn parses_a_process_section_alongside_container_sections() {
1040        // One workload.toml describes both tiers; each reconciler reads its own
1041        // section and ignores the other's.
1042        let src = r#"
1043schema_version = 1
1044kind = "container"
1045
1046[build]
1047image = "yah-local/x:dev"
1048
1049[run]
1050port = 4325
1051
1052[process]
1053cargo_package = "yah-cloud-admin"
1054port = 4325
1055args = ["--verbose"]
1056
1057[process.env]
1058YAH_CLOUD_ADMIN_ADDR = "127.0.0.1:4325"
1059"#;
1060        let c: ProcessComponent = toml::from_str(src).unwrap();
1061        let p = c.process.unwrap();
1062        assert_eq!(p.cargo_package.as_deref(), Some("yah-cloud-admin"));
1063        assert_eq!(p.port, Some(4325));
1064        assert_eq!(p.args, vec!["--verbose".to_string()]);
1065        assert_eq!(p.profile, "debug");
1066        assert_eq!(
1067            p.env.get("YAH_CLOUD_ADMIN_ADDR").map(String::as_str),
1068            Some("127.0.0.1:4325")
1069        );
1070    }
1071
1072    /// R715-T2: the noisetable shape — a native GUI with no network surface.
1073    /// `port` must be *absent*, not zero: a zero would be a real port number
1074    /// to every layer downstream that reads one.
1075    #[test]
1076    fn a_process_section_without_a_port_is_portless() {
1077        let src = r#"
1078[process]
1079cargo_package = "dev"
1080profile = "release"
1081args = ["--window"]
1082"#;
1083        let p: ProcessComponent = toml::from_str(src).unwrap();
1084        let p = p.process.unwrap();
1085        assert_eq!(p.port, None, "an omitted port must stay absent");
1086        assert_eq!(p.cargo_package.as_deref(), Some("dev"));
1087        assert_eq!(p.profile, "release");
1088    }
1089
1090    /// The macOS/iOS shape: no `cargo_package` (there is no runnable
1091    /// top-level binary from a plain `cargo build -p pkg` — Rust only
1092    /// supplies a static lib for Xcode), the entire build is `pre_build`.
1093    #[test]
1094    fn parses_a_pre_build_argv() {
1095        let src = r#"
1096[process]
1097pre_build = ["./build-dist.sh", "arm64", "--sign"]
1098bin = "app/macos/dist/NoiseTable.app/Contents/MacOS/NoiseTable"
1099"#;
1100        let p: ProcessComponent = toml::from_str(src).unwrap();
1101        let p = p.process.unwrap();
1102        assert_eq!(
1103            p.pre_build,
1104            Some(vec![
1105                "./build-dist.sh".to_string(),
1106                "arm64".to_string(),
1107                "--sign".to_string(),
1108            ])
1109        );
1110        assert_eq!(p.cargo_package, None);
1111    }
1112
1113    /// The house default: a portless component opting into the control
1114    /// channel with an empty `[process.control]` and nothing else. The socket
1115    /// path is the supervisor's to choose — the process reads it from
1116    /// `$YAH_CONTROL_SOCK`.
1117    #[test]
1118    fn an_empty_control_section_yields_the_supervisor_chosen_socket() {
1119        let src = r#"
1120[process]
1121cargo_package = "dev"
1122
1123[process.control]
1124"#;
1125        let c: ProcessComponent = toml::from_str(src).unwrap();
1126        let spec = c.process.unwrap();
1127        let got = control_endpoint(&spec, std::path::Path::new("/s"), "svc-dev-app");
1128        assert_eq!(
1129            got,
1130            Some(ControlEndpoint::Socket(PathBuf::from(
1131                "/s/svc-dev-app/control.sock"
1132            ))),
1133        );
1134    }
1135
1136    #[test]
1137    fn no_control_section_means_no_channel() {
1138        assert_eq!(
1139            control_endpoint(&spec(Some("bin/x"), None), std::path::Path::new("/s"), "id"),
1140            None,
1141        );
1142    }
1143
1144    #[test]
1145    fn an_http_path_binds_the_channel_to_the_declared_port() {
1146        let src = r#"
1147[process]
1148cargo_package = "svc"
1149port = 4325
1150
1151[process.control]
1152http_path = "/_yah/status"
1153"#;
1154        let spec = toml::from_str::<ProcessComponent>(src).unwrap().process.unwrap();
1155        assert_eq!(
1156            control_endpoint(&spec, std::path::Path::new("/s"), "id"),
1157            Some(ControlEndpoint::Http(
1158                "http://127.0.0.1:4325/_yah/status".into()
1159            )),
1160        );
1161    }
1162
1163    /// An empty `http_path` means "the conventional one" rather than a URL
1164    /// ending in the port — the shape most likely to be written by hand.
1165    #[test]
1166    fn an_empty_http_path_falls_back_to_the_conventional_one() {
1167        let src = r#"
1168[process]
1169cargo_package = "svc"
1170port = 4325
1171
1172[process.control]
1173http_path = ""
1174"#;
1175        let spec = toml::from_str::<ProcessComponent>(src).unwrap().process.unwrap();
1176        assert_eq!(
1177            control_endpoint(&spec, std::path::Path::new("/s"), "id"),
1178            Some(ControlEndpoint::Http(format!(
1179                "http://127.0.0.1:4325{}",
1180                crate::proc_control::DEFAULT_HTTP_PATH
1181            ))),
1182        );
1183    }
1184
1185    /// `http_path` with no port is a contradiction. Silently falling back to a
1186    /// socket would give the component a channel it never agreed to serve, and
1187    /// then fail readiness twenty seconds later with a message about a socket
1188    /// nobody mentioned.
1189    #[test]
1190    fn an_http_path_without_a_port_yields_no_endpoint() {
1191        let src = r#"
1192[process]
1193cargo_package = "svc"
1194
1195[process.control]
1196http_path = "/_yah/status"
1197"#;
1198        let spec = toml::from_str::<ProcessComponent>(src).unwrap().process.unwrap();
1199        assert_eq!(control_endpoint(&spec, std::path::Path::new("/s"), "id"), None);
1200    }
1201
1202    #[test]
1203    fn an_explicit_socket_path_overrides_the_default() {
1204        let src = r#"
1205[process]
1206cargo_package = "svc"
1207
1208[process.control]
1209socket = "/tmp/mine.sock"
1210"#;
1211        let spec = toml::from_str::<ProcessComponent>(src).unwrap().process.unwrap();
1212        assert_eq!(
1213            control_endpoint(&spec, std::path::Path::new("/s"), "id"),
1214            Some(ControlEndpoint::Socket(PathBuf::from("/tmp/mine.sock"))),
1215        );
1216    }
1217
1218    /// The sidecar gained an optional `port` when portless components landed.
1219    /// Records written before that carry a bare `u16` and must still parse —
1220    /// otherwise the first reconcile after an upgrade silently fails to reap
1221    /// its predecessor, which is the exact collision the sidecar exists for.
1222    #[test]
1223    fn an_owner_record_written_before_portless_components_still_parses() {
1224        let old: OwnerRecord = serde_json::from_str(r#"{"pid":54869,"port":4325}"#).unwrap();
1225        assert_eq!(old.pid, 54869);
1226        assert_eq!(old.port, Some(4325));
1227
1228        let portless: OwnerRecord = serde_json::from_str(r#"{"pid":71455}"#).unwrap();
1229        assert_eq!(portless.port, None);
1230    }
1231
1232    #[test]
1233    fn a_workload_without_a_process_section_parses_as_none() {
1234        let c: ProcessComponent = toml::from_str("[run]\nport = 1\n").unwrap();
1235        assert!(c.process.is_none());
1236    }
1237
1238    #[test]
1239    fn binary_defaults_to_the_cargo_convention() {
1240        let tmp = tempfile::tempdir().unwrap();
1241        std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1242        std::fs::write(tmp.path().join("target/debug/yah-cloud-admin"), b"").unwrap();
1243        let got = resolve_binary(&spec(None, Some("yah-cloud-admin")), tmp.path()).unwrap();
1244        assert_eq!(got, tmp.path().join("target/debug/yah-cloud-admin"));
1245    }
1246
1247    #[test]
1248    fn explicit_bin_wins_over_the_cargo_convention() {
1249        let tmp = tempfile::tempdir().unwrap();
1250        std::fs::create_dir_all(tmp.path().join("bin")).unwrap();
1251        std::fs::write(tmp.path().join("bin/custom"), b"").unwrap();
1252        let got = resolve_binary(&spec(Some("bin/custom"), Some("pkg")), tmp.path()).unwrap();
1253        assert_eq!(got, tmp.path().join("bin/custom"));
1254    }
1255
1256    /// Spawning a path that isn't there fails with an exec error several
1257    /// layers down; naming the missing file here is the actionable version.
1258    #[test]
1259    fn a_missing_binary_is_named_before_we_try_to_exec_it() {
1260        let tmp = tempfile::tempdir().unwrap();
1261        let err = resolve_binary(&spec(None, Some("nope")), tmp.path()).unwrap_err();
1262        assert!(err.to_string().contains("does not exist"), "{err}");
1263    }
1264
1265    #[test]
1266    fn neither_bin_nor_package_is_an_error() {
1267        let tmp = tempfile::tempdir().unwrap();
1268        let err = resolve_binary(&spec(None, None), tmp.path()).unwrap_err();
1269        assert!(err.to_string().contains("neither"), "{err}");
1270    }
1271
1272    /// The bug this whole path exists to prevent: a second `up()` in a fresh
1273    /// process must stop the first one's child. NativeRuntime's table is
1274    /// in-memory, so the sidecar is the only link between the two runs.
1275    ///
1276    /// The assertion is on the child's exit status, not on [`pid_alive`],
1277    /// because this test *is* the child's parent: a signalled child it has not
1278    /// waited on stays a zombie, and `kill(pid, 0)` succeeds against a zombie.
1279    /// In production the spawning process is either gone (CLI — init reaps) or
1280    /// still holding kamaji's supervisor task (desktop — that reaps), so
1281    /// neither leaves one behind.
1282    #[tokio::test]
1283    async fn reap_stops_a_live_predecessor_and_clears_the_record() {
1284        let tmp = tempfile::tempdir().unwrap();
1285        let owner_path = tmp.path().join("owner.json");
1286
1287        let mut child = tokio::process::Command::new("sleep")
1288            .arg("120")
1289            .spawn()
1290            .unwrap();
1291        let pid = child.id().unwrap() as i32;
1292        assert!(pid_alive(pid));
1293
1294        write_owner(&owner_path, pid, Some(4325)).await;
1295        reap_predecessor(&owner_path).await;
1296
1297        let status = tokio::time::timeout(Duration::from_secs(5), child.wait())
1298            .await
1299            .expect("a signalled `sleep 120` must have exited well inside 5s")
1300            .unwrap();
1301        assert!(!status.success(), "predecessor should have been signalled");
1302        assert!(!owner_path.exists(), "sidecar should be cleared");
1303    }
1304
1305    /// A record left behind by a crash names a pid that is gone. Reaping it
1306    /// must be a no-op that still clears the file — a stale record that
1307    /// survives would make every later run wait on a corpse.
1308    #[tokio::test]
1309    async fn reap_clears_a_stale_record_without_signalling_anything() {
1310        let tmp = tempfile::tempdir().unwrap();
1311        let owner_path = tmp.path().join("owner.json");
1312
1313        // Exited process: spawn and wait, so the pid is definitely dead.
1314        let mut child = tokio::process::Command::new("true").spawn().unwrap();
1315        let pid = child.id().unwrap() as i32;
1316        child.wait().await.unwrap();
1317
1318        write_owner(&owner_path, pid, Some(4325)).await;
1319        reap_predecessor(&owner_path).await;
1320        assert!(!owner_path.exists());
1321    }
1322
1323    #[tokio::test]
1324    async fn reap_with_no_record_is_a_no_op() {
1325        let tmp = tempfile::tempdir().unwrap();
1326        reap_predecessor(&tmp.path().join("owner.json")).await;
1327    }
1328
1329    // ── End-to-end: a portless component through the real reconciler ────────
1330    //
1331    // The unit tests above pin parsing and endpoint resolution. This one runs
1332    // `up()` itself — kamaji native backend, real child process, real sidecar
1333    // — because the claim R715-T2 makes is not "the struct has an Option", it
1334    // is "a component with no port comes up and reports no dev_url", and only
1335    // the whole path can say that.
1336
1337    fn portless_service() -> crate::ServiceConfig {
1338        crate::ServiceConfig {
1339            schema_version: 1,
1340            name: "noisy".into(),
1341            address: crate::config::ServiceAddress::front_door("noisy.example"),
1342            description: None,
1343            components: vec![crate::ServiceComponent {
1344                mount: None,
1345                id: "gui".into(),
1346                kind: "container".into(),
1347                path: "gui".into(),
1348                role: "compute".into(),
1349                publishes: None,
1350                wave: 0,
1351                git: None,
1352                deploy: Default::default(),
1353            }],
1354            db: crate::DbCatalog::default(),
1355        }
1356    }
1357
1358    fn local_process_mirror() -> crate::MirrorConfig {
1359        let mut providers = BTreeMap::new();
1360        providers.insert(
1361            SLOT.to_string(),
1362            MirrorProviderSlot::Inline {
1363                kind: Provider::LocalProcess,
1364                fields: Default::default(),
1365            },
1366        );
1367        crate::MirrorConfig {
1368            schema_version: 1,
1369            shape: MirrorShape::Local,
1370            providers,
1371            ingress: Default::default(),
1372            ingress_machines: Vec::new(),
1373            drivers: Default::default(),
1374            asset_aliases: Default::default(),
1375            build: Default::default(),
1376        }
1377    }
1378
1379    /// The noisetable shape end to end: no `port`, no control channel. Comes
1380    /// up, reports no `dev_url`, and says in its notes that readiness here is
1381    /// liveness only — so nobody reads a green row as a health claim.
1382    #[tokio::test]
1383    async fn a_portless_component_comes_up_with_no_dev_url() {
1384        let ws = tempfile::tempdir().unwrap();
1385        std::fs::create_dir_all(ws.path().join("gui")).unwrap();
1386        std::fs::write(
1387            ws.path().join("gui/workload.toml"),
1388            "schema_version = 1\nkind = \"container\"\n\n[process]\nbin = \"/bin/sleep\"\nargs = [\"30\"]\n",
1389        )
1390        .unwrap();
1391
1392        let svc = portless_service();
1393        let mirror = local_process_mirror();
1394        let ctx = ReconcileCtx {
1395            workspace_root: ws.path(),
1396            service: &svc,
1397            component: &svc.components[0],
1398            mirror: &mirror,
1399            env: "dev",
1400            scope: crate::ProviderScope::singleton(),
1401        };
1402
1403        let mut running = LocalProcessReconciler::new().up(ctx).await.unwrap();
1404        assert_eq!(running.dev_url, None, "a portless component has no URL");
1405        assert_eq!(running.slot, SLOT);
1406        assert_eq!(
1407            running.build_log_end, None,
1408            "no cargo_package was declared, so nothing was built"
1409        );
1410        assert!(
1411            running.notes.iter().any(|n| n.contains("portless")),
1412            "the liveness-only caveat must be visible to an operator: {:?}",
1413            running.notes,
1414        );
1415
1416        // The sidecar is what lets a later reconcile in another process reap
1417        // this child. Portless or not, it must be written — and its `port`
1418        // must be absent rather than a placeholder zero.
1419        let owner: OwnerRecord = serde_json::from_slice(
1420            &std::fs::read(
1421                ws.path()
1422                    .join(".yah/jit/native")
1423                    .join(sanitize_ident("local-process-noisy-dev-gui"))
1424                    .join("owner.json"),
1425            )
1426            .expect("owner sidecar must exist"),
1427        )
1428        .unwrap();
1429        assert_eq!(owner.port, None);
1430        assert!(pid_alive(owner.pid), "the child should still be running");
1431
1432        if let Some(tx) = running.shutdown.take() {
1433            tx.send(()).ok();
1434        }
1435        if let Some(sup) = running.supervisor.take() {
1436            let _ = tokio::time::timeout(Duration::from_secs(5), sup).await;
1437        }
1438    }
1439
1440    /// The house default, end to end: a portless component that declares
1441    /// `[process.control]` is held at "not ready" until something answers
1442    /// `status` with `running`.
1443    ///
1444    /// The child here is `/bin/sleep` and the socket is served by the test,
1445    /// which is the point — the reconciler must not care *who* answers, only
1446    /// that the declared endpoint does. A real workload binds the same path
1447    /// out of `$YAH_CONTROL_SOCK`.
1448    #[tokio::test]
1449    async fn a_control_channel_decides_readiness_when_declared() {
1450        let ws = tempfile::tempdir().unwrap();
1451        let sock = ws.path().join("control.sock");
1452        std::fs::create_dir_all(ws.path().join("gui")).unwrap();
1453        std::fs::write(
1454            ws.path().join("gui/workload.toml"),
1455            format!(
1456                "schema_version = 1\nkind = \"container\"\n\n[process]\nbin = \"/bin/sleep\"\n\
1457                 args = [\"30\"]\n\n[process.control]\nsocket = \"{}\"\n",
1458                sock.display()
1459            ),
1460        )
1461        .unwrap();
1462
1463        // Stand-in producer: answers `starting` once, then `running`.
1464        //
1465        // It binds *after* a delay on purpose. `up()` unlinks a predecessor's
1466        // socket file before spawning (a leftover file makes `bind` fail with
1467        // EADDRINUSE even with nobody listening), so a producer that bound
1468        // first would have its socket deleted out from under it — which is
1469        // exactly the order a real child sees: supervisor unlinks, child
1470        // starts, child binds.
1471        let server = {
1472            let sock = sock.clone();
1473            tokio::spawn(async move {
1474                tokio::time::sleep(Duration::from_millis(400)).await;
1475                let listener = tokio::net::UnixListener::bind(&sock).unwrap();
1476                for doc in [r#"{"state":"starting","detail":"loading"}"#, r#"{"state":"running","detail":"1 window"}"#] {
1477                    let Ok((stream, _)) = listener.accept().await else {
1478                        return;
1479                    };
1480                    let (rd, mut wr) = stream.into_split();
1481                    let mut line = String::new();
1482                    tokio::io::AsyncBufReadExt::read_line(
1483                        &mut tokio::io::BufReader::new(rd),
1484                        &mut line,
1485                    )
1486                    .await
1487                    .ok();
1488                    tokio::io::AsyncWriteExt::write_all(&mut wr, format!("{doc}\n").as_bytes())
1489                        .await
1490                        .ok();
1491                }
1492            })
1493        };
1494
1495        let svc = portless_service();
1496        let mirror = local_process_mirror();
1497        let ctx = ReconcileCtx {
1498            workspace_root: ws.path(),
1499            service: &svc,
1500            component: &svc.components[0],
1501            mirror: &mirror,
1502            env: "dev",
1503            scope: crate::ProviderScope::singleton(),
1504        };
1505
1506        let mut running = LocalProcessReconciler::new().up(ctx).await.unwrap();
1507        assert_eq!(running.dev_url, None);
1508        assert!(
1509            running
1510                .notes
1511                .iter()
1512                .any(|n| n.contains("control:") && n.contains("1 window")),
1513            "the process's own words should reach the operator: {:?}",
1514            running.notes,
1515        );
1516        assert!(
1517            !running.notes.iter().any(|n| n.contains("liveness only")),
1518            "a component WITH a channel must not be labelled liveness-only: {:?}",
1519            running.notes,
1520        );
1521
1522        server.abort();
1523        if let Some(tx) = running.shutdown.take() {
1524            tx.send(()).ok();
1525        }
1526        if let Some(sup) = running.supervisor.take() {
1527            let _ = tokio::time::timeout(Duration::from_secs(5), sup).await;
1528        }
1529    }
1530
1531    /// A portless component that dies on exec must fail the reconcile, not
1532    /// register a healthy row over a corpse. `/usr/bin/false` is the smallest
1533    /// honest stand-in for "binary exits immediately".
1534    #[tokio::test]
1535    async fn a_portless_component_that_exits_immediately_fails_the_reconcile() {
1536        let ws = tempfile::tempdir().unwrap();
1537        std::fs::create_dir_all(ws.path().join("gui")).unwrap();
1538        std::fs::write(
1539            ws.path().join("gui/workload.toml"),
1540            "schema_version = 1\nkind = \"container\"\n\n[process]\nbin = \"/usr/bin/false\"\n",
1541        )
1542        .unwrap();
1543
1544        let svc = portless_service();
1545        let mirror = local_process_mirror();
1546        let ctx = ReconcileCtx {
1547            workspace_root: ws.path(),
1548            service: &svc,
1549            component: &svc.components[0],
1550            mirror: &mirror,
1551            env: "dev",
1552            scope: crate::ProviderScope::singleton(),
1553        };
1554
1555        let err = LocalProcessReconciler::new()
1556            .up(ctx)
1557            .await
1558            .expect_err("a process that exits immediately is not a successful bring-up");
1559        assert!(
1560            err.to_string().contains("portless"),
1561            "the error should name why it was judged this way: {err}"
1562        );
1563    }
1564
1565    /// A failing `[process.pre_build]` must fail the reconcile before ever
1566    /// touching `cargo_package`/spawn — a broken xcodebuild/codesign step
1567    /// should surface as its own error, not as a confusing "binary not
1568    /// found" from a later stage that never should have run.
1569    #[tokio::test]
1570    async fn a_failing_pre_build_fails_the_reconcile_before_spawn() {
1571        let ws = tempfile::tempdir().unwrap();
1572        std::fs::create_dir_all(ws.path().join("gui")).unwrap();
1573        std::fs::write(
1574            ws.path().join("gui/workload.toml"),
1575            "schema_version = 1\nkind = \"container\"\n\n[process]\npre_build = [\"sh\", \"-c\", \"exit 1\"]\nbin = \"/bin/sleep\"\nargs = [\"30\"]\n",
1576        )
1577        .unwrap();
1578
1579        let svc = portless_service();
1580        let mirror = local_process_mirror();
1581        let ctx = ReconcileCtx {
1582            workspace_root: ws.path(),
1583            service: &svc,
1584            component: &svc.components[0],
1585            mirror: &mirror,
1586            env: "dev",
1587            scope: crate::ProviderScope::singleton(),
1588        };
1589
1590        let err = LocalProcessReconciler::new()
1591            .up(ctx)
1592            .await
1593            .expect_err("a failing pre_build must not be treated as a successful bring-up");
1594        assert!(
1595            err.to_string().contains("pre_build"),
1596            "the error should name the stage that failed: {err}"
1597        );
1598    }
1599
1600    #[test]
1601    fn mirror_profile_override_reads_the_inline_extra_field() {
1602        let mut mirror = local_process_mirror();
1603        if let Some(MirrorProviderSlot::Inline { fields, .. }) = mirror.providers.get_mut(SLOT) {
1604            fields.insert("profile".to_string(), toml::Value::String("release".to_string()));
1605        }
1606        assert_eq!(
1607            mirror_profile_override(&mirror),
1608            Some("release".to_string())
1609        );
1610    }
1611
1612    #[test]
1613    fn mirror_profile_override_is_absent_by_default() {
1614        // No `profile` key declared on the mirror's compute slot — falls back
1615        // to whatever workload.toml itself declares.
1616        assert_eq!(mirror_profile_override(&local_process_mirror()), None);
1617    }
1618
1619    #[test]
1620    fn slot_declared_only_matches_the_local_process_compute_slot() {
1621        let mut m = crate::MirrorConfig {
1622            schema_version: 1,
1623            shape: MirrorShape::Local,
1624            ingress: Default::default(),
1625            ingress_machines: Vec::new(),
1626            providers: Default::default(),
1627            drivers: Default::default(),
1628            asset_aliases: Default::default(),
1629            build: Default::default(),
1630        };
1631        assert!(!slot_declared(&m));
1632        m.providers.insert(
1633            SLOT.to_string(),
1634            MirrorProviderSlot::Inline {
1635                kind: Provider::LocalContainer,
1636                fields: Default::default(),
1637            },
1638        );
1639        assert!(!slot_declared(&m), "a container slot is not a process slot");
1640        m.providers.insert(
1641            SLOT.to_string(),
1642            MirrorProviderSlot::Inline {
1643                kind: Provider::LocalProcess,
1644                fields: Default::default(),
1645            },
1646        );
1647        assert!(slot_declared(&m));
1648    }
1649}