# PID-1 Boot Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Boot hh-prototest with zyginit as PID 1, replacing SMF's boot chain with 21 zyginit-managed services, with clean shutdown orchestration and safe BE-based rollback. **Architecture:** Two code phases (zyginit/zygctl changes for shutdown orchestration + uadmin FFI; zygctl subcommand additions), one content phase (21 TOMLs + method scripts in `services/hammerhead/`), one deployment phase (BE-based install on hh-prototest), one validation phase (Phase A dry run + Phase B PID-1 boot + rollback drill). **Tech Stack:** Reef 0.5.9, reefc, libcontract (Hammerhead), Mercurial, ZFS Boot Environments, libvirt/KVM, zsh method scripts, TOML service definitions. **Spec:** `docs/superpowers/specs/2026-04-24-pid1-boot-design.md` --- ## File Structure **Code to create:** - `src/shutdown.reef` — new module: `uadmin`-based shutdown primitives, `AD_*` constants, PID-1 detection - `services/hammerhead/` — staging tree for production Hammerhead service definitions (distinct from `services/examples/` which is Linux learning scaffolding) - `services/hammerhead/.toml` — 21 TOML files - `services/hammerhead//start.sh` and `/stop.sh` — method scripts for non-trivial services - `services/hammerhead/enabled.d/` → `../.toml` — 21 symlinks - `scripts/install-to-be.sh` — dev-host utility: installs zyginit binaries + config tree into a mounted BE at a given path - `tests/integration/test_shutdown.sh` — new integration test focused on shutdown orchestration **Code to modify:** - `src/main.reef` — PID-1 detection, shutdown-type tracking, call `sync + zyginit_shutdown()` at end of PID-1 shutdown - `src/supervisor.reef` — extend shutdown path to walk tiers in reverse and invoke `stop_cmd` for oneshots that declared one - `src/socket.reef` — parse halt/reboot/poweroff/single/multiuser commands, set shutdown flags - `src/helpers.c` — add `zyginit_shutdown(int fcn)` wrapper around `uadmin(2)` - `src/contract_linux_stubs.c` — add stub `zyginit_shutdown` returning -1 - `tools/zygctl/src/main.reef` — new subcommands: halt, reboot, poweroff, single (stub), multiuser (stub) - `tests/integration/run_tests.sh` — source or call the new shutdown-orchestration tests - `reef.toml` — build command comments updated if helpers.c surface grows (likely no change) Each phase's tasks assume earlier phases are complete and committed. --- ## Phase 1 — Core zyginit code changes ### Task 1: Add `zyginit_shutdown()` C wrapper around uadmin(2) **Files:** - Modify: `src/helpers.c` - Modify: `src/contract_linux_stubs.c` - [ ] **Step 1: Add uadmin include and wrapper to `src/helpers.c`** Append at the bottom of `src/helpers.c` (after the `zyginit_drop_privileges` function): ```c /* Shutdown wrapper — uadmin(A_SHUTDOWN, fcn, 0). On Hammerhead this is the * primitive init uses to halt, reboot, or poweroff the system. fcn is one of: * AD_HALT (0) — stop scheduler, wait for operator * AD_BOOT (1) — reboot * AD_POWEROFF (6) — ACPI/BMC chassis off * Returns 0 on success, -1 on error (errno set). On success, does not return * for AD_BOOT / AD_POWEROFF; for AD_HALT the kernel enters a halted state. */ #include int zyginit_shutdown(int fcn) { return uadmin(A_SHUTDOWN, fcn, 0); } ``` - [ ] **Step 2: Add Linux stub to `src/contract_linux_stubs.c`** Append at the bottom of `src/contract_linux_stubs.c`: ```c /* Shutdown stub — on Linux we never actually uadmin; return -1 so the * calling code path is exercised without side effects. */ int zyginit_shutdown(int fcn) { return -1; } ``` - [ ] **Step 3: Rebuild on Linux to verify it compiles** Run: ```bash cd /home/ctusa/repos/zyginit clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o ``` Expected: both compile with no errors. If clang errors on `` on Linux, that's expected for the `helpers.c` side — the header isn't on Linux. In that case, guard with `#ifdef __sun`: ```c #ifdef __sun #include int zyginit_shutdown(int fcn) { return uadmin(A_SHUTDOWN, fcn, 0); } #else int zyginit_shutdown(int fcn) { (void)fcn; return -1; } #endif ``` If that's needed, remove the stub from `contract_linux_stubs.c` (it'd be a duplicate symbol). - [ ] **Step 4: Verify on Hammerhead (ssh to hh-prototest)** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 \ 'grep -E "A_SHUTDOWN|AD_HALT|AD_BOOT|AD_POWEROFF" /usr/include/sys/uadmin.h' ``` Expected output should confirm the constant values: ``` #define A_SHUTDOWN 2 #define AD_HALT 0 #define AD_BOOT 1 #define AD_POWEROFF 6 ``` (A_SHUTDOWN = 2 on illumos, not 1 as might be assumed from older references. The code uses the symbolic constant so this value never appears in Reef/C source; the header does the resolution at compile time.) - [ ] **Step 5: Commit** ```bash hg commit -m "shutdown FFI: add zyginit_shutdown() wrapping uadmin(A_SHUTDOWN, fcn, 0) Adds the C wrapper used by zyginit to halt, reboot, or poweroff the system once all services are stopped. Linux gets a stub returning -1. No Reef-side caller yet — wiring comes in later tasks." ``` --- ### Task 2: Create `src/shutdown.reef` with FFI extern and AD_* constants **Files:** - Create: `src/shutdown.reef` - [ ] **Step 1: Write the new module** Write to `src/shutdown.reef`: ```reef /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: shutdown.reef Authors: Chris Tusa License: Description: PID-1 shutdown primitives — uadmin(2) FFI and shutdown-type constants. ******************************************************************************/ module shutdown export // Shutdown function codes (sys/uadmin.h A_SHUTDOWN fcn values) fn AD_HALT(): int fn AD_BOOT(): int fn AD_POWEROFF(): int // Symbolic shutdown-type codes used internally by zyginit. // These are NOT the uadmin AD_* codes — they're zyginit's own // enum for distinguishing what a shutdown was triggered as, // before it's mapped to AD_* at the actual uadmin call site. fn SHUT_NONE(): int fn SHUT_HALT(): int fn SHUT_REBOOT(): int fn SHUT_POWEROFF(): int // Map our internal type to the uadmin fcn code. fn to_ad_code(shut_type: int): int // Call uadmin(A_SHUTDOWN, fcn, 0). Returns 0 on success, -1 on error. // Only succeeds when running as PID 1. fn do_shutdown(fcn: int): int end export extern "C" fn zyginit_shutdown(fcn: int): int fn AD_HALT(): int return 0 end AD_HALT fn AD_BOOT(): int return 1 end AD_BOOT fn AD_POWEROFF(): int return 6 end AD_POWEROFF fn SHUT_NONE(): int return 0 end SHUT_NONE fn SHUT_HALT(): int return 1 end SHUT_HALT fn SHUT_REBOOT(): int return 2 end SHUT_REBOOT fn SHUT_POWEROFF(): int return 3 end SHUT_POWEROFF fn to_ad_code(shut_type: int): int if shut_type == SHUT_HALT() return AD_HALT() elif shut_type == SHUT_REBOOT() return AD_BOOT() elif shut_type == SHUT_POWEROFF() return AD_POWEROFF() end if // Default to halt for unknown / NONE return AD_HALT() end to_ad_code fn do_shutdown(fcn: int): int return zyginit_shutdown(fcn) end do_shutdown end module ``` - [ ] **Step 2: Build on Linux to verify module compiles** ```bash cd /home/ctusa/repos/zyginit reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: build completes. If build errors about shutdown.reef not being found, ensure it's under `src/` which is in `source_dirs` in `reef.toml`. Note: main.reef / supervisor.reef / etc. don't import `shutdown` yet, so this only validates the module compiles standalone. (Dead-code elimination may not compile it at all — that's OK for this step; we'll exercise it in later tasks.) - [ ] **Step 3: Commit** ```bash hg commit -m "src/shutdown.reef: new module with uadmin FFI + shutdown-type enum Adds AD_HALT/AD_BOOT/AD_POWEROFF constants matching sys/uadmin.h, a separate SHUT_* internal enum for tracking what kind of shutdown was requested, and a do_shutdown() entry point that calls through the zyginit_shutdown() C wrapper. No callers yet." ``` --- ### Task 3: Add PID-1 detection helper **Files:** - Modify: `src/main.reef` - [ ] **Step 1: Add `is_pid_1()` helper near the top of main.reef** Open `src/main.reef`. After the `MAX_SERVICES()` function (around line 50), add: ```reef // Returns true when running as PID 1 (i.e., actual init). When not PID 1, // the integration-test path: SIGTERM → stop services → normal exit // (no uadmin call). When PID 1, shutdown must call uadmin or the kernel // panics. fn is_pid_1(): bool return process.getpid() == 1 end is_pid_1 ``` - [ ] **Step 2: Add startup banner line so PID-1 mode is visible in logs** Find the startup block in `main()` (search for `"zyginit v"`). Add a line right after the version banner: ```reef println("zyginit v" + VERSION() + " starting") if is_pid_1() println("zyginit: running as PID 1 (init mode)") else println("zyginit: running as PID " + int_to_str(process.getpid()) + " (non-init mode)") end if ``` Remove any duplicate "running as PID ..." prints if they exist. - [ ] **Step 3: Rebuild and run integration tests (should still be 57/57)** ```bash cd /home/ctusa/repos/zyginit rm -f build/zyginit clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/run_tests.sh 2>&1 | tail -5 ``` Expected: `Results: 57 passed, 0 failed (of 57)`. The test harness runs zyginit as a regular user process, so `is_pid_1()` returns false and behavior is unchanged. - [ ] **Step 4: Commit** ```bash hg commit -m "main.reef: add is_pid_1() helper and startup banner Sets up the PID-1 detection used by later tasks to decide whether shutdown ends with uadmin() or a normal process exit." ``` --- ### Task 4: Extend supervisor shutdown to run oneshot stop commands in reverse tier order **Files:** - Modify: `src/supervisor.reef` - Modify: `src/main.reef` This is the biggest code change in Phase 1. Shutdown currently walks services in the order they were added to the table and only stops `STATE_RUNNING` services. We need tier-ordered reverse walk that also runs `exec.stop` commands for oneshots that declared one. - [ ] **Step 1: Add a `stop_oneshot` supervisor function** Open `src/supervisor.reef`. The existing `stop_service` function (around line 423) handles daemons with stop.method=contract. Add a new function below it that handles oneshot teardown: ```reef // Run a oneshot's declared exec.stop command synchronously. // Used during shutdown to reverse setup that the matching exec.start did. // Does nothing if no stop command is declared. // Returns true if stop was attempted (whether or not it succeeded). fn stop_oneshot(table: ServiceTable, idx: int): bool let rt = supervisor.get_runtime(table, idx) let def = supervisor.rt_def(rt) let name = config.svc_name(def) let stop_cmd = config.svc_stop_cmd(def) if str.length(stop_cmd) == 0 // Nothing to reverse — normal for oneshots with no teardown. return false end if println("supervisor: running stop for " + name + " (" + stop_cmd + ")") // Parse stop_cmd into program + args (same pattern as start). let parts = new [string](32) let argc = str.split(stop_cmd, ' ', parts, 32) if argc <= 0 println("supervisor: " + name + ": empty stop command, skipping") return false end if let program = parts[0] let argv = new [string](argc) mut i = 0 while i < argc argv[i] = parts[i] i = i + 1 end while let pid = process.process_spawn(program, argv) if pid < 0 println("supervisor: " + name + ": stop spawn failed") return true end if // Block until the stop command exits. Oneshot stops are expected to be // short (umount, swap -d, etc.) — they must complete before we proceed. let exit_code = process.process_wait(pid) if exit_code != 0 println("supervisor: " + name + ": stop exited non-zero (" + int_to_str(exit_code) + ")") end if return true end stop_oneshot ``` Export it in the module's `export` block (near the top of supervisor.reef, alongside `stop_service`): ```reef fn stop_oneshot(table: ServiceTable, idx: int): bool ``` - [ ] **Step 2: Replace `shutdown_services` in main.reef with a tier-reversed walk** Open `src/main.reef`. Find `proc shutdown_services(table: supervisor.ServiceTable)` (around line 283). Replace with: ```reef // Stop all services in reverse tier order. // - Daemons in RUNNING: stop via contract SIGTERM (existing stop_service path). // - Oneshots in STOPPED with a declared exec.stop: run the stop command. // - Oneshots without stop: skip. proc shutdown_services(table: supervisor.ServiceTable, tiers: [string], tier_counts: [int], num_tiers: int) println("zyginit: stopping all services...") // Compute total offsets per tier so we can scan each tier's slice. let offsets = new [int](num_tiers) mut off = 0 mut i = 0 while i < num_tiers offsets[i] = off off = off + tier_counts[i] i = i + 1 end while // Walk tiers in reverse. mut tier = num_tiers - 1 while tier >= 0 let tier_off = offsets[tier] let tier_sz = tier_counts[tier] println("zyginit: shutting down tier " + int_to_str(tier) + " (" + int_to_str(tier_sz) + " services)") mut j = 0 while j < tier_sz let name = tiers[tier_off + j] let idx = supervisor.find_service(table, name) if idx >= 0 let rt = supervisor.get_runtime(table, idx) let state = supervisor.rt_state(rt) if state == supervisor.STATE_RUNNING() // Running daemon: issue contract stop. supervisor.stop_service(table, idx) elif state == supervisor.STATE_STOPPED() // Oneshot that already exited: run its stop if declared. let _ = supervisor.stop_oneshot(table, idx) end if end if j = j + 1 end while // Between tiers: wait for daemons in this tier to reach STOPPED // so we don't teardown upstream deps while downstream is still // alive. Simple: loop with a short sleep until all RUNNING in // this tier are gone, with a safety deadline. mut remaining = 1 mut waits = 0 while remaining > 0 and waits < 60 remaining = 0 mut k = 0 while k < tier_sz let name2 = tiers[tier_off + k] let idx2 = supervisor.find_service(table, name2) if idx2 >= 0 let rt2 = supervisor.get_runtime(table, idx2) if supervisor.rt_state(rt2) == supervisor.STATE_STOPPING() remaining = remaining + 1 end if end if k = k + 1 end while if remaining > 0 clock.sleep_millis(500) // Also reap any child exits that happened during the wait. reap_children(table) waits = waits + 1 end if end while if remaining > 0 println("zyginit: tier " + int_to_str(tier) + " shutdown timed out with " + int_to_str(remaining) + " services still STOPPING; proceeding anyway") end if tier = tier - 1 end while println("zyginit: all service tiers stopped") end shutdown_services ``` Find the caller of `shutdown_services` in `main()` (search for `shutdown_services(table)`). Update the call site to pass the tier arrays (which are already computed at startup — pass the same ones that were used for `start_services_by_tier`): ```reef // ---- Shutdown ---- println("") shutdown_services(table, tiers, tier_counts, num_tiers) ``` Make sure `tiers`, `tier_counts`, and `num_tiers` are still in scope at that point; if they were declared inside an inner block, promote them to the outer scope. - [ ] **Step 3: Add `clock` import in main.reef if not already present** Near the top imports of `main.reef`: ```reef import time.clock as clock ``` (If already imported, skip.) - [ ] **Step 4: Rebuild and run integration tests** ```bash cd /home/ctusa/repos/zyginit reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/run_tests.sh 2>&1 | tail -5 ``` Expected: `Results: 57 passed, 0 failed (of 57)`. Oneshot stops aren't exercised by existing tests (no example service declares `exec.stop`), so this step only validates nothing regressed. - [ ] **Step 5: Commit** ```bash hg commit -m "supervisor: shutdown in reverse tier order + oneshot stop commands Shutdown now walks computed tiers from last to first: - RUNNING daemons get stopped via contract SIGTERM (unchanged) - STOPPED oneshots with exec.stop declared run their stop command - Waits up to 30s per tier for daemons to finish STOPPING before moving to the prior tier This makes it safe to define stop.sh for services like filesystem (umountall) and root-fs (remount ro) and have them run in the right order during shutdown." ``` --- ### Task 5: Add shutdown-type tracking + sync + uadmin call for PID 1 **Files:** - Modify: `src/main.reef` - [ ] **Step 1: Import shutdown module and add global shutdown-type variable** Near the top imports of `main.reef`, add: ```reef import shutdown ``` Below the existing mutable globals (e.g., `g_signal_pipe_read`), add: ```reef // Requested shutdown type (set by signal handler or socket command). // SHUT_NONE = no shutdown in progress. mut g_shutdown_type: int = 0 // = shutdown.SHUT_NONE() — but can't call fn in init ``` Reef may not allow calling a function in a global initializer; using `0` which matches SHUT_NONE by definition. - [ ] **Step 2: Modify `handle_signals` to set shutdown type** Find `fn handle_signals` (around line 211). Update the SIGTERM/SIGINT branch: ```reef fn handle_signals(table: supervisor.ServiceTable): bool // SIGTERM / SIGINT — initiate shutdown if signal.signal_received(signal.SIGTERM()) or signal.signal_received(signal.SIGINT()) println("zyginit: received shutdown signal") // Default: halt (conservative). Socket commands can override. if g_shutdown_type == shutdown.SHUT_NONE() g_shutdown_type = shutdown.SHUT_HALT() end if return false end if // SIGHUP — reload config if signal.signal_received(signal.SIGHUP()) println("zyginit: SIGHUP received, reloading configuration") reload_services(table) end if // SIGCHLD — reap exited children if signal.signal_received(signal.SIGCHLD()) reap_children(table) end if return true end handle_signals ``` - [ ] **Step 3: Call sync + uadmin at end of main() when PID 1** At the very end of `main()`, after `shutdown_services` and any cleanup prints, add: ```reef println("zyginit: shutdown complete") // As PID 1, we MUST NOT return normally — kernel panics on "init died". // Call sync() then uadmin() based on the requested shutdown type. if is_pid_1() let shut_type = g_shutdown_type if shut_type == shutdown.SHUT_NONE() shut_type = shutdown.SHUT_HALT() // defensive default end if println("zyginit: sync()") // No direct sync() FFI yet; the stop scripts handle umount + sync as // part of filesystem/stop.sh. Add explicit sync in a later iteration // if needed. let fcn = shutdown.to_ad_code(shut_type) println("zyginit: uadmin(A_SHUTDOWN, " + int_to_str(fcn) + ", 0)") let rc = shutdown.do_shutdown(fcn) // uadmin does not return on AD_BOOT/AD_POWEROFF; for AD_HALT the // kernel halts the CPU. If rc >= 0 we never reach here; if rc < 0 // something went wrong — loop forever to avoid returning from PID 1. println("zyginit: uadmin returned " + int_to_str(rc) + " (errno check needed)") while true clock.sleep_seconds(3600) end while end if // Non-PID-1 path falls through and returns from main() normally. ``` - [ ] **Step 4: Add explicit sync() FFI** Add to `src/helpers.c`: ```c #include void zyginit_sync(void) { sync(); } ``` And add a Reef extern + wrapper in `src/shutdown.reef`, inside the export block: ```reef proc do_sync() ``` And the implementation (before `end module`): ```reef extern "C" proc zyginit_sync() proc do_sync() zyginit_sync() end do_sync ``` Update the Linux stub in `src/contract_linux_stubs.c` — we CAN call sync on Linux (POSIX), but to keep stubs minimal and deterministic on the dev host, add: ```c void zyginit_sync(void) { /* sync() is POSIX but we no-op on Linux dev to keep tests deterministic */ } ``` Actually — **keep it real**. Remove the Linux stub; let both Linux and Hammerhead call the real POSIX `sync()`. So instead: - Leave `zyginit_sync` defined only in `src/helpers.c` (portable — POSIX) - Do NOT add a stub version in `contract_linux_stubs.c` Now update the `main()` shutdown block to call `shutdown.do_sync()` instead of the placeholder comment: ```reef println("zyginit: sync()") shutdown.do_sync() ``` - [ ] **Step 5: Build and test** ```bash cd /home/ctusa/repos/zyginit clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/run_tests.sh 2>&1 | tail -5 ``` Expected: `Results: 57 passed, 0 failed (of 57)`. Non-PID-1 code path is unchanged; tests don't trip the new uadmin branch. - [ ] **Step 6: Commit** ```bash hg commit -m "shutdown: PID-1 calls sync + uadmin; non-PID-1 exits normally Adds g_shutdown_type tracking (set by signal handler, eventually also by socket commands). At end of main(): - PID 1: sync() then uadmin(A_SHUTDOWN, fcn, 0); fcn from shutdown type - non-PID-1: return from main() (existing behavior) Also adds zyginit_sync() (POSIX sync(2)) in helpers.c, exposed via shutdown module. Defensive infinite loop after uadmin returns in error — prevents kernel panic from PID 1 exit on unexpected uadmin failure." ``` --- ### Task 6: Socket handler sets shutdown type for halt/reboot/poweroff **Files:** - Modify: `src/socket.reef` - Modify: `src/main.reef` zygctl will send commands like `halt\n` or `reboot\n`. The socket handler needs to map them to the appropriate `g_shutdown_type` and trigger the shutdown flow (same way SIGTERM does). - [ ] **Step 1: Expose a setter for `g_shutdown_type` from main.reef** In `src/main.reef`, below the variable definition, add: ```reef // Setter for socket handler to request a specific shutdown type. // Must be called before handle_signals sees a SIGTERM (or the socket // handler itself triggers the shutdown via signal_pipe_notify). proc request_shutdown(shut_type: int) g_shutdown_type = shut_type signal_pipe_notify() end request_shutdown ``` Also export it at the top of the module so socket.reef can call it. In the `export` block of `main.reef` (if main.reef has one; if not, proc visibility may already be module-level — check existing `reload_services` export): ```reef proc request_shutdown(shut_type: int) ``` Alternatively, if cross-module proc calls are awkward, move the shutdown request flag into `socket.reef`'s existing reload-flag pattern. Prefer the cleaner direct call if it works. - [ ] **Step 2: Add halt/reboot/poweroff/single/multiuser command handlers to socket.reef** Open `src/socket.reef`. Find the command dispatch (search for `"status"` or `"start"` to locate the parser). Add new command cases: ```reef elif cmd == "halt" main.request_shutdown(shutdown.SHUT_HALT()) return "halting\n" elif cmd == "reboot" main.request_shutdown(shutdown.SHUT_REBOOT()) return "rebooting\n" elif cmd == "poweroff" main.request_shutdown(shutdown.SHUT_POWEROFF()) return "powering off\n" elif cmd == "single" return "error: single-user mode not yet implemented\n" elif cmd == "multiuser" return "error: multi-user target not yet implemented\n" ``` Add imports at the top of socket.reef: ```reef import main import shutdown ``` If a circular import (main imports socket, socket imports main) causes build failure, move `request_shutdown` to `shutdown.reef` and have it directly touch a global, or use an indirection via a shared "shutdown_state" module. First check whether the circular import is a problem; Reef may handle it. - [ ] **Step 3: Rebuild + test** ```bash cd /home/ctusa/repos/zyginit reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/run_tests.sh 2>&1 | tail -5 ``` Expected: 57/57 pass. New commands aren't yet exercised by existing tests. - [ ] **Step 4: Commit** ```bash hg commit -m "socket: accept halt/reboot/poweroff/single/multiuser commands halt/reboot/poweroff set g_shutdown_type and kick the signal pipe to wake the event loop, matching the SIGTERM path. single/multiuser return a 'not implemented' error (stubs for future runlevel support)." ``` --- ## Phase 2 — zygctl subcommands ### Task 7: Add halt/reboot/poweroff subcommands to zygctl **Files:** - Modify: `tools/zygctl/src/main.reef` - [ ] **Step 1: Add subcommand dispatch** Open `tools/zygctl/src/main.reef`. Find the subcommand dispatch (typically an if/elif chain matching `args.get(1)` against `"status"`, `"start"`, etc.). Add: ```reef elif cmd == "halt" send_command_and_print("halt") elif cmd == "reboot" send_command_and_print("reboot") elif cmd == "poweroff" send_command_and_print("poweroff") elif cmd == "single" println("zygctl: single-user mode not yet implemented") println("zygctl: reserved for future runlevel support") process.exit_now(1) elif cmd == "multiuser" println("zygctl: multi-user target not yet implemented") println("zygctl: reserved for future runlevel support") process.exit_now(1) ``` If a `send_command_and_print` helper doesn't exist, use the same socket-call + response-print pattern as existing commands (e.g., look at how `"reload"` is implemented and mirror it). - [ ] **Step 2: Update help text** Find the `help` command output (typically a `println` of each subcommand). Add the new ones in a sensible grouping: ```reef println(" halt Cleanly stop all services and halt the system") println(" reboot Cleanly stop all services and reboot") println(" poweroff Cleanly stop all services and power off") println(" single (not yet implemented) Enter single-user mode") println(" multiuser (not yet implemented) Enter multi-user target") ``` - [ ] **Step 3: Build zygctl** ```bash cd /home/ctusa/repos/zyginit/tools/zygctl reefc build --obj build/symlink_wrapper.o ./build/zygctl help 2>&1 | grep -E "halt|reboot|poweroff|single|multiuser" ``` Expected: all five new commands visible in help output. - [ ] **Step 4: Commit** ```bash cd /home/ctusa/repos/zyginit hg commit -m "zygctl: add halt/reboot/poweroff + single/multiuser stubs halt/reboot/poweroff send the matching socket command to zyginit. single/multiuser are explicit stubs printing 'not yet implemented'; they're in help so the command surface is stable for future expansion." ``` --- ### Task 8: Integration test for clean-shutdown orchestration **Files:** - Create: `tests/integration/test_shutdown.sh` - Modify: `tests/integration/run_tests.sh` This is a new shell-based black-box integration test covering the new shutdown path. Runs zyginit in non-PID-1 mode, starts test services, issues `zygctl halt`, and verifies the expected shutdown log. - [ ] **Step 1: Write the new test script** Write to `tests/integration/test_shutdown.sh`: ```bash #!/bin/sh # tests/integration/test_shutdown.sh # Verifies tier-reversed shutdown walk and oneshot stop command invocation. set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" ZYGINIT="$PROJECT_DIR/build/zyginit" ZYGCTL="$PROJECT_DIR/tools/zygctl/build/zygctl" TEST_DIR=$(mktemp -d /tmp/zyginit-shutdown-test.XXXXXX) trap "rm -rf $TEST_DIR; kill %1 2>/dev/null || true" EXIT CONFIG_DIR="$TEST_DIR/config" ENABLED_DIR="$CONFIG_DIR/enabled.d" BIN_DIR="$TEST_DIR/bin" LOG_DIR="$TEST_DIR/log" mkdir -p "$CONFIG_DIR" "$ENABLED_DIR" "$BIN_DIR" "$LOG_DIR" # Trivial oneshot: start prints "START alpha", stop prints "STOP alpha" cat > "$BIN_DIR/alpha-start" <> "$LOG_DIR/trace" EOF cat > "$BIN_DIR/alpha-stop" <> "$LOG_DIR/trace" EOF chmod +x "$BIN_DIR/alpha-start" "$BIN_DIR/alpha-stop" cat > "$CONFIG_DIR/alpha.toml" < "$LOG_DIR/zyginit.out" 2>&1 & ZPID=$! # Wait for socket to appear for i in 1 2 3 4 5; do [ -S "$TEST_DIR/zyginit.sock" ] && break sleep 1 done [ -S "$TEST_DIR/zyginit.sock" ] || { echo "FAIL: socket never appeared"; exit 1; } # Give alpha oneshot a moment to run sleep 1 # Confirm START alpha ran grep -q "START alpha" "$LOG_DIR/trace" || { echo "FAIL: alpha start didn't run"; exit 1; } # Issue halt via zygctl ZYGINIT_SOCKET="$TEST_DIR/zyginit.sock" "$ZYGCTL" halt # Wait for zyginit to exit (non-PID-1 path returns from main) for i in 1 2 3 4 5; do kill -0 $ZPID 2>/dev/null || break sleep 1 done # Confirm STOP alpha ran during shutdown grep -q "STOP alpha" "$LOG_DIR/trace" || { echo "FAIL: alpha stop didn't run during shutdown" echo "trace contents:" cat "$LOG_DIR/trace" exit 1 } echo "PASS: oneshot stop command runs at shutdown" ``` - [ ] **Step 2: Wire it into `run_tests.sh`** Open `tests/integration/run_tests.sh`. Near the end, before the final summary, add a line that sources / calls the new test. Mirror whatever pattern the existing suite uses (suite-based structure). Simplest: at the bottom, before the final `echo`: ```bash # Shutdown orchestration tests if bash "$SCRIPT_DIR/test_shutdown.sh" >> "$LOG_DIR/shutdown.log" 2>&1; then echo " PASS: shutdown orchestration" PASS=$((PASS + 1)) TOTAL=$((TOTAL + 1)) else echo " FAIL: shutdown orchestration — see $LOG_DIR/shutdown.log" FAIL=$((FAIL + 1)) TOTAL=$((TOTAL + 1)) fi ``` Adjust variable names to match the existing suite's counters. - [ ] **Step 3: Make new test executable and run full suite** ```bash chmod +x tests/integration/test_shutdown.sh ./tests/integration/run_tests.sh 2>&1 | tail -10 ``` Expected: `Results: 58 passed, 0 failed (of 58)` — 57 old + 1 new shutdown test. - [ ] **Step 4: Commit** ```bash hg commit -m "tests: add integration test for oneshot stop at shutdown Covers the new tier-reversed shutdown walk and exec.stop invocation for oneshots. Uses a trivial alpha oneshot that appends 'START alpha' / 'STOP alpha' to a trace file. The test asserts 'STOP alpha' appears after 'zygctl halt'." ``` --- ## Phase 3 — TOML and method script authoring All services go under `services/hammerhead/` in the repo. `scripts/install-to-be.sh` (Task 20) will copy them to `/etc/zyginit/` on the target. ### Task 9: Create `services/hammerhead/` dir and root-fs service **Files:** - Create: `services/hammerhead/root-fs.toml` - Create: `services/hammerhead/root-fs/stop.sh` - Create: `services/hammerhead/enabled.d/root-fs` (symlink) - [ ] **Step 1: Create directory structure** ```bash cd /home/ctusa/repos/zyginit mkdir -p services/hammerhead/enabled.d mkdir -p services/hammerhead/root-fs ``` - [ ] **Step 2: Write root-fs.toml** Write to `services/hammerhead/root-fs.toml`: ```toml # root-fs — Remount / read-write after kernel boot # # Tier 0: no dependencies. First oneshot to run. # Reverses at shutdown: remount read-only before uadmin. [service] name = "root-fs" description = "Remount root filesystem read-write" type = "oneshot" [exec] start = "/usr/sbin/mount -o remount,rw /" stop = "/etc/zyginit/root-fs/stop.sh" [restart] on = "never" ``` - [ ] **Step 3: Write root-fs/stop.sh** Write to `services/hammerhead/root-fs/stop.sh`: ```zsh #!/bin/zsh # root-fs stop — remount / read-only before halt/poweroff/reboot # # Must run AFTER filesystem/stop.sh (which umounts non-root filesystems). # Tier ordering guarantees this: root-fs is Tier 0, filesystem is Tier 2, # so shutdown hits filesystem first, root-fs last. set -e set -u /usr/sbin/mount -o remount,ro / exit 0 ``` - [ ] **Step 4: Make script executable, create enabled symlink** ```bash chmod +x services/hammerhead/root-fs/stop.sh ln -sf ../root-fs.toml services/hammerhead/enabled.d/root-fs ``` - [ ] **Step 5: Verify TOML parses via a dry-run zyginit invocation** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 tools/zygctl/build/zygctl -s /tmp/zyg-dry.sock list 2>/dev/null || \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list kill %1 2>/dev/null grep -E "root-fs|error|warn" /tmp/zyg-dry.log ``` Expected: `root-fs (oneshot)` appears in `zygctl list`; no parse errors. - [ ] **Step 6: Commit** ```bash hg add services/hammerhead/root-fs.toml services/hammerhead/root-fs/stop.sh \ services/hammerhead/enabled.d/root-fs hg commit -m "services/hammerhead: add root-fs (Tier 0) First of the 21 consolidated services. Oneshot that remounts / rw at boot and remounts ro at shutdown (via stop.sh). No dependencies." ``` --- ### Task 10: Tier 1 services — devfs, swap, crypto **Files:** - Create: `services/hammerhead/devfs.toml` - Create: `services/hammerhead/swap.toml` - Create: `services/hammerhead/swap/stop.sh` - Create: `services/hammerhead/crypto.toml` - Create: `services/hammerhead/crypto/start.sh` - Create: 3 symlinks under `services/hammerhead/enabled.d/` - [ ] **Step 1: devfs (inline, no script)** Write `services/hammerhead/devfs.toml`: ```toml # devfs — Populate /dev with devfsadm [service] name = "devfs" description = "Dynamic /dev population" type = "oneshot" [exec] start = "/usr/sbin/devfsadm" [dependencies] requires = ["root-fs"] [restart] on = "never" ``` - [ ] **Step 2: swap (inline start, stop.sh)** Write `services/hammerhead/swap.toml`: ```toml # swap — Enable swap devices from /etc/vfstab [service] name = "swap" description = "Enable swap devices" type = "oneshot" [exec] start = "/usr/sbin/swapadd -a" stop = "/etc/zyginit/swap/stop.sh" [dependencies] requires = ["root-fs"] [restart] on = "never" ``` Write `services/hammerhead/swap/stop.sh`: ```zsh #!/bin/zsh # swap stop — disable all swap devices before halt/reboot set -e set -u # swap -l lists active entries with the device path in column 1 # (skipping the header line). Disable each. if /usr/sbin/swap -l >/dev/null 2>&1; then /usr/sbin/swap -l | awk 'NR>1 {print $1}' | while read dev; do /usr/sbin/swap -d "$dev" || true done fi exit 0 ``` - [ ] **Step 3: crypto (start.sh)** Write `services/hammerhead/crypto.toml`: ```toml # crypto — Initialize Kernel Cryptographic Framework [service] name = "crypto" description = "Kernel cryptographic framework" type = "oneshot" [exec] start = "/etc/zyginit/crypto/start.sh" [dependencies] requires = ["root-fs"] [restart] on = "never" ``` Write `services/hammerhead/crypto/start.sh`: ```zsh #!/bin/zsh # crypto — configure the Kernel Cryptographic Framework # # The kernel crypto framework is loaded at boot via kcf module; this # oneshot applies userland configuration (kcfd persistent state). # cryptoadm refresh reloads the kernel policy from /etc/crypto/*.conf set -e set -u /usr/sbin/cryptoadm refresh exit 0 ``` - [ ] **Step 4: Create enabled.d symlinks** ```bash cd services/hammerhead chmod +x swap/stop.sh crypto/start.sh ln -sf ../devfs.toml enabled.d/devfs ln -sf ../swap.toml enabled.d/swap ln -sf ../crypto.toml enabled.d/crypto ``` - [ ] **Step 5: Dry-run verify** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list | head kill %1 2>/dev/null ``` Expected: devfs, swap, crypto all appear in list; no parse errors. - [ ] **Step 6: Commit** ```bash hg add services/hammerhead/devfs.toml \ services/hammerhead/swap.toml services/hammerhead/swap/stop.sh \ services/hammerhead/crypto.toml services/hammerhead/crypto/start.sh \ services/hammerhead/enabled.d/devfs \ services/hammerhead/enabled.d/swap \ services/hammerhead/enabled.d/crypto hg commit -m "services/hammerhead: add Tier 1 (devfs, swap, crypto)" ``` --- ### Task 11: Tier 2 services — filesystem, identity, sysconfig **Files:** - Create: `services/hammerhead/filesystem.toml`, `filesystem/start.sh`, `filesystem/stop.sh` - Create: `services/hammerhead/identity.toml`, `identity/start.sh` - Create: `services/hammerhead/sysconfig.toml`, `sysconfig/start.sh` - Create: 3 symlinks - [ ] **Step 1: filesystem** Write `services/hammerhead/filesystem.toml`: ```toml # filesystem — Mount all local filesystems from /etc/vfstab + ZFS [service] name = "filesystem" description = "Local filesystem mounts and ZFS imports" type = "oneshot" [exec] start = "/etc/zyginit/filesystem/start.sh" stop = "/etc/zyginit/filesystem/stop.sh" [dependencies] requires = ["root-fs", "devfs"] [restart] on = "never" ``` Write `services/hammerhead/filesystem/start.sh`: ```zsh #!/bin/zsh # filesystem — mount local filesystems + import ZFS pools # # mountall -l mounts all local-type entries from /etc/vfstab (idempotent # — skips already-mounted). zpool import -a imports any pools on attached # disks. ZFS filesystems with mountpoint set auto-mount. set -e set -u # Mount everything in /etc/vfstab with mount-at-boot = yes, excluding / /usr/sbin/mountall -l # Import all ZFS pools visible to the system /usr/sbin/zpool import -a || true # Auto-mount ZFS filesystems with canmount=on /usr/sbin/zfs mount -a exit 0 ``` Write `services/hammerhead/filesystem/stop.sh`: ```zsh #!/bin/zsh # filesystem — umount everything except / before root-fs goes read-only # # umountall -l unmounts all local-type filesystems that are NOT /. # ZFS pools export is handled implicitly by umount of their mountpoints. set -e set -u # Best-effort: anything that can't umount (busy) is logged but doesn't # block shutdown — ZFS in-kernel state will still checkpoint correctly. /usr/sbin/umountall -l || true # Explicitly sync ZFS before root goes ro /usr/sbin/zpool sync || true exit 0 ``` - [ ] **Step 2: identity** Write `services/hammerhead/identity.toml`: ```toml # identity — Set hostname, domain, and hostid from /etc files [service] name = "identity" description = "Hostname, domain, hostid" type = "oneshot" [exec] start = "/etc/zyginit/identity/start.sh" [dependencies] requires = ["filesystem"] [restart] on = "never" ``` Write `services/hammerhead/identity/start.sh`: ```zsh #!/bin/zsh # identity — configure hostname, domain, hostid # # Reads /etc/nodename, /etc/defaultdomain, /etc/hostid. All optional — # missing files fall back to system default. Does NOT generate missing # hostid — that's a separate first-boot setup concern out of scope here. set -e set -u if [[ -r /etc/nodename ]]; then /usr/bin/hostname "$(cat /etc/nodename)" fi if [[ -r /etc/defaultdomain ]]; then /usr/bin/domainname "$(cat /etc/defaultdomain)" fi # hostid is already loaded by the kernel from /etc/hostid; nothing to do # unless we later want to generate it on first boot. Flag for follow-up. exit 0 ``` - [ ] **Step 3: sysconfig** Write `services/hammerhead/sysconfig.toml`: ```toml # sysconfig — Misc one-time boot configuration (grab-bag) [service] name = "sysconfig" description = "Misc boot config: core, dump, keymap, scheduler, rctl, tmp cleanup" type = "oneshot" [exec] start = "/etc/zyginit/sysconfig/start.sh" [dependencies] requires = ["filesystem"] [restart] on = "never" ``` Write `services/hammerhead/sysconfig/start.sh`: ```zsh #!/bin/zsh # sysconfig — misc one-time boot configuration # # Each command is deliberately wrapped in || true for commands where a # specific failure (no device present, no config file, etc.) is acceptable. # A truly mandatory setting would be its own service. set -u # Note: NOT using set -e here — individual failures are handled per-command. # Core files: enable per-process pattern, apply config from /etc/coreadm.conf /usr/bin/coreadm --init 2>/dev/null || true # Crash dump: use configured dump device (set once per system, may be a zvol) /usr/sbin/dumpadm -u 2>/dev/null || true # Keyboard map from /etc/default/kbd (console only; tty layout) if [[ -x /usr/bin/kbd ]]; then /usr/bin/kbd -s 2>/dev/null || true fi # Scheduler default dispatch table (TS = time-share, usual default) /usr/sbin/dispadmin -d TS 2>/dev/null || true # Resource controls (/etc/project-based rctls) — reload into kernel if [[ -x /usr/sbin/rctladm ]]; then /usr/sbin/rctladm -u 2>/dev/null || true fi # Clean up /tmp on boot (tmpfs is usually empty after reboot but defend) if [[ -d /tmp ]]; then /usr/bin/find /tmp -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true fi exit 0 ``` - [ ] **Step 4: Create symlinks, make scripts executable** ```bash cd services/hammerhead chmod +x filesystem/start.sh filesystem/stop.sh identity/start.sh sysconfig/start.sh ln -sf ../filesystem.toml enabled.d/filesystem ln -sf ../identity.toml enabled.d/identity ln -sf ../sysconfig.toml enabled.d/sysconfig ``` - [ ] **Step 5: Dry-run verify all 7 services parse and tier correctly** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list kill %1 2>/dev/null grep -E "tier|error|warn" /tmp/zyg-dry.log ``` Expected: 7 services listed (root-fs, devfs, swap, crypto, filesystem, identity, sysconfig); tier output shows filesystem/identity/sysconfig in tier 2; no parse errors. - [ ] **Step 6: Commit** ```bash hg add services/hammerhead/filesystem.toml \ services/hammerhead/filesystem/start.sh \ services/hammerhead/filesystem/stop.sh \ services/hammerhead/identity.toml \ services/hammerhead/identity/start.sh \ services/hammerhead/sysconfig.toml \ services/hammerhead/sysconfig/start.sh \ services/hammerhead/enabled.d/filesystem \ services/hammerhead/enabled.d/identity \ services/hammerhead/enabled.d/sysconfig hg commit -m "services/hammerhead: add Tier 2 (filesystem, identity, sysconfig)" ``` --- ### Task 12: Tier 3 — dlmgmtd, ipmgmtd, network **Files:** - Create: `services/hammerhead/dlmgmtd.toml` - Create: `services/hammerhead/ipmgmtd.toml` - Create: `services/hammerhead/network.toml`, `network/start.sh` - Create: 3 symlinks - [ ] **Step 1: dlmgmtd + ipmgmtd daemons (inline, no script)** Write `services/hammerhead/dlmgmtd.toml`: ```toml # dlmgmtd — Datalink management daemon [service] name = "dlmgmtd" description = "Datalink management daemon" type = "daemon" [exec] start = "/sbin/dlmgmtd" [dependencies] requires = ["filesystem"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` Write `services/hammerhead/ipmgmtd.toml`: ```toml # ipmgmtd — IP interface management daemon [service] name = "ipmgmtd" description = "IP interface management daemon" type = "daemon" [exec] start = "/lib/inet/ipmgmtd" [dependencies] requires = ["filesystem", "dlmgmtd"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` - [ ] **Step 2: network (script — brings up lo0, physical, DHCP)** Write `services/hammerhead/network.toml`: ```toml # network — Bring up loopback, physical interfaces, routes # # Consolidates SMF's network/loopback, network/physical, network/initial, # network/netmask, network/service, network/routing-setup. Runs DHCP # request for interfaces configured for DHCP; ipadm create-addr -T dhcp # implicitly spawns /sbin/dhcpagent (we don't manage it as a service). [service] name = "network" description = "Bring up network interfaces and routes" type = "oneshot" [exec] start = "/etc/zyginit/network/start.sh" [dependencies] requires = ["identity", "dlmgmtd", "ipmgmtd"] [restart] on = "never" ``` Write `services/hammerhead/network/start.sh`: ```zsh #!/bin/zsh # network — configure loopback, physical interfaces, routes # # Idempotent: check-before-create pattern for interfaces since # ipadm create-if fails if the interface already exists. set -e set -u # --- Loopback --- # lo0 is auto-plumbed by the kernel on boot but ipadm wants explicit # IP objects. Check first because create-if errors if already present. if ! /usr/sbin/ipadm show-if lo0 >/dev/null 2>&1; then /usr/sbin/ipadm create-if lo0 fi if ! /usr/sbin/ipadm show-addr lo0/v4 >/dev/null 2>&1; then /usr/sbin/ipadm create-addr -T static -a 127.0.0.1/8 lo0/v4 fi if ! /usr/sbin/ipadm show-addr lo0/v6 >/dev/null 2>&1; then /usr/sbin/ipadm create-addr -T static -a ::1/128 lo0/v6 fi # --- Physical interfaces --- # For each datalink that's UP (not loopback), create an ipadm interface # and request a DHCP lease. On hh-prototest this is typically 'vioif0'. # Real production systems would look up /etc/hostname. or similar, # but for first-boot-test we assume one physical NIC and DHCP. for link in $(/usr/sbin/dladm show-link -p -o link 2>/dev/null); do # Skip loopback and any link that's not 'up' (as reported by dladm) if [[ "$link" == "lo0" ]]; then continue fi if ! /usr/sbin/ipadm show-if "$link" >/dev/null 2>&1; then /usr/sbin/ipadm create-if "$link" fi if ! /usr/sbin/ipadm show-addr "$link/v4" >/dev/null 2>&1; then # -T dhcp implicitly spawns /sbin/dhcpagent if not already running /usr/sbin/ipadm create-addr -T dhcp "$link/v4" || true fi done # --- Routes --- # Default route from DHCP is installed automatically by dhcpagent on # lease acquisition. Static routes from /etc/inet/static_routes if present. if [[ -r /etc/inet/static_routes ]]; then while read line; do case "$line" in \#*|'') continue;; *) /usr/sbin/route -f add $line || true;; esac done < /etc/inet/static_routes fi exit 0 ``` - [ ] **Step 3: Symlinks + executable** ```bash cd services/hammerhead chmod +x network/start.sh ln -sf ../dlmgmtd.toml enabled.d/dlmgmtd ln -sf ../ipmgmtd.toml enabled.d/ipmgmtd ln -sf ../network.toml enabled.d/network ``` - [ ] **Step 4: Dry-run verify** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list kill %1 2>/dev/null ``` Expected: 10 services now listed (root-fs, devfs, swap, crypto, filesystem, identity, sysconfig, dlmgmtd, ipmgmtd, network); tiers 0-3 populated. - [ ] **Step 5: Commit** ```bash hg add services/hammerhead/dlmgmtd.toml \ services/hammerhead/ipmgmtd.toml \ services/hammerhead/network.toml \ services/hammerhead/network/start.sh \ services/hammerhead/enabled.d/dlmgmtd \ services/hammerhead/enabled.d/ipmgmtd \ services/hammerhead/enabled.d/network hg commit -m "services/hammerhead: add Tier 3 (dlmgmtd, ipmgmtd, network)" ``` --- ### Task 13: Tier 4 daemons (7 files) **Files:** - Create: 7 TOML files for `syseventd`, `fmd`, `syslogd`, `utmpd`, `pfexecd`, `powerd`, `rpcbind` - Create: 7 symlinks All follow the same daemon template. Writing them in one shot. - [ ] **Step 1: Write all 7 TOMLs** For each service, write the file below. Paths come from the `ps -ef` capture we have from hh-prototest. `services/hammerhead/syseventd.toml`: ```toml [service] name = "syseventd" description = "Sysevent framework daemon" type = "daemon" [exec] start = "/usr/lib/sysevent/syseventd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/fmd.toml`: ```toml [service] name = "fmd" description = "Fault Manager Daemon" type = "daemon" [exec] start = "/usr/lib/fm/fmd/fmd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/syslogd.toml`: ```toml [service] name = "syslogd" description = "System log daemon" type = "daemon" [exec] start = "/usr/sbin/syslogd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/utmpd.toml`: ```toml [service] name = "utmpd" description = "utmpx monitor daemon" type = "daemon" [exec] start = "/usr/lib/utmpd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/pfexecd.toml`: ```toml [service] name = "pfexecd" description = "Profile exec daemon (RBAC privilege execution)" type = "daemon" [exec] start = "/usr/lib/pfexecd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/powerd.toml`: ```toml [service] name = "powerd" description = "Power management daemon" type = "daemon" [exec] start = "/usr/lib/power/powerd" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/rpcbind.toml`: ```toml [service] name = "rpcbind" description = "RPC portmapper" type = "daemon" [exec] start = "/usr/sbin/rpcbind" [dependencies] requires = ["network"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` - [ ] **Step 2: Create symlinks** ```bash cd services/hammerhead for s in syseventd fmd syslogd utmpd pfexecd powerd rpcbind; do ln -sf ../$s.toml enabled.d/$s done ``` - [ ] **Step 3: Dry-run verify** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list | wc -l kill %1 2>/dev/null ``` Expected: 17 services listed (10 prior + 7 new). - [ ] **Step 4: Commit** ```bash hg add services/hammerhead/{syseventd,fmd,syslogd,utmpd,pfexecd,powerd,rpcbind}.toml \ services/hammerhead/enabled.d/{syseventd,fmd,syslogd,utmpd,pfexecd,powerd,rpcbind} hg commit -m "services/hammerhead: add Tier 4 daemons (syseventd, fmd, syslogd, utmpd, pfexecd, powerd, rpcbind)" ``` --- ### Task 14: Tier 5 daemons — cron, inetd, sshd, console-login **Files:** - Create: 4 TOML files - Create: 4 symlinks - [ ] **Step 1: Write the 4 TOMLs** `services/hammerhead/cron.toml`: ```toml [service] name = "cron" description = "cron daemon" type = "daemon" [exec] start = "/usr/sbin/cron" [dependencies] requires = ["syslogd"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/inetd.toml`: ```toml [service] name = "inetd" description = "Internet services daemon" type = "daemon" [exec] start = "/usr/lib/inet/inetd start" [dependencies] requires = ["rpcbind", "syslogd"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/sshd.toml`: ```toml [service] name = "sshd" description = "Secure Shell daemon" type = "daemon" [exec] start = "/usr/lib/ssh/sshd -D" [dependencies] requires = ["syslogd", "pfexecd"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` `services/hammerhead/console-login.toml`: ```toml [service] name = "console-login" description = "Console login (ttymon on /dev/console)" type = "daemon" [exec] start = "/usr/lib/saf/ttymon -g -d /dev/console -l console -m ldterm,ttcompat -h -p 'hammerhead console login: '" [dependencies] requires = ["syslogd", "utmpd"] [restart] on = "failure" delay = 5 max_retries = 3 [contract] param = ["inherit", "noorphan"] fatal = ["hwerr"] ``` - [ ] **Step 2: Symlinks** ```bash cd services/hammerhead for s in cron inetd sshd console-login; do ln -sf ../$s.toml enabled.d/$s done ``` - [ ] **Step 3: Full dry-run verify — all 21 services** ```bash cd /home/ctusa/repos/zyginit ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 2 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list | wc -l grep -E "tier|error|warn|cycle" /tmp/zyg-dry.log kill %1 2>/dev/null ``` Expected: - `wc -l` output = 21 - Log shows 6 tiers (0-5), no cycle detection errors, no warnings - No missing-dependency warnings (every `requires` resolves) - [ ] **Step 4: Commit** ```bash hg add services/hammerhead/{cron,inetd,sshd,console-login}.toml \ services/hammerhead/enabled.d/{cron,inetd,sshd,console-login} hg commit -m "services/hammerhead: add Tier 5 (cron, inetd, sshd, console-login) All 21 zyginit services now authored. Dry-run parse confirms the full dependency graph resolves into 6 tiers with no cycles." ``` --- ## Phase 4 — Deployment tooling ### Task 15: Write install-to-be.sh **Files:** - Create: `scripts/install-to-be.sh` - [ ] **Step 1: Write the installer script** Write to `scripts/install-to-be.sh`: ```bash #!/bin/sh # install-to-be.sh — Install zyginit binaries + config tree into a # mounted Boot Environment (or any target root). # # Usage: install-to-be.sh # # e.g., after `beadm create zyginit && beadm mount zyginit /mnt`: # install-to-be.sh /mnt # # Copies: # /sbin/zyginit (built binary) → /sbin/zyginit # /sbin/zygctl (built binary) → /sbin/zygctl # services/hammerhead/*.toml → /etc/zyginit/*.toml # services/hammerhead// → /etc/zyginit// # services/hammerhead/enabled.d → /etc/zyginit/enabled.d/ # # Preserves existing /sbin/init as /sbin/init.smf and # replaces it with a symlink to /sbin/zyginit. set -e set -u if [ $# -ne 1 ]; then echo "usage: $0 " >&2 exit 1 fi TARGET="$1" REPO="$(cd "$(dirname "$0")/.." && pwd)" if [ ! -d "$TARGET" ]; then echo "error: $TARGET does not exist or is not a directory" >&2 exit 1 fi if [ ! -x "$REPO/build/zyginit" ]; then echo "error: $REPO/build/zyginit not built yet" >&2 exit 1 fi if [ ! -x "$REPO/tools/zygctl/build/zygctl" ]; then echo "error: $REPO/tools/zygctl/build/zygctl not built yet" >&2 exit 1 fi echo "installing zyginit into $TARGET..." # Binaries install -m 0755 "$REPO/build/zyginit" "$TARGET/sbin/zyginit" install -m 0755 "$REPO/tools/zygctl/build/zygctl" "$TARGET/sbin/zygctl" # Config tree mkdir -p "$TARGET/etc/zyginit" mkdir -p "$TARGET/etc/zyginit/enabled.d" # Copy TOMLs (flat files at top of services/hammerhead/) for t in "$REPO"/services/hammerhead/*.toml; do cp "$t" "$TARGET/etc/zyginit/" done # Copy per-service script subdirs (root-fs/, swap/, crypto/, filesystem/, # identity/, sysconfig/, network/) — anything that exists gets copied for d in "$REPO"/services/hammerhead/*/; do [ "$(basename "$d")" = "enabled.d" ] && continue dname="$(basename "$d")" mkdir -p "$TARGET/etc/zyginit/$dname" cp -r "$d"* "$TARGET/etc/zyginit/$dname/" 2>/dev/null || true chmod -R +x "$TARGET/etc/zyginit/$dname" done # Symlinks in enabled.d (recreate since cp doesn't preserve across fs) for l in "$REPO"/services/hammerhead/enabled.d/*; do name="$(basename "$l")" ln -sf "../$name.toml" "$TARGET/etc/zyginit/enabled.d/$name" done # Runtime dirs mkdir -p "$TARGET/var/run/zyginit/log" # Replace /sbin/init if [ -e "$TARGET/sbin/init" ] && [ ! -L "$TARGET/sbin/init" ]; then # Preserve original (won't overwrite if already preserved) if [ ! -e "$TARGET/sbin/init.smf" ]; then mv "$TARGET/sbin/init" "$TARGET/sbin/init.smf" echo " preserved original /sbin/init as /sbin/init.smf" fi fi ln -sf /sbin/zyginit "$TARGET/sbin/init" echo " installed /sbin/init → /sbin/zyginit" echo "zyginit install complete in $TARGET" echo "" echo "verify:" echo " ls -la $TARGET/sbin/init" echo " ls $TARGET/etc/zyginit/enabled.d/" ``` - [ ] **Step 2: Make executable and add to repo** ```bash cd /home/ctusa/repos/zyginit chmod +x scripts/install-to-be.sh hg add scripts/install-to-be.sh ``` - [ ] **Step 3: Commit** ```bash hg commit -m "scripts/install-to-be.sh: deploy zyginit + hammerhead service set to a target root Takes a target directory (typically a mounted BE) and installs: - /sbin/zyginit, /sbin/zygctl binaries - /etc/zyginit/ config tree (TOMLs + per-service script dirs + enabled.d) - /var/run/zyginit/log/ runtime dir - /sbin/init symlinked to /sbin/zyginit (old init preserved as init.smf) Used by Phase B to populate a fresh Boot Environment." ``` --- ## Phase 5 — Phase A non-PID-1 dry run on hh-prototest ### Task 16: Ship code + configs to hh-prototest **Files:** - N/A (operational only) - [ ] **Step 1: Rebuild zyginit on Hammerhead** ```bash cd /home/ctusa/repos/zyginit tar --exclude='*/build/*' --exclude='.hg' -czf - \ src services/hammerhead scripts reef.toml tools 2>/dev/null | \ ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 \ "cd /root/zyginit && tar -xzf -" ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 \ 'cd /root/zyginit && gcc -c src/helpers.c -o build/helpers.o && reefc build -l contract --obj build/helpers.o 2>&1 | tail -3' ``` Expected: Build clean on Hammerhead. - [ ] **Step 2: Rebuild zygctl on Hammerhead** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 \ 'cd /root/zyginit/tools/zygctl && reefc build 2>&1 | tail -3' ``` Expected: Build clean (zygctl doesn't need -l contract). - [ ] **Step 3: Stage configs to /tmp/zyginit-dry on VM** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' set -e rm -rf /tmp/zyginit-dry cp -r /root/zyginit/services/hammerhead /tmp/zyginit-dry ls /tmp/zyginit-dry/enabled.d/ | wc -l EOF ``` Expected: 21 symlinks listed. - [ ] **Step 4: Commit (nothing to commit; operational step)** No commit. Continue. --- ### Task 17: Dry-run parse + dependency graph verification **Files:** - N/A (operational + diagnostic) - [ ] **Step 1: Start zyginit in non-PID-1 mode on hh-prototest with the real TOML set** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' mkdir -p /tmp/zyg-run ZYGINIT_CONFIG_DIR=/tmp/zyginit-dry \ ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock \ ZYGINIT_LOG_DIR=/tmp/zyg-run/log \ /root/zyginit/build/zyginit > /tmp/zyg-run/zyginit.out 2>&1 & echo "started PID=$!" sleep 3 ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock /root/zyginit/tools/zygctl/build/zygctl list echo "--- boot order tiers from zyginit log ---" grep -E "tier [0-9]+" /tmp/zyg-run/zyginit.out echo "--- errors / warnings ---" grep -iE "error|warn|fail|cycle" /tmp/zyg-run/zyginit.out || echo "(none)" EOF ``` Expected: - `zygctl list` shows all 21 services - 6 tiers (0-5) enumerated - No errors / warnings **STOP IF:** parse errors, cycle detection, missing dependency messages appear. Debug the TOMLs before proceeding. - [ ] **Step 2: Start a test daemon to verify supervision on Hammerhead** The real services would conflict with SMF's running copies. Use a scratch test service: ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' mkdir -p /tmp/zyg-test/enabled.d cat > /tmp/zyg-test/watchdog.toml </dev/null sleep 1 rm -rf /tmp/zyg-run mkdir -p /tmp/zyg-run ZYGINIT_CONFIG_DIR=/tmp/zyg-test \ ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock \ ZYGINIT_LOG_DIR=/tmp/zyg-run/log \ /root/zyginit/build/zyginit > /tmp/zyg-run/zyginit.out 2>&1 & sleep 3 echo "--- initial status ---" ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock /root/zyginit/tools/zygctl/build/zygctl status # Kill the sleep — should trigger restart WPID=$(pgrep -f '/bin/sleep 300' | head -1) echo "killing watchdog PID $WPID" kill $WPID sleep 3 echo "--- after kill (should show restart count 1) ---" ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock /root/zyginit/tools/zygctl/build/zygctl status # Clean halt ZYGINIT_SOCKET=/tmp/zyg-run/zyginit.sock /root/zyginit/tools/zygctl/build/zygctl halt sleep 2 echo "--- zyginit output ---" cat /tmp/zyg-run/zyginit.out EOF ``` Expected: - First status: watchdog RUNNING - After kill: restart count = 1, watchdog back to RUNNING (or WAITING briefly) - Halt cleanly exits zyginit, output shows "stopping all services" → "shutdown complete" - NO uadmin call (non-PID-1 path) - [ ] **Step 3: Commit (nothing to commit; operational)** No commit. Document results in the team report. --- ## Phase 6 — Phase B PID-1 boot in a fresh BE ### Task 18: Create BE and install zyginit **Files:** - N/A (operational) - [ ] **Step 1: Verify all pre-reqs installed on VM** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' echo "--- zyginit binary ---" ls -la /root/zyginit/build/zyginit echo "--- zygctl binary ---" ls -la /root/zyginit/tools/zygctl/build/zygctl echo "--- service set ---" ls /root/zyginit/services/hammerhead/*.toml | wc -l echo "--- install script ---" ls -la /root/zyginit/scripts/install-to-be.sh EOF ``` Expected: 21 TOMLs, both binaries, install script present. - [ ] **Step 2: Create and mount the new BE** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' set -e beadm create zyginit beadm mount zyginit /mnt beadm list EOF ``` Expected: BE `zyginit` created, mounted at /mnt. `beadm list` shows it mounted but not active. - [ ] **Step 3: Run the installer into /mnt** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 \ 'cd /root/zyginit && ./scripts/install-to-be.sh /mnt' ``` Expected output: ``` installing zyginit into /mnt... preserved original /sbin/init as /sbin/init.smf installed /sbin/init → /sbin/zyginit zyginit install complete in /mnt ``` - [ ] **Step 4: Sanity-check the install** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' ls -la /mnt/sbin/init /mnt/sbin/init.smf /mnt/sbin/zyginit /mnt/sbin/zygctl ls /mnt/etc/zyginit/enabled.d/ | wc -l ls /mnt/etc/zyginit/*.toml | wc -l EOF ``` Expected: - `/mnt/sbin/init` is a symlink to `/sbin/zyginit` - `/mnt/sbin/init.smf` is the original init binary - 21 enabled.d symlinks - 21 TOML files - [ ] **Step 5: Umount and activate the BE** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' beadm umount zyginit beadm activate zyginit beadm list EOF ``` Expected: `zyginit` BE shows as `R` (activated for reboot). `hammerhead` BE shows as `N` (currently running, not active for reboot). --- ### Task 19: Boot into zyginit BE and run success checklist **Files:** - N/A (operational + validation) - [ ] **Step 1: Open a console session on the dev host (keep ssh open too)** From a second terminal on the dev host: ```bash virsh --connect=qemu:///system console hh-prototest ``` Leave this open; it's our diagnostic channel if zyginit wedges. - [ ] **Step 2: Reboot into the zyginit BE** From the existing ssh session: ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 'init 6' ``` Watch the virsh console for boot output. Expected sequence (from zyginit): - `zyginit v0.1.0 starting` - `zyginit: running as PID 1 (init mode)` - Tier-by-tier service startup - `zyginit: entering event loop` **If boot wedges:** reboot the VM, pick `hammerhead` from GRUB menu on reboot. Debug from there. - [ ] **Step 3: SSH in and run success checklist (§9.A-D of spec)** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' echo "--- A. boot complete ---" uptime ps -ef | head -1 ps -ef | grep zyginit | grep -v grep echo "--- B. service states ---" /sbin/zygctl list echo "---" /sbin/zygctl status echo "--- C. functional tests ---" dladm show-link ipadm show-addr ping -c 2 192.168.122.1 || echo "ping failed" zfs list logger -p user.info "zyginit-first-boot marker" tail -5 /var/adm/messages || echo "messages unavailable" echo "--- D. zygctl end-to-end ---" /sbin/zygctl status cron /sbin/zygctl stop cron /sbin/zygctl status cron /sbin/zygctl start cron /sbin/zygctl status cron /sbin/zygctl log sshd | head -5 EOF ``` Check each line against the spec's §9 checklist. Record results. - [ ] **Step 4: Document pass/fail** Write results to `docs/superpowers/specs/2026-04-24-pid1-boot-results.md` (new file): ```markdown # PID-1 Boot — Phase B Test Results **Date:** **BE:** `zyginit` **Host:** hh-prototest (192.168.122.197) ## Success Criteria (from spec §9) ### A. Boot completes - [x/ ] No kernel panic - [x/ ] zyginit event loop entered - [x/ ] All tiers completed within 60s - [x/ ] Console login prompt appears ### B. Service states - (fill in) ### C. Functional tests - (fill in) ### D. zygctl operations - (fill in) ``` Commit this file regardless of outcome — it's the test log. --- ### Task 20: Test clean shutdown (halt, reboot, poweroff) **Files:** - N/A (operational) - [ ] **Step 1: Test zygctl halt** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 '/sbin/zygctl halt' ``` Watch console. Expected: - Reverse-tier shutdown log (`tier 5 → 4 → 3 → 2 → 1 → 0`) - Daemons stop via contract - Oneshot stop scripts run (swap -d, filesystem umountall, root-fs remount ro) - `zyginit: uadmin(A_SHUTDOWN, 0, 0)` logged - VM halts; `virsh list` shows `running` but kernel is halted (CPU stopped) If the VM remains in `running` state but unresponsive, that's AD_HALT behavior — expected. - [ ] **Step 2: Forced reboot from host, reboot into zyginit BE again** ```bash virsh --connect=qemu:///system destroy hh-prototest # force power-off since halt has no kernel running virsh --connect=qemu:///system start hh-prototest ``` Wait for it to come up. Should boot back into zyginit BE (it's still activated). - [ ] **Step 3: Test zygctl reboot** ```bash ssh ... 'uname -a; /sbin/zygctl reboot' ``` Expected: - Reverse-tier shutdown - `uadmin(AD_BOOT)` logged - VM reboots automatically - Comes back up through zyginit BE (still active) - [ ] **Step 4: Test zygctl poweroff** ```bash ssh ... '/sbin/zygctl poweroff' ``` Expected: - Reverse-tier shutdown - `uadmin(AD_POWEROFF)` logged - VM transitions to `shut off` in libvirt (`virsh list --all`) - [ ] **Step 5: Check ZFS pool clean after each shutdown type** ```bash virsh start hh-prototest # wait for boot ssh ... 'zpool status -x' ``` Expected: `all pools are healthy`. No `replay required`, no degraded devices. - [ ] **Step 6: Append results to the test results file and commit** Update `docs/superpowers/specs/2026-04-24-pid1-boot-results.md` with §E checklist outcomes. ```bash hg commit docs/superpowers/specs/2026-04-24-pid1-boot-results.md \ -m "docs: Phase B PID-1 boot test results — shutdown path" ``` --- ### Task 21: Test rollback paths **Files:** - N/A (operational) - [ ] **Step 1: Boot back into the original `hammerhead` BE from GRUB** Reboot the VM, intercept the GRUB menu via `virsh console`, select `hammerhead` BE. Expected: SMF-based boot. `svcs -a | wc -l` returns ~142 (original count). Everything working as before zyginit. - [ ] **Step 2: Switch back to zyginit BE** From within the `hammerhead` BE: ```bash ssh ... 'beadm list' # verify zyginit BE still present ssh ... 'beadm activate zyginit && init 6' ``` Expected: reboots back into zyginit BE. - [ ] **Step 3: Disk-level restore drill** Shut down the VM, restore the pre-change disk, verify it boots normally, then restore the experimental disk back. ```bash virsh --connect=qemu:///system shutdown hh-prototest # or force destroy if needed # wait for shut off sudo cp /var/lib/libvirt/images/hh-prototest-backup-2026-04-24-pre-zyginit-pid1.qcow2 \ /tmp/hh-prototest-experimental.qcow2 sudo cp /var/lib/libvirt/images/hh-prototest.qcow2 \ /var/lib/libvirt/images/hh-prototest-experimental.qcow2.bak sudo cp /var/lib/libvirt/images/hh-prototest-backup-2026-04-24-pre-zyginit-pid1.qcow2 \ /var/lib/libvirt/images/hh-prototest.qcow2 virsh start hh-prototest # wait for boot — should be the pre-zyginit state ssh ... 'uptime; beadm list' ``` Expected: VM is in pre-zyginit state (only `hammerhead` BE present, no `zyginit` BE). - [ ] **Step 4: Restore the experimental disk** ```bash virsh --connect=qemu:///system shutdown hh-prototest # wait for shut off sudo cp /var/lib/libvirt/images/hh-prototest-experimental.qcow2.bak \ /var/lib/libvirt/images/hh-prototest.qcow2 virsh start hh-prototest ``` Expected: back to the zyginit + hammerhead BE state. - [ ] **Step 5: Record rollback drill results in the results file and commit** Update `docs/superpowers/specs/2026-04-24-pid1-boot-results.md` with §F checklist outcomes. Commit. --- ## Phase 6.5 — BSD-pivot v2 service set (after iter 5 hit configd wall) **Context:** Iter 1-5 of Task 19 (revs 24–50) exec'd the v1 21-service set on hh-prototest and surfaced that `dlmgmtd`, `ipmgmtd`, `inetd`, `rpcbind`, and `fmd` are tightly coupled to `svc.configd` via `libscf`. Setting `SMF_FMRI` in the env bypasses their refusal-to-start banner, but `scf_handle_decode_fmri()` still fails without `/etc/svc/volatile/repository_door`, leaving the daemons running but degraded — `ipmgmtd` doesn't open its door socket, `ipadm` errors with "Could not open handle to library", `lo0` is never plumbed, `rpcbind` cascades into "could not find loopback transports", and the entire network stack stays dead. **Pivot:** Spec §1.1 (revision 2026-04-25) drops the 5 configd-coupled daemons and moves the network tier to direct `ifconfig` invocation, BSD-style — matching IRIX 6.5's `/etc/init.d/network` pattern (`/sbin/ifconfig` plumbs interfaces directly via DLPI, no `ipmgmtd` registry needed; verified on hh-prototest in spec §13). The new default service count is **15**: tier 0 root-fs (1) → tier 1 devfs/swap/crypto (3) → tier 2 filesystem/identity/sysconfig (3) → tier 3 network (1) → tier 4 syseventd/syslogd/utmpd/pfexecd (4) → tier 5 cron/sshd/console-login (3). **This phase produces the diff** against the on-disk v1 service set: 4 `enabled.d/` symlinks dropped, `network.toml` deps loosened, `network/start.sh` rewritten as a `/etc/hostname.` parser, new `network/stop.sh`, sample interface-config files, then iter 6 boot test against the slimmed set. **Carry-forwards from iter 1-5 already on disk** (do not redo): rev 43 (root-fs ZFS dispatch), rev 44 (argv[0] supervisor fix), rev 45 (PID-1 fd setup), rev 47 (tier-ordering enforcement), rev 48 (persistent log dir), rev 49 (path corrections for `/bin/hostname`, `/sbin/swapadd`), rev 50 (cron FIFO at `/etc/cron.d/FIFO`). ### Task 22: Drop configd-coupled symlinks from `enabled.d/` **Files:** - Delete: `services/hammerhead/enabled.d/dlmgmtd` - Delete: `services/hammerhead/enabled.d/ipmgmtd` - Delete: `services/hammerhead/enabled.d/inetd` - Delete: `services/hammerhead/enabled.d/rpcbind` The TOML files themselves stay in `services/hammerhead/` — they're now opt-in via `zygctl enable ` per spec §4.7. (`fmd` and `powerd` already have no symlink as of iter 5; nothing to do for those.) - [ ] **Step 1: Verify pre-state** Run: `ls services/hammerhead/enabled.d/ | wc -l` Expected: `19` Run: `ls services/hammerhead/enabled.d/ | sort` Expected to include `dlmgmtd`, `ipmgmtd`, `inetd`, `rpcbind` among the 19. - [ ] **Step 2: Remove the four symlinks** ```bash hg remove services/hammerhead/enabled.d/dlmgmtd \ services/hammerhead/enabled.d/ipmgmtd \ services/hammerhead/enabled.d/inetd \ services/hammerhead/enabled.d/rpcbind ``` - [ ] **Step 3: Verify post-state** Run: `ls services/hammerhead/enabled.d/ | wc -l` Expected: `15` Run: `ls services/hammerhead/enabled.d/ | sort` Expected (alphabetical): `console-login cron crypto devfs filesystem identity network pfexecd root-fs sshd swap sysconfig syseventd syslogd utmpd` - [ ] **Step 4: Commit** ```bash hg commit -m "services/hammerhead: drop configd-coupled daemons from default enabled set Iter 1-5 found that dlmgmtd/ipmgmtd/inetd/rpcbind require svc.configd via libscf for property reads. Without configd, scf_handle_decode_fmri() fails and the daemons silently degrade (ipmgmtd's door never opens, ipadm errors out, lo0 never plumbs, rpcbind cascades). Spec §1.1 v2 BSD-pivot drops them in favor of direct ifconfig invocation. TOMLs retained for opt-in via zygctl enable." ``` --- ### Task 23: Update `network.toml` to drop dlmgmtd/ipmgmtd dependencies **Files:** - Modify: `services/hammerhead/network.toml` `network.toml` currently lists `requires = ["identity", "dlmgmtd", "ipmgmtd"]`. After Task 22 those services aren't enabled, so the dependency edges would dangle. The replacement also drops the v1 comment block referring to `ipadm create-addr`. - [ ] **Step 1: Replace the file contents** Write `services/hammerhead/network.toml`: ```toml # network — Bring up loopback, physical interfaces, routes (BSD-pivot v2) # # Direct /sbin/ifconfig invocation, no ipmgmtd or dlmgmtd. Reads # /etc/hostname. for each physical interface (line-oriented format # documented in start.sh) and /etc/defaultrouter for static default # routes. DHCP is handled via `ifconfig dhcp start`, which forks # /sbin/dhcpagent directly. # # Replaces the v1 SMF stack (network/loopback, network/physical, # network/initial, network/netmask, network/service, network/routing-setup, # dlmgmtd, ipmgmtd) with a single oneshot using only DLPI primitives. [service] name = "network" description = "Bring up network interfaces and routes (BSD-style)" type = "oneshot" [exec] start = "/etc/zyginit/network/start.sh" stop = "/etc/zyginit/network/stop.sh" [dependencies] requires = ["identity"] [restart] on = "never" ``` - [ ] **Step 2: Verify TOML still parses (Linux dry-run)** Run from repo root: ```bash ZYGINIT_CONFIG_DIR=$PWD/services/hammerhead \ ZYGINIT_SOCKET=/tmp/zyg-dry.sock \ ./build/zyginit > /tmp/zyg-dry.log 2>&1 & sleep 1 ZYGINIT_SOCKET=/tmp/zyg-dry.sock tools/zygctl/build/zygctl list | grep network kill %1 2>/dev/null ``` Expected: line of form `network ...` printed; no `requires unknown service` errors in `/tmp/zyg-dry.log`. - [ ] **Step 3: Commit** ```bash hg commit services/hammerhead/network.toml \ -m "services/hammerhead/network: drop dlmgmtd/ipmgmtd deps; declare stop script" ``` --- ### Task 24: Rewrite `network/start.sh` as `/etc/hostname.` parser **Files:** - Modify: `services/hammerhead/network/start.sh` (full rewrite) The v1 script used `ipadm create-if` / `ipadm create-addr` which both go through `ipmgmtd`. The v2 script invokes `/sbin/ifconfig` directly — operates at DLPI/kernel-ioctl layer, no daemon registry needed (verified in spec §13). Line forms supported (per spec §4.4): | Line | Action | |---|---| | (empty/whitespace-only file) | `ifconfig plumb up` | | `# …` or blank line | skipped | | `dhcp` | `ifconfig plumb`; `ifconfig dhcp start` | | `inet /` | `ifconfig inet / up` | | `inet netmask ` | `ifconfig inet netmask up` | | `addif /` | additional alias address (`ifconfig addif`) | | `inet6 /` | `ifconfig inet6 / up` | | `up` / `down` | explicit link state | - [ ] **Step 1: Replace the file contents** Write `services/hammerhead/network/start.sh`: ```zsh #!/bin/zsh # network/start.sh — BSD-style interface/route bring-up (v2) # # No ipmgmtd, no dlmgmtd. Uses /sbin/ifconfig directly for plumbing, # /sbin/dhcpagent (forked by ifconfig dhcp start) for DHCP, and # /usr/sbin/route for static routes from /etc/defaultrouter. # # Walks /etc/hostname. files (one per interface to configure). Each # file is line-oriented; see the case statement below for supported syntax. set -e set -u # Enable [[:space:]]## "one or more" repetition in pattern matching. setopt EXTENDED_GLOB IFCONFIG=/sbin/ifconfig ROUTE=/usr/sbin/route # --- Force-load IP STREAMS modules --- # zyginit's tier ordering runs network/start.sh before SMF's traditional # autoload triggers fire, so socket(PF_INET) returns EAFNOSUPPORT until # the ip / ip6 drivers are explicitly loaded. modload is idempotent: # returns success on already-loaded modules. /sbin/modload /kernel/drv/ip || true /sbin/modload /kernel/drv/ip6 || true # --- Loopback --- # Plumb lo0 explicitly (the kernel auto-attaches it but we want predictable # state). `ifconfig lo0 plumb` is idempotent — repeats just succeed. $IFCONFIG lo0 plumb || true $IFCONFIG lo0 inet 127.0.0.1 netmask 255.0.0.0 up || true $IFCONFIG lo0 inet6 ::1/128 up || true # --- Physical interfaces --- # Each /etc/hostname. file marks an interface to configure (BSD style). # The configure_interface() helper plumbs the named link and applies the # directives in the file. configure_interface() { local iface="$1" local cfgfile="/etc/hostname.${iface}" if [[ ! -e "$cfgfile" ]]; then # No config — leave the interface untouched. (Operator can plumb # by-hand or add a config file and re-run; this matches OpenBSD.) return 0 fi # Plumb first; downstream lines may add addresses to it. $IFCONFIG "$iface" plumb || true # If the config file has no non-comment content, just bring it up. if ! grep -Eq '^[[:space:]]*[^#[:space:]]' "$cfgfile" 2>/dev/null; then $IFCONFIG "$iface" up return 0 fi # Walk the file line by line. while IFS= read -r line || [[ -n "$line" ]]; do # Strip comments, then leading and trailing whitespace. # `[[:space:]]##` is zsh extended glob for "one or more whitespace # chars" — covers tab-indented lines and multi-space margins that # an editor might insert. EXTENDED_GLOB is enabled at script top; # the `\#` escape is needed because EXTENDED_GLOB makes bare `#` a # pattern operator. line="${line%%\#*}" line="${line##[[:space:]]##}" line="${line%%[[:space:]]##}" [[ -z "$line" ]] && continue # Split on first whitespace into verb + rest. local verb="${line%%[[:space:]]*}" local rest="" if [[ "$line" == *[[:space:]]* ]]; then rest="${line#*[[:space:]]}" fi # ${=rest} forces word-splitting on $rest — necessary because # ifconfig args like `192.168.122.50/24 up` need to arrive as # separate arguments, not one quoted string. case "$verb" in dhcp) $IFCONFIG "$iface" dhcp start ;; inet) # `inet /` or `inet netmask ` $IFCONFIG "$iface" inet ${=rest} up ;; addif) $IFCONFIG "$iface" addif ${=rest} up ;; inet6) $IFCONFIG "$iface" inet6 ${=rest} up ;; up|down) $IFCONFIG "$iface" "$verb" ;; *) print -u2 "network: ignoring unknown directive in $cfgfile: $line" ;; esac done < "$cfgfile" } # Iterate /etc/hostname. files — the BSD-style marker for "configure # this interface." Iter 7 boot showed /dev/net/ enumeration silently # skipped vioif0 on Hammerhead (the directory either doesn't exist or # doesn't contain DLPI link nodes for virtio NICs); switching to # hostname.* lookup is both more portable and closer to OpenBSD's # original semantics. for cfg in /etc/hostname.*(N); do fname="${cfg:t}" # ${cfg:t} returns "hostname.vioif0"; strip the prefix. iface="${fname#hostname.}" # Defensively skip loopback (we already handled lo0 above). [[ "$iface" == "lo0" ]] && continue configure_interface "$iface" done # --- Static default routes --- # /etc/defaultrouter is one IPv4 address per line. DHCP-acquired routes # don't need this file; only set one when the operator opts in. if [[ -r /etc/defaultrouter ]]; then while IFS= read -r line || [[ -n "$line" ]]; do # Same trim treatment as the hostname. parser. `\#` is # escaped because EXTENDED_GLOB is set script-wide. line="${line%%\#*}" line="${line##[[:space:]]##}" line="${line%%[[:space:]]##}" [[ -z "$line" ]] && continue $ROUTE -n add default "$line" || true done < /etc/defaultrouter fi exit 0 ``` - [ ] **Step 2: Make sure it's still executable** ```bash chmod +x services/hammerhead/network/start.sh ls -l services/hammerhead/network/start.sh ``` Expected: mode `-rwxr-xr-x` (or compatible). - [ ] **Step 3: Sanity-check syntax with `zsh -n`** ```bash zsh -n services/hammerhead/network/start.sh ``` Expected: no output (clean parse). If your dev host doesn't have `zsh`, skip — the test VM will be the source of truth. - [ ] **Step 4: Commit** ```bash hg commit services/hammerhead/network/start.sh \ -m "services/hammerhead/network/start.sh: BSD-style /etc/hostname. parser Drops ipadm/dladm in favor of direct /sbin/ifconfig invocation. Walks /dev/net/ for physical links, reads /etc/hostname. per interface (line-oriented: dhcp / inet ADDR/PREFIX / addif / inet6 / up / down), and /etc/defaultrouter for static defaults. Verified in spec §13 that ifconfig plumb/inet/dhcp all work without ipmgmtd." ``` --- ### Task 25: Author `network/stop.sh` **Files:** - Create: `services/hammerhead/network/stop.sh` Symmetric reversal: kill any `dhcpagent` we forked, then unplumb non-loopback interfaces. Called during shutdown via the `[exec] stop = ...` line added in Task 23. - [ ] **Step 1: Write the file** Write `services/hammerhead/network/stop.sh`: ```zsh #!/bin/zsh # network/stop.sh — reverse of start.sh (BSD-pivot v2) # # Releases DHCP leases and unplumbs all configured non-loopback interfaces. # Best-effort: each command may fail (e.g., interface already torn down, # dhcpagent already gone) and we continue regardless. ZFS sync happens # in main.reef after this runs. set -u # NOTE: no `set -e` — every step is best-effort during shutdown. IFCONFIG=/sbin/ifconfig # Release any DHCP leases first so the server marks them free. if [[ -d /dev/net ]]; then for dev in /dev/net/*(N); do link="${dev:t}" [[ "$link" == "lo0" ]] && continue # `dhcp release` is idempotent — silently no-op if no lease. $IFCONFIG "$link" dhcp release 2>/dev/null || true done fi # Kill the dhcpagent itself. It manages all leases globally, so one kill # tears down every interface's state at once. pkill -TERM -x dhcpagent 2>/dev/null || true # Unplumb. inet6 first (Hammerhead requires this ordering for clean teardown). if [[ -d /dev/net ]]; then for dev in /dev/net/*(N); do link="${dev:t}" [[ "$link" == "lo0" ]] && continue $IFCONFIG "$link" inet6 unplumb 2>/dev/null || true $IFCONFIG "$link" unplumb 2>/dev/null || true done fi exit 0 ``` - [ ] **Step 2: Make executable** ```bash chmod +x services/hammerhead/network/stop.sh ``` - [ ] **Step 3: Syntax check** ```bash zsh -n services/hammerhead/network/stop.sh ``` Expected: no output. - [ ] **Step 4: Commit** ```bash hg add services/hammerhead/network/stop.sh hg commit -m "services/hammerhead/network: add stop.sh — release DHCP, unplumb interfaces" ``` --- ### Task 26: Author sample `/etc/hostname.` and `/etc/defaultrouter` files **Files:** - Create: `services/hammerhead/examples/etc-hostname.vioif0-static` - Create: `services/hammerhead/examples/etc-hostname.vioif0-dhcp` - Create: `services/hammerhead/examples/etc-defaultrouter` These are reference templates the operator copies onto the test VM during iter 6 install. They don't ship via `install-to-be.sh` (that would clobber operator network config); they're versioned artifacts the operator manually drops into `/etc/` on the target BE. The hh-prototest VM lives on `192.168.122.0/24` behind libvirt's dnsmasq. `192.168.122.50` is inside libvirt's default dnsmasq pool (`192.168.122.2`–`192.168.122.254`); before iter 6 boot, the operator must reserve `.50` via `` in `virsh net-edit default` or shrink the pool's `` to exclude it. The static sample's header comment documents both options. - [ ] **Step 1: Create the examples directory** ```bash mkdir -p services/hammerhead/examples ``` - [ ] **Step 2: Write the static-IP sample** Write `services/hammerhead/examples/etc-hostname.vioif0-static`: ``` # /etc/hostname.vioif0 — static IPv4 for hh-prototest libvirt VM. # Copy to /etc/hostname.vioif0 on the target BE. # # 192.168.122.50 is INSIDE libvirt's default dnsmasq pool # (192.168.122.2-192.168.122.254). Before iter 6 boot, prevent # collision via one of: # 1. Add a host reservation to the libvirt network XML: # virsh net-edit default # ... # 2. Shrink the dnsmasq range, e.g. # # 3. Use an address outside the libvirt subnet entirely (requires extra routing). # # See spec §4.4 for the full /etc/hostname. grammar. inet 192.168.122.50/24 ``` - [ ] **Step 3: Write the DHCP sample** Write `services/hammerhead/examples/etc-hostname.vioif0-dhcp`: ``` # /etc/hostname.vioif0 — DHCP-acquired address for hh-prototest # Copy to /etc/hostname.vioif0 on the target BE. # # Caveat (spec §4.4): /sbin/dhcpagent links libipadm.so.1 which talks to # ipmgmtd's door. Without ipmgmtd running, lease application may degrade. # Use the static sample first to derisk; switch to this once iter 6 # proves DHCP-without-ipmgmtd works. dhcp ``` - [ ] **Step 4: Write the defaultrouter sample** Write `services/hammerhead/examples/etc-defaultrouter`: ``` # /etc/defaultrouter — one IPv4 default-gateway address per line. # Copy to /etc/defaultrouter on the target BE *only* when using the # static-IP sample; DHCP supplies the route automatically. # # 192.168.122.1 is libvirt's default-network virtual router. 192.168.122.1 ``` - [ ] **Step 5: Commit** ```bash hg add services/hammerhead/examples/ hg commit -m "services/hammerhead/examples: sample /etc/hostname. and /etc/defaultrouter For hh-prototest libvirt VM (192.168.122.0/24). Static-IP sample uses .50 (inside default dnsmasq pool — header comment documents the required reservation/range-shrink); DHCP sample documents the libipadm caveat from spec §4.4. Operator copies these onto the target BE during iter 6." ``` --- ### Task 27: Iter 6 boot test — static-IP first, DHCP follow-up **Files:** - N/A (operational) - Modify: `docs/superpowers/specs/2026-04-24-pid1-boot-results.md` (the test log file from Task 19, if it exists; create otherwise) This task replaces the boot procedure originally captured in Task 19 + Task 20, now operating on the v2 service set. The recovery ladder from spec §8.4 still applies; the disk-level backup (`hh-prototest-backup-2026-04-24-pre-zyginit-pid1.qcow2`) and the ~75s recovery cycle are proven. - [ ] **Step 1: Restore from disk backup if the BE from iter 5 is dirty** If the on-disk hh-prototest qcow2 is in a known-bad state from iter 5: ```bash virsh --connect=qemu:///system destroy hh-prototest 2>/dev/null || true sudo cp /var/lib/libvirt/images/hh-prototest-backup-2026-04-24-pre-zyginit-pid1.qcow2 \ /var/lib/libvirt/images/hh-prototest.qcow2 virsh --connect=qemu:///system start hh-prototest ``` Wait for SSH to be reachable: `until ssh -o ConnectTimeout=2 -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 'true' 2>/dev/null; do sleep 5; done`. If iter 5's disk is salvageable (boots into `hammerhead` BE cleanly), skip this step. - [ ] **Step 2: Build fresh binaries on the dev host and ship them** ```bash cd /home/ctusa/repos/zyginit # Hammerhead build target — see CLAUDE.md / MEMORY.md reefc build -l contract (cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o) rsync -a build/zyginit tools/zygctl/build/zygctl services/ scripts/ \ -e 'ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519' \ root@192.168.122.197:/root/zyginit/ ``` Expected: 15 TOMLs in `/root/zyginit/services/hammerhead/`, 15 enabled.d entries, both binaries fresh. - [ ] **Step 3: Create fresh BE and install** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' set -e # If a stale 'zyginit' BE exists from iter 5, destroy it first. beadm list | awk '$1 == "zyginit" {print "exists"}' | grep -q exists && { beadm activate hammerhead beadm destroy -F zyginit } beadm create zyginit beadm mount zyginit /mnt cd /root/zyginit && ./scripts/install-to-be.sh /mnt ls /mnt/etc/zyginit/enabled.d/ | wc -l EOF ``` Expected last line: `15`. - [ ] **Step 4: Drop the static-IP `/etc/hostname.vioif0` and `/etc/defaultrouter` onto the new BE** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' cp /root/zyginit/services/hammerhead/examples/etc-hostname.vioif0-static /mnt/etc/hostname.vioif0 cp /root/zyginit/services/hammerhead/examples/etc-defaultrouter /mnt/etc/defaultrouter cat /mnt/etc/hostname.vioif0 /mnt/etc/defaultrouter EOF ``` Expected: contents of both sample files printed. - [ ] **Step 5: Activate and reboot** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 <<'EOF' beadm umount zyginit beadm activate zyginit beadm list EOF ``` In a separate terminal, attach the console (so we can watch the boot): ```bash virsh --connect=qemu:///system console hh-prototest ``` Then reboot: ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.197 'init 6' ``` - [ ] **Step 6: Verify SSH comes up at 192.168.122.50** The static IP is `.50`, NOT `.197`. After reboot: ```bash until ssh -o ConnectTimeout=2 -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.50 'true' 2>/dev/null; do sleep 3 done ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.50 \ 'ifconfig -a; route -n get default; /sbin/zygctl list' ``` Expected: - `vioif0: ... inet 192.168.122.50 netmask ffffff00 ...` - `route ... default 192.168.122.1` reachable - `zygctl list` shows 15 services, all `RUNNING` (daemons) or `STOPPED exit=0` (oneshots), none in `MAINTENANCE`. - [ ] **Step 7: Run §9.A-D success checklist** ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.50 <<'EOF' echo "--- A. boot ---" uptime ps -p 1 -o pid,comm ps -ef | grep -E 'zyginit|sshd|cron' | grep -v grep echo "--- B. service states ---" /sbin/zygctl list echo "--- C. functional ---" ifconfig vioif0 ping -c 2 192.168.122.1 || echo "ping failed" zfs list | head -5 logger -p user.info "iter6-static marker" tail -5 /var/adm/messages 2>/dev/null || echo "messages unavailable" echo "--- D. zygctl e2e ---" /sbin/zygctl status cron /sbin/zygctl stop cron /sbin/zygctl status cron /sbin/zygctl start cron /sbin/zygctl status cron EOF ``` Record pass/fail for each line in `docs/superpowers/specs/2026-04-24-pid1-boot-results.md`. Spec §9 is the source of truth for what counts as pass. - [ ] **Step 8: Iter 6 DHCP variant (only if Step 7 fully passes)** Switch the interface to DHCP and reboot: ```bash ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@192.168.122.50 <<'EOF' cp /root/zyginit/services/hammerhead/examples/etc-hostname.vioif0-dhcp /etc/hostname.vioif0 rm -f /etc/defaultrouter init 6 EOF ``` After reboot, the VM may come up at `192.168.122.197` (its previous DHCP lease), at any other address libvirt's dnsmasq hands out, or — if DHCP-via-dhcpagent degrades without ipmgmtd — without an address at all. Probe behavior: ```bash # Watch the libvirt DHCP leases: virsh --connect=qemu:///system net-dhcp-leases default # And the console for ifconfig errors during boot. ``` Three outcomes: 1. **DHCP works** → record pass in spec §13 ("DHCP-via-dhcpagent without ipmgmtd: confirmed"); update `examples/etc-hostname.vioif0-dhcp` comment to remove the caveat. 2. **DHCP partially works** (lease acquired, address not applied) → log the failure mode, leave the static sample as the documented default, file follow-up to investigate `dhcpagent -a -f` adopt mode. 3. **DHCP fails entirely** → log, revert to the static sample with concrete commands: ```bash cp /root/zyginit/services/hammerhead/examples/etc-hostname.vioif0-static /etc/hostname.vioif0 cp /root/zyginit/services/hammerhead/examples/etc-defaultrouter /etc/defaultrouter ``` Then document in spec §13 / §11 that DHCP needs a separate design. - [ ] **Step 9: Update the test results file** Write or extend `docs/superpowers/specs/2026-04-24-pid1-boot-results.md` with iter 6 outcomes (sections A through F per spec §9). Commit: ```bash hg add docs/superpowers/specs/2026-04-24-pid1-boot-results.md 2>/dev/null || true hg commit docs/superpowers/specs/2026-04-24-pid1-boot-results.md \ -m "docs: iter 6 PID-1 boot results — v2 BSD-pivot service set" ``` - [ ] **Step 10: Run shutdown + rollback drills (Task 20 + Task 21)** Tasks 20 and 21 still apply unchanged for v2 — they exercise `zygctl halt`/`reboot`/`poweroff` and the BE rollback path. Run them now against the iter-6 BE and append results to the same `2026-04-24-pid1-boot-results.md` file. --- ## Phase 7 — Wrap-up ### Task 28: Update CLAUDE.md, README.md, ROADMAP.md with results **Files:** - Modify: `CLAUDE.md` - Modify: `README.md` - Modify: `ROADMAP.md` - [ ] **Step 1: Mark PID-1 boot as complete in ROADMAP.md** Open `ROADMAP.md`. Find the "PID 1 Boot Testing" section. Change the unchecked items under it to `[x]` for the ones that passed in the iter-6 boot test (per the results file from Task 27). - [ ] **Step 2: Add a note in CLAUDE.md about the new service set** Under the source layout section, add: ``` - `services/hammerhead/` — Production Hammerhead service set (15 services enabled by default after the v2 BSD-pivot; 6 more TOMLs ship for opt-in via `zygctl enable`. Distinct from `services/examples/` Linux scaffolding.) - `services/hammerhead/examples/` — Reference `/etc/hostname.` and `/etc/defaultrouter` templates for hh-prototest; operator-copied, not shipped via install-to-be.sh. ``` - [ ] **Step 3: Commit** ```bash hg commit CLAUDE.md README.md ROADMAP.md \ -m "docs: mark PID-1 boot milestone complete; document services/hammerhead/ v2" ``` --- ## Self-Review After writing the plan, cross-check each spec section against tasks: **Spec coverage:** - §1.1 BSD-pivot rationale → Phase 6.5 prelude documents the configd wall and §1.1's three-choice decision - §3.1 Components → Task 15 (installer copies binaries + config tree); unchanged in v2 - §3.2 Boot sequence → Tasks 9-14 + Task 23 (TOMLs); §3.2's tier ordering is enforced by rev 47, not re-implemented in v2 - §3.3 PID-1 vs non-PID-1 → Tasks 3, 5 (detection + branching) - §4 Service set v2 (15 services) → Tasks 22-26 produce the diff from v1's 21-service set; the 4 dropped enabled.d entries align with §4.7/§4.8 - §4.4 `/etc/hostname.` line forms → Task 24's case statement implements every row of the spec table (dhcp / inet / addif / inet6 / up / down) - §4.4 `/etc/defaultrouter` → Task 24's tail block reads it; Task 26 ships a sample - §4.7 Optional-but-not-enabled set → Task 22 keeps the 4 TOMLs in `services/hammerhead/` while removing only the symlinks - §5 Schema usage → Tasks 9-14 + Task 23 (TOML content) - §6 Script conventions → Tasks 24, 25 follow the `#!/bin/zsh` + `set -e -u` template; Task 25 deliberately drops `set -e` per the convention's "best-effort during shutdown" allowance - §7 Shutdown orchestration → Tasks 1-6 (code changes), Task 8 (integration test); v2 adds `network/stop.sh` (Task 25) which is invoked by the existing reverse-tier walk - §8 Rollout phases → Phase 5 (dry run), Phase 6 + 6.5 (BE boot), Task 21 (rollback drill); Task 27 Step 1 reuses §8.4's disk-restore recovery - §9 Success criteria → Tasks 19-21 + Task 27 Step 7-10 run the checklist - §11 Deferrals → Task 7 ships `single`/`multiuser` stubs; Task 27 Step 8 outcomes feed §11's "configd-coupled daemons re-add" deferral - §13 Open questions → Task 27 Step 8 empirically resolves the DHCP-without-ipmgmtd question **Placeholder scan:** none — all TOMLs, scripts, and code blocks contain actual content. Task 27 Steps 7-8 do reference "record results" actions, but the recording targets are concrete (specific spec sections / file path). **Type consistency:** shutdown-type constants (SHUT_HALT=1, SHUT_REBOOT=2, SHUT_POWEROFF=3) unchanged in v2. The `network` service name (depended on by syseventd/syslogd/utmpd/pfexecd in tier 4) is preserved across Task 23 — only the inner `requires` list of `network.toml` itself changes. `network/start.sh` and `network/stop.sh` paths match the `[exec] start/stop` lines in the revised TOML. **Scope check:** Phase 6.5 is contained within the existing PID-1-boot milestone; the v2 pivot replaces 4 enabled-set members and one start.sh, but doesn't introduce a new subsystem. Single plan remains correct. --- ## Plan complete Plan saved to `docs/superpowers/plans/2026-04-24-pid1-boot.md`. # Contract Re-adoption (Live Replace) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Spec:** `docs/superpowers/specs/2026-05-02-contract-readoption-design.md` (rev 84). **Goal:** Allow `zygctl replace` to upgrade the running zyginit PID-1 process via in-place `execve("/sbin/init", ...)` while preserving every running service across the swap, with state-file handoff for in-memory bookkeeping. **Architecture:** New `src/replace.reef` module owns the state-file format, serialization, recovery, and pre-condition check. `main.reef` gains a `replace_self` proc (outgoing flow: drain, write state, unlink socket, exec) and an `apply_recovered_state` proc (incoming flow: stitch state.toml against current `enabled.d/`, register `contract_map`, idempotent post-recovery empty-contract check). `socket.reef` adds a `replace [--wait=N]` command. `contract.reef` adds an `is_contract_empty` helper. `zygctl.reef` adds the `replace` subcommand. **No `ct_ctl_adopt` call is needed**: PID-1 in-place execve preserves `proc_t.p_ct_process` so kernel-level contract ownership survives automatically; only userspace bookkeeping needs rebuilding. **Tech Stack:** Reef 0.4.0+, Hammerhead libcontract, POSIX FFI, TOML (existing `encoding.toml` parser), Mercurial (`hg`) for VCS. **Conventions** — pulled from CLAUDE.md / SRCHEADER.txt. **All new source files MUST start with the SRCHEADER.txt template** with `${project}=zyginit`, `${file.name}` = the filename, `${file.description}` filled in. **VCS is `hg` not `git`.** **License is CDDL-1.0.** **"Hammerhead" not "illumos"** in code/comments. **No emojis** in source. **Build commands** (used in test/verify steps throughout): ```bash # Linux dev (stubs the contract bindings) clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o # zygctl Linux cd tools/zygctl clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o reefc build --obj build/symlink_wrapper.o cd ../.. ``` --- ## File structure | File | Action | Responsibility | |---|---|---| | `src/helpers.c` | Modify | Add `zyginit_read_boot_time` C helper. | | `src/replace.reef` | Create (~250 lines) | State-file format, serialize, recover, pre-condition check. | | `src/contract.reef` | Modify (+~25 lines) | Add `is_contract_empty(ctid): bool` helper. | | `src/contract_linux_stubs.c` | Modify (+5 lines) | Linux stub for `is_contract_empty`. | | `src/socket.reef` | Modify (+~50 lines) | Add `replace [--wait=N]` command, flag, accessors. | | `src/main.reef` | Modify (+~120 lines) | Add `apply_recovered_state`, `replace_self`, integrate into startup + event loop. | | `tools/zygctl/src/zygctl.reef` | Modify (+~30 lines) | Add `replace [--wait=N]` subcommand. | | `utils/replace_probe/` | Create | Standalone Reef program for state-file format unit tests, modeled on `utils/contract_probe/`. | | `tests/integration/run_tests.sh` | Modify | Add Linux integration scenarios (smoke + pre-condition refusal). | | `tests/integration/test_replace_hammerhead.md` | Create | Manual checklist for Hammerhead `hh-prototest` validation. | | `man/zygctl.8` | Modify | Document `zygctl replace [--wait=N]`. | | `man/zyginit.8` | Modify | Document state.toml file path + replace flow at a high level. | --- ## Task 1: Add `zyginit_read_boot_time` C helper **Files:** - Modify: `src/helpers.c` (append a new function near the existing utmpx writers around line 150) The helper reads `BOOT_TIME` from `/var/adm/utmpx` and returns its `ut_tv.tv_sec` as `int`, or `-1` if not found / not readable. This becomes the boot-id sentinel that survives execve and lets the new zyginit detect a stale state.toml from before a real reboot. - [ ] **Step 1: Read existing helpers.c around the BOOT_TIME writer to confirm placement and headers** Run: `grep -n 'BOOT_TIME\|getutxent\|setutxent\|endutxent' src/helpers.c` Expected: see `BOOT_TIME` referenced in `zyginit_write_boot_utmpx`, header `utmpx.h` already included. - [ ] **Step 2: Add the read helper after `zyginit_write_runlvl_utmpx`** Append to `src/helpers.c`: ```c /* Read the BOOT_TIME utmpx record's tv_sec value, used as the boot-id * sentinel for the live-replace state file. PID-1 in-place execve * preserves the same proc_t but the kernel does not preserve a "boot * id" anywhere obvious — utmpx BOOT_TIME survives because it lives in * /var/adm/utmpx, written by zyginit_write_boot_utmpx() at boot. A * real reboot writes a new BOOT_TIME with a different tv_sec, so * comparing against it lets the new zyginit detect a stale state file. * * Returns the BOOT_TIME tv_sec on success, or -1 if the file is not * readable, has no BOOT_TIME entry, or any utmpx call fails. */ int zyginit_read_boot_time(void) { struct utmpx *up; int result = -1; setutxent(); while ((up = getutxent()) != NULL) { if (up->ut_type == BOOT_TIME) { result = (int) up->ut_tv.tv_sec; break; } } endutxent(); return result; } ``` - [ ] **Step 3: Build helpers.o on Linux to verify it compiles** Run: `clang -c src/helpers.c -o build/helpers.o` Expected: no errors, no warnings. (`utmpx.h` is in libc; on Linux it's `glibc`'s utmpx.) - [ ] **Step 4: Commit** ```bash hg add src/helpers.c hg commit -m "helpers: add zyginit_read_boot_time for live-replace boot-id check" ``` (Note: `hg add` is a no-op for already-tracked files, but harmless.) --- ## Task 2: Skeleton `src/replace.reef` module **Files:** - Create: `src/replace.reef` Empty-bodied module with SRCHEADER, FFI extern, and the export block that subsequent tasks will fill in. Establishing the surface up front lets later tasks be small and incremental. - [ ] **Step 1: Create the file with header and module declaration** Write `src/replace.reef`: ```reef /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: replace.reef Authors: Chris Tusa License: Description: Live-replace state-file handoff (operator-driven PID-1 upgrade) ******************************************************************************/ // Live-replace operator flow: zygctl replace -> old zyginit serializes // runtime state to /var/run/zyginit/state.toml, execs /sbin/init in // place; new zyginit reads state.toml, validates boot_time matches // current utmpx BOOT_TIME (else stale across a real reboot), and // rebuilds the contract_map / runtime fields in supervisor.ServiceTable. // // PID-1 in-place execve preserves proc_t.p_ct_process at the kernel // level, so contract ownership survives automatically — no // ct_ctl_adopt call is needed. This module owns userspace bookkeeping. module replace import config import supervisor import io.file import sys.fd as fd import sys.env as sysenv import core.str import encoding.toml import time.time as time export type RecoveredService type RecoveredState // Accessors — RecoveredState fn rs_count(s: RecoveredState): int fn rs_service(s: RecoveredState, idx: int): RecoveredService fn rs_boot_time(s: RecoveredState): int fn rs_is_empty(s: RecoveredState): bool // Accessors — RecoveredService fn svc_name(rs: RecoveredService): string fn svc_contract_id(rs: RecoveredService): int fn svc_restart_count(rs: RecoveredService): int fn svc_last_start_time(rs: RecoveredService): int fn svc_last_exit_code(rs: RecoveredService): int // Outgoing flow — write state, return true on success fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool // Incoming flow — read state file. Returns an empty RecoveredState // if the file is missing, corrupt, or has a mismatched boot_time. fn recover_state(current_boot_time: int): RecoveredState // Pre-condition check. Returns "" if all services are stable, else // a comma-separated "name:state" list suitable for logging. fn check_preconditions(table: supervisor.ServiceTable): string // FFI for boot-time sentinel (helpers.c) fn read_boot_time(): int // Constants fn STATE_FILE_PATH(): string fn STATE_TMP_PATH(): string fn STATE_DIR(): string end export // FFI — implemented in helpers.c extern "C" fn zyginit_read_boot_time(): int extern "C" fn zyginit_fsync_path(path: string): int // FFI — POSIX extern "C" fn open(path: string, flags: int, mode: int): int extern "C" fn close(fd: int): int extern "C" fn rename(oldpath: string, newpath: string): int extern "C" fn unlink(path: string): int // Reads utmpx BOOT_TIME via the C helper. Returns -1 if unavailable. fn read_boot_time(): int return zyginit_read_boot_time() end read_boot_time // File-system layout. Keep grouped here so a future tmpfs migration // only edits one place. fn STATE_DIR(): string return sysenv.get_env_or("ZYGINIT_RUN_DIR", "/var/run/zyginit") end STATE_DIR fn STATE_FILE_PATH(): string return str.concat(STATE_DIR(), "/state.toml") end STATE_FILE_PATH fn STATE_TMP_PATH(): string return str.concat(STATE_DIR(), "/state.toml.tmp") end STATE_TMP_PATH end module ``` - [ ] **Step 2: Add to `reef.toml` if it lists modules explicitly (it doesn't currently — `source_dirs = ["src"]`); confirm with build** Run: `clang -c src/helpers.c -o build/helpers.o && clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o && reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o` Expected: build fails with errors about missing function bodies (`rs_count`, `serialize_state`, etc. are exported but not yet implemented). This is expected — subsequent tasks fill them in. If reefc complains about exporting types/fns without bodies in the same module, comment out the export block temporarily by wrapping it in `/* ... */` (Reef does not support `/* */` comments — use line `//` per item) until enough is in place. The skeleton below in Task 3+ will populate types and fn bodies in the order needed to keep the build happy. If `reefc` allows undefined exports as forward declarations, the build will fail on the first reference site instead — that's fine, fix it as the next task adds bodies. - [ ] **Step 3: Commit even though build is incomplete** ```bash hg add src/replace.reef hg commit -m "replace: skeleton module with FFI declarations and exports" ``` --- ## Task 3: Define `RecoveredService` and `RecoveredState` types **Files:** - Modify: `src/replace.reef` Add the types and trivial accessors. After this task the module compiles even with stub bodies for the larger fns. - [ ] **Step 1: Add type definitions before the `end module` at the bottom of `src/replace.reef`** ```reef // ============================================================================ // Types // ============================================================================ type RecoveredService = struct name: string contract_id: int restart_count: int last_start_time: int last_exit_code: int end RecoveredService type RecoveredState = struct boot_time: int services: [RecoveredService] count: int end RecoveredState fn new_recovered_state(): RecoveredState return RecoveredState{ boot_time: 0 - 1, services: new [RecoveredService](0), count: 0 } end new_recovered_state // ============================================================================ // Accessors // ============================================================================ fn rs_count(s: RecoveredState): int return s.count end rs_count fn rs_service(s: RecoveredState, idx: int): RecoveredService return s.services[idx] end rs_service fn rs_boot_time(s: RecoveredState): int return s.boot_time end rs_boot_time fn rs_is_empty(s: RecoveredState): bool return s.count == 0 end rs_is_empty fn svc_name(rs: RecoveredService): string return rs.name end svc_name fn svc_contract_id(rs: RecoveredService): int return rs.contract_id end svc_contract_id fn svc_restart_count(rs: RecoveredService): int return rs.restart_count end svc_restart_count fn svc_last_start_time(rs: RecoveredService): int return rs.last_start_time end svc_last_start_time fn svc_last_exit_code(rs: RecoveredService): int return rs.last_exit_code end svc_last_exit_code ``` - [ ] **Step 2: Add stub bodies for the three larger fns so the module compiles. They are filled in by Tasks 4, 5, and 6.** ```reef // ============================================================================ // Outgoing — stub (filled by Task 4) // ============================================================================ fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool return false end serialize_state // ============================================================================ // Incoming — stub (filled by Task 5) // ============================================================================ fn recover_state(current_boot_time: int): RecoveredState return new_recovered_state() end recover_state // ============================================================================ // Pre-condition check — stub (filled by Task 6) // ============================================================================ fn check_preconditions(table: supervisor.ServiceTable): string return "" end check_preconditions ``` - [ ] **Step 3: Build** Run: `clang -c src/helpers.c -o build/helpers.o && clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o && reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o` Expected: build succeeds. zyginit binary produced at `build/zyginit`. The replace module is compiled but unused. - [ ] **Step 4: Commit** ```bash hg commit -m "replace: types, accessors, and stub bodies for serialize/recover/check" ``` --- ## Task 4: Implement `serialize_state` (TDD via `utils/replace_probe`) **Files:** - Create: `utils/replace_probe/reef.toml` - Create: `utils/replace_probe/src/main.reef` - Modify: `src/replace.reef` (replace the stub `serialize_state` body) Round-trip is verified in Task 5 (after `recover_state` is implemented). For now, the probe just calls `serialize_state` against a synthetic table and dumps the resulting file to stdout for visual inspection. - [ ] **Step 1: Scaffold `utils/replace_probe/`** Create `utils/replace_probe/reef.toml`: ```toml [package] name = "replace_probe" version = "0.1.0" author = "Chris Tusa " description = "Unit-test probe for zyginit live-replace state-file format" license = "CDDL-1.0" [build] entry = "src/main.reef" output = "replace_probe" output_dir = "build" source_dirs = ["src", "../../src"] # This probe round-trips state.toml serialization without needing a # running zyginit. Models utils/contract_probe. # # Linux: clang -c ../../src/helpers.c -o build/helpers.o && \ # clang -c ../../src/contract_linux_stubs.c -o build/contract_linux_stubs.o && \ # reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Create `utils/replace_probe/src/main.reef` (test harness — exercises serialize and, in Task 5, also recover): ```reef /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: main.reef Authors: Chris Tusa License: Description: Probe — round-trip the state.toml format through replace.reef ******************************************************************************/ import config import supervisor import replace import core.str import sys.env as sysenv mut g_pass: int = 0 mut g_fail: int = 0 proc check(label: string, ok: bool) if ok g_pass = g_pass + 1 println(" PASS: " + label) else g_fail = g_fail + 1 println(" FAIL: " + label) end if end check // Build a synthetic ServiceTable with known data. Uses minimal // ServiceDef fields — enough for serialize/recover round-trip. fn build_synthetic_table(): supervisor.ServiceTable let table = supervisor.new_service_table(8) // sshd: RUNNING with restart_count=2 and last_exit_code=0 let def_sshd = config.new_service_def_minimal("sshd") let idx_sshd = supervisor.add_service(table, def_sshd) supervisor.set_runtime_for_test(table, idx_sshd, supervisor.STATE_RUNNING(), 100, 23, 2, 0, 1746204051) // syslogd: RUNNING with no restarts let def_syslog = config.new_service_def_minimal("syslogd") let idx_syslog = supervisor.add_service(table, def_syslog) supervisor.set_runtime_for_test(table, idx_syslog, supervisor.STATE_RUNNING(), 101, 17, 0, 0 - 1, 1746204047) // cron: STOPPED — must NOT be serialized let def_cron = config.new_service_def_minimal("cron") let idx_cron = supervisor.add_service(table, def_cron) supervisor.set_runtime_for_test(table, idx_cron, supervisor.STATE_STOPPED(), 0 - 1, 0 - 1, 0, 0, 0) return table end build_synthetic_table proc test_serialize() println("== serialize_state ==") // Use $TMPDIR to avoid touching /var/run during dev tests. let tmpdir = sysenv.get_env_or("TMPDIR", "/tmp") sysenv.set_env("ZYGINIT_RUN_DIR", str.concat(tmpdir, "/replace_probe")) let table = build_synthetic_table() let ok = replace.serialize_state(table, 1746204000) check("serialize returns true on writable dir", ok) // (Round-trip assertions added in Task 5.) end test_serialize proc main() test_serialize() println("") println("--- " + int_to_str(g_pass) + " passed, " + int_to_str(g_fail) + " failed ---") if g_fail > 0 sysenv.exit(1) end if end main fn int_to_str(n: int): string if n == 0 return "0" end if mut value = n mut neg = false if n < 0 value = 0 - n neg = true end if mut result = "" while value > 0 let digit = value % 10 result = str.concat(str.substring("0123456789", digit, 1), result) value = value / 10 end while if neg result = str.concat("-", result) end if return result end int_to_str ``` The probe references three new helpers in `supervisor` and `config` that don't exist yet: - `config.new_service_def_minimal(name): ServiceDef` — constructs a ServiceDef with `name` set and other fields at default. Add it as a tiny `#[cfg(test)]`-equivalent helper at the bottom of `config.reef`'s export block, but unconditionally exported (Reef has no cfg-test gate). - `supervisor.set_runtime_for_test(table, idx, state, pid, ctid, restarts, last_exit, last_start)` — directly writes the runtime fields on a `ServiceRuntime`, bypassing the normal start/stop lifecycle. Add to `supervisor.reef`'s export block. - [ ] **Step 2: Add `config.new_service_def_minimal` to `src/config.reef`** Locate the export block in `src/config.reef`, add the line `fn new_service_def_minimal(name: string): ServiceDef` to it. Find the place where `ServiceDef` is constructed (search for `ServiceDef{`) and add this fn body near it: ```reef // Test/probe helper — minimal ServiceDef used by utils/replace_probe. // Not used by production zyginit code paths. fn new_service_def_minimal(name: string): ServiceDef return ServiceDef{ name: name, description: "", type_: SERVICE_TYPE_DAEMON(), start_cmd: "/bin/true", stop_cmd: "", working_dir: "", user: "", group: "", environment: new [string](0), environment_count: 0, stop_method: STOP_METHOD_CONTRACT(), stop_signal: "TERM", stop_timeout: 60, requires: new [string](0), requires_count: 0, after: new [string](0), after_count: 0, contract_params: 0, contract_fatal: 0, restart_policy: RESTART_FAILURE(), restart_delay: 5, max_retries: 0 - 1, runlevel_mode: RUNLEVEL_MODE_MULTI() } end new_service_def_minimal ``` Field names and constants in this constructor must match the actual `ServiceDef` struct in `config.reef`. Open the file, find `type ServiceDef = struct`, and copy the field list verbatim — adjust the constructor above if names differ. (Some fields may not exist in the struct as written here; that's OK — adapt to the real fields.) - [ ] **Step 3: Add `supervisor.set_runtime_for_test` to `src/supervisor.reef`** Add to the export block: `proc set_runtime_for_test(table: ServiceTable, idx: int, state: int, pid: int, contract_id: int, restart_count: int, last_exit_code: int, last_start_time: int)`. Then add the body near the other accessors: ```reef // Test/probe helper — directly write runtime fields on a ServiceRuntime, // bypassing the normal start/stop lifecycle. Used only by // utils/replace_probe; production code goes through start_service / // handle_contract_event. proc set_runtime_for_test(table: ServiceTable, idx: int, state: int, pid: int, contract_id: int, restart_count: int, last_exit_code: int, last_start_time: int) let rt = table.runtimes[idx] rt.state = state rt.pid = pid rt.contract_id = contract_id rt.restart_count = restart_count rt.last_exit_code = last_exit_code rt.last_start_time = last_start_time table.runtimes[idx] = rt // Also register contract mapping so apply_recovered_state can find // it later if the test exercises recovery. if contract_id >= 0 table.contract_map.set(int_to_str_helper(contract_id), idx) end if end set_runtime_for_test // Local helper — int_to_str is private in this module; if not already // present scan for it. (It exists at line ~870 as a private fn — reuse.) fn int_to_str_helper(n: int): string return int_to_str(n) end int_to_str_helper ``` - [ ] **Step 4: Implement `serialize_state` in `src/replace.reef`** Replace the stub. The body opens a tmp file, writes the boot_time line then a `[[service]]` block per RUNNING service, fsyncs, closes, renames to the final path, fsyncs the parent dir. ```reef fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool let tmp_path = STATE_TMP_PATH() let final_path = STATE_FILE_PATH() let dir_path = STATE_DIR() // O_WRONLY|O_CREAT|O_TRUNC = 0x301 on Hammerhead/Linux for the values // we use in fd.O_*. Using fd module's helpers keeps this portable. let flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_TRUNC() let out = fd.fd_open(tmp_path, flags, 384) // mode 0600 if out < 0 println("replace: serialize: cannot open " + tmp_path) return false end if // Write boot_time header let header = str.concat("boot_time = ", int_to_str(boot_time)) fd.fd_write(out, str.concat(header, "\n")) // Iterate services — only RUNNING ones go in. Skip the rest. let count = supervisor.service_count(table) mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) if supervisor.rt_state(rt) == supervisor.STATE_RUNNING() let def = supervisor.rt_def(rt) fd.fd_write(out, "\n[[service]]\n") fd.fd_write(out, str.concat("name = \"", str.concat(config.svc_name(def), "\"\n"))) fd.fd_write(out, str.concat("contract_id = ", str.concat(int_to_str(supervisor.rt_contract_id(rt)), "\n"))) fd.fd_write(out, str.concat("restart_count = ", str.concat(int_to_str(supervisor.rt_restart_count(rt)), "\n"))) fd.fd_write(out, str.concat("last_start_time = ", str.concat(int_to_str(supervisor.rt_last_start_time(rt)), "\n"))) fd.fd_write(out, str.concat("last_exit_code = ", str.concat(int_to_str(supervisor.rt_last_exit_code(rt)), "\n"))) end if i = i + 1 end while // fsync + close if fd.fd_fsync(out) < 0 fd.fd_close(out) unlink(tmp_path) return false end if fd.fd_close(out) // Atomic rename if rename(tmp_path, final_path) != 0 unlink(tmp_path) return false end if // fsync the parent dir so the rename is durable let _ = zyginit_fsync_path(dir_path) return true end serialize_state // Local int_to_str — same shape as in supervisor.reef. Replicate to // keep the module standalone. fn int_to_str(n: int): string if n == 0 return "0" end if mut value = n mut neg = false if n < 0 value = 0 - n neg = true end if mut result = "" while value > 0 let digit = value % 10 result = str.concat(str.substring("0123456789", digit, 1), result) value = value / 10 end while if neg result = str.concat("-", result) end if return result end int_to_str ``` The `fd.fd_fsync` call may not exist if Reef's `sys.fd` doesn't expose it — if reefc errors on that name, replace with a direct `extern "C" fn fsync(fd: int): int` declaration in this module and call it as `fsync(out)`. - [ ] **Step 5: Build the main project to verify replace.reef compiles** Run: `clang -c src/helpers.c -o build/helpers.o && clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o && reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o` Expected: success. - [ ] **Step 6: Build the probe** Run: ```bash cd utils/replace_probe mkdir -p build clang -c ../../src/helpers.c -o build/helpers.o clang -c ../../src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o cd ../.. ``` Expected: success. - [ ] **Step 7: Run the probe and inspect the file** Run: ```bash mkdir -p /tmp/replace_probe ./utils/replace_probe/build/replace_probe cat /tmp/replace_probe/state.toml ``` Expected: - "PASS: serialize returns true on writable dir" - File contents: ``` boot_time = 1746204000 [[service]] name = "sshd" contract_id = 23 restart_count = 2 last_start_time = 1746204051 last_exit_code = 0 [[service]] name = "syslogd" contract_id = 17 restart_count = 0 last_start_time = 1746204047 last_exit_code = -1 ``` - `cron` (STATE_STOPPED) is **not** in the output. - [ ] **Step 8: Commit** ```bash hg add utils/replace_probe/reef.toml utils/replace_probe/src/main.reef hg commit -m "replace: implement serialize_state + replace_probe round-trip harness" ``` --- ## Task 5: Implement `recover_state` (TDD via `utils/replace_probe`) **Files:** - Modify: `src/replace.reef` (replace the stub `recover_state` body) - Modify: `utils/replace_probe/src/main.reef` (add round-trip + edge-case assertions) `recover_state` reads the state.toml file via the existing `encoding.toml` parser, validates `boot_time`, and returns either an empty `RecoveredState` (if file missing/corrupt/stale) or a populated one. The empty/stale path also deletes the file. - [ ] **Step 1: Implement `recover_state`** Replace the stub in `src/replace.reef`: ```reef fn recover_state(current_boot_time: int): RecoveredState let path = STATE_FILE_PATH() if not file.fileExists(path) return new_recovered_state() end if let content = file.readFile(path) if str.length(content) == 0 // Treat empty as missing let _ = unlink(path) return new_recovered_state() end if // Parse via encoding.toml. Section-array entries flatten to // dotted-key arrays; the parser exposes them via toml.get_array. // The helper below wraps a parse + extraction. let parsed = toml.parse(content) if not toml.parse_ok(parsed) println("replace: recover: parse error in " + path + " — ignoring") let _ = unlink(path) return new_recovered_state() end if let saved_boot = toml.get_int_or(parsed, "boot_time", 0 - 1) if saved_boot != current_boot_time or current_boot_time < 0 println("replace: recover: stale state file (boot_time " + int_to_str(saved_boot) + " != current " + int_to_str(current_boot_time) + "); ignoring") let _ = unlink(path) return new_recovered_state() end if let n = toml.array_count(parsed, "service") let arr = new [RecoveredService](n) mut i = 0 while i < n let prefix = str.concat("service.", int_to_str(i)) arr[i] = RecoveredService{ name: toml.get_string_or(parsed, str.concat(prefix, ".name"), ""), contract_id: toml.get_int_or(parsed, str.concat(prefix, ".contract_id"), 0 - 1), restart_count: toml.get_int_or(parsed, str.concat(prefix, ".restart_count"), 0), last_start_time: toml.get_int_or(parsed, str.concat(prefix, ".last_start_time"), 0), last_exit_code: toml.get_int_or(parsed, str.concat(prefix, ".last_exit_code"), 0 - 1) } i = i + 1 end while // State file consumed — delete to avoid panic-restart loops. let _ = unlink(path) return RecoveredState{ boot_time: saved_boot, services: arr, count: n } end recover_state ``` The `toml.parse_ok`, `toml.get_int_or`, `toml.get_string_or`, `toml.array_count` helper names are placeholders — open `~/repos/reef-lang/stdlib/encoding/toml.reef` (or wherever the project's encoding.toml lives — try `grep -rn 'module toml' ~/repos/reef-lang/`) and use the actual function names. The existing `config.load_enabled_services` in `src/config.reef` is a working example of how to walk a parsed TOML array — copy its access pattern verbatim if the helper names above don't exist. - [ ] **Step 2: Extend the probe with round-trip + edge-case assertions** Replace `test_serialize()` in `utils/replace_probe/src/main.reef` with: ```reef proc test_round_trip() println("== round-trip ==") let tmpdir = sysenv.get_env_or("TMPDIR", "/tmp") sysenv.set_env("ZYGINIT_RUN_DIR", str.concat(tmpdir, "/replace_probe")) // Make sure the state dir exists let _ = io.dir.create_dir_all(str.concat(tmpdir, "/replace_probe")) let table = build_synthetic_table() let serialized = replace.serialize_state(table, 1746204000) check("serialize returns true", serialized) let recovered = replace.recover_state(1746204000) check("recover boot_time matches", replace.rs_boot_time(recovered) == 1746204000) check("recover count == 2 (running only)", replace.rs_count(recovered) == 2) // Find services by name (order is implementation-defined) mut found_sshd = false mut found_syslog = false mut i = 0 while i < replace.rs_count(recovered) let svc = replace.rs_service(recovered, i) let name = replace.svc_name(svc) if name == "sshd" found_sshd = true check("sshd contract_id = 23", replace.svc_contract_id(svc) == 23) check("sshd restart_count = 2", replace.svc_restart_count(svc) == 2) check("sshd last_exit_code = 0", replace.svc_last_exit_code(svc) == 0) elif name == "syslogd" found_syslog = true check("syslogd contract_id = 17", replace.svc_contract_id(svc) == 17) check("syslogd last_exit_code = -1", replace.svc_last_exit_code(svc) == 0 - 1) end if i = i + 1 end while check("found sshd", found_sshd) check("found syslogd", found_syslog) // After recover_state, state.toml is deleted check("state.toml deleted after recovery", not io.file.fileExists(replace.STATE_FILE_PATH())) end test_round_trip proc test_stale_boot_time() println("== stale state file ==") let tmpdir = sysenv.get_env_or("TMPDIR", "/tmp") sysenv.set_env("ZYGINIT_RUN_DIR", str.concat(tmpdir, "/replace_probe")) let table = build_synthetic_table() let _ = replace.serialize_state(table, 1746204000) // Recover with DIFFERENT boot_time -> stale -> empty + deleted let recovered = replace.recover_state(1746999999) check("stale file -> empty state", replace.rs_is_empty(recovered)) check("stale file deleted", not io.file.fileExists(replace.STATE_FILE_PATH())) end test_stale_boot_time proc test_missing_file() println("== missing state file ==") let tmpdir = sysenv.get_env_or("TMPDIR", "/tmp") sysenv.set_env("ZYGINIT_RUN_DIR", str.concat(tmpdir, "/replace_probe_missing")) // Make sure file definitely doesn't exist let _ = io.dir.create_dir_all(str.concat(tmpdir, "/replace_probe_missing")) let _ = unlink(replace.STATE_FILE_PATH()) let recovered = replace.recover_state(1746204000) check("missing file -> empty state", replace.rs_is_empty(recovered)) end test_missing_file extern "C" fn unlink(path: string): int proc main() test_round_trip() test_stale_boot_time() test_missing_file() println("") println("--- " + int_to_str(g_pass) + " passed, " + int_to_str(g_fail) + " failed ---") if g_fail > 0 sysenv.exit(1) end if end main ``` Add `import io.dir` and `import io.file` near the top of the probe. - [ ] **Step 3: Build and run** Run: ```bash cd utils/replace_probe reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/replace_probe cd ../.. ``` Expected output ends with `--- 11 passed, 0 failed ---` (1 from serialize + 10 from round_trip / stale / missing — adjust if asserts differ). If it fails, the most likely cause is the TOML parser helper names. Read `encoding.toml`'s actual API and adjust `recover_state`. - [ ] **Step 4: Commit** ```bash hg commit -m "replace: implement recover_state with round-trip + edge-case probe tests" ``` --- ## Task 6: Implement `check_preconditions` (TDD) **Files:** - Modify: `src/replace.reef` (replace the stub `check_preconditions`) - Modify: `utils/replace_probe/src/main.reef` (add precondition tests) - [ ] **Step 1: Implement** Replace the stub: ```reef fn check_preconditions(table: supervisor.ServiceTable): string let count = supervisor.service_count(table) mut report = "" mut found = 0 mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let st = supervisor.rt_state(rt) if st == supervisor.STATE_STARTING() or st == supervisor.STATE_STOPPING() or st == supervisor.STATE_WAITING() let entry = str.concat(config.svc_name(supervisor.rt_def(rt)), str.concat(":", supervisor.state_name(st))) if found > 0 report = str.concat(report, ", ") end if report = str.concat(report, entry) found = found + 1 end if i = i + 1 end while return report end check_preconditions ``` Note: `STATE_WAITING` is currently used both for "not yet started" services at boot and for "scheduled-for-restart". For the precondition check we want only the latter (a transient mid-restart state) — but the state machine doesn't distinguish them. Conservative: treat all WAITING as transient. Operators using `replace` on a fresh boot before services have started would otherwise see "blocked"; that's an acceptable false positive. - [ ] **Step 2: Add probe assertions** Append to `utils/replace_probe/src/main.reef`: ```reef proc test_preconditions() println("== preconditions ==") let table = supervisor.new_service_table(8) // sshd RUNNING — stable let def_sshd = config.new_service_def_minimal("sshd") let idx_sshd = supervisor.add_service(table, def_sshd) supervisor.set_runtime_for_test(table, idx_sshd, supervisor.STATE_RUNNING(), 100, 23, 0, 0 - 1, 1746204051) let r1 = replace.check_preconditions(table) check("all-RUNNING -> empty report", str.length(r1) == 0) // Add a STOPPING service let def_cron = config.new_service_def_minimal("cron") let idx_cron = supervisor.add_service(table, def_cron) supervisor.set_runtime_for_test(table, idx_cron, supervisor.STATE_STOPPING(), 101, 24, 0, 0 - 1, 1746204060) let r2 = replace.check_preconditions(table) check("STOPPING -> non-empty report", str.length(r2) > 0) check("report mentions cron", str.index_of(r2, "cron") >= 0) check("report mentions stopping", str.index_of(r2, "stopping") >= 0) end test_preconditions ``` Add `test_preconditions()` to `proc main()`. - [ ] **Step 3: Build and run** Run: ```bash cd utils/replace_probe reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/replace_probe cd ../.. ``` Expected: tests pass. - [ ] **Step 4: Commit** ```bash hg commit -m "replace: implement check_preconditions with transient-state detection" ``` --- ## Task 7: Add `contract.is_contract_empty` helper **Files:** - Modify: `src/contract.reef` - Modify: `src/contract_linux_stubs.c` `is_contract_empty(ctid)` reads contract status via `ct_status_read` on `/system/contract/process//status`, then checks the member-count via `ct_status_get_nmembers`. Returns `true` if the contract has zero members (or the status read fails — caller treats unknown as "assume empty for safety"). - [ ] **Step 1: Add the FFI for nmembers and the helper** In `src/contract.reef`, add to the FFI declarations near the other `ct_status_*` lines: ```reef extern "C" fn ct_status_get_nmembers(stathdl: pointer): int ``` Add to the export block: ```reef fn is_contract_empty(contract_id: int): bool ``` Add the body near `adopt_contract`: ```reef // Check whether a contract has zero member processes. Used by the // post-recovery idempotency step in main.apply_recovered_state — if a // contract was reported in state.toml but the kernel says it's empty, // the service exited during the exec gap and we should apply restart // policy as if a contract-empty event arrived. // // Returns true if the contract has zero members OR if the status // read fails (treat unknown as empty so the recovery path applies // restart policy rather than leaving a phantom service in RUNNING). fn is_contract_empty(contract_id: int): bool let status_path = str.concat(str.concat("/system/contract/process/", int_to_str(contract_id)), "/status") let st_fd = open(status_path, O_RDONLY()) if st_fd < 0 return true end if unsafe let stathdl_buf = new [pointer](1) let rc = ct_status_read(st_fd, CTD_COMMON(), stathdl_buf) close(st_fd) if rc != 0 return true end if let stathdl = stathdl_buf[0] let nmembers = ct_status_get_nmembers(stathdl) ct_status_free(stathdl) return nmembers == 0 end unsafe end is_contract_empty ``` - [ ] **Step 2: Add the Linux stub** In `src/contract_linux_stubs.c`, append: ```c int ct_status_get_nmembers(void *stathdl) { (void)stathdl; return 0; } ``` - [ ] **Step 3: Build** Run: `clang -c src/helpers.c -o build/helpers.o && clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o && reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o` Expected: success. - [ ] **Step 4: Commit** ```bash hg commit -m "contract: add is_contract_empty helper for post-recovery empty-contract check" ``` --- ## Task 8: Add socket `replace` command + flag plumbing **Files:** - Modify: `src/socket.reef` Add a `g_replace_requested` flag and a `g_replace_wait` int, accessors, and the command handler. Mirror the existing `g_reload_requested` pattern. - [ ] **Step 1: Add accessors to the export block** In `src/socket.reef`, add to the existing export block (near `reload_requested` line 37): ```reef fn replace_requested(): bool proc clear_replace_flag() fn replace_wait_seconds(): int ``` - [ ] **Step 2: Add the globals and accessors** Near the existing `mut g_reload_requested: bool = false` declaration (around line 49), add: ```reef // Replace flag — set by "replace" command, checked by main event loop mut g_replace_requested: bool = false mut g_replace_wait: int = 0 fn replace_requested(): bool return g_replace_requested end replace_requested proc clear_replace_flag() g_replace_requested = false g_replace_wait = 0 end clear_replace_flag fn replace_wait_seconds(): int return g_replace_wait end replace_wait_seconds ``` - [ ] **Step 3: Add the command handler in `dispatch_command`** Insert in `dispatch_command` (around line 184, after the reload branch): ```reef elif command == "replace" // Optional --wait=N argument mut wait_seconds = 0 if str.length(arg) > 0 // Parse --wait=N if str.starts_with(arg, "--wait=") let val = str.substring(arg, 7, str.length(arg) - 7) wait_seconds = parse_int_or(val, 0) else return "error: replace: unknown argument: " + arg + "\n" end if end if g_replace_requested = true g_replace_wait = wait_seconds return str.concat("replace queued (wait=", str.concat(int_to_str(wait_seconds), ")\n")) ``` - [ ] **Step 4: Add the `parse_int_or` helper if not present** Search for it: `grep -n 'parse_int_or\|fn parse_int' src/socket.reef`. If absent, add at the bottom of the file: ```reef fn parse_int_or(s: string, default_val: int): int let n = str.length(s) if n == 0 return default_val end if mut result = 0 mut i = 0 while i < n let c = str.char_at(s, i) if c < '0' or c > '9' return default_val end if result = result * 10 + (c - '0') i = i + 1 end while return result end parse_int_or ``` If `str.starts_with` doesn't exist, write it inline using `str.substring(arg, 0, 7) == "--wait="`. - [ ] **Step 5: Update the `cmd_help` line to mention replace** Find `cmd_help` (around line 413) and add `replace [--wait=N]` to the comma-separated command list. - [ ] **Step 6: Build** Run: full Linux build command. Expected: success. - [ ] **Step 7: Smoke test the socket command** Run: ```bash TEST=$(mktemp -d) ZYGINIT_CONFIG_DIR=$TEST/cfg ZYGINIT_SOCKET=$TEST/sock ZYGINIT_LOG_DIR=$TEST/log mkdir -p $TEST/cfg/enabled.d ZYGINIT_CONFIG_DIR=$TEST/cfg ZYGINIT_SOCKET=$TEST/sock ZYGINIT_LOG_DIR=$TEST/log ./build/zyginit & ZPID=$! sleep 0.5 echo 'replace --wait=10' | nc -U $TEST/sock kill $ZPID 2>/dev/null rm -rf $TEST ``` Expected: `replace queued (wait=10)`. (Don't worry that the daemon doesn't actually replace — the actual flow lands in Task 11 once `replace_self` is wired up. This test only verifies the command parses and the flag is set.) - [ ] **Step 8: Commit** ```bash hg commit -m "socket: add replace [--wait=N] command + flag plumbing" ``` --- ## Task 9: Add `apply_recovered_state` proc in `main.reef` **Files:** - Modify: `src/main.reef` This is the incoming-side stitching logic. For each entry in the recovered state, find the service by name, populate runtime fields, register the contract_map entry, and run the post-recovery `is_contract_empty` check. - [ ] **Step 1: Add the proc near `reload_services`** Find `reload_services` (around line 328) and insert before it: ```reef // Apply state from a previous zyginit instance (live-replace). // For each entry in `recovered`, locate the service in `table` and // patch in runtime fields. Services in state.toml that are no longer // in enabled.d/ get their contract abandoned but their member // processes left running. After patching, run the post-recovery // empty-contract check to catch services that exited during the // exec gap. proc apply_recovered_state(table: supervisor.ServiceTable, recovered: replace.RecoveredState) if replace.rs_is_empty(recovered) return end if let n = replace.rs_count(recovered) println("zyginit: replace: applying " + int_to_str(n) + " recovered services") mut i = 0 while i < n let rs = replace.rs_service(recovered, i) let name = replace.svc_name(rs) let ctid = replace.svc_contract_id(rs) let idx = supervisor.find_service(table, name) if idx < 0 // Operator removed the symlink between old start and replace. // Don't kill — abandon the contract; member processes // continue running unsupervised. let abrc = contract.abandon_contract(ctid) println("zyginit: replace: orphaned " + name + " (no longer enabled, contract " + int_to_str(ctid) + " abandon rc=" + int_to_str(abrc) + ")") i = i + 1 continue end if // Patch runtime fields. PID is unknown (not serialized); contract // path is sufficient for supervision. supervisor.set_runtime_for_test(table, idx, supervisor.STATE_RUNNING(), 0 - 1, ctid, replace.svc_restart_count(rs), replace.svc_last_exit_code(rs), replace.svc_last_start_time(rs)) println("zyginit: replace: re-attached " + name + " (ctid=" + int_to_str(ctid) + ", restarts=" + int_to_str(replace.svc_restart_count(rs)) + ")") // Idempotency: did the contract go empty during the exec gap? if contract.is_contract_empty(ctid) println("zyginit: replace: " + name + " contract empty post-recovery, applying restart policy") supervisor.handle_contract_event(table, ctid, 0 - 1) end if i = i + 1 end while end apply_recovered_state ``` `supervisor.set_runtime_for_test` is the same helper added in Task 4 — its name communicates "test/probe usage" but it does exactly what `apply_recovered_state` needs (write the runtime fields directly). Acceptable. If the name bothers you, rename to `supervisor.adopt_runtime` and update both call sites. - [ ] **Step 2: Add the import at the top of `main.reef`** Find the imports block (top of file). Add: ```reef import replace ``` - [ ] **Step 3: Build** Run: full Linux build. Expected: success. - [ ] **Step 4: Commit** ```bash hg commit -m "main: add apply_recovered_state proc for live-replace stitching" ``` --- ## Task 10: Add `replace_self` proc in `main.reef` **Files:** - Modify: `src/main.reef` The outgoing-side flow. Pre-condition check (with optional `--wait` poll), state-file write, socket unlink, exec. - [ ] **Step 1: Add FFI and helpers near the top of `main.reef`** If not already present, add to the extern block: ```reef extern "C" fn zyginit_fsync_path(path: string): int extern "C" fn unlink(path: string): int ``` - [ ] **Step 2: Add the proc near `apply_recovered_state`** ```reef // Operator-driven live replace. Triggered by zygctl replace; the // socket handler sets g_replace_requested and we pick it up on the // next event-loop tick. After this returns successfully, control // transfers to the new /sbin/init process; this fn does not return // on success. // // On precondition failure or any I/O error before exec, we log and // return false; the caller clears the flag and continues normally. proc replace_self(table: supervisor.ServiceTable, boot_time: int, wait_seconds: int) println("zyginit: replace: requested (wait=" + int_to_str(wait_seconds) + ")") // Pre-condition check, with optional wait window mut elapsed = 0 mut report = replace.check_preconditions(table) while str.length(report) > 0 and elapsed < wait_seconds clock.sleep_seconds(1) elapsed = elapsed + 1 report = replace.check_preconditions(table) end while if str.length(report) > 0 println("zyginit: replace: blocked: " + report) return end if // Final draining of pending contract events — services may have just // exited and their state hasn't been recorded yet. // (Reuse the existing handle_contract_events drain logic if exposed; // otherwise the post-recovery is_contract_empty check on the new // side covers any miss.) // Serialize state if not replace.serialize_state(table, boot_time) println("zyginit: replace: state serialization failed; aborting") return end if println("zyginit: replace: state written to " + replace.STATE_FILE_PATH()) // Unlink socket. Proceed even on failure — new zyginit will overwrite. // zyginit_fsync_path falls back to fsyncing the parent dir when // the path itself doesn't exist, which is exactly what we want // after unlink — the socket's parent dir gets the rename committed. let sock_path = SOCKET_PATH() let urc = unlink(sock_path) if urc != 0 println("zyginit: replace: warning: socket unlink failed (rc=" + int_to_str(urc) + ")") end if let _ = zyginit_fsync_path(sock_path) // Exec /sbin/init. argv[0] should match what the kernel originally // exec'd us with; we pass "/sbin/init" verbatim. let target = "/sbin/init" let argv = new [string](1) argv[0] = target println("zyginit: replace: execve " + target) process.process_exec(target, argv) // If we reach here, exec failed. We have already written state.toml // and unlinked the socket — recovery requires reboot. println("zyginit: replace: FATAL: execve returned (kernel could not load " + target + ")") end replace_self ``` The "drain pending contract events" comment block is intentional — the current code path for handling bundle events lives in the main loop. If you want to drain them once more here, factor that out into a `proc drain_bundle_events(table, bundle_fd)` and call it; otherwise rely on the post-recovery `is_contract_empty` check (which is sufficient per the spec). - [ ] **Step 3: Build** Expected: success. - [ ] **Step 4: Commit** ```bash hg commit -m "main: add replace_self proc for live-replace outgoing flow" ``` --- ## Task 11: Wire `main.reef` startup + event loop **Files:** - Modify: `src/main.reef` Two integration points: - **Startup:** call `replace.recover_state` and `apply_recovered_state` between Phase 3 (table built) and Phase 6 (start_services_by_tier). - **Event loop:** check `socket.replace_requested()` and call `replace_self`. Also: capture `g_boot_time` once during PID-1 init so it's available to both call sites. - [ ] **Step 1: Add `g_boot_time` global** Near the top of `main.reef` with the other `mut g_*` globals, add: ```reef mut g_boot_time: int = 0 - 1 ``` - [ ] **Step 2: Capture boot_time at the start of `main()` after writing the BOOT_TIME utmpx record** Right after the existing `let _ = zyginit_write_boot_utmpx()` at line ~725: ```reef // Cache the BOOT_TIME we just wrote so live-replace can use // it as the boot-id sentinel. Read it back from utmpx (rather // than time_now() locally) so the value matches what the new // zyginit will read after exec. g_boot_time = replace.read_boot_time() if g_boot_time < 0 // Fallback: use time_now(). This degrades the staleness // check (a real reboot might collide if seconds-resolution // happens to repeat), but it's better than refusing all // replaces. g_boot_time = time.time_now() println("zyginit: warning: utmpx BOOT_TIME read failed; using time_now() = " + int_to_str(g_boot_time)) end if ``` For non-PID-1 supervisor mode, `g_boot_time` stays at -1; replace.serialize_state still writes "-1" and the new instance reads "-1" — they match, and the path is intentionally permissive in supervisor mode. Actually — set it for non-PID-1 too: ```reef // (After the if is_pid_1() block) if g_boot_time < 0 g_boot_time = time.time_now() end if ``` - [ ] **Step 3: Insert recovery between Phase 3 and Phase 6** After the `i + 1` add_service loop (around line 778) and before signal init: ```reef // ---- Phase 3.5: Apply state from previous zyginit (live replace) ---- let recovered = replace.recover_state(g_boot_time) if not replace.rs_is_empty(recovered) apply_recovered_state(table, recovered) end if ``` This lands AFTER `add_service` so all services are registered, BEFORE `start_services_by_tier` so re-attached services are skipped (they're in `STATE_RUNNING` and `start_service` only acts on `STATE_WAITING`). - [ ] **Step 4: Add the replace check in the main event loop** Find the existing reload check (around line 858-862): ```reef // Check for reload requested via socket if socket.reload_requested() socket.clear_reload_flag() println("zyginit: reload requested via socket") reload_services(table) end if ``` Add immediately after: ```reef // Check for replace requested via socket if socket.replace_requested() let wait = socket.replace_wait_seconds() socket.clear_replace_flag() replace_self(table, g_boot_time, wait) // If replace_self exec'd successfully, we never get here. // If it returned, log and continue running normally. end if ``` - [ ] **Step 5: Build the project AND zygctl** Run: ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: success, `build/zyginit` produced. - [ ] **Step 6: Smoke test in supervisor mode** ```bash TEST=$(mktemp -d) mkdir -p $TEST/cfg/enabled.d $TEST/log ZYGINIT_CONFIG_DIR=$TEST/cfg ZYGINIT_SOCKET=$TEST/sock ZYGINIT_LOG_DIR=$TEST/log ZYGINIT_RUN_DIR=$TEST/run ./build/zyginit > $TEST/zyginit.log 2>&1 & ZPID=$! sleep 0.5 echo 'replace' | nc -U $TEST/sock sleep 0.5 # zyginit should have logged "zyginit: replace: state written..." and # attempted execve /sbin/init (which fails on Linux dev because # /sbin/init isn't this binary). Acceptable failure. cat $TEST/zyginit.log kill $ZPID 2>/dev/null rm -rf $TEST ``` Expected log lines: - `zyginit: replace: requested (wait=0)` - `zyginit: replace: state written to /tmp/.../state.toml` (or the ZYGINIT_RUN_DIR you set) - `zyginit: replace: execve /sbin/init` - `zyginit: replace: FATAL: execve returned ...` (only if /sbin/init isn't usable as a Linux binary in this dev environment) The integration test will refine this in Task 13. - [ ] **Step 7: Commit** ```bash hg commit -m "main: wire live-replace into startup recovery and event loop" ``` --- ## Task 12: Add `zygctl replace` subcommand **Files:** - Modify: `tools/zygctl/src/zygctl.reef` - [ ] **Step 1: Locate the command dispatch** Open `tools/zygctl/src/zygctl.reef` and find the dispatch (search for `"reload"` — there'll be a similar pattern for the existing socket-relayed commands). - [ ] **Step 2: Add `replace` to the dispatch** Pattern: build the command line (`replace` + optional `--wait=N`), connect to socket, send, read response, print, handle `EPIPE` / connection-closed cleanly. ```reef elif cmd == "replace" // Optional --wait=N mut payload = "replace" let argc = sys.args.count() if argc >= 3 let wait_arg = sys.args.get(2) if str.starts_with(wait_arg, "--wait=") payload = str.concat(payload, str.concat(" ", wait_arg)) else println("zygctl: replace: unknown argument: " + wait_arg) println("zygctl: usage: zygctl replace [--wait=N]") process.exit_now(2) end if end if let response = send_command(payload) if str.length(response) == 0 // Connection closed without a response — the server proceeded // to exec immediately. Treat as success. println("replace: socket closed (process replaced)") else print(response) end if ``` `send_command` is the existing helper that connects to the socket and reads the response — adapt to whatever name is in use. If the existing pattern doesn't gracefully handle "connection closed without bytes received", treat that as a successful replace and print the documented message. - [ ] **Step 3: Update help text** Find the help text in zygctl.reef (likely a `cmd_help` proc) and add `replace [--wait=N]` to the list. - [ ] **Step 4: Build zygctl** Run: ```bash cd tools/zygctl clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o reefc build --obj build/symlink_wrapper.o cd ../.. ``` Expected: success, `tools/zygctl/build/zygctl` produced. - [ ] **Step 5: Smoke test** ```bash TEST=$(mktemp -d) mkdir -p $TEST/cfg/enabled.d $TEST/log ZYGINIT_CONFIG_DIR=$TEST/cfg ZYGINIT_SOCKET=$TEST/sock ZYGINIT_LOG_DIR=$TEST/log ZYGINIT_RUN_DIR=$TEST/run ./build/zyginit > $TEST/zyginit.log 2>&1 & ZPID=$! sleep 0.5 ZYGINIT_SOCKET=$TEST/sock ./tools/zygctl/build/zygctl replace --wait=5 sleep 0.5 cat $TEST/zyginit.log kill $ZPID 2>/dev/null rm -rf $TEST ``` Expected zygctl output: `replace queued (wait=5)` OR `replace: socket closed (process replaced)`. - [ ] **Step 6: Commit** ```bash hg commit -m "zygctl: add replace [--wait=N] subcommand" ``` --- ## Task 13: Linux integration smoke tests **Files:** - Modify: `tests/integration/run_tests.sh` Add new test scenarios after the existing tests. Linux can't exercise actual contract adoption (stubs return -1), but it CAN verify: - `replace` command parses and acks - Pre-condition failures are reported - State file writes correctly - The exec attempt fires (it'll fail because `/sbin/init` isn't this binary on a Linux dev box, but that's a known Linux-only failure) - [ ] **Step 1: Locate where new test sections are appended in run_tests.sh** `grep -n '^section ' tests/integration/run_tests.sh | tail` will show the existing section markers. Insert a new section block before the final summary. - [ ] **Step 2: Add the replace command-parse test** Append before the final summary: ```bash section "replace command — basic parsing" # Start a fresh zyginit start_zyginit mkdir -p "$TEST_DIR/run" export ZYGINIT_RUN_DIR="$TEST_DIR/run" restart_zyginit_with_run_dir() { stop_zyginit "$ZYGINIT" >> "$DAEMON_LOG" 2>&1 & DAEMON_PID=$! wait_for_socket } restart_zyginit_with_run_dir # zygctl replace with no args out=$("$ZYGCTL" replace 2>&1 || true) assert_contains "$out" "replace queued" "replace acks with no args" # zygctl replace --wait=10 out=$("$ZYGCTL" replace --wait=10 2>&1 || true) assert_contains "$out" "replace queued (wait=10)" "replace acks with --wait=10" # Check state.toml was written before exec attempt sleep 0.2 if [ -f "$TEST_DIR/run/state.toml" ]; then pass "state.toml present after replace" else # Either still waiting OR exec succeeded and the new instance deleted it. # Since /sbin/init exec fails on Linux dev, the file should still be there # if the test sequence was fast. fail "state.toml missing after replace" "file present" "missing" fi # Verify state.toml contents if [ -f "$TEST_DIR/run/state.toml" ]; then out=$(cat "$TEST_DIR/run/state.toml") assert_contains "$out" "boot_time =" "state.toml has boot_time" fi ``` You may need to adapt `start_zyginit` / `stop_zyginit` / `wait_for_socket` to use `ZYGINIT_RUN_DIR`. Check the existing helpers in run_tests.sh for the pattern. - [ ] **Step 3: Add the precondition refusal test** Append: ```bash section "replace — refuses on transient state" # Set up a service whose start.sh hangs for 10s, so the service stays # in STATE_STARTING long enough for us to issue a replace. mkdir -p "$BIN_DIR" cat > "$BIN_DIR/slow-start.sh" <<'EOF' #!/bin/sh sleep 10 EOF chmod +x "$BIN_DIR/slow-start.sh" cat > "$ENABLED_DIR/slow.toml" </dev/null || true # Reload to pick up the new service "$ZYGCTL" reload >/dev/null 2>&1 sleep 0.2 # Issue replace — should ack but be refused on the server side out=$("$ZYGCTL" replace 2>&1 || true) assert_contains "$out" "replace queued" "replace acks even when refused" # Server should log "replace: blocked" since slow service is in STARTING sleep 0.5 log=$(cat "$DAEMON_LOG") assert_contains "$log" "replace: blocked" "server logs blocked refusal" ``` - [ ] **Step 4: Run the test suite** Run: `tests/integration/run_tests.sh` Expected: all existing tests pass (regression check) plus the new replace tests pass. - [ ] **Step 5: Commit** ```bash hg commit -m "tests: add Linux integration coverage for zygctl replace" ``` --- ## Task 14: Hammerhead test plan + man page updates **Files:** - Create: `tests/integration/test_replace_hammerhead.md` - Modify: `man/zygctl.8` - Modify: `man/zyginit.8` Hammerhead testing is manual — it requires `hh-prototest` and exercises the actual contract-driven supervision path. Document the test cases as a checklist the operator runs by hand. - [ ] **Step 1: Create the Hammerhead test checklist** Write `tests/integration/test_replace_hammerhead.md`: ````markdown # Live Replace — Hammerhead Test Checklist Manual checklist for validating `zygctl replace` on `hh-prototest` (192.168.122.50). Companion to the spec at `docs/superpowers/specs/2026-05-02-contract-readoption-design.md`. ## Build + deploy ```bash # From dev host scp src/replace.reef src/main.reef src/contract.reef src/socket.reef \ src/contract_linux_stubs.c src/helpers.c \ root@192.168.122.50:/root/zyginit/src/ scp tools/zygctl/src/zygctl.reef \ root@192.168.122.50:/root/zyginit/tools/zygctl/src/ # On hh-prototest ssh root@192.168.122.50 'cd /root/zyginit && \ gcc -c src/helpers.c -o build/helpers.o && \ reefc build -l contract --obj build/helpers.o && \ cp build/zyginit /sbin/init.new && mv /sbin/init.new /sbin/init && \ cd tools/zygctl && \ gcc -c src/symlink_wrapper.c -o build/symlink_wrapper.o && \ reefc build --obj build/symlink_wrapper.o && \ cp build/zygctl /sbin/zygctl' ``` After the binary swap, the running zyginit is still the OLD one. The first `zygctl replace` will swap to the new binary. ## Test cases ### 1. Smoke: replace into the same binary - [ ] `zygctl status` — note all PIDs and ctids; capture uptimes - [ ] `zygctl replace` — should print `replace queued (wait=0)` then close - [ ] Wait ~1 second; `zygctl status` again - [ ] **All services have the same PIDs** (kernel-level continuity) - [ ] **All services have the same contract IDs** - [ ] **Uptimes are continuous** (delta from last_start_time, not reset) - [ ] **The SSH session driving this test is still alive** - [ ] `who -b` and `who -r` still report the same boot_time / runlevel ### 2. Pre-condition refusal - [ ] `zygctl restart sshd` (puts sshd briefly in STOPPING/STARTING) - [ ] Immediately: `zygctl replace` - [ ] Expect server log line: `zyginit: replace: blocked: sshd:stopping` - [ ] Verify the running zyginit did NOT exec (check uptime via /proc/1/start) ### 3. `--wait=N` success - [ ] `zygctl restart syslogd && zygctl replace --wait=10` - [ ] Replace should succeed once syslogd stabilizes - [ ] Confirm syslogd's restart_count incremented and is preserved post-replace ### 4. Dirty disable - [ ] `rm /etc/zyginit/enabled.d/cron.toml` (no `zygctl reload`) - [ ] `zygctl replace` - [ ] After replace, `zygctl status` does NOT list cron - [ ] But: `pgrep cron` still shows the cron pid running (process not killed) - [ ] Server log shows `zyginit: replace: orphaned cron` ### 5. Crash during exec gap - [ ] Pick a service with `restart.on = "always"`, e.g. sshd - [ ] `pkill -9 -f "sshd -D"` (or whatever the daemon's argv looks like) - [ ] Within ~0.5 seconds: `zygctl replace` - [ ] After replace, server log includes `zyginit: replace: sshd contract empty post-recovery, applying restart policy` - [ ] sshd is restarted with restart_count incremented ### 6. Stale state.toml across real reboot - [ ] `cp /var/run/zyginit/state.toml /tmp/saved-state.toml` while a replace hasn't been issued (file may not exist; create one by issuing replace first then copy quickly before the new zyginit deletes it) - [ ] `zygctl reboot` (real reboot) - [ ] After boot: `cp /tmp/saved-state.toml /var/run/zyginit/state.toml` - [ ] Stop+start zyginit somehow OR observe that the next replace handles it - [ ] Server log includes `replace: stale state file (boot_time ...)` - [ ] state.toml is deleted after that read ### 7. Repeated replace - [ ] Note sshd's restart_count and uptime - [ ] `zygctl replace` × 5 in a row, ~5 seconds apart - [ ] After all 5: restart_count is unchanged (replace doesn't bump it) - [ ] Uptime is continuous from the original start (no resets) - [ ] No leftover state.toml.tmp files in /var/run/zyginit/ ## Reverting If a replace puts the system in a bad state and SSH is still alive: ```bash mv /sbin/init.bak /sbin/init # if you saved the old binary zygctl replace # swap back to known-good ``` If SSH is dead but the VM is still running, `virsh reset hh-prototest` will reboot. The new binary at /sbin/init persists (filesystem-level swap), so a reboot uses the new binary cleanly — re-test from cold boot. ```` - [ ] **Step 2: Update `man/zygctl.8`** Add a `replace` entry in the COMMANDS section. Example pattern (adapt to existing groff macros): ```groff .TP .B replace [--wait=N] Replace the running zyginit process via in-place .BR execve (2) of .IR /sbin/init . Preserves all running services across the swap. With .BR --wait=N , poll up to N seconds for any transient-state service to stabilize before refusing. The operator must drop the new binary at .I /sbin/init before issuing this command. See .BR zyginit (8) for the live-replace mechanism. ``` - [ ] **Step 3: Update `man/zyginit.8`** Add a "LIVE REPLACE" section describing: - The state-file path (`/var/run/zyginit/state.toml`) - The exec target is hard-wired to `/sbin/init` - The boot-id sentinel (utmpx BOOT_TIME) - That the design is operator-driven and does NOT cover crash recovery Refer to the spec for full details. - [ ] **Step 4: Verify man pages render** Run: ```bash man -l man/zygctl.8 | head -50 man -l man/zyginit.8 | head -50 ``` Expected: no troff errors, the `replace` entry appears in zygctl.8. - [ ] **Step 5: Commit** ```bash hg add tests/integration/test_replace_hammerhead.md hg commit -m "docs: Hammerhead replace test checklist + man page updates" ``` --- ## Done — final verification After all 14 tasks land: - [ ] **Linux build clean:** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o cd tools/zygctl && reefc build --obj build/symlink_wrapper.o && cd ../.. ``` - [ ] **All Linux integration tests pass:** `tests/integration/run_tests.sh` - [ ] **Replace-probe tests pass:** `cd utils/replace_probe && ./build/replace_probe && cd ../..` - [ ] **Spec coverage check** — re-read the spec, point at a task for each requirement: - §3 "no `adopt_contract`" → Task 9 (no adopt call in apply_recovered_state) ✓ - §4 module split → Tasks 1-12 cover all five files ✓ - §5 state file format → Tasks 4, 5 ✓ - §5.2 boot-id staleness check → Tasks 1, 5 ✓ - §5.3 atomic write → Task 4 ✓ - §6.1 zygctl interface → Task 12 ✓ - §6.2 --wait=N → Tasks 8, 10 ✓ - §6.3 pre-conditions → Tasks 6, 10 ✓ - §7.2 algorithm → Task 9 ✓ - §7.3 config divergence → Task 9 (orphan abandon path) ✓ - §8 error handling → Tasks 4, 5, 9, 10 (defensive throughout) ✓ - §9.1 Linux tests → Task 13 ✓ - §9.2 Hammerhead tests → Task 14 ✓ - §11 decisions table → spec already mapped to design choices, reflected in implementation ✓ - [ ] **Hammerhead deploy + run the manual checklist** in `tests/integration/test_replace_hammerhead.md`. Capture results in a memory note (`pid1_replace_iter1.md` or similar). --- ## Non-trivial gotchas to watch for - **Reef stdlib API names** in this plan (`toml.parse_ok`, `toml.get_int_or`, `fd.fd_fsync`, `str.starts_with`, `str.char_at`, `process.exit_now`) are best-effort. The implementer should grep the actual Reef stdlib (`~/repos/reef-lang/`) and use the real names, adjusting code locally. The shapes are right; only the spellings may differ. - **`set_runtime_for_test` naming**. The proc is used by both the probe (legitimate test usage) and `apply_recovered_state` (production path). If the name bothers a reviewer, rename to `supervisor.adopt_runtime` and update both call sites. The function does what the production path needs; the test-y name is cosmetic. - **`STATE_WAITING` ambiguity**. Conservative interpretation: refuse replace whenever any service is in WAITING. This includes the boot-time "not-yet-started" case, which can't actually happen at the point a replace is issued (since the table is fully started before the event loop entry that processes the replace flag). False positives only land in pathological timing. Document in commit message if concerns surface. - **utmpx BOOT_TIME is read-once on Hammerhead** if the file is on a not-yet-mounted filesystem. The fallback to `time.time_now()` covers this gap, but means a panic-restart followed by a real reboot followed by another zyginit start will see different boot_times — that's CORRECT behavior (real reboot must invalidate state.toml). - **`process.process_exec` argv handling**. Reef's `process_exec` may or may not require argv terminated by a sentinel. Match the pattern used elsewhere in the codebase (e.g., `start_service` in supervisor.reef). The existing pattern there uses `[string]` arrays — same pattern in `replace_self`. - **socket.reef `cmd` parsing**. The existing dispatch only takes one string argument; for `--wait=N` we need to parse it from the `arg` string. Confirm by reading the existing handler shape; if `arg` is the rest of the line after the verb, the impl above is correct. # Versioning and Release Tarball — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire a single canonical version through the zyginit suite (zyginit + zygctl + sysv-wrapper), give every binary a `--version` flag, and add scripts to bump the version and produce a source release tarball for vendoring into Hammerhead. **Architecture:** Root `reef.toml` `[package].version` is canonical. `scripts/bump-version.sh` rewrites it plus four derived files (`tools/zygctl/reef.toml`, `src/version.reef`, `tools/zygctl/src/version.reef`, `tools/sysv-wrapper/version.h`) in lock-step. The two Reef binaries import a `version` module that exposes `pub fn VERSION(): string`. The C wrapper includes `version.h` and prints ` `. `scripts/make-release.sh` produces `releases/zyginit--source.tar.xz` plus a `.sha256` companion, modeled on Coral's `make-release.sh`. **Tech Stack:** Reef 0.4.0+ (compiled via `reefc`), C (POSIX), POSIX shell (`/bin/sh`), Mercurial (`hg`) for VCS, GNU `tar` with `--transform`/`--exclude`, `sha256sum`. **Spec:** `docs/superpowers/specs/2026-05-05-versioning-and-release-tarball-design.md` **Working version:** Throughout this plan, examples use `0.1.0` as the **current** version (matching today's `reef.toml`). Bump examples use `0.2.0` as the **next** version. The actual version in source after these tasks is unchanged at `0.1.0` — bumping to `0.2.0` is a separate, post-implementation step the maintainer runs. --- ## File Structure **New files:** - `src/version.reef` — generated; defines `module version` with `pub fn VERSION(): string` - `tools/zygctl/src/version.reef` — generated; same shape as above - `tools/sysv-wrapper/version.h` — generated; defines `ZYGINIT_VERSION` macro - `scripts/bump-version.sh` — rewrites all five version locations - `scripts/make-release.sh` — creates source tarball + sha256 **Modified files:** - `src/main.reef` — drop inline `VERSION()` (lines 67-69); `import version`; add early `--version` flag handling - `src/socket.reef` — `import version`; replace literal `"zyginit 0.1.0\n"` (line 248) - `tools/zygctl/src/main.reef` — `import version`; replace literal `"zygctl 0.1.0"` (line 55) - `tools/sysv-wrapper/wrapper.c` — `#include "version.h"`; add `--version`/`-V` handling - `tools/sysv-wrapper/Makefile` — add `version.h` to `wrapper.o` dependencies - `tests/integration/run_tests.sh` — add `--version` assertions for both binaries --- ## Task 1: Add `version` module to zyginit core **Files:** - Create: `src/version.reef` - Modify: `src/main.reef:35` (add import), `src/main.reef:67-69` (drop inline `VERSION()`), `src/main.reef:835-849` (add `--version` flag handling) - Modify: `src/socket.reef:23` (add import after existing imports), `src/socket.reef:248` (replace literal) - [ ] **Step 1: Create `src/version.reef` with the canonical version constant** ```reef /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: version.reef Authors: Chris Tusa License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ module version pub fn VERSION(): string return "0.1.0" end VERSION end module ``` - [ ] **Step 2: Wire `src/main.reef` to use the module and handle `--version`** Edit `src/main.reef`. Add `import version` after line 36 (after the `import core.str` line): ```reef import core.str import version ``` Delete lines 67-69 (the existing inline `VERSION()` definition): ```reef fn VERSION(): string return "0.1.0" end VERSION ``` Replace the call at line 843 (`println("zyginit v" + VERSION() + " starting")`) with: ```reef println("zyginit v" + version.VERSION() + " starting") ``` Add `--version` flag handling at the top of `main()`. Find the current start of `proc main()` (around line 835) and insert this block as the **very first** statements in `main()`, before the `is_pid_1()` check: ```reef proc main() // --version / -V short-circuits before any PID-1 setup. Manual invocations // (zyginit --version on a shell) have stdio fds; the kernel never passes // --version when exec'ing init as PID 1 (it passes -s, -m, etc. instead). if args.has_flag("version") println("zyginit " + version.VERSION()) return end if // PID-1 fd setup MUST run before any println — when the kernel exec()s // init, stdin/stdout/stderr are not preopened. See setup_pid1_console // for the full explanation. if is_pid_1() let _ = setup_pid1_console() end if ... ``` - [ ] **Step 3: Wire `src/socket.reef` to use the module** Edit `src/socket.reef`. Add `import version` after line 31 (after the existing `import core.str`): ```reef import core.str import version ``` Replace line 248: ```reef return "zyginit 0.1.0\n" ``` With: ```reef return "zyginit " + version.VERSION() + "\n" ``` - [ ] **Step 4: Build zyginit and confirm the new module compiles** Run: ``` cd /home/ctusa/repos/zygaena-project/zyginit clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: build succeeds, produces `build/zyginit`. If reefc reports `undefined symbol: VERSION`, search for stray callers of the old `VERSION()` (without the `version.` prefix); the only callers in-tree are the two we just edited. - [ ] **Step 5: Verify `--version` works** Run: ``` ./build/zyginit --version ``` Expected output, exit 0: ``` zyginit 0.1.0 ``` If the binary prints "zyginit v0.1.0 starting" instead of returning, the early-exit block in Step 2 wasn't placed before the PID-1 fd setup; move it to the top of `main()`. - [ ] **Step 6: Verify socket version path** There's no easy unit test for the socket dispatcher; instead, rebuild was the type check. Confirm with `grep`: ``` grep -n 'version.VERSION' src/socket.reef ``` Expected: one match on the line we edited (around line 248). The grep failing to match means the edit didn't land. - [ ] **Step 7: Commit** ``` hg add src/version.reef hg ci -m 'version: introduce version module; remove inline VERSION() and socket literal' ``` --- ## Task 2: Add `version` module to zygctl **Files:** - Create: `tools/zygctl/src/version.reef` - Modify: `tools/zygctl/src/main.reef:27` (add import), `tools/zygctl/src/main.reef:55` (replace literal) - [ ] **Step 1: Create `tools/zygctl/src/version.reef`** ```reef /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zygctl Filename: version.reef Authors: Chris Tusa License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ module version pub fn VERSION(): string return "0.1.0" end VERSION end module ``` Note `Project: zygctl` — distinct from the zyginit-core copy. - [ ] **Step 2: Wire `tools/zygctl/src/main.reef` to use it** Edit `tools/zygctl/src/main.reef`. Add `import version` after line 26 (after `import core.str`): ```reef import core.str import version ``` Replace line 55: ```reef println("zygctl 0.1.0") ``` With: ```reef println("zygctl " + version.VERSION()) ``` - [ ] **Step 3: Build zygctl** Run: ``` cd /home/ctusa/repos/zygaena-project/zyginit/tools/zygctl clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o reefc build --obj build/symlink_wrapper.o ``` Expected: build succeeds, produces `tools/zygctl/build/zygctl`. - [ ] **Step 4: Verify `--version` works** Run from `tools/zygctl/`: ``` ./build/zygctl --version ``` Expected output, exit 0: ``` zygctl 0.1.0 ``` Also confirm the bare `version` subcommand still works: ``` ./build/zygctl version ``` Expected (same output): ``` zygctl 0.1.0 ``` - [ ] **Step 5: Commit** ``` hg add tools/zygctl/src/version.reef hg ci -m 'zygctl: use version module instead of literal' ``` --- ## Task 3: Add `version.h` and `--version` to sysv-wrapper **Files:** - Create: `tools/sysv-wrapper/version.h` - Modify: `tools/sysv-wrapper/wrapper.c` (add `#include`, add flag handling at top of `main()`) - Modify: `tools/sysv-wrapper/Makefile` (depend on `version.h`) - [ ] **Step 1: Create `tools/sysv-wrapper/version.h`** ```c /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: version.h Authors: Chris Tusa License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ #ifndef ZYGINIT_VERSION_H #define ZYGINIT_VERSION_H #define ZYGINIT_VERSION "0.1.0" #endif ``` - [ ] **Step 2: Edit `tools/sysv-wrapper/wrapper.c` to add `--version` handling** Add the include after line 30 (after `#include `): ```c #include #include "version.h" ``` In `main()`, add a `--version` short-circuit **before** the `argv0_copy = strdup(argv[0])` line (currently line 84). The check has to come after computing `me` (the basename), so the printout can use the personality name. The cleanest restructure: keep the existing basename computation, then add the flag check immediately after `me = basename(argv0_copy);`. Replace the block at lines 80-101 with: ```c const char *me; char *argv0_copy; /* basename(3) on illumos modifies its argument, so duplicate first */ argv0_copy = strdup(argv[0]); if (argv0_copy == NULL) { (void) fprintf(stderr, "out of memory\n"); return (1); } me = basename(argv0_copy); if (argc >= 2 && (strcmp(argv[1], "--version") == 0 || strcmp(argv[1], "-V") == 0)) { (void) printf("%s %s\n", me, ZYGINIT_VERSION); free(argv0_copy); return (0); } if (strcmp(me, "init") == 0 || strcmp(me, "telinit") == 0) return (init_dispatch(argc, argv)); if (strcmp(me, "halt") == 0) return (run_zygctl("halt")); if (strcmp(me, "reboot") == 0) return (run_zygctl("reboot")); if (strcmp(me, "poweroff") == 0) return (run_zygctl("poweroff")); (void) fprintf(stderr, "sysv-wrapper: unknown invocation '%s'\n", me); return (1); ``` (Existing run_zygctl paths leak `argv0_copy` already — preserving that to minimize unrelated changes.) - [ ] **Step 3: Edit `tools/sysv-wrapper/Makefile` to depend on `version.h`** Replace the build rule: ```make sysv-wrapper: wrapper.c $(CC) $(CFLAGS) -o $@ $< ``` With: ```make sysv-wrapper: wrapper.c version.h $(CC) $(CFLAGS) -o $@ wrapper.c ``` (Switched `$<` to explicit `wrapper.c` because `$<` would only refer to `wrapper.c`, but adding a second prerequisite makes the explicit form clearer.) - [ ] **Step 4: Build sysv-wrapper** Run from `tools/sysv-wrapper/`: ``` make clean && make ``` Expected: produces `./sysv-wrapper`, no warnings. - [ ] **Step 5: Verify `--version` for each personality** Run: ``` ./sysv-wrapper --version ln -sf sysv-wrapper /tmp/halt && /tmp/halt --version ln -sf sysv-wrapper /tmp/reboot && /tmp/reboot --version ln -sf sysv-wrapper /tmp/poweroff && /tmp/poweroff --version ln -sf sysv-wrapper /tmp/telinit && /tmp/telinit --version rm -f /tmp/halt /tmp/reboot /tmp/poweroff /tmp/telinit ``` Expected (one line per invocation, exit 0 each): ``` sysv-wrapper 0.1.0 halt 0.1.0 reboot 0.1.0 poweroff 0.1.0 telinit 0.1.0 ``` - [ ] **Step 6: Commit** ``` hg add tools/sysv-wrapper/version.h hg ci -m 'sysv-wrapper: add --version (and -V) flag' ``` --- ## Task 4: Write `scripts/bump-version.sh` **Files:** - Create: `scripts/bump-version.sh` - Test (one-shot, not committed): `/tmp/bump-test.sh` - [ ] **Step 1: Write a one-shot test harness for the bump script** This isn't a permanent test — just a sanity check we run before committing the script. Create `/tmp/bump-test.sh`: ```sh #!/bin/sh # Sanity test for scripts/bump-version.sh. # Snapshots all five version locations, runs the script, verifies all # five rewrite to the new version, then restores from snapshot. set -e cd /home/ctusa/repos/zygaena-project/zyginit # Snapshot cp reef.toml /tmp/bump.snap.root.toml cp tools/zygctl/reef.toml /tmp/bump.snap.zygctl.toml cp src/version.reef /tmp/bump.snap.zyginit.version.reef cp tools/zygctl/src/version.reef /tmp/bump.snap.zygctl.version.reef cp tools/sysv-wrapper/version.h /tmp/bump.snap.sysv.h # Run ./scripts/bump-version.sh 9.9.9 # Verify fail=0 grep -q '^version = "9.9.9"' reef.toml || { echo FAIL: root reef.toml; fail=1; } grep -q '^version = "9.9.9"' tools/zygctl/reef.toml || { echo FAIL: zygctl reef.toml; fail=1; } grep -q 'return "9.9.9"' src/version.reef || { echo FAIL: src/version.reef; fail=1; } grep -q 'return "9.9.9"' tools/zygctl/src/version.reef || { echo FAIL: zygctl version.reef; fail=1; } grep -q '#define ZYGINIT_VERSION "9.9.9"' tools/sysv-wrapper/version.h || { echo FAIL: sysv version.h; fail=1; } # Restore cp /tmp/bump.snap.root.toml reef.toml cp /tmp/bump.snap.zygctl.toml tools/zygctl/reef.toml cp /tmp/bump.snap.zyginit.version.reef src/version.reef cp /tmp/bump.snap.zygctl.version.reef tools/zygctl/src/version.reef cp /tmp/bump.snap.sysv.h tools/sysv-wrapper/version.h rm -f /tmp/bump.snap.* if [ $fail -eq 0 ]; then echo "PASS: bump-version rewrote all five files" else echo "FAIL" exit 1 fi ``` `chmod +x /tmp/bump-test.sh`. Don't run it yet — the script doesn't exist. - [ ] **Step 2: Run the test to confirm it fails** ``` /tmp/bump-test.sh ``` Expected: error message that `./scripts/bump-version.sh` is not found. - [ ] **Step 3: Write `scripts/bump-version.sh`** Create `scripts/bump-version.sh` with mode 0755: ```sh #!/bin/sh # # Bump the canonical zyginit version. # # Usage: ./scripts/bump-version.sh # # Rewrites in lock-step: # - reef.toml (canonical) # - tools/zygctl/reef.toml # - src/version.reef (regenerated) # - tools/zygctl/src/version.reef (regenerated) # - tools/sysv-wrapper/version.h (regenerated) # # Does NOT auto-commit. Review with `hg diff` and commit by hand. # set -e if [ $# -ne 1 ]; then echo "usage: $0 " >&2 exit 1 fi NEW="$1" # Validate semver shape (no pre-release suffix yet — see plan §10). echo "$NEW" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "error: '$NEW' is not in MAJOR.MINOR.PATCH form" >&2 exit 1 } SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) ROOT=$(dirname "$SCRIPT_DIR") cd "$ROOT" CURRENT=$(grep '^version' reef.toml | head -1 | sed 's/.*"\([^"]*\)".*/\1/') if [ -z "$CURRENT" ]; then echo "error: could not read current version from reef.toml" >&2 exit 1 fi if [ "$CURRENT" = "$NEW" ]; then echo "error: version is already $NEW" >&2 exit 1 fi echo "zyginit: bumping $CURRENT -> $NEW" # 1. Root reef.toml — sed in place. Match only the first 'version = "..."' # in the [package] table (top of file). echo " rewriting reef.toml" sed -i "0,/^version = \"[^\"]*\"/s//version = \"$NEW\"/" reef.toml # 2. tools/zygctl/reef.toml echo " rewriting tools/zygctl/reef.toml" sed -i "0,/^version = \"[^\"]*\"/s//version = \"$NEW\"/" tools/zygctl/reef.toml # 3. src/version.reef — full regenerate. echo " regenerating src/version.reef" gen_reef_module zyginit src/version.reef "$NEW" # 4. tools/zygctl/src/version.reef — full regenerate. echo " regenerating tools/zygctl/src/version.reef" gen_reef_module zygctl tools/zygctl/src/version.reef "$NEW" # 5. tools/sysv-wrapper/version.h — full regenerate. echo " regenerating tools/sysv-wrapper/version.h" gen_c_header tools/sysv-wrapper/version.h "$NEW" echo "done. review with 'hg diff' and commit:" echo " hg ci -m 'Bump version to $NEW'" ``` Then add the helper functions ABOVE the `set -e` line (so they're defined before use). Insert this block right after the comment header, before `set -e`: ```sh # ---------------------------------------------------------------------------- # Helpers for regenerating templated files. # ---------------------------------------------------------------------------- gen_reef_module() { project="$1" path="$2" version="$3" cat > "$path" < License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ module version pub fn VERSION(): string return "$version" end VERSION end module EOF } gen_c_header() { path="$1" version="$2" cat > "$path" < License: Description: Generated version constant. Do not edit by hand — regenerated by scripts/bump-version.sh. ******************************************************************************/ #ifndef ZYGINIT_VERSION_H #define ZYGINIT_VERSION_H #define ZYGINIT_VERSION "$version" #endif EOF } ``` `chmod +x scripts/bump-version.sh`. - [ ] **Step 4: Run the test** ``` /tmp/bump-test.sh ``` Expected: `PASS: bump-version rewrote all five files`. The script also restored the snapshot, so `hg status` should show no changes. If the test reports a `FAIL: ` line, inspect that file by hand: the `sed` substitution may not have matched (e.g., if reef.toml's `version` line has a different indentation), or the `cat <-source.tar.xz # releases/zyginit--source.tar.xz.sha256 # set -e SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) ROOT=$(dirname "$SCRIPT_DIR") cd "$ROOT" if [ -n "$1" ]; then VERSION="$1" else VERSION=$(grep '^version' reef.toml | head -1 | sed 's/.*"\([^"]*\)".*/\1/') fi if [ -z "$VERSION" ]; then echo "error: could not determine version" >&2 exit 1 fi NAME="zyginit-${VERSION}-source" RELEASES="releases" TARBALL="${RELEASES}/${NAME}.tar.xz" echo "Creating release: $NAME" mkdir -p "$RELEASES" tar \ --exclude='build' \ --exclude='build/*' \ --exclude='tools/*/build' \ --exclude='tools/*/build/*' \ --exclude='releases' \ --exclude='.hg' \ --exclude='.hgignore' \ --exclude='.hgtags' \ --exclude='resume' \ --exclude='ss' \ --exclude='stub' \ --exclude='*.o' \ --exclude='*.a' \ --exclude='*.swp' \ --transform "s,^,${NAME}/," \ -cJf "$TARBALL" \ reef.toml \ README.md \ ROADMAP.md \ CLAUDE.md \ SRCHEADER.txt \ src \ docs \ tests \ services \ tools \ utils \ scripts echo "Created: $TARBALL" ls -la "$TARBALL" sha256sum "$TARBALL" > "${TARBALL}.sha256" echo "Checksum: ${TARBALL}.sha256" cat "${TARBALL}.sha256" ``` `chmod +x scripts/make-release.sh`. - [ ] **Step 2: Run it** ``` ./scripts/make-release.sh ``` Expected stdout (with `0.1.0` from current reef.toml): ``` Creating release: zyginit-0.1.0-source Created: releases/zyginit-0.1.0-source.tar.xz -rw-r--r-- 1 ctusa ctusa releases/zyginit-0.1.0-source.tar.xz Checksum: releases/zyginit-0.1.0-source.tar.xz.sha256 releases/zyginit-0.1.0-source.tar.xz ``` - [ ] **Step 3: Verify tarball contents** ``` tar tJf releases/zyginit-0.1.0-source.tar.xz | head -30 tar tJf releases/zyginit-0.1.0-source.tar.xz | grep -c '^zyginit-0.1.0-source/' ``` The first command shows the top of the listing — every entry MUST start with `zyginit-0.1.0-source/`. The second prints the total entry count. Also confirm the excludes worked: ``` tar tJf releases/zyginit-0.1.0-source.tar.xz | grep -E '\.hg|/build/|/releases/|/resume/|/ss/|/stub/|\.o$|\.a$' && echo FAIL || echo OK ``` Expected: `OK`. Any line printed by `grep` indicates an exclude that didn't take. - [ ] **Step 4: Verify the tarball is buildable as-is** ``` mkdir -p /tmp/release-check tar xJf releases/zyginit-0.1.0-source.tar.xz -C /tmp/release-check cd /tmp/release-check/zyginit-0.1.0-source clang -c src/helpers.c -o build/helpers.o 2>/dev/null || \ (mkdir -p build && clang -c src/helpers.c -o build/helpers.o) clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/zyginit --version cd /home/ctusa/repos/zygaena-project/zyginit rm -rf /tmp/release-check ``` Expected: `zyginit 0.1.0`, exit 0. This is the smoke test that the tarball is self-sufficient. - [ ] **Step 5: Verify checksum file is well-formed** ``` sha256sum -c releases/zyginit-0.1.0-source.tar.xz.sha256 ``` Expected: `releases/zyginit-0.1.0-source.tar.xz: OK`. - [ ] **Step 6: Add `releases/` to `.hgignore`** Check whether `releases/` is already ignored: ``` hg status releases/ ``` If `hg status` lists the new tarball as untracked (`?`), add it to `.hgignore`. The existing `.hgignore` uses `syntax: glob` (already declared at the top), so append the line: ``` releases/ ``` Verify by re-running `hg status releases/` — it should now show no output (the directory is ignored). - [ ] **Step 7: Commit (script and `.hgignore` only — not the tarball)** ``` hg status hg add scripts/make-release.sh # Only `hg add .hgignore` if it's not already tracked. hg ci -m 'scripts: add make-release.sh for source tarball generation' ``` `hg status` after commit should show only the untracked `releases/` directory if `.hgignore` worked, or nothing if `releases/` is empty/absent. --- ## Task 6: Add `--version` integration tests **Files:** - Modify: `tests/integration/run_tests.sh` (add new section) - [ ] **Step 1: Add a `version` test section near the bottom of `run_tests.sh`** Find the last `section "..."` block in `tests/integration/run_tests.sh`. Add a new section just before the final summary print. Search for a stable anchor like `printf "\n=== Summary ===\n"` or wherever the final `PASS=`/`FAIL=` rollup happens, and add this block immediately before it: ```sh # ============================================================================ section "Version reporting" # ============================================================================ # Both binaries should respond to --version (and `version` subcommand for zygctl) # with a single line " " matching reef.toml. EXPECTED_VERSION=$(grep '^version' "$PROJECT_DIR/reef.toml" | head -1 | sed 's/.*"\([^"]*\)".*/\1/') OUT="$("$ZYGINIT" --version 2>&1)" assert_equals "$OUT" "zyginit $EXPECTED_VERSION" "zyginit --version output" OUT="$(zygctl --version)" assert_equals "$OUT" "zygctl $EXPECTED_VERSION" "zygctl --version output" OUT="$(zygctl version)" assert_equals "$OUT" "zygctl $EXPECTED_VERSION" "zygctl version subcommand output" ``` (The script already has `EXPECTED_VERSION` as a fresh variable name; if there's a collision, rename to `_VERSION`. The `zygctl()` shell function on line 109 already wraps the binary call, so `zygctl --version` here uses that wrapper.) - [ ] **Step 2: Run the integration tests** ``` ./tests/integration/run_tests.sh ``` Expected: total test count increased by 3, all passing. The tail of the output should include: ``` === Version reporting === PASS: zyginit --version output PASS: zygctl --version output PASS: zygctl version subcommand output ``` If `zyginit --version` fails by printing the startup banner instead of returning, Task 1 Step 2's flag short-circuit was placed in the wrong spot — re-check that it's the first thing in `main()`. - [ ] **Step 3: Commit** ``` hg ci -m 'tests/integration: assert --version output for zyginit and zygctl' ``` --- ## Task 7: End-to-end bump rehearsal (no commit) This task verifies the whole pipeline works together: bump → build → release → install-check. It's a rehearsal — we don't commit the bumped version. Use `9.9.9` so it's obviously a test value. **Files:** No persistent changes. - [ ] **Step 1: Snapshot current state** ``` hg status # should be clean before starting ``` If not clean, stop and resolve before proceeding. - [ ] **Step 2: Bump to a sentinel version** ``` ./scripts/bump-version.sh 9.9.9 hg diff --stat ``` Expected diff: 5 files changed (`reef.toml`, `tools/zygctl/reef.toml`, `src/version.reef`, `tools/zygctl/src/version.reef`, `tools/sysv-wrapper/version.h`), each with a small change. - [ ] **Step 3: Build all three binaries with the new version** ``` clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o) (cd tools/sysv-wrapper && make clean && make) ``` Expected: all three succeed. - [ ] **Step 4: Verify all three report 9.9.9** ``` ./build/zyginit --version ./tools/zygctl/build/zygctl --version ./tools/sysv-wrapper/sysv-wrapper --version ``` Expected: ``` zyginit 9.9.9 zygctl 9.9.9 sysv-wrapper 9.9.9 ``` - [ ] **Step 5: Make a release tarball at 9.9.9** ``` ./scripts/make-release.sh ls -la releases/zyginit-9.9.9-source.tar.xz releases/zyginit-9.9.9-source.tar.xz.sha256 ``` Expected: both files present. Filename includes `9.9.9`. - [ ] **Step 6: Roll back the bump** ``` hg revert reef.toml tools/zygctl/reef.toml src/version.reef tools/zygctl/src/version.reef tools/sysv-wrapper/version.h rm -f releases/zyginit-9.9.9-source.tar.xz releases/zyginit-9.9.9-source.tar.xz.sha256 hg status ``` Expected: `hg status` clean, releases dir contains no `9.9.9` artifacts. - [ ] **Step 7: No commit** This is a rehearsal. The actual first version bump is left to the maintainer to perform when they're ready to cut a real release. --- ## Final Verification Checklist - [ ] `./build/zyginit --version` prints `zyginit 0.1.0` - [ ] `./tools/zygctl/build/zygctl --version` prints `zygctl 0.1.0` - [ ] `./tools/zygctl/build/zygctl version` prints `zygctl 0.1.0` - [ ] `./tools/sysv-wrapper/sysv-wrapper --version` prints `sysv-wrapper 0.1.0` - [ ] `halt --version` (via symlink) prints `halt 0.1.0` - [ ] `./scripts/bump-version.sh 9.9.9` rewrites all five files; rehearsal then reverted - [ ] `./scripts/make-release.sh` produces `releases/zyginit-0.1.0-source.tar.xz` + `.sha256` - [ ] Extracted tarball builds cleanly with `reefc build` and yields a working `zyginit --version` - [ ] `tests/integration/run_tests.sh` passes with three new assertions - [ ] `hg log --limit 6` shows six commits in the order: version module, zygctl, sysv-wrapper, bump-version, make-release, integration tests # zyginit Visual Pass Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement the unified visual identity defined in `docs/superpowers/specs/2026-05-12-zyginit-visual-pass-design.md`: boot rolling tape, progress bar, spinner, final cards, shutdown mirror, and `zygctl status` banner. **Architecture:** Single new module `src/ui.reef` owns all rendering — palette, glyphs, sigil, TTY/TERM detection, ANSI sequences, rolling tape state, progress bar, spinner. Existing `println` lifecycle sites in `main.reef` and `supervisor.reef` route through it. zygctl client detects its own TTY and asks the daemon for `STATUS PLAIN` when piped. A `--ui-demo ` flag on zyginit drives snapshot-based tests against canned event sequences (no daemon needed). **Tech Stack:** Reef 0.4+; FFI via `extern "C"` to a new `zyginit_isatty` helper in `src/helpers.c`; SGR / VT100 sequences for rich rendering; existing integration shell harness for end-to-end tests; Mercurial for VCS. --- ## File Structure **Create:** - `src/ui.reef` — rendering module (all UI state and procedures) - `tests/integration/snapshots/` — golden output files for snapshot tests - `tests/integration/ui_tests.sh` — shell-based snapshot test runner **Modify:** - `src/helpers.c` — add `zyginit_isatty(int fd)` (returns 1/0) - `src/main.reef` — wire `ui_init`, `ui_boot_start`, `ui_tier_start/done`, `ui_boot_complete`, `ui_shutdown_start/complete`, `ui_tick`; add `--ui-demo ` flag dispatch - `src/supervisor.reef` — replace ~8 lifecycle `println` sites with `ui_event_*` calls - `src/socket.reef` — `cmd_status` / `cmd_list` delegate to `ui_render_*`; parse `PLAIN` modifier - `tools/zygctl/src/main.reef` — pre-flight `detect_rich_mode`; send `STATUS` or `STATUS PLAIN` - `tests/integration/run_tests.sh` — `export ZYGINIT_NO_UI=1` for diff stability - `reef.toml`, `tools/zygctl/reef.toml`, `tools/sysv-wrapper/Makefile`, `src/version.reef`, `tools/zygctl/src/version.reef` — version bump 0.1.2 → 0.1.3 via the existing `scripts/bump-version.sh` **Branching:** all work on a fresh Mercurial bookmark `visual-pass` off rev 122. --- ## Task 1: ui.reef skeleton + isatty C helper + `--ui-demo` flag **Files:** - Create: `src/ui.reef` - Modify: `src/helpers.c` (append `zyginit_isatty`) - Modify: `src/main.reef` (argv parsing for `--ui-demo`) - Test: `tests/integration/ui_tests.sh` (new) - [ ] **Step 1: Write the failing snapshot test for `--ui-demo skeleton`** Create `tests/integration/ui_tests.sh`: ```sh #!/bin/sh # Snapshot tests for the ui.reef renderer. # Each scenario invokes `zyginit --ui-demo ` and diffs against a golden file. set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" ZYGINIT="$PROJECT_DIR/build/zyginit" SNAP_DIR="$SCRIPT_DIR/snapshots" mkdir -p "$SNAP_DIR" PASS=0 FAIL=0 check_scenario() { name=$1 env_prefix=$2 expected="$SNAP_DIR/$name.txt" got=$(mktemp) eval "$env_prefix $ZYGINIT --ui-demo $name" > "$got" 2>&1 if [ ! -f "$expected" ]; then cp "$got" "$expected" echo " [created] $name" PASS=$((PASS+1)) rm -f "$got" return fi if diff -u "$expected" "$got" > /dev/null; then echo " [pass] $name" PASS=$((PASS+1)) else echo " [FAIL] $name" diff -u "$expected" "$got" || true FAIL=$((FAIL+1)) fi rm -f "$got" } echo "ui snapshot tests" check_scenario skeleton "ZYGINIT_NO_UI=1" echo echo "passed: $PASS failed: $FAIL" [ "$FAIL" -eq 0 ] ``` Then make it executable: `chmod +x tests/integration/ui_tests.sh` - [ ] **Step 2: Run the test to confirm it fails** Run: `./tests/integration/ui_tests.sh` Expected: FAIL — `build/zyginit` either doesn't exist or doesn't recognize `--ui-demo`. Either is the failing condition we want. - [ ] **Step 3: Add the `zyginit_isatty` C helper** In `src/helpers.c`, append after the existing helpers (before the final closing line): ```c /* zyginit_isatty: thin wrapper around isatty(3) so Reef FFI sees an int * return. Returns 1 if fd is a terminal, 0 otherwise. */ #include int zyginit_isatty(int fd) { return isatty(fd) ? 1 : 0; } ``` - [ ] **Step 4: Create `src/ui.reef` skeleton** Create `src/ui.reef`: ```reef /* SRCHEADER goes here — copy from project root SRCHEADER.txt, fill in: Project: zyginit, Filename: ui.reef, Description: Unified visual rendering: palette, glyphs, sigil, rolling tape, progress bar, spinner, final cards. */ module ui import core.str as str import sys.env as env // Render modes. Decided once at startup by ui_init(). fn MODE_PLAIN(): int return 0 end MODE_PLAIN fn MODE_RICH_ASCII(): int return 1 end MODE_RICH_ASCII fn MODE_RICH_16(): int return 2 end MODE_RICH_16 fn MODE_RICH_TRUE(): int return 3 end MODE_RICH_TRUE mut g_mode: int = 0 mut g_failed: bool = false // set if write() to /dev/console errors extern "C" fn zyginit_isatty(fd: int): int proc ui_init(rich_mode: int) g_mode = rich_mode g_failed = false end ui_init fn ui_mode(): int return g_mode end ui_mode // Scenario dispatcher for --ui-demo. Called from main.reef. // Returns 0 on success, 1 if the scenario name is unknown. fn ui_demo(scenario: string): int if scenario == "skeleton" println("ui.reef skeleton ok, mode=" + int_to_str(g_mode)) return 0 end if println("ui.reef: unknown demo scenario: " + scenario) return 1 end ui_demo end module ``` - [ ] **Step 5: Wire `--ui-demo` into `src/main.reef`** Find the argv parsing block near the top of `main()` in `src/main.reef`. Before the existing `--version` check, add: ```reef // --ui-demo : run a canned UI scenario for snapshot testing. if args.has_flag(argv, "--ui-demo") let scenario = args.get_flag_value(argv, "--ui-demo") ui.ui_init(ui.MODE_PLAIN()) process.exit(ui.ui_demo(scenario)) end if ``` And add `import ui` to the imports near the top of `src/main.reef`. - [ ] **Step 6: Build and re-run the test** ```sh clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Expected: snapshot is auto-created on first run. Output line: `ui.reef skeleton ok, mode=0`. - [ ] **Step 7: Commit** ```sh hg add src/ui.reef tests/integration/ui_tests.sh tests/integration/snapshots/skeleton.txt hg commit -m "ui: skeleton + --ui-demo flag + isatty C helper" ``` --- ## Task 2: `detect_rich_mode` and plain-mode line renderer **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Add failing snapshot tests for plain-mode scenarios** In `tests/integration/ui_tests.sh`, add after the `skeleton` check: ```sh check_scenario plain_boot "ZYGINIT_NO_UI=1 TERM=xterm-256color" check_scenario detect_no_color "NO_COLOR=1 TERM=xterm-256color" check_scenario detect_dumb "TERM=dumb" check_scenario detect_sun "TERM=sun" check_scenario detect_sun_color "TERM=sun-color" ``` - [ ] **Step 2: Run tests to confirm failure** Run: `./tests/integration/ui_tests.sh` Expected: FAILs — scenarios are unknown. - [ ] **Step 3: Implement `detect_rich_mode` and plain-mode emitter in `src/ui.reef`** Add to `src/ui.reef` (after the mode constants, before `ui_init`): ```reef fn detect_rich_mode(): int if env.has_env("ZYGINIT_NO_UI") return MODE_PLAIN() end if if env.has_env("NO_COLOR") return MODE_PLAIN() end if // PID check: non-PID-1 zyginit instances always emit plain. // sys.process.getpid is imported at top of file. if process.getpid() != 1 // Allow override for unit tests via --ui-demo: caller calls ui_init() directly. // detect_rich_mode is only invoked from boot path in main.reef. return MODE_PLAIN() end if if zyginit_isatty(1) == 0 return MODE_PLAIN() end if let term = env.get_env_or("TERM", "") if str.length(term) == 0 return MODE_PLAIN() end if if term == "dumb" return MODE_PLAIN() end if if env.has_env("ZYGINIT_ASCII") return MODE_RICH_ASCII() end if if term == "sun-color" return MODE_RICH_TRUE() end if if str.index_of(term, "256color") >= 0 return MODE_RICH_TRUE() end if return MODE_RICH_16() end detect_rich_mode ``` Add `import sys.process as process` at the top of `src/ui.reef`. Then add a plain-mode emitter: ```reef // Plain-mode line: " [k=v ...]" // Used for boot/shutdown tape entries in MODE_PLAIN and as fallback. fn fmt_plain_event(elapsed_ms: int, level: string, event: string, name: string, kv: string): string let secs = int_to_str(elapsed_ms / 1000) let frac = int_to_str((elapsed_ms mod 1000) / 10) // 2-digit decimal let pad = "" if str.length(frac) < 2 pad = "0" end if mut line = str.concat(" ", secs) line = str.concat(line, ".") line = str.concat(line, pad) line = str.concat(line, frac) line = str.concat(line, " ") line = str.concat(line, level) line = str.concat(line, " ") line = str.concat(line, event) line = str.concat(line, " ") line = str.concat(line, name) if str.length(kv) > 0 line = str.concat(line, " ") line = str.concat(line, kv) end if return line end fmt_plain_event ``` - [ ] **Step 4: Add demo scenarios to `ui_demo`** Replace the `ui_demo` body in `src/ui.reef`: ```reef fn ui_demo(scenario: string): int if scenario == "skeleton" println("ui.reef skeleton ok, mode=" + int_to_str(g_mode)) return 0 end if if scenario == "plain_boot" println(fmt_plain_event(40, "info", "started", "root-fs", "")) println(fmt_plain_event(120, "info", "started", "crypto", "dur_ms=80")) println(fmt_plain_event(310, "info", "started", "devfs", "dur_ms=190")) println(fmt_plain_event(5020, "err", "failed", "network", "exit=1 dur_ms=4023")) return 0 end if if str.index_of(scenario, "detect_") == 0 // Override g_mode by calling detect_rich_mode under the test env. g_mode = detect_rich_mode() println("mode=" + int_to_str(g_mode)) return 0 end if println("ui.reef: unknown demo scenario: " + scenario) return 1 end ui_demo ``` - [ ] **Step 5: Build and re-run tests** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Expected snapshots get auto-created. Verify their content by hand: ```sh cat tests/integration/snapshots/plain_boot.txt ``` Should show 4 lines, last one being the failed network event. Check the detect snapshots match expectations: - `detect_no_color.txt`: `mode=0` - `detect_dumb.txt`: `mode=0` - `detect_sun.txt`: `mode=2` (RICH_16) - `detect_sun_color.txt`: `mode=3` (RICH_TRUE) If any are wrong, fix `detect_rich_mode` and re-run. - [ ] **Step 6: Commit** ```sh hg add tests/integration/snapshots/*.txt hg commit -m "ui: detect_rich_mode + plain-mode line formatter + snapshot tests" ``` --- ## Task 3: Rich primitives — palette, glyphs, sigil **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Add failing snapshots for color and glyph helpers** In `tests/integration/ui_tests.sh`: ```sh check_scenario palette_true "TERM=xterm-256color" check_scenario palette_16 "TERM=sun" check_scenario glyphs_unicode "TERM=xterm-256color" check_scenario glyphs_ascii "TERM=xterm-256color ZYGINIT_ASCII=1" check_scenario sigil_rich "TERM=sun-color" ``` - [ ] **Step 2: Run tests — confirm failure** Run: `./tests/integration/ui_tests.sh` Expected: 5 new FAILs. - [ ] **Step 3: Add palette + glyph + sigil functions to `src/ui.reef`** ```reef // SGR escape sequences. CSI is "\e[", reset is "\e[0m". fn esc_reset(): string return "\x1b[0m" end esc_reset // 16-color foreground codes. fn sgr_steel(): string return "\x1b[37m" end sgr_steel // white fn sgr_teal(): string return "\x1b[36m" end sgr_teal // cyan fn sgr_ok(): string return "\x1b[32m" end sgr_ok // green fn sgr_warn(): string return "\x1b[33m" end sgr_warn // yellow fn sgr_fail(): string return "\x1b[31m" end sgr_fail // red fn sgr_mute(): string return "\x1b[2m" end sgr_mute // dim // Truecolor foreground codes. fn sgr_steel_true(): string return "\x1b[38;2;110;123;139m" end sgr_steel_true fn sgr_teal_true(): string return "\x1b[38;2;0;106;111m" end sgr_teal_true fn sgr_ok_true(): string return "\x1b[38;2;46;139;87m" end sgr_ok_true fn sgr_warn_true(): string return "\x1b[38;2;255;191;0m" end sgr_warn_true fn sgr_fail_true(): string return "\x1b[38;2;200;32;31m" end sgr_fail_true // Wrap text in the given palette token, honoring g_mode. fn paint(token: string, text: string): string if g_mode == MODE_PLAIN() return text end if mut on = "" if g_mode == MODE_RICH_TRUE() if token == "frame" on = sgr_steel_true() else if token == "accent" on = sgr_teal_true() else if token == "ok" on = sgr_ok_true() else if token == "warn" on = sgr_warn_true() else if token == "fail" on = sgr_fail_true() else if token == "mute" on = sgr_mute() end if else if token == "frame" on = sgr_steel() else if token == "accent" on = sgr_teal() else if token == "ok" on = sgr_ok() else if token == "warn" on = sgr_warn() else if token == "fail" on = sgr_fail() else if token == "mute" on = sgr_mute() end if end if return str.concat(str.concat(on, text), esc_reset()) end paint // Glyphs. Selected by g_mode. fn glyph_ok(): string if g_mode == MODE_RICH_ASCII() return "#" end if if g_mode == MODE_PLAIN() return "ok" end if return "●" end glyph_ok fn glyph_fail(): string if g_mode == MODE_RICH_ASCII() return "X" end if if g_mode == MODE_PLAIN() return "fail" end if return "✕" end glyph_fail fn glyph_pending(): string if g_mode == MODE_RICH_ASCII() return "." end if if g_mode == MODE_PLAIN() return "pending" end if return "·" end glyph_pending // Static starting glyph used when not animating (rare, e.g. snapshots). fn glyph_starting(): string if g_mode == MODE_RICH_ASCII() return "o" end if if g_mode == MODE_PLAIN() return "starting" end if return "◉" end glyph_starting // Sigil. Always "[Z]", colored c.accent in rich modes. fn sigil(): string return paint("accent", "[Z]") end sigil ``` - [ ] **Step 4: Add demo scenarios for the primitives** In `ui_demo`, add before the final `unknown` line: ```reef if scenario == "palette_true" or scenario == "palette_16" g_mode = detect_rich_mode() println(paint("frame", "frame text")) println(paint("accent", "accent text")) println(paint("ok", "ok text")) println(paint("warn", "warn text")) println(paint("fail", "fail text")) println(paint("mute", "mute text")) return 0 end if if scenario == "glyphs_unicode" or scenario == "glyphs_ascii" g_mode = detect_rich_mode() println(glyph_ok() + " " + glyph_starting() + " " + glyph_fail() + " " + glyph_pending()) return 0 end if if scenario == "sigil_rich" g_mode = detect_rich_mode() println(sigil() + " zyginit 0.1.3") return 0 end if ``` - [ ] **Step 5: Build and verify snapshots** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` By-hand sanity: - `palette_true.txt` contains `\x1b[38;2;` truecolor escapes. - `palette_16.txt` contains `\x1b[37m` etc — no truecolor. - `glyphs_unicode.txt`: `● ◉ ✕ ·` - `glyphs_ascii.txt`: `# o X .` - `sigil_rich.txt` starts with `\x1b[38;2;0;106;111m[Z]\x1b[0m`. - [ ] **Step 6: Commit** ```sh hg add tests/integration/snapshots/*.txt hg commit -m "ui: palette + glyph + sigil primitives with mode-aware fallback" ``` --- ## Task 4: Boot header + rolling tape **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Failing snapshot for boot tape scenario** In `tests/integration/ui_tests.sh`: ```sh check_scenario boot_tape_rich "TERM=xterm-256color" check_scenario boot_tape_ascii "TERM=xterm ZYGINIT_ASCII=1" check_scenario boot_tape_plain "ZYGINIT_NO_UI=1" ``` Run: `./tests/integration/ui_tests.sh` → FAIL. - [ ] **Step 2: Implement tape state + header + emit_started/failed** Add to `src/ui.reef`: ```reef // Layout constants. fn TAPE_HEIGHT(): int return 12 end TAPE_HEIGHT fn LINE_WIDTH(): int return 78 end LINE_WIDTH // Boot/shutdown phase tracker for the header. mut g_phase: string = "" // "boot" | "shutdown" mut g_runlevel: string = "" mut g_num_svc: int = 0 mut g_num_tiers: int = 0 mut g_cur_tier: int = 0 mut g_done: int = 0 mut g_failed_count: int = 0 mut g_failed_first: string = "" mut g_failed_first_exit: int = 0 mut g_boot_start_ms: int = 0 // monotonic ms at boot start // Rolling tape: ring buffer of TAPE_HEIGHT lines. type TapeLine = struct elapsed_ms: int glyph: string name: string note: string end TapeLine mut g_tape: [TapeLine] = new [TapeLine](12) mut g_tape_head: int = 0 mut g_tape_count: int = 0 // time_now_ms: monotonic milliseconds. Wraps sys.time.time_now_ms when // available; for the demo path we read a static counter. extern "C" fn zyginit_monotonic_ms(): int proc tape_push(elapsed_ms: int, glyph: string, name: string, note: string) let slot = g_tape_head mod TAPE_HEIGHT() g_tape[slot] = TapeLine{ elapsed_ms: elapsed_ms, glyph: glyph, name: name, note: note } g_tape_head = g_tape_head + 1 if g_tape_count < TAPE_HEIGHT() g_tape_count = g_tape_count + 1 end if end tape_push // Format a tape row (mode-aware coloring on the glyph). fn fmt_tape_row(line: TapeLine): string let secs = line.elapsed_ms / 1000 let frac = (line.elapsed_ms mod 1000) / 10 mut sf = int_to_str(frac) if str.length(sf) < 2 sf = str.concat("0", sf) end if let elapsed = int_to_str(secs) + "." + sf // Pad elapsed to 6 cols right-aligned. mut pad = "" let pad_n = 6 - str.length(elapsed) mut i = 0 while i < pad_n pad = str.concat(pad, " ") i = i + 1 end while mut row = pad + elapsed + " " + line.glyph + " " + line.name if str.length(line.note) > 0 row = str.concat(row, " ") row = str.concat(row, paint("mute", "(" + line.note + ")")) end if return row end fmt_tape_row // Header banner + summary + failed-line + divider. 4 rows. fn fmt_header(elapsed_ms: int): string let secs = elapsed_ms / 1000 let frac = (elapsed_ms mod 1000) / 100 let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s" let phase_label = g_phase + " · " + g_runlevel mut out = " " + sigil() + " zyginit 0.1.3 · " + paint("frame", phase_label) out = str.concat(out, " ") // pad to ~LINE_WIDTH then append elapsed out = str.concat(out, paint("accent", "elapsed " + elapsed)) out = str.concat(out, "\n") out = str.concat(out, " ") out = str.concat(out, paint("frame", int_to_str(g_num_svc) + " services · tier " + int_to_str(g_cur_tier) + " · " + int_to_str(g_done) + " done · " + int_to_str(g_failed_count) + " failed")) out = str.concat(out, "\n ") if g_failed_count == 0 out = str.concat(out, paint("mute", "failed: none")) else if g_failed_count == 1 out = str.concat(out, paint("fail", "failed: " + g_failed_first + " exit=" + int_to_str(g_failed_first_exit))) else out = str.concat(out, paint("fail", "failed: " + int_to_str(g_failed_count) + " services")) end if out = str.concat(out, "\n ") mut div = "" mut k = 0 while k < 62 div = str.concat(div, "─") k = k + 1 end while out = str.concat(out, paint("accent", div)) return out end fmt_header // Render the entire screen for the current state. fn render_boot_screen(elapsed_ms: int): string if g_mode == MODE_PLAIN() // Plain mode: no fixed regions; tape rows printed live as events fire. return "" end if mut out = fmt_header(elapsed_ms) + "\n" let start = g_tape_head - g_tape_count mut i = 0 while i < TAPE_HEIGHT() if i < g_tape_count let slot = (start + i) mod TAPE_HEIGHT() out = str.concat(out, fmt_tape_row(g_tape[slot])) end if out = str.concat(out, "\n") i = i + 1 end while return out end render_boot_screen // Lifecycle entry points. proc ui_boot_start(num_svc: int, num_tiers: int, runlevel: string) g_phase = "boot" g_runlevel = runlevel g_num_svc = num_svc g_num_tiers = num_tiers g_cur_tier = 0 g_done = 0 g_failed_count = 0 g_failed_first = "" g_failed_first_exit = 0 g_tape_head = 0 g_tape_count = 0 g_boot_start_ms = zyginit_monotonic_ms() end ui_boot_start proc ui_tier_start(tier: int) g_cur_tier = tier end ui_tier_start proc ui_tier_done(tier: int) // Header repaint happens on next emit/tick. end ui_tier_done proc ui_event_started(name: string, dur_ms: int) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 tape_push(elapsed, paint("ok", glyph_ok()), name, "") emit_or_redraw(elapsed) end ui_event_started proc ui_event_failed(name: string, exit_code: int, dur_ms: int) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 g_failed_count = g_failed_count + 1 if g_failed_count == 1 g_failed_first = name g_failed_first_exit = exit_code end if tape_push(elapsed, paint("fail", glyph_fail()), name, "exit=" + int_to_str(exit_code)) emit_or_redraw(elapsed) end ui_event_failed // In plain mode: print one line. In rich mode: clear screen and redraw. proc emit_or_redraw(elapsed_ms: int) if g_mode == MODE_PLAIN() // Find the most recent tape row and emit it as a plain line. if g_tape_count == 0 return end if let last = (g_tape_head - 1) mod TAPE_HEIGHT() let line = g_tape[last] let level = "info" let event = "started" // Crude: failure glyph means we render as failed. if str.index_of(line.glyph, glyph_fail()) >= 0 println(fmt_plain_event(elapsed_ms, "err", "failed", line.name, line.note)) else println(fmt_plain_event(elapsed_ms, "info", "started", line.name, line.note)) end if return end if // Rich mode: CSI 2J (clear) + CSI H (home). print("\x1b[2J\x1b[H") print(render_boot_screen(elapsed_ms)) end emit_or_redraw ``` Add `zyginit_monotonic_ms` to `src/helpers.c`: ```c #include int zyginit_monotonic_ms(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; return (int)((ts.tv_sec * 1000) + (ts.tv_nsec / 1000000)); } ``` - [ ] **Step 3: Add boot_tape demo scenario** In `ui_demo`: ```reef if scenario == "boot_tape_rich" or scenario == "boot_tape_ascii" or scenario == "boot_tape_plain" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") // Hand-rolled event sequence so snapshots are deterministic. // Override monotonic_ms by setting g_boot_start_ms to 0 and emitting // explicit elapsed values via direct tape_push. g_boot_start_ms = 0 let elapsed_seq = [40, 160, 310, 490, 1040, 1080, 1360, 1550] let name_seq = ["root-fs", "crypto", "devfs", "swap", "filesystem", "identity", "sysconfig", "dlmgmtd"] mut i = 0 while i < 8 g_done = g_done + 1 tape_push(elapsed_seq[i], paint("ok", glyph_ok()), name_seq[i], "") i = i + 1 end while // Inject one failure. g_failed_count = 1 g_failed_first = "network" g_failed_first_exit = 1 g_cur_tier = 5 g_done = g_done + 1 tape_push(5020, paint("fail", glyph_fail()), "network", "exit=1") // Render once. if g_mode == MODE_PLAIN() // Plain: re-emit the 9 events. let levels = ["info","info","info","info","info","info","info","info","err"] let evs = ["started","started","started","started", "started","started","started","started","failed"] let allnames = ["root-fs","crypto","devfs","swap", "filesystem","identity","sysconfig","dlmgmtd","network"] let allelap = [40,160,310,490,1040,1080,1360,1550,5020] mut j = 0 while j < 9 let kv = "" let kv2 = if evs[j] == "failed" then "exit=1" else "" end println(fmt_plain_event(allelap[j], levels[j], evs[j], allnames[j], kv2)) j = j + 1 end while else print(render_boot_screen(5020)) end if return 0 end if ``` (Note: the `if ... then ... else ... end` ternary form may differ in Reef syntax; if it doesn't compile, fall back to a small helper that returns the right string.) - [ ] **Step 4: Build, run, sanity-check snapshots** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Inspect: - `boot_tape_rich.txt` — should show the 4-row header with truecolor escapes around `[Z]`, the `failed: network exit=1` line in red, and a tape with 9 rows. The `network` row has the `✕` glyph in red. - `boot_tape_ascii.txt` — same shape but `#` and `X` glyphs, no truecolor escapes (16-color only). - `boot_tape_plain.txt` — 9 plain machine-parseable lines; no escapes. If the rendered tape is misaligned, fix `fmt_tape_row` padding before moving on. - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/boot_tape_*.txt hg commit -m "ui: boot header + rolling tape + emit_or_redraw" ``` --- ## Task 5: Progress bar **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Failing snapshot for progress bar** In `tests/integration/ui_tests.sh`: ```sh check_scenario progress_rich "TERM=xterm-256color" check_scenario progress_ascii "TERM=xterm ZYGINIT_ASCII=1" check_scenario progress_full "TERM=xterm-256color" # 17/17 done ``` Run → FAIL. - [ ] **Step 2: Implement `fmt_progress_bar` and append to `render_boot_screen`** Add to `src/ui.reef`: ```reef fn PROGRESS_WIDTH(): int return 36 end PROGRESS_WIDTH fn fmt_progress_bar(done: int, total: int): string if g_mode == MODE_PLAIN() return "" end if let width = PROGRESS_WIDTH() mut filled = 0 if total > 0 filled = (done * width) / total end if if filled > width filled = width end if let pct = if total > 0 then (done * 100) / total else 0 end mut bar = "" if g_mode == MODE_RICH_ASCII() // [######### ] form bar = str.concat(bar, "[") mut i = 0 while i < (width - 2) if i < ((filled * (width - 2)) / width) bar = str.concat(bar, "#") else bar = str.concat(bar, " ") end if i = i + 1 end while bar = str.concat(bar, "]") else mut i = 0 while i < width if i < filled bar = str.concat(bar, paint("accent", "█")) else bar = str.concat(bar, paint("mute", "░")) end if i = i + 1 end while end if return " " + bar + " " + int_to_str(done) + "/" + int_to_str(total) + " " + int_to_str(pct) + "%" end fmt_progress_bar ``` Modify `render_boot_screen` to append: ```reef // Inside render_boot_screen, after the tape loop, before return: mut div = "" mut k = 0 while k < 62 div = str.concat(div, "─") k = k + 1 end while out = str.concat(out, " ") out = str.concat(out, paint("accent", div)) out = str.concat(out, "\n") out = str.concat(out, fmt_progress_bar(g_done, g_num_svc)) out = str.concat(out, "\n") return out ``` - [ ] **Step 3: Add `progress_*` demo scenarios** ```reef if scenario == "progress_rich" or scenario == "progress_ascii" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_done = 12 g_failed_count = 1 print(fmt_progress_bar(g_done, g_num_svc)) return 0 end if if scenario == "progress_full" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_done = 17 print(fmt_progress_bar(g_done, g_num_svc)) return 0 end if ``` - [ ] **Step 4: Build and inspect** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Check `progress_rich.txt` shows `█████████████░░░░... 12/17 70%`. Check `progress_ascii.txt` shows `[#################### ] 12/17 70%`. Check `progress_full.txt` shows `█` x 36 + ` 17/17 100%`. - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/progress_*.txt hg commit -m "ui: progress bar with block + ASCII fallback forms" ``` --- ## Task 6: Spinner + ui_tick **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Failing snapshot for spinner frames** ```sh check_scenario spinner_forward "TERM=xterm-256color" check_scenario spinner_reverse "TERM=xterm-256color" check_scenario spinner_in_tape "TERM=xterm-256color" ``` Run → FAIL. - [ ] **Step 2: Implement spinner state** Add to `src/ui.reef`: ```reef mut g_tick: int = 0 mut g_starting_name: string = "" // service in STARTING state, if any mut g_stopping_name: string = "" // service in STOPPING state, if any fn N_FRAMES(): int return 4 end N_FRAMES fn ui_spinner_frame(reverse: bool): string let frames = ["/", "-", "\\", "|"] if reverse return frames[(N_FRAMES() - 1) - (g_tick mod N_FRAMES())] end if return frames[g_tick mod N_FRAMES()] end ui_spinner_frame proc ui_tick() g_tick = g_tick + 1 if g_mode == MODE_PLAIN() return end if if str.length(g_starting_name) == 0 and str.length(g_stopping_name) == 0 return end if // Just redraw the screen on each tick — the tape's last row holds the // currently-starting/stopping service, so its glyph updates. let elapsed = zyginit_monotonic_ms() - g_boot_start_ms print("\x1b[2J\x1b[H") print(render_boot_screen(elapsed)) end ui_tick proc ui_event_starting(name: string) g_starting_name = name let elapsed = zyginit_monotonic_ms() - g_boot_start_ms tape_push(elapsed, paint("warn", ui_spinner_frame(false)), name, "starting") emit_or_redraw(elapsed) end ui_event_starting proc ui_event_stopping(name: string) g_stopping_name = name let elapsed = zyginit_monotonic_ms() - g_boot_start_ms tape_push(elapsed, paint("warn", ui_spinner_frame(true)), name, "stopping") emit_or_redraw(elapsed) end ui_event_stopping ``` When `ui_event_started` or `ui_event_failed` fires, clear `g_starting_name`: ```reef // At the top of ui_event_started and ui_event_failed: g_starting_name = "" ``` And update `render_boot_screen` so the *active* spinner row's glyph reflects the current frame. The cleanest approach: when rendering the tape, if a row's `name` matches `g_starting_name`, replace its glyph with `paint("warn", ui_spinner_frame(false))`; if it matches `g_stopping_name`, use reverse. Modify `fmt_tape_row` to accept and re-paint the spinner row, or — simpler — keep a separate `g_active_glyph` that `ui_tick` updates and that the renderer reads when emitting the matching row: ```reef // In fmt_tape_row, before assembling `row`: mut glyph = line.glyph if str.length(g_starting_name) > 0 and line.name == g_starting_name glyph = paint("warn", ui_spinner_frame(false)) end if if str.length(g_stopping_name) > 0 and line.name == g_stopping_name glyph = paint("warn", ui_spinner_frame(true)) end if let row = pad + elapsed + " " + glyph + " " + line.name ``` (Update the assembled `row` accordingly.) - [ ] **Step 3: Add spinner demo scenarios** ```reef if scenario == "spinner_forward" g_mode = detect_rich_mode() mut i = 0 while i < 8 g_tick = i print(ui_spinner_frame(false)) i = i + 1 end while println("") return 0 end if if scenario == "spinner_reverse" g_mode = detect_rich_mode() mut i = 0 while i < 8 g_tick = i print(ui_spinner_frame(true)) i = i + 1 end while println("") return 0 end if if scenario == "spinner_in_tape" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_boot_start_ms = 0 // 3 done, 1 starting (deterministic frame 2 = "\\") tape_push(40, paint("ok", glyph_ok()), "root-fs", "") tape_push(160, paint("ok", glyph_ok()), "crypto", "") tape_push(310, paint("ok", glyph_ok()), "devfs", "") g_done = 3 ui_event_starting("network") g_tick = 2 // force frame "\\" print("\x1b[2J\x1b[H") print(render_boot_screen(2100)) return 0 end if ``` - [ ] **Step 4: Build and verify** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Sanity: - `spinner_forward.txt`: `/-\|/-\|` - `spinner_reverse.txt`: `|\-/|\-/` - `spinner_in_tape.txt`: tape rows show `root-fs ●`, `crypto ●`, `devfs ●`, then `network \` (frame index 2) with `(starting)` note. - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/spinner_*.txt hg commit -m "ui: spinner with reverse rotation for stopping state" ``` --- ## Task 7: Boot final card **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Failing snapshots** ```sh check_scenario final_card_ok "TERM=xterm-256color" check_scenario final_card_failed "TERM=xterm-256color" ``` - [ ] **Step 2: Implement `ui_boot_complete`** Add to `src/ui.reef`: ```reef type BootStats = struct elapsed_ms: int online: int failed: int slowest: [string] // up to 3 entries: "name N.NNs" end BootStats proc ui_boot_complete(stats: BootStats) if g_mode == MODE_PLAIN() println(fmt_plain_event(stats.elapsed_ms, "info", "boot_complete", "summary", "online=" + int_to_str(stats.online) + " failed=" + int_to_str(stats.failed))) return end if // Clear screen, paint final card. print("\x1b[2J\x1b[H") let secs = stats.elapsed_ms / 1000 let frac = (stats.elapsed_ms mod 1000) / 100 let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s" let summary_count = int_to_str(stats.online + stats.failed) mut out = " " + sigil() + " zyginit 0.1.3 · " + paint("frame", g_runlevel) + " " + paint("accent", "boot " + elapsed) + "\n" out = str.concat(out, " " + paint("frame", summary_count + " services — " + int_to_str(stats.online) + " online, " + int_to_str(stats.failed) + " failed") + "\n") out = str.concat(out, "\n") if stats.failed == 0 out = str.concat(out, " " + paint("ok", "all services online") + "\n") else if stats.failed == 1 out = str.concat(out, " " + paint("fail", "failed: " + g_failed_first + " exit=" + int_to_str(g_failed_first_exit)) + "\n") out = str.concat(out, " " + paint("accent", "→ zygctl log " + g_failed_first) + paint("mute", " to see why") + "\n") else out = str.concat(out, " " + paint("fail", "failed: " + int_to_str(stats.failed) + " services") + "\n") out = str.concat(out, " " + paint("accent", "→ zygctl status") + paint("mute", " to see them") + "\n") end if mut div = "" mut k = 0 while k < 62 div = str.concat(div, "─") k = k + 1 end while out = str.concat(out, " " + paint("accent", div) + "\n") if str.length_array(stats.slowest) > 0 mut slow = "slowest:" mut i = 0 while i < str.length_array(stats.slowest) and i < 3 slow = str.concat(slow, " ") slow = str.concat(slow, stats.slowest[i]) i = i + 1 end while out = str.concat(out, " " + paint("frame", slow) + "\n") end if print(out) end ui_boot_complete ``` (Note: `str.length_array` may be spelled `len()` in Reef — verify against `~/repos/reef-lang/docs/`. If different, swap to the correct name.) - [ ] **Step 3: Add demo scenarios** ```reef if scenario == "final_card_ok" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") let stats = BootStats{ elapsed_ms: 8300, online: 17, failed: 0, slowest: ["dlmgmtd 1.2s", "filesystem 0.55s", "sshd 0.37s"] } ui_boot_complete(stats) return 0 end if if scenario == "final_card_failed" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_failed_first = "network" g_failed_first_exit = 1 let stats = BootStats{ elapsed_ms: 8300, online: 16, failed: 1, slowest: ["network 5.02s", "filesystem 0.55s", "sshd 0.37s"] } ui_boot_complete(stats) return 0 end if ``` - [ ] **Step 4: Build and verify** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Inspect both snapshots by hand for layout and color escapes. - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/final_card_*.txt hg commit -m "ui: boot final card (success and failure variants)" ``` --- ## Task 8: Shutdown mirror + final card **Files:** - Modify: `src/ui.reef` - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Failing snapshots** ```sh check_scenario shutdown_mirror "TERM=xterm-256color" check_scenario shutdown_final "TERM=xterm-256color" ``` - [ ] **Step 2: Implement shutdown procs** Add to `src/ui.reef`: ```reef mut g_shutdown_reason: string = "" proc ui_shutdown_start(reason: string) g_phase = "shutdown" g_shutdown_reason = reason g_runlevel = "stopping for " + reason g_done = 0 // counts services that have reached STOPPED g_failed_count = 0 g_failed_first = "" g_failed_first_exit = 0 g_tape_head = 0 g_tape_count = 0 g_boot_start_ms = zyginit_monotonic_ms() end ui_shutdown_start proc ui_event_stopped(name: string, dur_ms: int) g_stopping_name = "" let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_done = g_done + 1 tape_push(elapsed, paint("mute", glyph_pending()), name, "") emit_or_redraw(elapsed) end ui_event_stopped proc ui_shutdown_complete(reason: string, elapsed_ms: int) if g_mode == MODE_PLAIN() println(fmt_plain_event(elapsed_ms, "info", "shutdown_complete", reason, "down=" + int_to_str(g_done))) return end if print("\x1b[2J\x1b[H") let secs = elapsed_ms / 1000 let frac = (elapsed_ms mod 1000) / 100 let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s" mut out = " " + sigil() + " zyginit 0.1.3 · " + paint("frame", reason + " complete") + " " + paint("accent", reason + " " + elapsed) + "\n" let stat = if g_failed_count == 0 then "stopped cleanly" else "stopped (" + int_to_str(g_failed_count) + " force-killed)" end out = str.concat(out, " " + paint("frame", int_to_str(g_num_svc) + " services " + stat) + "\n") mut div = "" mut k = 0 while k < 62 div = str.concat(div, "─") k = k + 1 end while out = str.concat(out, " " + paint("accent", div) + "\n") out = str.concat(out, " " + paint("mute", "invoking uadmin(A_SHUTDOWN, AD_BOOT)") + "\n") print(out) end ui_shutdown_complete ``` The progress bar already works in shutdown direction because it computes `done / total`. Update `fmt_progress_bar` to show "down" when `g_phase == "shutdown"`: ```reef let label = if g_phase == "shutdown" then " down" else "" end return " " + bar + " " + int_to_str(done) + "/" + int_to_str(total) + label + " " + int_to_str(pct) + "%" ``` - [ ] **Step 3: Add demo scenarios** ```reef if scenario == "shutdown_mirror" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") ui_shutdown_start("reboot") g_boot_start_ms = 0 tape_push(40, paint("mute", glyph_pending()), "sshd", "") tape_push(160, paint("mute", glyph_pending()), "console-login", "") tape_push(310, paint("mute", glyph_pending()), "cron", "") ui_event_stopping("syslogd") g_tick = 3 // force last frame "/" g_done = 4 g_cur_tier = 6 print("\x1b[2J\x1b[H") print(render_boot_screen(480)) return 0 end if if scenario == "shutdown_final" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") ui_shutdown_start("reboot") g_num_svc = 17 g_done = 17 ui_shutdown_complete("reboot", 2100) return 0 end if ``` - [ ] **Step 4: Build and verify** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Inspect: - `shutdown_mirror.txt` — reverse spinner on `syslogd` row (frame `/`), header says "stopping for reboot". - `shutdown_final.txt` — final card with "reboot complete" and "stopped cleanly". - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/shutdown_*.txt hg commit -m "ui: shutdown mirror + shutdown final card" ``` --- ## Task 9: `zygctl status` server-side rendering + PLAIN modifier **Files:** - Modify: `src/ui.reef` (add `ui_render_status`) - Modify: `src/socket.reef` (delegate `cmd_status`) - Modify: `tests/integration/run_tests.sh` (add tests, set ZYGINIT_NO_UI=1) - [ ] **Step 1: Failing test in the integration harness** In `tests/integration/run_tests.sh`, add toward the end (after existing tests): ```sh echo "TEST: zygctl status renders banner+table when not piped" $ZYGINIT & sleep 0.5 out=$(script -qfc "$ZYGCTL status" /dev/null 2>&1) echo "$out" | grep -q "\[Z\] zyginit" || fail "expected [Z] sigil in TTY status output" echo "$out" | grep -q "SERVICE" || fail "expected SERVICE column header" pass echo "TEST: zygctl status returns plain when piped" out=$($ZYGCTL status | cat) echo "$out" | grep -q "\[Z\]" && fail "did not expect sigil in piped output" echo "$out" | grep -q "SERVICE" || fail "expected SERVICE column header even in plain" pass ``` And ensure `ZYGINIT_NO_UI` is unset for these specific tests (the boot UI of the daemon is irrelevant — only the rendered status response). - [ ] **Step 2: Run integration suite — confirm failure** ```sh ./tests/integration/run_tests.sh ``` Expected: the two new tests fail (sigil absent because the renderer hasn't been wired). - [ ] **Step 3: Add `ui_render_status` to `src/ui.reef`** ```reef fn ui_render_status(table: supervisor.ServiceTable, want_banner: int, plain: int): string let count = supervisor.service_count(table) let prev_mode = g_mode if plain != 0 g_mode = MODE_PLAIN() end if mut out = "" // Compute summary counts. mut online = 0 mut failed = 0 mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let st = supervisor.rt_state(rt) if st == supervisor.STATE_RUNNING() online = online + 1 else if st == supervisor.STATE_FAILED() or st == supervisor.STATE_MAINTENANCE() failed = failed + 1 end if i = i + 1 end while if want_banner != 0 and g_mode != MODE_PLAIN() out = str.concat(out, " " + sigil() + " zyginit 0.1.3 · ") out = str.concat(out, paint("frame", "multi-user · " + int_to_str(count) + " services · " + int_to_str(online) + " online, " + int_to_str(failed) + " failed") + "\n") mut div = "" mut k = 0 while k < 62 div = str.concat(div, "─") k = k + 1 end while out = str.concat(out, " " + paint("accent", div) + "\n") end if // Column header. out = str.concat(out, " ") out = str.concat(out, paint("frame", pad_right("SERVICE", 14) + pad_right("STATE", 11) + pad_right("PID", 7) + pad_right("CTID", 7) + pad_right("UPTIME", 10) + "NOTES")) out = str.concat(out, "\n") // Service rows. mut j = 0 while j < count let rt = supervisor.get_runtime(table, j) out = str.concat(out, fmt_status_row(rt)) out = str.concat(out, "\n") j = j + 1 end while g_mode = prev_mode return out end ui_render_status fn fmt_status_row(rt): string // Implementation detail: read fields from the runtime struct (consult // src/supervisor.reef for accessor names). Output one row: // name glyph state pid ctid uptime notes // For plain mode: no glyph, no escapes. // Re-use the existing supervisor.format_duration() helper for uptime. // …pseudo-code body… return " …" end fmt_status_row fn pad_right(s: string, n: int): string let extra = n - str.length(s) if extra <= 0 return s end if mut out = s mut i = 0 while i < extra out = str.concat(out, " ") i = i + 1 end while return out end pad_right ``` Fill in `fmt_status_row` body using the existing runtime accessors in `src/supervisor.reef:835` (`get_status_line`). Reuse its logic for uptime / exit_code / restart_count, but format as columns with the glyph in the STATE column. State → glyph mapping: - `STATE_RUNNING` → `glyph_ok()` + " running" - `STATE_EXITED` (oneshot success) → `glyph_ok()` + " online" - `STATE_STARTING` → `glyph_starting()` + " starting" - `STATE_STOPPING` → `glyph_starting()` + " stopping" - `STATE_STOPPED` → `glyph_pending()` + " stopped" - `STATE_FAILED` → `glyph_fail()` + " failed" - `STATE_MAINTENANCE` → `glyph_fail()` + " maint." - [ ] **Step 4: Wire `cmd_status` to delegate** Add a single-row renderer to `src/ui.reef` (avoids needing a "subtable" abstraction): ```reef fn ui_render_status_one(table: supervisor.ServiceTable, idx: int, plain: int): string let prev_mode = g_mode if plain != 0 g_mode = MODE_PLAIN() end if let rt = supervisor.get_runtime(table, idx) let row = fmt_status_row(rt) + "\n" g_mode = prev_mode return row end ui_render_status_one ``` Then in `src/socket.reef`, replace the body of `cmd_status` (around line 261): ```reef fn cmd_status(table: supervisor.ServiceTable, arg: string): string let count = supervisor.service_count(table) if str.length(arg) > 0 let idx = supervisor.find_service(table, arg) if idx < 0 return "error: unknown service: " + arg + "\n" end if return ui.ui_render_status_one(table, idx, 1) end if if count == 0 return "no services loaded\n" end if return ui.ui_render_status(table, 1, 0) end cmd_status ``` - [ ] **Step 5: Add the `STATUS PLAIN` wire modifier** In `src/socket.reef`, find the request parser (the dispatch around `cmd_status` / `cmd_list`). Adjust to accept a trailing `PLAIN` token: ```reef // Existing dispatch (rough shape): // "STATUS" → cmd_status(table, "") // "STATUS sshd" → cmd_status(table, "sshd") // New: // "STATUS PLAIN" → cmd_status_plain(table, "") // "STATUS PLAIN sshd" → cmd_status_plain(table, "sshd") fn cmd_status_plain(table: supervisor.ServiceTable, arg: string): string if str.length(arg) > 0 let idx = supervisor.find_service(table, arg) if idx < 0 return "error: unknown service: " + arg + "\n" end if let one_table = supervisor.subtable(table, idx) return ui.ui_render_status(one_table, 0, 1) end if return ui.ui_render_status(table, 0, 1) end cmd_status_plain ``` Update the request tokenizer to recognize `STATUS PLAIN` before falling through to `STATUS`. - [ ] **Step 6: Build + run the integration suite** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ``` Both new tests should pass; existing 44 should remain green. - [ ] **Step 7: Commit** ```sh hg commit -m "ui+socket: zygctl status banner+table + STATUS PLAIN modifier" ``` - [ ] **Step 8: Add `ui_render_list` and wire `cmd_list`** `zygctl list` keeps its minimal one-line-per-service shape but adds a small enhancement: enabled state, and color on the type chip when not plain. Add to `src/ui.reef`: ```reef fn ui_render_list(table: supervisor.ServiceTable, plain: int): string let count = supervisor.service_count(table) if count == 0 return "no services loaded\n" end if let prev_mode = g_mode if plain != 0 g_mode = MODE_PLAIN() end if mut out = "" mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let def = supervisor.rt_def(rt) let name = config.svc_name(def) let stype = config.service_type_name(config.svc_type(def)) let enabled = supervisor.rt_enabled(rt) let chip = paint("accent", stype) let enabled_str = if enabled then "enabled" else "disabled" end out = str.concat(out, name) out = str.concat(out, " (") out = str.concat(out, chip) out = str.concat(out, ", ") out = str.concat(out, enabled_str) out = str.concat(out, ")\n") i = i + 1 end while g_mode = prev_mode return out end ui_render_list ``` If `supervisor.rt_enabled` doesn't exist, fall back to `true` (every service in the table is enabled, since that's how it got loaded). In `src/socket.reef`, replace `cmd_list` body: ```reef fn cmd_list(table: supervisor.ServiceTable): string return ui.ui_render_list(table, 0) end cmd_list fn cmd_list_plain(table: supervisor.ServiceTable): string return ui.ui_render_list(table, 1) end cmd_list_plain ``` Add `LIST PLAIN` to the request tokenizer alongside `STATUS PLAIN`. - [ ] **Step 9: Build + verify list rendering** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/run_tests.sh ``` The existing `zygctl list` tests should still pass (the response now contains `(type, enabled)` instead of `(type)` — if any test does an exact string match, update it to match the new shape). - [ ] **Step 10: Commit** ```sh hg commit -m "ui+socket: zygctl list enabled chip + LIST PLAIN modifier" ``` --- ## Task 10: zygctl client pre-flight + STATUS PLAIN / LIST PLAIN dispatch **Files:** - Modify: `tools/zygctl/src/main.reef` - [ ] **Step 1: Failing assertion is already in place from Task 9** (Both tests added in Task 9 still need the client side to dispatch correctly. The server side renders correctly only if asked.) - [ ] **Step 2: Add `detect_rich_mode` equivalent in zygctl client** In `tools/zygctl/src/main.reef`, add near the top (above `cmd_status` / client dispatch): ```reef extern "C" fn zyginit_isatty(fd: int): int fn client_should_be_plain(): bool if env.has_env("NO_COLOR") return true end if if zyginit_isatty(1) == 0 return true end if let term = env.get_env_or("TERM", "") if str.length(term) == 0 return true end if if term == "dumb" return true end if return false end client_should_be_plain ``` Note: `zygctl` already links `symlink_wrapper.c`. Add the `zyginit_isatty` function to that same file (it's a thin POSIX call, no special wiring): ```c #include int zyginit_isatty(int fd) { return isatty(fd) ? 1 : 0; } ``` - [ ] **Step 3: Update the `status` dispatch in `tools/zygctl/src/main.reef`** Find where `status` builds its socket request (~line 60 area, exact line depends on current rev). Replace with: ```reef if cmd == "status" mut req = "STATUS" if client_should_be_plain() req = "STATUS PLAIN" end if if str.length(arg) > 0 req = req + " " + arg end if send_to_socket(req) return end if if cmd == "list" mut req = "LIST" if client_should_be_plain() req = "LIST PLAIN" end if send_to_socket(req) return end if ``` (`send_to_socket` is whatever helper the existing code uses for the same purpose — keep the same call site.) - [ ] **Step 4: Build + run** ```sh (cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ``` The two zygctl status tests from Task 9 should now both pass. - [ ] **Step 5: Commit** ```sh hg commit -m "zygctl: TTY-aware dispatch (STATUS vs STATUS PLAIN)" ``` --- ## Task 11: Wire ui_* calls into main.reef and supervisor.reef **Files:** - Modify: `src/main.reef` - Modify: `src/supervisor.reef` - [ ] **Step 1: Run the existing integration suite — capture baseline** ```sh ./tests/integration/run_tests.sh 2>&1 | tail -5 ``` Should currently report 46 passing (44 original + 2 from Task 9). - [ ] **Step 2: In `src/main.reef`, replace the boot-banner prints with ui calls** Find the section in `src/main.reef:main()` where the daemon prints `zyginit v0.1.3 starting`, `running as PID 1`, `booting into multi-user`, `loaded N services`, etc. Replace with: ```reef let rich_mode = ui.detect_rich_mode() ui.ui_init(rich_mode) // Existing dep graph loading happens here… let svc_count = supervisor.service_count(g_table) let num_tiers = depgraph.tier_count(g_boot_order) let runlevel = if g_single_user then "single-user" else "multi-user" end ui.ui_boot_start(svc_count, num_tiers, runlevel) ``` Keep the existing `zyginit: …` warning/error prints — those route to log files in rich mode (or stay on-console in plain mode, which is fine). - [ ] **Step 3: Wrap each tier in `ui_tier_start` / `ui_tier_done`** Find the tier-iteration loop (`for each tier in g_boot_order …`). Insert calls: ```reef mut tier = 0 while tier < num_tiers ui.ui_tier_start(tier) // …existing per-tier service start logic… ui.ui_tier_done(tier) tier = tier + 1 end while ``` - [ ] **Step 4: Add `ui_tick` to the main poll loop** In the steady-state poll loop, after `poll()` returns: ```reef ui.ui_tick() ``` This is cheap (no-op once boot completes and there are no STARTING/STOPPING services). - [ ] **Step 5: Call `ui_boot_complete` once all tiers finish** After the tier loop and before "entering event loop": ```reef let stats = build_boot_stats(g_table, ...) // collect online/failed/slowest ui.ui_boot_complete(stats) ``` `build_boot_stats` is a small helper that scans the service table and computes online count, failed count, and the 3 slowest services by start time. Add it inline in `main.reef`. - [ ] **Step 6: Wire shutdown** Find the existing `shutdown_services` / shutdown sequence. Wrap: ```reef ui.ui_shutdown_start(g_shutdown_reason) // …existing reverse-tier stop loop… ui.ui_shutdown_complete(g_shutdown_reason, total_elapsed_ms) ``` For each stop, in `supervisor.reef` (next step), `ui_event_stopping` and `ui_event_stopped` will fire. - [ ] **Step 7: In `src/supervisor.reef`, route lifecycle prints** Find these lines (approximate refs from rev 122): - `supervisor: started ${name}` near line 456 → replace with `ui.ui_event_started(name, dur_ms)` - `supervisor: stopping ${name} (signal)` near line 490 → replace with `ui.ui_event_stopping(name)` - `supervisor: ${name} stopped` near line 622 → replace with `ui.ui_event_stopped(name, dur_ms)` - `supervisor: ${name} stopped (exit ${code})` near line 653 → replace with `ui.ui_event_failed(name, exit_code, dur_ms)` when exit_code != 0 - `supervisor: ${name} completed (oneshot)` near line 610 → `ui.ui_event_started(name, dur_ms)` (oneshot completion is "online") - `supervisor: ${name} failed (oneshot, exit N)` near line 613 → `ui.ui_event_failed(...)` Add `import ui` at the top of `src/supervisor.reef`. For warning prints (`supervisor: ${name} stop timed out, killing`, `entered maintenance`, `scheduled for restart`, etc.) — keep them as `println` for now. They land in the log file under rich mode and on the console under plain mode. - [ ] **Step 8: Build and run integration suite** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ``` Expected: 46 pass. The integration harness sets `ZYGINIT_NO_UI=1` (added in Task 12 below — set it manually for this run if needed), forcing plain mode, so the assertions on string outputs don't shift. - [ ] **Step 9: Run `./tests/integration/ui_tests.sh`** All previous snapshots should still match. - [ ] **Step 10: Commit** ```sh hg commit -m "main+supervisor: route lifecycle prints through ui.reef" ``` --- ## Task 12: Version bump, integration test hygiene, hh-prototest smoke **Files:** - Modify: `tests/integration/run_tests.sh` (set `ZYGINIT_NO_UI=1`) - Run: `scripts/bump-version.sh 0.1.3` - Modify: spec/plan dates / status if needed - [ ] **Step 1: Force plain mode in the integration harness** In `tests/integration/run_tests.sh`, near the existing `export ZYGINIT_*` block: ```sh export ZYGINIT_NO_UI=1 ``` This keeps the boot UI from polluting integration-test output. The two `zygctl status` tests added in Task 9 explicitly *unset* this var before testing the rich vs plain dispatch. - [ ] **Step 2: Verify the integration harness still passes end-to-end** ```sh ./tests/integration/run_tests.sh ./tests/integration/ui_tests.sh ``` Both should pass. - [ ] **Step 3: Bump version 0.1.2 → 0.1.3** ```sh ./scripts/bump-version.sh 0.1.3 hg diff --stat ``` Expected: changes in `reef.toml`, `tools/zygctl/reef.toml`, `tools/sysv-wrapper/Makefile`, `src/version.reef`, `tools/zygctl/src/version.reef`. Verify with: ```sh grep -nE 'version|VERSION' reef.toml tools/zygctl/reef.toml \ src/version.reef tools/zygctl/src/version.reef ``` All should show `0.1.3`. - [ ] **Step 4: Update spec doc-string references in `src/ui.reef`** Any hardcoded `0.1.3` strings in the demo scenarios are intentional — verify they still match by running the snapshot suite. If the snapshots were captured at 0.1.2, the version-string change will produce diffs. Re-run with snapshot regeneration: ```sh rm tests/integration/snapshots/*.txt ./tests/integration/ui_tests.sh # regenerates hg diff tests/integration/snapshots/ | head -50 ``` Confirm the only changes are `0.1.2 → 0.1.3` in the version strings. Commit the regenerated snapshots. - [ ] **Step 5: Hammerhead deploy + smoke test on hh-prototest** ```sh # From dev host: scp src/ui.reef src/helpers.c src/main.reef src/supervisor.reef \ src/socket.reef tools/zygctl/src/main.reef tools/zygctl/src/symlink_wrapper.c \ root@192.168.122.50:/root/zyginit/src/ # adjust paths per file # Or rsync the whole tree: rsync -av --exclude build/ . root@192.168.122.50:/root/zyginit/ # On hh-prototest: ssh root@192.168.122.50 <<'EOF' cd /root/zyginit gcc -c src/helpers.c -o build/helpers.o reefc build -l contract --obj build/helpers.o cp build/zyginit /sbin/init.new mv /sbin/init.new /sbin/init cd tools/zygctl gcc -c src/symlink_wrapper.c -o build/symlink_wrapper.o reefc build --obj build/symlink_wrapper.o cp build/zygctl /sbin/zygctl.new mv /sbin/zygctl.new /sbin/zygctl EOF # Reboot the VM (init 6 from PID-1 zyginit is a real reboot now): virsh --connect=qemu:///system reset hh-prototest # Watch the boot via virsh console (or video out if you can capture): virsh --connect=qemu:///system console hh-prototest ``` Verify visually: - The boot screen shows the `[Z]` sigil, rolling tape, and progress bar in 256-color (Hammerhead framebuffer = `TERM=sun-color`). - After all tiers complete, the final card appears with `boot N.Ns` and a summary line. - `console-login` overwrites the card with the `Login:` prompt. - Once logged in: `zygctl status` shows the banner+table in 256-color. If anything looks wrong, capture screenshots and iterate. Note that polled I/O is slow — pauses of ~100ms per redraw are expected and not a regression. - [ ] **Step 6: Test shutdown** From the SSH session on hh-prototest: ```sh zygctl reboot ``` Expected screen: "stopping for reboot" header, services tick from `●` → `·` in reverse tier order, final card "reboot complete · stopped cleanly", then the kernel reset takes over. - [ ] **Step 7: Commit and tag** ```sh hg commit -m "release: bump to 0.1.3 (visual pass)" hg tag v0.1.3 ``` - [ ] **Step 8: Update auto-memory** After deploy succeeds, the user will likely want a memory note. The plan ends here; the user updates `MEMORY.md` and adds a `visual_pass.md` memory file if they want to record the milestone. --- ## Self-review notes - **Spec coverage:** every section of the design spec has at least one task implementing it. §4.3 spinner → Task 6. §5.1 boot tape → Task 4. §5.2 final card → Task 7. §5.3 shutdown mirror → Task 8. §5.5 `zygctl status` → Task 9 + 10. §6 render policy → Task 2 (`detect_rich_mode`). §7 error handling → covered inline in `emit_or_redraw` and the `g_failed` flag. §8 data flow → Task 11. - **No placeholders:** every step has a concrete file path, code block, or shell command. - **Type consistency:** `BootStats` defined in Task 7; `TapeLine` in Task 4; both used by name in later tasks. - **Tests-first:** every task starts with a failing test (snapshot or integration). - **Frequent commits:** every task ends with a focused `hg commit`. - **YAGNI:** no speculative features. Spinner is event-driven, not timer-driven. No `tier_name` field. No graphical splash. - **DRY:** `paint`, `glyph_*`, `fmt_plain_event`, `pad_right` factored once; reused everywhere. # zyginit 0.2.0 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship 0.2.0 per `docs/superpowers/specs/2026-05-14-zyginit-0.2.0-design.md` — per-service directory layout, functional `task` type, `[condition]` block with SKIPPED state, plus migration tool. Hard-cut release. **Architecture:** Three integrated changes that share the same `service.toml` schema. Layout reshape comes first (everything downstream consumes it); `task` semantics and `[condition]` block are independent additions to the supervisor and config parser. New UI counter (`g_skipped`) and event proc (`ui_event_skipped`). All work on Mercurial bookmark `visual-pass` on top of rev 166 (current tip after the spec commit). **Tech Stack:** Reef 0.4+ (compiles to C via reefc), POSIX shell for the migration tool, Mercurial for VCS. --- ## File Structure **Create:** - `scripts/migrate-layout.sh` — migration tool (POSIX shell) - `tests/integration/migrate_layout_tests.sh` — migration tool tests (POSIX shell) - `tests/integration/fixtures/old-layout/` — fixture tree for migration tests - `src/condition.reef` — new module: condition evaluation (`evaluate_conditions`, `ConditionResult`) **Modify:** - `src/config.reef` — rename `SERVICE_TYPE_TRANSIENT` → `SERVICE_TYPE_TASK`, add `[condition]` parser, change `scan_services` to walk `services/*/service.toml`, change `scan_enabled` to dereference directory symlinks with escape check - `src/supervisor.reef` — add `task` branch in `handle_contract_event`; add `STATE_SKIPPED` constant + `skip_reason` field on `ServiceRuntime`; wire `evaluate_conditions` into `start_service` - `src/ui.reef` — add `g_skipped` counter + `ui_event_skipped` proc; update `fmt_header` and `ui_boot_complete` to show skipped count; update `render_boot_screen` progress-bar denominator - `src/ui_render.reef` — add `STATE_SKIPPED` mapping to `state_color` / `state_glyph` / `state_label`; show `skip_reason` in NOTES column - `src/main.reef` — fatal error when `services/` directory missing; reset `g_skipped` alongside other counters - `tools/zygctl/src/main.reef` — `enable`/`disable` create/remove symlinks targeting `../services//` (directory), not `.toml` (file) - `services/hammerhead/` — migrated to new layout in one commit - `examples/` — migrated to new layout (or relocated under services/) in same commit - `tests/integration/ui_tests.sh` — add 5 new scenarios (`task_ok`, `task_failed`, `boot_with_skipped`, `final_card_with_skipped`, `status_skipped`) - `tests/integration/run_tests.sh` — adapt fixtures to new layout where needed - `reef.toml`, `tools/zygctl/reef.toml`, `tools/sysv-wrapper/version.h`, `src/version.reef`, `tools/zygctl/src/version.reef` — version bump 0.1.8 → 0.2.0 via `scripts/bump-version.sh` --- ## Task 1: Migration tool **Files:** - Create: `scripts/migrate-layout.sh` - Create: `tests/integration/migrate_layout_tests.sh` - Create: `tests/integration/fixtures/old-layout/` (test fixture tree) - [ ] **Step 1: Create the test fixture tree** ```sh mkdir -p tests/integration/fixtures/old-layout/enabled.d cat > tests/integration/fixtures/old-layout/cron.toml <<'EOF' [service] name = "cron" type = "daemon" [exec] start = "/usr/sbin/cron" EOF cat > tests/integration/fixtures/old-layout/filesystem.toml <<'EOF' [service] name = "filesystem" type = "oneshot" [exec] start = "./start.sh" EOF mkdir -p tests/integration/fixtures/old-layout/filesystem cat > tests/integration/fixtures/old-layout/filesystem/start.sh <<'EOF' #!/bin/sh mount -a EOF chmod +x tests/integration/fixtures/old-layout/filesystem/start.sh # Symlink in enabled.d targeting the old .toml form ln -s ../cron.toml tests/integration/fixtures/old-layout/enabled.d/cron ln -s ../filesystem.toml tests/integration/fixtures/old-layout/enabled.d/filesystem ``` - [ ] **Step 2: Write the failing test harness** Create `tests/integration/migrate_layout_tests.sh`: ```sh #!/bin/sh # Tests for scripts/migrate-layout.sh set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" TOOL="$PROJECT_DIR/scripts/migrate-layout.sh" FIXTURE_SRC="$SCRIPT_DIR/fixtures/old-layout" PASS=0 FAIL=0 pass() { echo " [pass] $1"; PASS=$((PASS+1)); } fail() { echo " [FAIL] $1"; echo " $2"; FAIL=$((FAIL+1)); } setup_tmp() { TMP=$(mktemp -d /tmp/migrate-test.XXXXXX) cp -a "$FIXTURE_SRC"/. "$TMP"/ echo "$TMP" } # --- Test 1: --dry-run does not modify anything --- TMP=$(setup_tmp) BEFORE=$(find "$TMP" | sort) "$TOOL" --dry-run --config-dir "$TMP" > /dev/null AFTER=$(find "$TMP" | sort) if [ "$BEFORE" = "$AFTER" ]; then pass "dry-run leaves tree unchanged" else fail "dry-run leaves tree unchanged" "tree changed" fi rm -rf "$TMP" # --- Test 2: --apply migrates flat layout to per-service directories --- TMP=$(setup_tmp) "$TOOL" --apply --config-dir "$TMP" > /dev/null if [ -f "$TMP/services/cron/service.toml" ] && \ [ -f "$TMP/services/filesystem/service.toml" ] && \ [ -f "$TMP/services/filesystem/start.sh" ]; then pass "apply creates services//service.toml" else fail "apply creates services//service.toml" "missing expected files" fi rm -rf "$TMP" # --- Test 3: --apply retargets enabled.d symlinks to directories --- TMP=$(setup_tmp) "$TOOL" --apply --config-dir "$TMP" > /dev/null target=$(readlink "$TMP/enabled.d/cron") case "$target" in *services/cron) pass "enabled.d symlink targets directory" ;; *) fail "enabled.d symlink targets directory" "got '$target'" ;; esac rm -rf "$TMP" # --- Test 4: --apply is idempotent --- TMP=$(setup_tmp) "$TOOL" --apply --config-dir "$TMP" > /dev/null SECOND=$("$TOOL" --apply --config-dir "$TMP" 2>&1) if echo "$SECOND" | grep -q "already migrated\|nothing to migrate\|no changes"; then pass "apply is idempotent" else fail "apply is idempotent" "second run did not report no-op" fi rm -rf "$TMP" # --- Test 5: refuses when both old and new layout coexist --- TMP=$(setup_tmp) mkdir -p "$TMP/services/cron" cp "$TMP/cron.toml" "$TMP/services/cron/service.toml" # Now both $TMP/cron.toml and $TMP/services/cron/service.toml exist set +e "$TOOL" --apply --config-dir "$TMP" > /dev/null 2>&1 rc=$? set -e if [ "$rc" -ne 0 ]; then pass "refuses on ambiguous old+new coexistence" else fail "refuses on ambiguous old+new coexistence" "exit was 0" fi rm -rf "$TMP" echo echo "passed: $PASS failed: $FAIL" [ "$FAIL" -eq 0 ] ``` Make it executable: `chmod +x tests/integration/migrate_layout_tests.sh`. - [ ] **Step 3: Run tests to confirm they fail** Run: `./tests/integration/migrate_layout_tests.sh` Expected: FAILs — `scripts/migrate-layout.sh` doesn't exist yet. - [ ] **Step 4: Implement the migration tool** Create `scripts/migrate-layout.sh`: ```sh #!/bin/sh # # Migrate a 0.1.x /etc/zyginit/ tree to the 0.2.0 per-service directory layout. # Idempotent. Dry-run by default. Run with --apply to actually move files. # set -e usage() { cat <] --dry-run Print planned moves and exit (default) --apply Perform the moves --config-dir DIR Target directory (default /etc/zyginit/) -h, --help Show this help EOF } MODE=dry-run CONFIG_DIR=/etc/zyginit while [ $# -gt 0 ]; do case "$1" in --dry-run) MODE=dry-run; shift ;; --apply) MODE=apply; shift ;; --config-dir) CONFIG_DIR="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "error: unknown arg: $1" >&2; usage >&2; exit 2 ;; esac done if [ ! -d "$CONFIG_DIR" ]; then echo "error: $CONFIG_DIR is not a directory" >&2 exit 1 fi cd "$CONFIG_DIR" # Ambiguity check: refuse if any service has BOTH .toml at top level # AND services//service.toml. AMBIGUOUS="" if [ -d services ]; then for f in *.toml; do [ -f "$f" ] || continue name="${f%.toml}" if [ -f "services/$name/service.toml" ]; then AMBIGUOUS="$AMBIGUOUS $name" fi done fi if [ -n "$AMBIGUOUS" ]; then echo "error: ambiguous layout — both old .toml and services//service.toml exist for:$AMBIGUOUS" >&2 echo " Resolve manually before re-running." >&2 exit 3 fi # Plan moves PLAN_FILE=$(mktemp /tmp/migrate-plan.XXXXXX) trap 'rm -f "$PLAN_FILE"' EXIT COUNT=0 for f in *.toml; do [ -f "$f" ] || continue name="${f%.toml}" echo "mkdir -p services/$name" >> "$PLAN_FILE" echo "mv $f services/$name/service.toml" >> "$PLAN_FILE" if [ -d "$name" ] && [ ! -L "$name" ]; then # Move helper scripts (start.sh, stop.sh, etc.) into the new dir for sf in "$name"/*; do [ -e "$sf" ] || continue base=$(basename "$sf") echo "mv $name/$base services/$name/$base" >> "$PLAN_FILE" done echo "rmdir $name" >> "$PLAN_FILE" fi COUNT=$((COUNT + 1)) done # Plan enabled.d symlink retarget if [ -d enabled.d ]; then for link in enabled.d/*; do [ -L "$link" ] || continue target=$(readlink "$link") case "$target" in *.toml) name=$(basename "$link") echo "ln -sfn ../services/$name $link" >> "$PLAN_FILE" ;; esac done fi # Plan examples/ migration (same convention as services/) if [ -d examples ]; then for f in examples/*.toml; do [ -f "$f" ] || continue name=$(basename "${f%.toml}") echo "mkdir -p examples/$name" >> "$PLAN_FILE" echo "mv $f examples/$name/service.toml" >> "$PLAN_FILE" done fi if [ ! -s "$PLAN_FILE" ]; then echo "no changes — tree already in 0.2.0 layout" exit 0 fi if [ "$MODE" = "dry-run" ]; then echo "PLANNED MOVES ($COUNT services):" cat "$PLAN_FILE" echo echo "Re-run with --apply to perform these moves." exit 0 fi # Apply echo "Applying $COUNT services..." while IFS= read -r line; do eval "$line" done < "$PLAN_FILE" echo "Migration complete." ``` Make it executable: `chmod +x scripts/migrate-layout.sh`. - [ ] **Step 5: Run tests to confirm they pass** Run: `./tests/integration/migrate_layout_tests.sh` Expected: 5/5 pass. - [ ] **Step 6: Commit** ```sh hg add scripts/migrate-layout.sh tests/integration/migrate_layout_tests.sh tests/integration/fixtures/ hg commit -m "scripts: migrate-layout.sh + tests — converts 0.1.x flat layout to 0.2.0 per-service dirs" ``` --- ## Task 2: Layout parser in config.reef **Files:** - Modify: `src/config.reef` The current parser scans `/*.toml` and uses the basename as the service name. New behavior: scan `/services//service.toml`; service name comes from the directory name. - [ ] **Step 1: Identify current scan functions** Read `src/config.reef` and find: - The function that scans the config directory for service TOML files (likely `scan_services`, `load_services_from_dir`, or similar) - The function that scans `enabled.d/` and dereferences symlinks Note their current signatures and the call sites in `src/main.reef`. - [ ] **Step 2: Rewrite the services scanner to walk `services/`** Replace the implementation that walks `*.toml` at top level with one that walks `services/*/service.toml`. Reef-flavored: ```reef fn scan_services_dir(config_dir: string, table: ServiceTable): int let services_root = str.concat(config_dir, "/services") if !io.dir.exists(services_root) // Fatal: empty boot println("zyginit: FATAL: services/ directory not found at " + services_root) println("zyginit: run scripts/migrate-layout.sh to convert a 0.1.x tree") return 0 end if let dirs = io.dir.list(services_root) let n = array_length(dirs) // or whatever Reef's array-length idiom is mut count = 0 mut i = 0 while i < n let name = dirs[i] let svc_dir = str.concat(str.concat(services_root, "/"), name) let toml_path = str.concat(svc_dir, "/service.toml") if !io.file.exists(toml_path) println("zyginit: services/" + name + "/ missing service.toml — skipping") i = i + 1 continue end if let result = parse_service_toml(toml_path, name, svc_dir) if result.ok add_to_table(table, result.svc) count = count + 1 else println("zyginit: services/" + name + "/service.toml parse error: " + result.error) end if i = i + 1 end while return count end scan_services_dir ``` `parse_service_toml(toml_path, name, svc_dir)` is the existing TOML parser plumbing renamed/restructured to: - Take the **directory** path (`svc_dir`) so it can resolve `./start.sh`-style paths to absolute (`/start.sh`). - Use **name** as the service identifier (not a field in the TOML; if the TOML has `[service].name`, ignore or warn that it's unused now). Match the project's existing parser style; this is a refactor rather than a from-scratch rewrite. - [ ] **Step 3: Update path resolution for `./*.sh`** In whatever section of `parse_service_toml` builds the executable path from `exec.start`, add: if the path starts with `./`, prepend `/`. Absolute paths (starting with `/`) are unchanged. ```reef fn resolve_exec_path(raw: string, svc_dir: string): string if str.starts_with(raw, "./") let rel = str.substring(raw, 2, str.length(raw) - 2) return str.concat(str.concat(svc_dir, "/"), rel) end if return raw end resolve_exec_path ``` Apply to both `exec.start` and (if present) `exec.stop`. - [ ] **Step 4: Rewrite enabled.d scanner with symlink dereference + escape check** Update `scan_enabled_dir` (or equivalent) to: - List `/enabled.d/` - For each entry, `readlink` it (use `os.fs.readlink` or whatever Reef stdlib provides; add a C helper `zyginit_readlink` if needed) - Resolve to an absolute path - Verify it points inside `/services/` - Reject (with warning log) if it escapes or is dangling ```reef fn scan_enabled_dir(config_dir: string): [string] let enabled_root = str.concat(config_dir, "/enabled.d") let services_prefix = str.concat(config_dir, "/services/") let result = new [string](MAX_SERVICES) mut count = 0 if !io.dir.exists(enabled_root) return result // no enabled.d means nothing enabled end if let entries = io.dir.list(enabled_root) mut i = 0 while i < array_length(entries) let entry = entries[i] let link_path = str.concat(str.concat(enabled_root, "/"), entry) let target = zyginit_readlink_resolved(link_path) if str.length(target) == 0 println("zyginit: enabled.d/" + entry + " is dangling — ignoring") i = i + 1 continue end if if !str.starts_with(target, services_prefix) println("zyginit: enabled.d/" + entry + " escapes services/ — ignoring") i = i + 1 continue end if result[count] = entry count = count + 1 i = i + 1 end while return result end scan_enabled_dir ``` If Reef stdlib doesn't have a "resolve a symlink to an absolute canonical path" function, add a C helper: ```c /* in src/helpers.c */ #include #include static char zyginit_readlink_buf[PATH_MAX]; char *zyginit_readlink_resolved(char *path) { char *resolved = realpath(path, zyginit_readlink_buf); return resolved ? resolved : (char*)""; } ``` And extern declare in config.reef: ```reef extern "C" fn zyginit_readlink_resolved(path: string): string ``` - [ ] **Step 5: Build + verify** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Existing tests will FAIL at this point because the in-repo `services/hammerhead/` tree is still in the old layout. That's expected — Task 3 migrates it. - [ ] **Step 6: Commit** ```sh hg commit -m "config: scan services//service.toml; enabled.d symlinks target directories" ``` --- ## Task 3: Migrate in-repo `services/hammerhead/` and `examples/` **Files:** - Modify: `services/hammerhead/*` (extensive) - Modify: `examples/*` (if present) - Modify: any test fixtures referencing the old layout - [ ] **Step 1: Dry-run the migration on the in-repo tree** ```sh ./scripts/migrate-layout.sh --dry-run --config-dir services/hammerhead/ ``` Verify the planned moves match the spec's per-service-directory layout. If the output looks right, proceed. - [ ] **Step 2: Apply the migration** ```sh ./scripts/migrate-layout.sh --apply --config-dir services/hammerhead/ ``` Verify each service is now `services/hammerhead/services//service.toml` plus any helper scripts in the same directory. The `services/hammerhead/services/` nesting is intentional — `services/hammerhead/` is the deploy-root, and the per-service layout starts inside it. - [ ] **Step 3: Migrate the examples directory if present** ```sh ls examples/ 2>/dev/null && ./scripts/migrate-layout.sh --apply --config-dir examples/ || echo "no examples/ at top level" ``` If examples live under `services/hammerhead/examples/` instead, migrate that location. - [ ] **Step 4: Update integration test fixtures** In `tests/integration/run_tests.sh`, the harness creates temporary services at runtime. Find the section that writes `/.toml` and update it to write `/services//service.toml`. Use a small helper at the top of the test script: ```sh create_service() { name=$1 toml_content=$2 mkdir -p "$CONFIG_DIR/services/$name" printf '%s\n' "$toml_content" > "$CONFIG_DIR/services/$name/service.toml" } enable_service() { name=$1 ln -sfn "../services/$name" "$ENABLED_DIR/$name" } ``` Then update every existing test that calls `cat > "$CONFIG_DIR/.toml"` to use `create_service` and every `ln -s` in `enabled.d/` to use `enable_service`. - [ ] **Step 5: Build + run all tests** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ./tests/integration/ui_tests.sh ./tests/integration/migrate_layout_tests.sh ``` All should pass. If integration tests fail, the most likely cause is a missed test-fixture update — grep for `\.toml` in `tests/integration/run_tests.sh` to find any stragglers. - [ ] **Step 6: Commit** ```sh hg addremove # picks up the moves hg commit -m "services+tests: migrate in-repo trees to 0.2.0 per-service layout" ``` --- ## Task 4: Update `zygctl enable`/`disable` symlink targets **Files:** - Modify: `tools/zygctl/src/main.reef` (and possibly `src/socket.reef` for the server-side equivalent if zygctl delegates) - [ ] **Step 1: Find current enable/disable logic** In `tools/zygctl/src/main.reef`, find the function that handles `zygctl enable `. Currently it creates a symlink `/enabled.d/` → `../.toml`. - [ ] **Step 2: Add a failing integration test** In `tests/integration/run_tests.sh`, add (in the appropriate test suite around enable/disable): ```sh echo "TEST: zygctl enable creates directory-target symlink" create_service test-svc 'foo' $ZYGCTL enable test-svc target=$(readlink "$ENABLED_DIR/test-svc") case "$target" in *services/test-svc) pass "zygctl enable targets directory" ;; *) fail "zygctl enable targets directory" "got: $target" ;; esac $ZYGCTL disable test-svc ``` (You'll need to wire `pass`/`fail` into the existing harness shape, or use whatever pattern the surrounding tests use.) - [ ] **Step 3: Run test to confirm it fails** ```sh ./tests/integration/run_tests.sh ``` Expected: the new test fails (current zygctl creates a `.toml` symlink, not a directory). - [ ] **Step 4: Update the symlink target** In `tools/zygctl/src/main.reef`, change the symlink-create call: ```reef // Old: let target = str.concat("../", str.concat(name, ".toml")) // New: let target = str.concat("../services/", name) ``` The link path stays the same (`enabled.d/`). - [ ] **Step 5: Build + run** ```sh (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ``` Test should now pass. - [ ] **Step 6: Commit** ```sh hg commit -m "zygctl: enable/disable target services// directory instead of .toml" ``` --- ## Task 5: Rename `TRANSIENT` → `TASK` + supervisor branch **Files:** - Modify: `src/config.reef` - Modify: `src/supervisor.reef` - [ ] **Step 1: Add a failing test for task semantics** Add to `tests/integration/run_tests.sh` (in a new test suite or appropriate existing one): ```sh echo "=== Task type semantics ===" create_service task-success "$(cat <<'EOF' [service] type = "task" [exec] start = "/bin/true" EOF )" enable_service task-success create_service task-failure "$(cat <<'EOF' [service] type = "task" [exec] start = "/bin/false" EOF )" enable_service task-failure start_daemon sleep 2 out=$($ZYGCTL status) echo "$out" | grep -q "task-success.*stopped" && \ pass "task exit 0 = stopped" || \ fail "task exit 0 = stopped" "got: $(echo "$out" | grep task-success)" echo "$out" | grep -q "task-failure.*failed" && \ pass "task exit !=0 = failed" || \ fail "task exit !=0 = failed" "got: $(echo "$out" | grep task-failure)" # Verify no restart attempt — restart_count should be 0 echo "$out" | grep -E "task-failure.*restarts=" && \ fail "task does not restart" "restarts field present" || \ pass "task does not restart" stop_daemon ``` (Use whatever start_daemon / stop_daemon helpers the existing tests use.) - [ ] **Step 2: Run tests to confirm failure** ```sh ./tests/integration/run_tests.sh ``` Expected: the task-related assertions fail (parser rejects `type = "task"` or the supervisor doesn't handle it). - [ ] **Step 3: Rename in `src/config.reef`** ```sh hg cat -r tip src/config.reef | grep -n TRANSIENT ``` Find every occurrence of `SERVICE_TYPE_TRANSIENT` and `"transient"` in `src/config.reef`. Replace: - `SERVICE_TYPE_TRANSIENT` → `SERVICE_TYPE_TASK` (function name + every call site) - `"transient"` string literal in `service_type_name` and the parser → `"task"` In the parser: ```reef // Old: if type_str == "transient" return SERVICE_TYPE_TRANSIENT() end if // New: if type_str == "task" return SERVICE_TYPE_TASK() end if ``` For `type = "transient"` in TOML: reject with a clear error message: ```reef if type_str == "transient" println("error: type='transient' is no longer supported — rename to 'task' in your TOML") return SERVICE_TYPE_DAEMON() // safe fallback to keep parser going end if ``` - [ ] **Step 4: Add the supervisor task branch** In `src/supervisor.reef`, find `handle_contract_event` and the point right after the exit-code resolution block (around line 598 in 0.1.8). Insert: ```reef let svc_type = config.svc_type(def) // Task: may run for any duration. Exit 0 = STOPPED (counts as online). // Exit ≠ 0 = FAILED. Never restarts. Skips daemonize handshake. if svc_type == config.SERVICE_TYPE_TASK() let dur_ms = (time.time_now() - rt.last_start_time) * 1000 let ec = if exit_code >= 0 then exit_code else 0 end if rt.pid = 0 - 1 rt.contract_id = 0 - 1 rt.last_exit_code = ec table.contract_map.remove(int_to_str(contract_id)) if ec == 0 rt.state = STATE_STOPPED() table.runtimes[idx] = rt ui.ui_event_stopped(name, dur_ms) else rt.state = STATE_FAILED() table.runtimes[idx] = rt ui.ui_event_failed(name, ec, dur_ms) end if return end if ``` This **must** be placed BEFORE the existing daemonize-handshake check (the `if svc_type == config.SERVICE_TYPE_DAEMON() and hint_exit_code >= 0 and exit_code == 0 ...` block). - [ ] **Step 5: Build + run all tests** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ./tests/integration/ui_tests.sh ``` All should pass including the new task tests. - [ ] **Step 6: Commit** ```sh hg commit -m "config+supervisor: rename TRANSIENT to TASK with real semantics (exit 0 = stopped, exit != 0 = failed, no restart)" ``` --- ## Task 6: `[condition]` schema and `evaluate_conditions` **Files:** - Create: `src/condition.reef` - Modify: `src/config.reef` (parser additions) - [ ] **Step 1: Define the ConditionResult type and entry signature** Create `src/condition.reef`. Copy the SRCHEADER from existing modules; fill in: - Project: `zyginit` - Filename: `condition.reef` - Description: `Evaluate [condition] blocks: exists_file, exists_file_any, command. Returns pass/fail + reason.` Skeleton: ```reef module condition import core.str as str import io.file as file import sys.process as process export type ConditionResult fn evaluate_conditions(def: config.ServiceDef): ConditionResult fn cond_passed(r: ConditionResult): bool fn cond_reason(r: ConditionResult): string end export type ConditionResult = struct passed: bool reason: string end ConditionResult fn cond_passed(r: ConditionResult): bool return r.passed end cond_passed fn cond_reason(r: ConditionResult): string return r.reason end cond_reason ``` - [ ] **Step 2: Add the parser for `[condition]` to config.reef** In `src/config.reef`, extend `ServiceDef` with optional fields: ```reef type ServiceDef = struct // ... existing fields ... cond_exists_file: string // empty if not set cond_exists_file_any: [string] cond_exists_file_any_count: int // companion length field cond_command: string // empty if not set end ServiceDef ``` In the TOML parser, find the section that walks parsed keys after `[exec]` and `[stop]` etc. Add a handler for `[condition]`: ```reef // Inside the parse loop, when section == "condition": if key == "exists_file" def.cond_exists_file = value_str elif key == "exists_file_any" let arr = parse_string_array(value) def.cond_exists_file_any = arr def.cond_exists_file_any_count = array_length(arr) elif key == "command" def.cond_command = value_str else println("warning: unknown [condition] key: " + key) end if ``` Match the existing TOML-parser shape; the project already has similar handlers for `[exec]`, `[restart]`, etc. - [ ] **Step 3: Add accessor fns to ServiceDef** In `src/config.reef`: ```reef fn svc_cond_exists_file(svc: ServiceDef): string return svc.cond_exists_file end svc_cond_exists_file fn svc_cond_exists_file_any(svc: ServiceDef): [string] return svc.cond_exists_file_any end svc_cond_exists_file_any fn svc_cond_exists_file_any_count(svc: ServiceDef): int return svc.cond_exists_file_any_count end svc_cond_exists_file_any_count fn svc_cond_command(svc: ServiceDef): string return svc.cond_command end svc_cond_command ``` Export each. - [ ] **Step 4: Implement `evaluate_conditions` in condition.reef** ```reef fn evaluate_conditions(def: config.ServiceDef): ConditionResult // exists_file let f = config.svc_cond_exists_file(def) if str.length(f) > 0 if !file.exists(f) return ConditionResult{ passed: false, reason: str.concat("missing ", f) } end if end if // exists_file_any let n = config.svc_cond_exists_file_any_count(def) if n > 0 let paths = config.svc_cond_exists_file_any(def) mut found = false mut i = 0 while i < n if file.exists(paths[i]) found = true end if i = i + 1 end while if !found return ConditionResult{ passed: false, reason: "none of exists_file_any paths exist" } end if end if // command (with 5-second timeout) let cmd = config.svc_cond_command(def) if str.length(cmd) > 0 let rc = run_command_with_timeout(cmd, 5) if rc != 0 if rc == TIMEOUT_RC() return ConditionResult{ passed: false, reason: "command timeout" } elif rc == NOT_FOUND_RC() return ConditionResult{ passed: false, reason: str.concat("command not found: ", cmd) } elif rc == EXEC_FAILED_RC() return ConditionResult{ passed: false, reason: "command exec failed" } else return ConditionResult{ passed: false, reason: str.concat("command exit=", int_to_str(rc)) } end if end if end if return ConditionResult{ passed: true, reason: "" } end evaluate_conditions fn TIMEOUT_RC(): int return 0 - 2 end TIMEOUT_RC fn NOT_FOUND_RC(): int return 0 - 3 end NOT_FOUND_RC fn EXEC_FAILED_RC(): int return 0 - 4 end EXEC_FAILED_RC // Runs command via a new C helper that handles fork/exec/timeout/wait. // Returns: 0 on success, command's exit code, TIMEOUT_RC if exceeded, // NOT_FOUND_RC if no such binary, EXEC_FAILED_RC on other exec error. extern "C" fn run_command_with_timeout(cmd: string, timeout_sec: int): int ``` - [ ] **Step 5: Add the C helper `run_command_with_timeout`** In `src/helpers.c`, append: ```c #include #include #include /* Run a command with a timeout in seconds. Returns: * command's exit code on normal completion (0 = success), * -2 if timeout exceeded (child killed), * -3 if command binary not found, * -4 on other exec failure. * The cmd string is run via /bin/sh -c so $PATH lookup works. */ int run_command_with_timeout(char *cmd, int timeout_sec) { pid_t pid = fork(); if (pid < 0) return -4; if (pid == 0) { /* child */ execl("/bin/sh", "sh", "-c", cmd, (char *)0); _exit(127); /* exec failed */ } /* parent */ time_t deadline = time(0) + timeout_sec; int status; for (;;) { pid_t r = waitpid(pid, &status, WNOHANG); if (r == pid) { if (WIFEXITED(status)) { int ec = WEXITSTATUS(status); if (ec == 127) return -3; return ec; } return -4; /* signaled */ } if (r < 0) return -4; if (time(0) >= deadline) { kill(pid, SIGKILL); waitpid(pid, &status, 0); return -2; } struct timespec ts = { 0, 50 * 1000 * 1000 }; /* 50ms */ nanosleep(&ts, 0); } } ``` - [ ] **Step 6: Build + unit-test via --ui-demo or direct config-parse test** Build: ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Since `evaluate_conditions` is a pure function, the easiest test is to wire a quick demo scenario in `ui_demo` (or add a small assertion in the integration harness). For now, just confirm the build succeeds. Real coverage comes in Task 8. - [ ] **Step 7: Commit** ```sh hg add src/condition.reef hg commit -m "condition: [condition] block schema + evaluate_conditions (exists_file, exists_file_any, command with timeout)" ``` --- ## Task 7: STATE_SKIPPED + `ui_event_skipped` + wire into start_service **Files:** - Modify: `src/supervisor.reef` - Modify: `src/ui.reef` - [ ] **Step 1: Add STATE_SKIPPED constant + skip_reason field** In `src/supervisor.reef`: ```reef fn STATE_SKIPPED(): int return 7 end STATE_SKIPPED // pick an unused state value ``` Add it to the state-name mapping function (the one that returns strings for states). Extend `ServiceRuntime` struct with: ```reef skip_reason: string ``` Initialize to `""` in the existing constructor / table init logic. - [ ] **Step 2: Add `ui_event_skipped` to ui.reef** In `src/ui.reef`, alongside other lifecycle procs: ```reef mut g_skipped: int = 0 // declared next to g_done/g_failed_count proc ui_event_skipped(name: string, reason: string) let elapsed = zyginit_monotonic_ms() - g_boot_start_ms g_skipped = g_skipped + 1 let note = str.concat("skipped: ", reason) tape_push(elapsed, paint("mute", glyph_pending()), name, note, 0) if g_mode == MODE_PLAIN() let kv = str.concat("reason=", reason) println(fmt_plain_event(elapsed, "info", "skipped", name, kv)) else emit_redraw(elapsed) end if end ui_event_skipped ``` Export `ui_event_skipped` and `g_skipped` access if needed. Also reset `g_skipped = 0` in `ui_boot_start` and `ui_shutdown_start` alongside the other resets. - [ ] **Step 3: Wire condition evaluation into start_service** In `src/supervisor.reef`, find the top of `start_service`. After the initial state read but BEFORE the fork, insert: ```reef let cond_result = condition.evaluate_conditions(def) if condition.cond_passed(cond_result) == false rt.state = STATE_SKIPPED() rt.skip_reason = condition.cond_reason(cond_result) table.runtimes[idx] = rt ui.ui_event_skipped(name, rt.skip_reason) return true // not an error from the caller's perspective end if ``` Add `import condition` at the top of `src/supervisor.reef`. - [ ] **Step 4: Build** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` - [ ] **Step 5: Add integration tests for SKIPPED** In `tests/integration/run_tests.sh`: ```sh echo "=== [condition] block — SKIPPED state ===" create_service skip-missing-file "$(cat <<'EOF' [service] type = "daemon" [exec] start = "/bin/sleep 1000" [condition] exists_file = "/this/path/definitely/does/not/exist" EOF )" enable_service skip-missing-file create_service skip-cmd-fail "$(cat <<'EOF' [service] type = "daemon" [exec] start = "/bin/sleep 1000" [condition] command = "/bin/false" EOF )" enable_service skip-cmd-fail start_daemon sleep 2 out=$($ZYGCTL status) echo "$out" | grep -q "skip-missing-file.*skipped" && \ pass "missing file → skipped" || \ fail "missing file → skipped" "got: $(echo "$out" | grep skip-missing-file)" echo "$out" | grep -q "skip-cmd-fail.*skipped" && \ pass "command exit non-zero → skipped" || \ fail "command exit non-zero → skipped" "got: $(echo "$out" | grep skip-cmd-fail)" # Reason text appears in NOTES echo "$out" | grep -q "skip-missing-file.*missing" && \ pass "reason text in status" || \ fail "reason text in status" "got: $(echo "$out" | grep skip-missing-file)" stop_daemon ``` - [ ] **Step 6: Run + verify** ```sh ./tests/integration/run_tests.sh ``` Some may still fail (because the UI render for SKIPPED isn't in ui_render.reef yet — that's Task 8). The supervisor-level tests should pass. - [ ] **Step 7: Commit** ```sh hg commit -m "supervisor+ui: STATE_SKIPPED + skip_reason + ui_event_skipped — conditions evaluated before fork" ``` --- ## Task 8: UI integration — banner, progress bar, final card, zygctl status **Files:** - Modify: `src/ui.reef` - Modify: `src/ui_render.reef` - [ ] **Step 1: Update banner to show skipped count** In `src/ui.reef`, find `fmt_header`. The summary line currently reads: ``` 23 services · tier 5 · 12 done · 1 failed ``` Modify to include skipped: ```reef out = str.concat(out, paint("frame", str.concat(str.concat(int_to_str(g_num_svc), " services · tier "), str.concat(str.concat(int_to_str(g_cur_tier), " · "), str.concat(str.concat(int_to_str(g_done), " done · "), str.concat(str.concat(int_to_str(g_failed_count), " failed · "), str.concat(int_to_str(g_skipped), " skipped"))))))) ``` - [ ] **Step 2: Update progress bar denominator** In `src/ui.reef`, find `render_boot_screen`. The progress bar call is: ```reef out = str.concat(out, fmt_progress_bar(g_done, g_num_svc)) ``` `g_done` already counts STARTED + FAILED. Add `g_skipped`: ```reef out = str.concat(out, fmt_progress_bar(g_done + g_skipped, g_num_svc)) ``` - [ ] **Step 3: Update final card summary line** In `src/ui.reef`, find `ui_boot_complete`. The summary line currently: ```reef "17 services — 16 online, 1 failed" ``` Modify the BootStats struct (in ui.reef) to include `skipped: int`. Update the line to: ```reef out = str.concat(out, " ") out = str.concat(out, paint("frame", str.concat(str.concat(summary_count, " services — "), str.concat(str.concat(int_to_str(stats.online), " online, "), str.concat(str.concat(int_to_str(stats.failed), " failed, "), str.concat(int_to_str(stats.skipped), " skipped")))))) ``` - [ ] **Step 4: Update build_boot_stats in main.reef** In `src/main.reef`, find `build_boot_stats`. Add a SKIPPED counter: ```reef fn build_boot_stats(table: supervisor.ServiceTable, boot_elapsed_ms: int): ui.BootStats let count = supervisor.service_count(table) mut online = 0 mut failed = 0 mut skipped = 0 mut i = 0 while i < count let rt = supervisor.get_runtime(table, i) let st = supervisor.rt_state(rt) if st == supervisor.STATE_RUNNING() online = online + 1 end if if st == supervisor.STATE_STOPPED() if supervisor.rt_last_exit_code(rt) == 0 online = online + 1 end if end if if st == supervisor.STATE_FAILED() or st == supervisor.STATE_MAINTENANCE() failed = failed + 1 end if if st == supervisor.STATE_SKIPPED() skipped = skipped + 1 end if i = i + 1 end while let slow = new [string](3) return ui.BootStats{ elapsed_ms: boot_elapsed_ms, online: online, failed: failed, skipped: skipped, slowest: slow, slowest_count: 0 } end build_boot_stats ``` - [ ] **Step 5: Update ui_render.reef state mapping** In `src/ui_render.reef`, find `state_color`, `state_glyph`, `state_label`. Add SKIPPED: ```reef // In state_color: if state == supervisor.STATE_SKIPPED() return "mute" end if // In state_glyph: if state == supervisor.STATE_SKIPPED() return glyph_pending() end if // In state_label: if state == supervisor.STATE_SKIPPED() return " skipped" end if ``` In `fmt_status_row`, ensure the NOTES column picks up `rt.skip_reason` when state is SKIPPED. Add right above the `last_exit > 0` block: ```reef if state == supervisor.STATE_SKIPPED() let reason = supervisor.rt_skip_reason(rt) if str.length(reason) > 0 notes = reason end if end if ``` (`supervisor.rt_skip_reason` is a new accessor — add it next to the other `rt_*` accessors and export it.) Update the banner in `ui_render_status` to show `skipped` count alongside online/failed. Same shape as boot banner. - [ ] **Step 6: Build + run all tests** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/run_tests.sh ./tests/integration/ui_tests.sh ./tests/integration/migrate_layout_tests.sh ``` All should pass. Existing snapshot tests may regenerate because the banner now has an extra ` · 0 skipped` segment. Regenerate them deliberately: ```sh rm tests/integration/snapshots/{boot_tape_*,shutdown_*,final_card_*}.txt ./tests/integration/ui_tests.sh hg diff tests/integration/snapshots/ | head -40 # confirm only banner diffs ``` If diffs look right (only the new ` · N skipped` text and the progress bar denominator), commit them. - [ ] **Step 7: Commit** ```sh hg commit -m "ui+ui_render: g_skipped counter, banner, progress denominator, final card, status table" ``` --- ## Task 9: New UI snapshot scenarios **Files:** - Modify: `src/ui.reef` (demo scenarios) - Modify: `tests/integration/ui_tests.sh` - [ ] **Step 1: Add the 5 new check_scenario lines** In `tests/integration/ui_tests.sh`: ```sh check_scenario task_ok "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color" check_scenario task_failed "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color" check_scenario boot_with_skipped "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color" check_scenario final_card_with_skipped "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color" check_scenario status_skipped "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color" ``` - [ ] **Step 2: Run tests — confirm 5 FAILs** ```sh ./tests/integration/ui_tests.sh ``` Expected: 5 unknown scenarios. - [ ] **Step 3: Implement the demo scenarios in ui.reef's ui_demo** ```reef if scenario == "task_ok" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("ok", glyph_ok()), "task-svc", "", 0) g_done = 2 let stats = BootStats{ elapsed_ms: 200, online: 5, failed: 0, skipped: 0, slowest: new [string](3), slowest_count: 0 } ui_boot_complete(stats) return 0 end if if scenario == "task_failed" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("fail", glyph_fail()), "task-svc", "exit=1", 0) g_done = 2 g_failed_count = 1 g_failed_first = "task-svc" g_failed_first_exit = 1 let stats = BootStats{ elapsed_ms: 200, online: 4, failed: 1, skipped: 0, slowest: new [string](3), slowest_count: 0 } ui_boot_complete(stats) return 0 end if if scenario == "boot_with_skipped" g_mode = detect_rich_mode() ui_boot_start(5, 2, "multi-user") g_boot_start_ms = 0 tape_push(40, paint("ok", glyph_ok()), "root-fs", "", 0) tape_push(160, paint("mute", glyph_pending()), "acpihpd", "skipped: missing /dev/acpihp", 0) tape_push(310, paint("ok", glyph_ok()), "cron", "", 0) g_done = 2 g_skipped = 1 // Render the screen mid-boot (not the final card) print(str.concat(str.concat(zyginit_esc_str(), "[2J"), str.concat(zyginit_esc_str(), "[H"))) print(render_boot_screen(400)) return 0 end if if scenario == "final_card_with_skipped" g_mode = detect_rich_mode() ui_boot_start(23, 9, "multi-user") let stats = BootStats{ elapsed_ms: 8300, online: 19, failed: 0, skipped: 4, slowest: new [string](3), slowest_count: 0 } ui_boot_complete(stats) return 0 end if if scenario == "status_skipped" // Renders just the zygctl status banner + table with one skipped row. // Use the same fixture pattern as the existing status scenarios. // (Implementation depends on how existing status demos are structured — // mirror that shape and inject one row with state=SKIPPED.) g_mode = detect_rich_mode() // ... see existing status snapshot demos for the pattern ... return 0 end if ``` For the `status_skipped` scenario specifically: look at how the existing status snapshots (if any) are generated. If there isn't a `--ui-demo` mode for the status table yet, this scenario may need to build a small `ServiceTable` mock — defer this specific scenario to a later iteration if it requires significant scaffolding. Document it as a known gap in the task report. - [ ] **Step 4: Build, run, inspect snapshots** ```sh reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./tests/integration/ui_tests.sh ``` Inspect each new snapshot: ```sh cat tests/integration/snapshots/task_ok.txt cat tests/integration/snapshots/boot_with_skipped.txt # etc. ``` Verify the visual shape matches the spec mockups in §7. - [ ] **Step 5: Commit** ```sh hg add tests/integration/snapshots/task_*.txt tests/integration/snapshots/boot_with_skipped.txt tests/integration/snapshots/final_card_with_skipped.txt tests/integration/snapshots/status_skipped.txt hg commit -m "ui-tests: 5 new snapshot scenarios — task ok/failed, boot/final with skipped, status skipped" ``` --- ## Task 10: Version bump + release **Files:** - Modify: `reef.toml`, `src/version.reef`, `tools/zygctl/reef.toml`, `tools/zygctl/src/version.reef`, `tools/sysv-wrapper/version.h` (via bump-version.sh) - [ ] **Step 1: Bump version 0.1.8 → 0.2.0** ```sh ./scripts/bump-version.sh 0.2.0 hg diff --stat ``` Confirm 5 files changed. - [ ] **Step 2: Regenerate version-stamped snapshots** ```sh grep -l "0\.1\.8" tests/integration/snapshots/*.txt | xargs rm 2>/dev/null ``` - [ ] **Step 3: Rebuild + full test pass** ```sh clang -c src/helpers.c -o build/helpers.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o (cd tools/zygctl && reefc build --obj build/symlink_wrapper.o) ./tests/integration/ui_tests.sh # regenerates and validates ./tests/integration/run_tests.sh ./tests/integration/migrate_layout_tests.sh ``` All must pass. - [ ] **Step 4: Commit + tag + release tarball** ```sh hg commit -m "release: bump to 0.2.0 (per-service layout + task type + [condition] block)" hg tag v0.2.0 ./scripts/make-release.sh cp releases/zyginit-0.2.0-source.tar.xz releases/zyginit-0.2.0-source.tar.xz.sha256 ~/reef-releases/ cat ~/reef-releases/zyginit-0.2.0-source.tar.xz.sha256 hg log -l 5 --template '{rev}: {desc|firstline}\n' ``` - [ ] **Step 5: Update release notes** Append to (or create) `docs/RELEASE_NOTES.md`: ```markdown ## 0.2.0 — 2026-05-14 **Breaking change.** Filesystem layout for `/etc/zyginit/` is restructured. Operators upgrading from 0.1.x MUST run the migration tool before installing 0.2.0 binaries: ``` /path/to/scripts/migrate-layout.sh --dry-run # preview /path/to/scripts/migrate-layout.sh --apply # apply ``` The tool is idempotent and refuses to proceed on ambiguous starting state. ### New: per-service directory layout Each service is now self-contained: ``` /etc/zyginit/services// ├── service.toml ├── start.sh └── stop.sh ``` `enabled.d/` symlinks target `../services//` (the directory). ### New: `type = "task"` service type For services that may run for a while but are expected to eventually exit cleanly (e.g., platform probes, one-time-but-slow setup tasks). Exit 0 = STOPPED (counts as online), exit ≠ 0 = FAILED, never restarts. Replaces the previously-unused `type = "transient"`. ### New: `[condition]` block Declarative pre-flight gating. Supports `exists_file`, `exists_file_any`, `command` (with 5-second timeout). Services with failing conditions enter the new SKIPPED state — no failure, no restart loop. ```toml [condition] exists_file = "/dev/acpihp" ``` ### Hammerhead vendoring The Hammerhead team runs the migration tool against their override tree in `base/usr/src/zyginit/overrides/` as part of consuming this drop. ``` - [ ] **Step 6: Commit release notes** ```sh hg add docs/RELEASE_NOTES.md # if newly created hg commit -m "docs: release notes for 0.2.0" ``` --- ## Self-review notes - **Spec coverage:** every spec section has at least one task. §4 layout → Tasks 2+3+4. §5 task type → Task 5. §6 condition block → Tasks 6+7. §7 UI → Task 8. §8 migration tool → Task 1. §9 data flow → covered across Tasks 2/5/7. §10 error handling → covered inline in each task. §11 testing → Tasks 1/3/5/7/8/9. §12 phased delivery → Tasks 1–10 maps roughly to the spec's Phase 1–7. - **No placeholders:** every step has concrete file paths, code blocks, and shell commands. Task 9 step 3 acknowledges that `status_skipped` may need scaffolding that doesn't yet exist; the implementer should document it as a deferral if needed rather than guessing. - **Type consistency:** `ConditionResult` defined in Task 6 step 1, used in Task 7. `BootStats.skipped: int` added in Task 8 step 3, used in build_boot_stats step 4. `STATE_SKIPPED()` defined Task 7 step 1, mapped in Task 8 step 5. `rt_skip_reason` accessor introduced Task 8 step 5; field added to ServiceRuntime Task 7 step 1. - **Tests-first:** every task begins with a failing test (snapshot or integration). - **Frequent commits:** every task ends with one `hg commit`. - **YAGNI:** no extra condition kinds, no transitional layout reader, no `[assert]` block, no auto-migrate at boot. - **DRY:** `evaluate_conditions` factored once; banner skipped counter added once and consumed by `fmt_header`, `ui_render_status`, and `ui_boot_complete`. # Progress Gauge in Header Divider — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Relocate the boot/shutdown progress indicator from the last row of the screen into the existing top divider of the boot header, fixing the disappear-during-scroll artifact and reclaiming two rows of vertical tape space. **Architecture:** All changes are local to `src/ui.reef`. A new pure function `fmt_divider_gauge(done, total)` renders the gauge as part of the divider. `fmt_header()` calls it instead of `make_divider()` for its trailing rule. `render_boot_screen()` drops its bottom divider and bottom progress bar (those rows are reclaimed by the tape). `ui_boot_complete()` and `ui_shutdown_complete()` already exist and continue to paint their final cards unchanged; the gauge only runs during the live boot/shutdown screen. **Tech Stack:** Reef 0.4.0+, GCC/Clang for `helpers.c`, Mercurial (`hg`) for version control, manual visual validation via the existing `--ui-demo ` scenario harness in `ui_demo()` at `src/ui.reef:852`. **Spec:** `docs/superpowers/specs/2026-06-01-progress-in-divider-design.md` (rev 188, amended rev 189, simplified rev 190). **Build commands (used throughout):** - **Linux dev** (with stubs — verifies rendering, not contracts): ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Result: `build/zyginit` executable. - **Hammerhead** (real binary for hh-prototest deploy): ```bash gcc -c src/helpers.c -o build/helpers.o reefc build -l contract --obj build/helpers.o ``` - **Run a scenario:** ```bash ./build/zyginit --ui-demo ``` **VCS:** Mercurial. `hg add ` for new files, `hg commit -m ""`. Do **not** use `git`. --- ### Task 1: Add gauge scenarios to the demo harness (failing test) **Files:** - Modify: `src/ui.reef:950-963` (the existing `progress_*` scenario blocks inside `ui_demo()`) The existing scenarios at lines 950-963 call `fmt_progress_bar(g_done, g_num_svc)`. Don't touch those yet — Task 5 will rewrite them. Instead, **add new scenarios** to the same `ui_demo` chain so the build will fail until `fmt_divider_gauge` exists. - [ ] **Step 1: Add three new scenarios just after the existing `progress_full` block at line 963.** Insert before the `if scenario == "spinner_forward"` block: ```reef if scenario == "gauge_live_31" or scenario == "gauge_live_31_ascii" if scenario == "gauge_live_31_ascii" g_mode = MODE_RICH_ASCII() else g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if end if ui_detect_winsize() println(fmt_divider_gauge(31, 100)) return 0 end if if scenario == "gauge_live_100" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() println(fmt_divider_gauge(100, 100)) return 0 end if if scenario == "gauge_live_0" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() println(fmt_divider_gauge(0, 100)) return 0 end if ``` Three scenarios: 31% (representative live state), 100% (boundary), 0% (boundary). `gauge_live_31_ascii` exercises the `MODE_RICH_ASCII` branch. The `if g_mode == MODE_PLAIN() ... = MODE_RICH_TRUE()` override is needed because demo runs on a non-TTY (when piped to `cat` for inspection) and otherwise short-circuits to plain mode — same pattern used by the existing `progress_*` scenarios indirectly via `detect_rich_mode()`. - [ ] **Step 2: Run the Linux dev build to verify it fails.** ```bash cd /home/ctusa/repos/zygaena-project/zyginit clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: compile or link error referencing `fmt_divider_gauge` not defined (Reef reports this at type-check stage). If the build accidentally succeeds, the scenario string was probably mistyped — re-check the inserted block. - [ ] **Step 3: Commit the failing test.** ```bash hg commit -m "test(ui): add gauge_live_* scenarios for fmt_divider_gauge (build fails) Three new scenarios in ui_demo: - gauge_live_31, gauge_live_31_ascii — representative live state, both rich-color and rich-ascii rendering paths - gauge_live_100 — full-bar boundary - gauge_live_0 — empty-bar boundary Build fails because fmt_divider_gauge does not exist yet." ``` --- ### Task 2: Implement `fmt_divider_gauge` (make scenarios pass) **Files:** - Modify: `src/ui.reef` — add export declaration and function body. The function replaces both `make_divider()` (semantically) and `fmt_progress_bar()` (visually). - [ ] **Step 1: Add the export declaration.** Edit `src/ui.reef` line 54 area (the `export` block). Insert a new line just after `fn fmt_progress_bar(done: int, total: int): string`: ```reef fn fmt_divider_gauge(done: int, total: int): string ``` So lines 54-55 read: ```reef fn fmt_progress_bar(done: int, total: int): string fn fmt_divider_gauge(done: int, total: int): string ``` (We'll delete `fmt_progress_bar` in Task 5; for now both coexist.) - [ ] **Step 2: Implement the function. Insert it immediately before `fmt_progress_bar` at line 393.** ```reef // Render the boot/shutdown header's bottom rule as a divider with an // embedded progress gauge. Replaces both make_divider() (in fmt_header) // and fmt_progress_bar() (in render_boot_screen). Total visual width = // g_line_width - 16 (matches make_divider). Brackets + 26-cell inner // content + equal lpad/rpad of dashes. Narrow-terminal fallback drops // the brackets and shows just "── NN% ──". fn fmt_divider_gauge(done: int, total: int): string if g_mode == MODE_PLAIN() return "" end if let total_width = g_line_width - 16 let bar_width = 20 let inner_width = 26 // bar(20) + " "(2) + "NNN%"(4) let bracket_width = inner_width + 2 // 28: '[' + inner + ']' mut filled = 0 if total > 0 filled = (done * bar_width) / total end if if filled > bar_width filled = bar_width end if if filled < 0 filled = 0 end if mut pct = 0 if total > 0 pct = (done * 100) / total end if if pct > 100 pct = 100 end if if pct < 0 pct = 0 end if // pct_field: right-aligned 3 cells + '%' = 4 cells. E.g. " 3%", " 31%", "100%". let pct_str = int_to_str(pct) let pct_len = str.length(pct_str) mut pct_field = "" mut pp = 0 while pp < (3 - pct_len) pct_field = str.concat(pct_field, " ") pp = pp + 1 end while pct_field = str.concat(pct_field, pct_str) pct_field = str.concat(pct_field, "%") // Narrow-terminal fallback: not enough room for brackets + padding. if total_width < bracket_width + 2 // Emit "── NN% ──" centered in total_width. let label = str.concat(" ", str.concat(pct_field, " ")) let label_len = 1 + 4 + 1 // " " + "NNN%" + " " mut pad = 0 if total_width > label_len pad = total_width - label_len end if let lpad_n = pad / 2 let rpad_n = pad - lpad_n mut out = "" mut a = 0 while a < lpad_n out = str.concat(out, "─") a = a + 1 end while out = str.concat(out, label) a = 0 while a < rpad_n out = str.concat(out, "─") a = a + 1 end while return paint("accent", out) end if // Full layout: lpad + '[' + bar + " " + pct_field + ']' + rpad let pad_total = total_width - bracket_width let lpad_n = pad_total / 2 let rpad_n = pad_total - lpad_n // Build the bar. Painting per-cell to support per-mode chars. mut bar = "" mut i = 0 while i < bar_width if g_mode == MODE_RICH_ASCII() if i < filled bar = str.concat(bar, "#") else bar = str.concat(bar, " ") end if else if i < filled bar = str.concat(bar, paint("accent", "█")) else bar = str.concat(bar, paint("mute", "░")) end if end if i = i + 1 end while // Assemble. Brackets, pct, and pad chars all painted "accent". mut out = "" mut j = 0 while j < lpad_n out = str.concat(out, "─") j = j + 1 end while out = paint("accent", out) out = str.concat(out, paint("accent", "[")) out = str.concat(out, bar) out = str.concat(out, " ") out = str.concat(out, paint("accent", pct_field)) out = str.concat(out, paint("accent", "]")) mut rpad = "" j = 0 while j < rpad_n rpad = str.concat(rpad, "─") j = j + 1 end while out = str.concat(out, paint("accent", rpad)) return out end fmt_divider_gauge ``` Notes for the implementer: - `paint()` returns the input text unchanged when the mode doesn't support color (e.g., `MODE_RICH_ASCII`); the ANSI escapes are only added in `MODE_RICH_16` / `MODE_RICH_TRUE`. So calling `paint("accent", "─")` is safe across modes. - The "─" character (U+2500) is used in **all** rich modes including `MODE_RICH_ASCII`, matching the pre-existing `make_divider()` behavior. (`make_divider` does not differentiate modes.) Only the bar fill character differentiates rich-ascii (`#`/space) from rich-color (`█`/`░`). - `str.length()` returns the byte length of the string. For ASCII content (`pct_field`), byte length equals visual cell count — safe. - The narrow-terminal fallback is exercised only when `total_width < 30` (i.e., `g_line_width < 46`). At the project's minimum `g_line_width = 40` (clamped in `ui_detect_winsize` at line 691-693), `total_width = 24`, which **does** hit the fallback. Verify visually. - [ ] **Step 3: Rebuild.** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: clean build. - [ ] **Step 4: Run the three new scenarios and visually inspect.** ```bash ./build/zyginit --ui-demo gauge_live_31 ./build/zyginit --ui-demo gauge_live_31_ascii ./build/zyginit --ui-demo gauge_live_100 ./build/zyginit --ui-demo gauge_live_0 ``` Expected output shapes (terminal width 80, so `g_line_width = 78`, `total_width = 62`): - **gauge_live_31** — colored 8-cell fill out of 20, ` 31%` label: ``` ─────────────────[████████░░░░░░░░░░░░ 31%]───────────────── ``` - **gauge_live_31_ascii** — ASCII `#`/space, same layout, no ANSI escapes: ``` -----------------[######## 31%]----------------- ``` (Hyphens shown here for clarity — the actual output uses `─`. Run with `| cat -v` to see escape sequences are absent.) - **gauge_live_100** — full bar, `100%`: ``` ─────────────────[████████████████████ 100%]───────────────── ``` - **gauge_live_0** — empty bar, ` 0%`: ``` ─────────────────[░░░░░░░░░░░░░░░░░░░░ 0%]───────────────── ``` If output looks off, inspect by piping through `cat -v` to see escape sequences explicitly. Common issues to check: bar width (must be exactly 20 cells of fill+empty), pct field width (must be exactly 4 cells: 3-digit number + `%`), brackets present, total visual width consistent across scenarios. - [ ] **Step 5: Commit.** ```bash hg commit -m "feat(ui): implement fmt_divider_gauge Renders the boot/shutdown header rule as a divider with embedded progress gauge: [bar NN%] flanked by '─' padding. Falls back to a narrow-terminal layout (no brackets, just '── NN% ──') when g_line_width < 46. ASCII mode uses #/space for fill; rich mode uses █/░ painted accent/mute." ``` --- ### Task 3: Wire `fmt_divider_gauge` into `fmt_header` **Files:** - Modify: `src/ui.reef:357-389` (`fmt_header` function) The header today ends with a cosmetic divider from `make_divider()`. Replace that call with `fmt_divider_gauge(g_done + g_skipped, g_num_svc)`. Also add the elapsed-suppress conditional. - [ ] **Step 1: Update `fmt_header` to suppress the `elapsed Xs` field once boot has visually completed.** Find lines 365-366 in `src/ui.reef`: ```reef out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat("elapsed ", elapsed))) ``` Wrap them in a phase-aware conditional. Replace with: ```reef let boot_visually_done = (g_phase == "boot") and ((g_done + g_skipped + g_failed_count) >= g_num_svc) and (g_num_svc > 0) if not boot_visually_done out = str.concat(out, " ") out = str.concat(out, paint("accent", str.concat("elapsed ", elapsed))) end if ``` Reef uses `not`, `and`, `or` for boolean operators (verified at `src/ui.reef:527` `if not has_active_tape_row()` and line 869 `or scenario ==`). The `==` / `>=` operators are numeric comparison as used at line 375. - [ ] **Step 2: Replace the trailing `make_divider()` call with the new gauge.** Find lines 386-387: ```reef out = str.concat(out, "\n ") out = str.concat(out, paint("accent", make_divider())) ``` Replace with: ```reef out = str.concat(out, "\n ") out = str.concat(out, fmt_divider_gauge(g_done + g_skipped, g_num_svc)) ``` Note: `fmt_divider_gauge` returns already-painted text — do **not** wrap in `paint("accent", ...)`. - [ ] **Step 3: Rebuild and exercise the existing full-header scenarios.** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/zyginit --ui-demo boot_tape_rich ``` Expected: the four-line header now ends with the gauge divider (e.g. `─────[███...░░░ 47%]──────`) instead of a plain `─────────` line. The bottom of the screen still shows the old divider+progress_bar pair — those go in Task 4. The `elapsed Xs` field on header line 1 should still be present (the scenario doesn't reach 100%). - [ ] **Step 4: Verify the elapsed-suppress branch.** Run `boot_tape_ascii` (ends at 9/9 in some scenarios) or check `final_card_ok` — though the latter exits through `ui_boot_complete()` which doesn't call `fmt_header`. Easiest verification: temporarily add a one-off scenario that calls `ui_boot_start(5, 1, "multi-user")`, sets `g_done = 5`, then `print(fmt_header(5000))`. Confirm header line 1 omits `elapsed Xs`. Roll that scenario back before commit (it's a debug aid, not part of the deliverable). - [ ] **Step 5: Commit.** ```bash hg commit -m "feat(ui): wire fmt_divider_gauge into header trailing rule fmt_header() now renders its trailing rule via fmt_divider_gauge instead of make_divider, so the boot screen header carries the live progress gauge. Also suppress the 'elapsed Xs' field on header line 1 once boot is visually complete (g_done + g_skipped + g_failed_count >= g_num_svc), avoiding stale-timer visual noise during the brief window before ui_boot_complete fires the final card." ``` --- ### Task 4: Strip bottom divider and bottom progress bar from `render_boot_screen` **Files:** - Modify: `src/ui.reef:499-521` (`render_boot_screen` function) - Modify: `src/ui.reef:682` (`g_tape_height` calculation) - [ ] **Step 1: Remove the bottom divider concat and the bottom progress_bar concat.** Find lines 513-520: ```reef i = i + 1 end while out = str.concat(out, str.concat(" ", paint("accent", make_divider()))) out = str.concat(out, "\n") out = str.concat(out, fmt_progress_bar(g_done + g_skipped, g_num_svc)) // No trailing newline: total height is exactly g_screen_rows. A trailing // \n would advance the cursor past the bottom edge and scroll the [Z] // banner off the top of the screen. return out ``` Replace with: ```reef i = i + 1 end while // No trailing newline: total height is exactly g_screen_rows. A trailing // \n would advance the cursor past the bottom edge and scroll the [Z] // banner off the top of the screen. The progress gauge lives in the // header rule (see fmt_divider_gauge), not on the last row. return out ``` Result: the tape loop is the last content; no trailing divider or progress bar. - [ ] **Step 2: Reduce `g_tape_height` reserve from 6 to 4.** Find line 681-682: ```reef // Tape height = screen rows - (header 4 + progress 2) with breathing room g_tape_height = g_screen_rows - 6 ``` Replace with: ```reef // Tape height = screen rows - (header 4 rows including gauge divider). // Gauge lives inside the header, not on a separate trailing row. g_tape_height = g_screen_rows - 4 ``` Leave the clamps (`< 4`, `> 32`) on lines 683-688 unchanged. - [ ] **Step 3: Rebuild and verify full layout.** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/zyginit --ui-demo boot_tape_rich ``` Expected: 4-row header ending with the gauge divider, followed immediately by the tape rows (no trailing divider, no trailing progress bar). The tape should have 2 more rows than before (because `g_tape_height` is +2). - [ ] **Step 4: Compare row count visually.** Count rows of the output. On a 25-row default terminal: 4 header rows + 21 tape rows = 25 total. Before the change: 4 header + 19 tape + 1 bottom divider + 1 progress = 25. Either way the total is 25; the change is two more tape slots. - [ ] **Step 5: Commit.** ```bash hg commit -m "feat(ui): strip bottom divider + progress bar from render_boot_screen Both are obsolete now that fmt_header's trailing rule carries the gauge. Tape height adjusted from g_screen_rows-6 to g_screen_rows-4 to claim the reclaimed rows. Fixes the disappearing-progress-bar artifact: the gauge no longer lives on the volatile last row of the screen." ``` --- ### Task 5: Convert legacy `progress_*` scenarios; delete dead code **Files:** - Modify: `src/ui.reef:950-963` (legacy scenarios) - Modify: `src/ui.reef:54` (export of `fmt_progress_bar`) - Modify: `src/ui.reef:391-439` (definitions of `PROGRESS_WIDTH` and `fmt_progress_bar`) - [ ] **Step 1: Rewrite the three legacy scenarios to use the gauge.** Find lines 950-963: ```reef if scenario == "progress_rich" or scenario == "progress_ascii" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_done = 12 g_failed_count = 1 print(fmt_progress_bar(g_done, g_num_svc)) return 0 end if if scenario == "progress_full" g_mode = detect_rich_mode() ui_boot_start(17, 8, "multi-user") g_done = 17 print(fmt_progress_bar(g_done, g_num_svc)) return 0 end if ``` Replace with: ```reef if scenario == "progress_rich" or scenario == "progress_ascii" if scenario == "progress_ascii" g_mode = MODE_RICH_ASCII() else g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if end if ui_detect_winsize() ui_boot_start(17, 8, "multi-user") g_done = 12 g_failed_count = 1 println(fmt_divider_gauge(g_done + g_skipped, g_num_svc)) return 0 end if if scenario == "progress_full" g_mode = detect_rich_mode() if g_mode == MODE_PLAIN() g_mode = MODE_RICH_TRUE() end if ui_detect_winsize() ui_boot_start(17, 8, "multi-user") g_done = 17 println(fmt_divider_gauge(g_done + g_skipped, g_num_svc)) return 0 end if ``` Same scenario names, same semantic intent (visualize boot progress at 12/17 with one failure, then at 17/17 full), but now using the new gauge. Switched `print` → `println` to match the new gauge scenarios (terminal-friendly). - [ ] **Step 2: Delete `PROGRESS_WIDTH()` and `fmt_progress_bar()`.** Find lines 391-439 and delete the entire block: ```reef fn PROGRESS_WIDTH(): int return 36 end PROGRESS_WIDTH fn fmt_progress_bar(done: int, total: int): string if g_mode == MODE_PLAIN() return "" end if let width = PROGRESS_WIDTH() mut filled = 0 if total > 0 filled = (done * width) / total end if if filled > width filled = width end if mut pct = 0 if total > 0 pct = (done * 100) / total end if mut bar = "" if g_mode == MODE_RICH_ASCII() let inner = width - 2 let inner_filled = (filled * inner) / width bar = str.concat(bar, "[") mut i = 0 while i < inner if i < inner_filled bar = str.concat(bar, "#") else bar = str.concat(bar, " ") end if i = i + 1 end while bar = str.concat(bar, "]") else mut i = 0 while i < width if i < filled bar = str.concat(bar, paint("accent", "█")) else bar = str.concat(bar, paint("mute", "░")) end if i = i + 1 end while end if mut label = "" if g_phase == "shutdown" label = " down" end if return str.concat( str.concat(" ", bar), str.concat( str.concat(str.concat(" ", int_to_str(done)), str.concat("/", int_to_str(total))), str.concat(label, str.concat(" ", str.concat(int_to_str(pct), "%"))))) end fmt_progress_bar ``` The block ends at the `end fmt_progress_bar` line (line 439). Delete everything from `fn PROGRESS_WIDTH(): int` through `end fmt_progress_bar` inclusive. - [ ] **Step 3: Remove the export declaration for `fmt_progress_bar`.** Find line 54: ```reef fn fmt_progress_bar(done: int, total: int): string ``` Delete this entire line. The line below (`fn fmt_divider_gauge(...)`) remains. - [ ] **Step 4: Grep to confirm no remaining callers.** ```bash grep -rn "fmt_progress_bar\|PROGRESS_WIDTH" src/ ``` Expected: **no output**. If any reference remains, that's a real bug — track it down before continuing. - [ ] **Step 5: Rebuild and run all gauge-related scenarios.** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ./build/zyginit --ui-demo gauge_live_0 ./build/zyginit --ui-demo gauge_live_31 ./build/zyginit --ui-demo gauge_live_31_ascii ./build/zyginit --ui-demo gauge_live_100 ./build/zyginit --ui-demo progress_rich ./build/zyginit --ui-demo progress_ascii ./build/zyginit --ui-demo progress_full ./build/zyginit --ui-demo boot_tape_rich ./build/zyginit --ui-demo boot_tape_ascii ``` Expected: clean build, all scenarios render correctly. `boot_tape_*` shows the full new layout (header + gauge divider + 8 tape rows, no trailing divider/progress). - [ ] **Step 6: Commit.** ```bash hg commit -m "refactor(ui): drop fmt_progress_bar and PROGRESS_WIDTH Both obsolete after the gauge moved into fmt_divider_gauge. Legacy progress_rich / progress_ascii / progress_full scenarios converted to exercise fmt_divider_gauge instead. No remaining callers. grep -rn 'fmt_progress_bar\\|PROGRESS_WIDTH' src/ → empty." ``` --- ### Task 6: Full Linux dev validation sweep **Files:** - None modified. Pure validation pass. - [ ] **Step 1: Run every existing UI scenario and confirm nothing regressed.** ```bash SCENARIOS=(skeleton plain_boot palette_true palette_16 \ glyphs_unicode glyphs_ascii sigil_rich \ boot_tape_rich boot_tape_ascii boot_tape_plain \ progress_rich progress_ascii progress_full \ spinner_forward spinner_reverse spinner_in_tape \ final_card_ok final_card_failed \ gauge_live_0 gauge_live_31 gauge_live_31_ascii gauge_live_100) for s in "${SCENARIOS[@]}"; do echo "--- $s ---" ./build/zyginit --ui-demo "$s" echo done ``` For each scenario, eyeball the output. Specific things to verify: - `boot_tape_rich`: header ends with gauge divider showing 8/17 → `(8*100)/17 = 47%` fill. - `final_card_ok` / `final_card_failed`: unchanged from before (these go through `ui_boot_complete`, not the live boot screen). - `boot_tape_plain`: still emits plain text lines (no gauge — `MODE_PLAIN`). - [ ] **Step 2: Verify narrow-terminal fallback.** ```bash stty cols 40 ./build/zyginit --ui-demo gauge_live_31 stty cols 80 # restore ``` Expected: gauge renders as `── 31% ──`-style narrow form, no brackets, no bar. If `stty cols 40` doesn't propagate to the child process (depends on shell + TTY semantics), an alternative: temporarily edit `ui_detect_winsize()` to force `g_line_width = 24`, run the scenario, then revert. - [ ] **Step 3: No commit needed** — validation pass, no files changed. --- ### Task 7: Hammerhead build, deploy, and live verification **Files:** - None modified. Deploy and verify on the test VM. - [ ] **Step 1: Build for Hammerhead from the dev host.** The dev host doesn't have GCC for Hammerhead targets — push source to the VM and build there. Two options: build remotely (preferred — fewer moving parts) or build locally with a cross-compiler. Use remote: ```bash # From dev host: scp src/ui.reef root@192.168.122.50:/root/zyginit/src/ ``` (Only `ui.reef` changed.) - [ ] **Step 2: Build on hh-prototest and replace `/sbin/init`.** ```bash ssh root@192.168.122.50 'cd /root/zyginit && \ gcc -c src/helpers.c -o build/helpers.o && \ reefc build -l contract --obj build/helpers.o && \ cp build/zyginit /sbin/init.new && \ mv /sbin/init.new /sbin/init' ``` Expected: clean build (no warnings about unused `fmt_progress_bar`), file replaced atomically via rename. - [ ] **Step 3: Force a cold reboot via virsh.** `init 6` from non-PID-1 zyginit doesn't reboot — it spawns a supervisor-mode child. Use virsh: ```bash virsh --connect=qemu:///system reset hh-prototest ``` - [ ] **Step 4: Observe the boot console via virt-viewer / virsh console.** Open the VM console (`virsh --connect=qemu:///system console hh-prototest`, or VNC via virt-manager). Expected during boot: - 4-row header (sigil line, counts line, failed line, gauge divider) - Gauge fills as services start across tiers - Tape rows occupy everything below the divider - **No** disappearing-bar artifact — kernel printks (if any) scroll lower content but the gauge stays put in row 4 Once boot completes, the final card appears (existing behavior, unchanged). - [ ] **Step 5: Test shutdown.** ```bash ssh root@192.168.122.50 'zygctl halt' ``` Expected: shutdown screen renders with gauge filling 0→100% as services stop. Once all services are stopped, the shutdown final card replaces the screen ("reboot complete · invoking uadmin..."). System reboots cleanly. After the reboot, SSH back in and verify `who -b` and `uptime` report correct times (sanity check that shutdown didn't corrupt anything). - [ ] **Step 6: Force-scroll test — confirm the bug is fixed.** The original bug surfaces during boot when something else writes to `/dev/console` between zyginit redraws. Easiest reproducer is the cold-boot path itself (Step 4): kernel printks and driver messages naturally interleave with zyginit's tape updates. Watch the VM console during boot. Expected: the gauge in the divider stays visible across console churn. It may flicker on redraw (as zyginit clears and repaints), but it does **not** disappear off the bottom of the screen as the pre-fix bar did. If you want a deliberate stress test, induce a one-shot kernel message in a non-PID-1 supervisor instance: ```bash ssh root@192.168.122.50 ' ZYGINIT_CONFIG_DIR=/etc/zyginit /sbin/init & sleep 1 echo "test message" > /dev/console sleep 1 kill %1 ' ``` The gauge should remain on row 4 across the `echo > /dev/console` write. - [ ] **Step 7: Commit the source changes if not already committed and tag.** All file changes were committed in earlier tasks. Final action is a single sanity commit if any final tweaks were needed, then tag: ```bash hg log -l 8 --template '{rev}: {desc|firstline}\n' # confirm clean history ``` No tag yet — that's a release decision; leave for the user. --- ## Self-Review **Spec coverage:** - §1 problem statement → addressed by entire plan, especially Tasks 3–4 (relocating gauge out of last row). - §2 goals (stability, no-twitch, reclaim space, shutdown symmetry, no plain/ASCII regression) → all covered. - §4 visual mockups → Tasks 2, 3, 4 deliver §4.2; §4.4 covered by the unchanged `ui_shutdown_complete` plus the gauge running during stop sequence. - §5 rendering rules (geometry, ASCII vs rich, narrow fallback) → Task 2. - §6 state changes (none) → respected: no new `g_*` flags introduced. - §7 files touched (only `src/ui.reef`) → all task edits target `src/ui.reef`. - §8 backwards compat (plain mode unchanged) → preserved (`MODE_PLAIN()` early-out in `fmt_divider_gauge`); legacy `progress_*` scenarios rewritten to use new gauge but keep the same scenario names so any external test scripts continue to find them. - §9 testing — manual on hh-prototest → Task 7. **Placeholder scan:** No "TBD", no "implement later", no "similar to Task N", no skeleton steps. Every code block is complete. **Type consistency:** `fmt_divider_gauge(done: int, total: int): string` used consistently in export declaration, function body, and three caller sites (Tasks 1, 3, 5). `g_done`, `g_skipped`, `g_failed_count`, `g_num_svc`, `g_phase`, `g_mode` all reference existing state in `src/ui.reef`. `MODE_PLAIN`, `MODE_RICH_ASCII`, `MODE_RICH_TRUE`, `detect_rich_mode`, `ui_detect_winsize`, `ui_boot_start`, `paint`, `str.concat`, `str.length`, `int_to_str` all exist (verified by grep). `paint()` signature is `(token: string, text: string): string`. **Scope:** Single focused UI change. One file. Seven tasks of escalating commitment (test → impl → wire → strip → cleanup → validate → deploy). Self-contained. # `conflicts` (Service Mutual Exclusion) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `conflicts` field to the `[dependencies]` block so two services can be declared mutually exclusive — at boot, both-enabled conflicting services fail; at runtime, starting a service whose conflicting peer is running is refused. **Architecture:** Two non-overlapping enforcement points funnel through known code. A boot-only pre-flight (`resolve_conflicts` in `main.reef`) marks both members of any active conflicting pair `FAILED` before any fork. A runtime guard inside `supervisor.start_service` (the single choke point for every start) refuses a start when a conflicting peer is `RUNNING`/`STARTING`. A symmetric helper `conflicts_with` backs both. `conflicts` is mutual exclusion, not ordering — it never enters `depgraph.reef`. **Tech Stack:** Reef (`.reef` → C → native via `reefc`); POSIX shell integration tests; Linux dev build (clang + contract stubs). No Hammerhead VM required. **Spec:** `docs/superpowers/specs/2026-06-13-conflicts-design.md` --- ## Reef / build orientation (read once) - Reef structs are **value types**: to mutate a runtime you read it, change fields, write it back — e.g. `let rt = table.runtimes[idx]` … `rt.state = …` … `table.runtimes[idx] = rt`. See `start_service` at `src/supervisor.reef:327` for the canonical pattern. - Blocks close with `end ` (`end if`, `end fn`, `end while`, `end proc`). `fn` returns a value; `proc` does not. `mut` = mutable, `let` = immutable. - String building uses `core.str` — `str.concat(a, b)` (already imported as `str` in `supervisor.reef`). `socket.reef` builds strings with the `+` operator (both work; match the file you're editing). - **Build (Linux dev):** ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` zygctl (needed for the integration suite): ```bash cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o && cd ../.. ``` - **Run the integration suite:** `./tests/integration/run_tests.sh` - **VCS is Mercurial.** Commit with `hg commit -m "..."`. New files need `hg add ` first. Every source file must carry the `SRCHEADER.txt` header — but in this plan we only *edit* existing `.reef` files (which already have headers) and add a shell test + docs, so no new `.reef` headers are introduced. --- ## File Map | File | Change | |------|--------| | `src/config.reef` | Add `conflicts`/`conflicts_count` to `ServiceDef`, init, accessors, TOML parse branch | | `src/supervisor.reef` | `conflicts_with`, `conflicting_running_peer`, `service_name_at`, `mark_conflict_failed`; runtime guard in `start_service` | | `src/main.reef` | `resolve_conflicts` + `warn_unknown_conflicts` procs; call before boot tier-start; skip non-`WAITING` services in `start_services_by_tier` | | `src/ui_render.reef` | Show the stored reason for `FAILED` rows in `zygctl status` | | `src/socket.reef` | `cmd_start` returns the specific conflict message | | `tests/integration/run_tests.sh` | `create_service_with_conflicts` helper + new "Service conflicts" suite | | `docs/DESIGN.md`, `docs/man/man5/zyginit.5`, `CLAUDE.md`, `ROADMAP.md` | Document `conflicts`; check roadmap box | --- ## Task 1: Parse `conflicts` and expose accessors (`config.reef`) Pure data plumbing — no observable behavior yet, so its "test" is: the project still builds and the existing suite stays green. (The repo has no Reef unit-test harness; `conflicts` parsing is verified end-to-end by Task 2's boot test, which cannot pass unless the array parses correctly.) **Files:** - Modify: `src/config.reef` (struct ~178, init ~219, accessors ~302, parser ~614) - [ ] **Step 1: Add struct fields** In `type ServiceDef = struct` (`src/config.reef`), immediately after: ``` after: [string] after_count: int ``` add: ``` conflicts: [string] conflicts_count: int ``` - [ ] **Step 2: Initialize in `new_service_def()`** In the `ServiceDef{ ... }` literal, immediately after: ``` after: new [string](16), after_count: 0, ``` add: ``` conflicts: new [string](16), conflicts_count: 0, ``` - [ ] **Step 3: Add accessors** After the `svc_after_count` accessor, add: ``` fn svc_conflicts(svc: ServiceDef): [string] return svc.conflicts end svc_conflicts fn svc_conflicts_count(svc: ServiceDef): int return svc.conflicts_count end svc_conflicts_count ``` - [ ] **Step 4: Add TOML parse branch** In the `[dependencies]` parsing area, immediately after the `dependencies.after` block: ``` if toml.toml_has_key(keys, vals, count, "dependencies.after") let after_str = toml.toml_get(keys, vals, count, "dependencies.after") svc.after_count = parse_toml_array(after_str, svc.after, 16) end if ``` add: ``` if toml.toml_has_key(keys, vals, count, "dependencies.conflicts") let conf_str = toml.toml_get(keys, vals, count, "dependencies.conflicts") svc.conflicts_count = parse_toml_array(conf_str, svc.conflicts, 16) end if ``` - [ ] **Step 5: Build** Run: ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: build succeeds, `build/zyginit` produced. - [ ] **Step 6: Verify no regression** Run: `./tests/integration/run_tests.sh` Expected: same pass count as before this task (all green). If zygctl isn't built yet, build it per the orientation block first. - [ ] **Step 7: Commit** ```bash hg commit src/config.reef -m "config: parse [dependencies] conflicts array" ``` --- ## Task 2: Boot-time resolution — fail both (`supervisor.reef` + `main.reef` + `ui_render.reef`) First observable behavior. TDD: add the boot test (red), implement, go green. **Files:** - Modify: `tests/integration/run_tests.sh` (helper + new suite) - Modify: `src/supervisor.reef` (`conflicts_with`, `mark_conflict_failed`, `service_name_at`) - Modify: `src/main.reef` (`resolve_conflicts`, `warn_unknown_conflicts`, call site, tier-start state guard) - Modify: `src/ui_render.reef` (status reason for `FAILED`) - [ ] **Step 1: Add the test helper** In `tests/integration/run_tests.sh`, after the `create_service_with_env()` helper, add: ```bash # Create a service with a [dependencies] conflicts array. # Usage: create_service_with_conflicts "\"other\"" create_service_with_conflicts() { local name="$1" local type="$2" local cmd="$3" local conflicts="$4" mkdir -p "$CONFIG_DIR/services/$name" cat > "$CONFIG_DIR/services/$name/service.toml" << TOML [service] description = "Test service: $name" type = "$type" [exec] start = "$cmd" [dependencies] conflicts = [$conflicts] [restart] on = "never" TOML } ``` - [ ] **Step 2: Add the boot-resolution test section** Append a new section near the other suites (after the "Restart policies" section). Use a fresh config dir like the other sections do: ```bash # ============================================================================ # Test Suite: Service conflicts # ============================================================================ section "Service conflicts" rm -rf "$CONFIG_DIR" mkdir -p "$CONFIG_DIR" "$ENABLED_DIR" # Two daemons that declare each other as conflicting; both enabled. create_service_with_conflicts "mailer-a" "daemon" "$BIN_DIR/sleeper.sh" "\"mailer-b\"" create_service "mailer-b" "daemon" "$BIN_DIR/sleeper.sh" enable_service "mailer-a" enable_service "mailer-b" start_daemon # Boot resolution: both enabled + conflicting => start neither, fail both. output=$(zygctl status mailer-a) assert_contains "$output" "failed" "conflict: mailer-a failed at boot" assert_contains "$output" "conflicts with" "conflict: mailer-a status shows reason" output=$(zygctl status mailer-b) assert_contains "$output" "failed" "conflict: mailer-b failed at boot" stop_daemon ``` - [ ] **Step 3: Run the suite — confirm the new asserts FAIL** Run: `./tests/integration/run_tests.sh` Expected: the four new `conflict:` asserts FAIL (both services currently start and report `running`, never `failed`). Existing tests still pass. - [ ] **Step 4: Add `conflicts_with` to `supervisor.reef`** After `service_count` (`src/supervisor.reef:294`), add: ``` // True if services a and b are declared mutually exclusive. Symmetric: a // conflict declared on either side counts for both. Self-comparison and // unknown names never match. fn conflicts_with(table: ServiceTable, a_idx: int, b_idx: int): bool if a_idx == b_idx return false end if let a_def = table.runtimes[a_idx].def let b_def = table.runtimes[b_idx].def let a_name = config.svc_name(a_def) let b_name = config.svc_name(b_def) let a_conf = config.svc_conflicts(a_def) let a_n = config.svc_conflicts_count(a_def) mut i = 0 while i < a_n if a_conf[i] == b_name return true end if i = i + 1 end while let b_conf = config.svc_conflicts(b_def) let b_n = config.svc_conflicts_count(b_def) i = 0 while i < b_n if b_conf[i] == a_name return true end if i = i + 1 end while return false end conflicts_with ``` - [ ] **Step 5: Add `service_name_at` and `mark_conflict_failed` to `supervisor.reef`** Directly after `conflicts_with`, add: ``` // Name of the service at idx (convenience for callers outside this module). fn service_name_at(table: ServiceTable, idx: int): string return config.svc_name(table.runtimes[idx].def) end service_name_at // Mark a service FAILED because of a conflict, recording the reason for // `zygctl status`. The skip_reason field doubles as a generic status reason. proc mark_conflict_failed(table: ServiceTable, idx: int, other_name: string) let rt = table.runtimes[idx] rt.state = STATE_FAILED() rt.skip_reason = str.concat("conflicts with ", other_name) table.runtimes[idx] = rt end mark_conflict_failed ``` - [ ] **Step 6: Add `resolve_conflicts` + `warn_unknown_conflicts` to `main.reef`** In `src/main.reef`, after the `start_services_by_tier` proc (ends at `:366`), add: ``` // Warn (once, at boot) about conflict targets that name a non-existent service. proc warn_unknown_conflicts(table: supervisor.ServiceTable, def: config.ServiceDef) let conf = config.svc_conflicts(def) let n = config.svc_conflicts_count(def) mut k = 0 while k < n if supervisor.find_service(table, conf[k]) < 0 println("zyginit: warning: " + config.svc_name(def) + " conflicts with unknown service: " + conf[k]) end if k = k + 1 end while end warn_unknown_conflicts // Boot-only pre-flight: for every pair of services that are both active in the // current runlevel and declared mutually exclusive, mark BOTH failed before any // fork. zyginit never auto-picks a winner. Runtime conflicts (zygctl start, // runlevel transitions) are handled by the guard in supervisor.start_service. proc resolve_conflicts(table: supervisor.ServiceTable) let n = supervisor.service_count(table) mut i = 0 while i < n let rt_i = supervisor.get_runtime(table, i) let def_i = supervisor.rt_def(rt_i) if supervisor.rt_state(rt_i) == supervisor.STATE_WAITING() and service_in_runlevel(def_i, g_runlevel) warn_unknown_conflicts(table, def_i) mut j = i + 1 while j < n let rt_j = supervisor.get_runtime(table, j) let def_j = supervisor.rt_def(rt_j) if supervisor.rt_state(rt_j) == supervisor.STATE_WAITING() and service_in_runlevel(def_j, g_runlevel) and supervisor.conflicts_with(table, i, j) let name_i = config.svc_name(def_i) let name_j = config.svc_name(def_j) supervisor.mark_conflict_failed(table, i, name_j) supervisor.mark_conflict_failed(table, j, name_i) if ui.ui_mode() == ui.MODE_PLAIN() println("zyginit: conflict: " + name_i + " and " + name_j + " are mutually exclusive; failing both") end if end if j = j + 1 end while end if i = i + 1 end while end resolve_conflicts ``` (`main.reef` already imports `config`, `supervisor`, and `ui`, and defines `service_in_runlevel` at `:187` and `g_runlevel` at `:167`.) - [ ] **Step 7: Call `resolve_conflicts` before the boot tier-start** In `src/main.reef`, in the "Phase 6: Start services" block, the boot call is at `:1093`: ``` ui.ui_boot_start(svc_count_clamped, num_tiers, runlevel_name) start_services_by_tier(table, tiers, tier_counts, num_tiers) ``` Insert the pre-flight between those two lines: ``` ui.ui_boot_start(svc_count_clamped, num_tiers, runlevel_name) resolve_conflicts(table) start_services_by_tier(table, tiers, tier_counts, num_tiers) ``` Do NOT add it to the runlevel-transition reuse of `start_services_by_tier` (`:862`); transitions are runtime and rely on the `start_service` guard (Task 3). - [ ] **Step 8: Make the tier-start loop skip already-FAILED services** In `start_services_by_tier` (`src/main.reef:278`), the start condition currently is: ``` if service_in_runlevel(def, g_runlevel) and state != supervisor.STATE_RUNNING() supervisor.start_service(table, idx) ``` A conflict-failed service has `state == FAILED` (not `RUNNING`), so it would otherwise be (re)started. Tighten the condition to only start `WAITING` services: ``` if service_in_runlevel(def, g_runlevel) and state == supervisor.STATE_WAITING() supervisor.start_service(table, idx) ``` This is safe for the runlevel-transition reuse: newly-active services are `WAITING` (or `mark_runlevel_filtered`), and the existing `elif ... state == STATE_WAITING()` branch below is unaffected. - [ ] **Step 9: Show the stored reason for FAILED rows in status (`ui_render.reef`)** In `src/ui_render.reef`, the notes block in `fmt_status_row` (`:168`) currently begins: ``` if state == supervisor.STATE_SKIPPED() let reason = supervisor.rt_skip_reason(rt) if str.length(reason) > 0 notes = reason end if elif last_exit >= 0 and state != supervisor.STATE_RUNNING() and state != supervisor.STATE_WAITING() ``` Replace the first `if` branch so a `FAILED` service with a stored reason shows it too: ``` let reason = supervisor.rt_skip_reason(rt) if state == supervisor.STATE_SKIPPED() and str.length(reason) > 0 notes = reason elif state == supervisor.STATE_FAILED() and str.length(reason) > 0 notes = reason elif last_exit >= 0 and state != supervisor.STATE_RUNNING() and state != supervisor.STATE_WAITING() ``` (A normally-failed daemon has `reason == ""`, so it still falls through to the `exit=` branch.) - [ ] **Step 10: Build** Run: ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: build succeeds. - [ ] **Step 11: Run the suite — boot asserts now PASS** Run: `./tests/integration/run_tests.sh` Expected: the four `conflict:` boot asserts now PASS; all previously-passing tests still pass. - [ ] **Step 12: Commit** ```bash hg add tests/integration/run_tests.sh hg commit src/supervisor.reef src/main.reef src/ui_render.reef tests/integration/run_tests.sh \ -m "supervisor: boot-time conflict resolution (fail both); status shows reason" ``` --- ## Task 3: Runtime guard — refuse (`supervisor.reef` + `socket.reef`) **Files:** - Modify: `tests/integration/run_tests.sh` (extend the "Service conflicts" section) - Modify: `src/supervisor.reef` (`conflicting_running_peer` + guard in `start_service`) - Modify: `src/socket.reef` (`cmd_start` specific message) - [ ] **Step 1: Add the runtime-refusal test** In `tests/integration/run_tests.sh`, inside the "Service conflicts" section, **before** the final `stop_daemon`, append a fresh-config runtime scenario: ```bash # --- Runtime refusal: starting a service whose conflicting peer runs is refused. stop_daemon rm -rf "$CONFIG_DIR" mkdir -p "$CONFIG_DIR" "$ENABLED_DIR" # Only svc-a is enabled at boot; svc-b conflicts with it and is started by hand. create_service "svc-a" "daemon" "$BIN_DIR/sleeper.sh" create_service_with_conflicts "svc-b" "daemon" "$BIN_DIR/sleeper.sh" "\"svc-a\"" enable_service "svc-a" enable_service "svc-b" # Disable svc-b's autostart by NOT enabling it... but boot resolution would fail # both if both enabled. Instead enable only svc-a; add svc-b via reload-less path: disable_service "svc-b" start_daemon output=$(zygctl status svc-a) assert_contains "$output" "running" "runtime: svc-a is running" # Enable svc-b and reload so it joins the table as WAITING, then try to start it. enable_service "svc-b" zygctl reload > /dev/null 2>&1 sleep 1 output=$(zygctl start svc-b) assert_contains "$output" "conflicts with running service" "runtime: start svc-b refused" output=$(zygctl status svc-a) assert_contains "$output" "running" "runtime: svc-a still running after refusal" stop_daemon ``` Note: `reload` adds the newly-enabled `svc-b` as `WAITING` without auto-starting it through the boot pre-flight, which is exactly the runtime path we want to exercise. If `reload` auto-starts new services in this codebase, the `zygctl start svc-b` still exercises the guard because the start is attempted while `svc-a` runs; assert on the refusal message regardless. - [ ] **Step 2: Run the suite — runtime asserts FAIL** Run: `./tests/integration/run_tests.sh` Expected: the two new `runtime:` asserts FAIL (`start svc-b` currently succeeds). - [ ] **Step 3: Add `conflicting_running_peer` to `supervisor.reef`** Directly after `conflicts_with` (added in Task 2), add: ``` // Index of a RUNNING/STARTING service that conflicts with idx, or -1 if none. // Shared by the start guard and by zygctl start for messaging. fn conflicting_running_peer(table: ServiceTable, idx: int): int mut i = 0 while i < table.count if i != idx and conflicts_with(table, idx, i) let st = table.runtimes[i].state if st == STATE_RUNNING() or st == STATE_STARTING() return i end if end if i = i + 1 end while return 0 - 1 end conflicting_running_peer ``` - [ ] **Step 4: Add the guard inside `start_service`** In `start_service` (`src/supervisor.reef`), immediately after the `[condition]` evaluation block closes (`end if` at `:341`) and **before** `ui.ui_event_starting(name)` (`:344`), insert: ``` // Conflict guard: refuse to start when a mutually-exclusive peer is already // running or starting. Leaves this service's state unchanged. Covers // zygctl start, runlevel transitions, and restarts (all funnel here). let conflict_peer = conflicting_running_peer(table, idx) if conflict_peer >= 0 let other = config.svc_name(table.runtimes[conflict_peer].def) if ui.ui_mode() == ui.MODE_PLAIN() println(str.concat(str.concat("supervisor: cannot start ", name), str.concat(": conflicts with running service ", other))) end if return false end if ``` - [ ] **Step 5: Return the specific message from `cmd_start` (`socket.reef`)** In `src/socket.reef`, in `cmd_start`, after the `already running` check and before `if supervisor.start_service(table, idx)` (`:318`), insert: ``` let conflict_peer = supervisor.conflicting_running_peer(table, idx) if conflict_peer >= 0 let other = supervisor.service_name_at(table, conflict_peer) return "error: cannot start " + name + ": conflicts with running service " + other + "\n" end if ``` - [ ] **Step 6: Build** Run: ```bash clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: build succeeds. - [ ] **Step 7: Run the suite — runtime asserts now PASS** Run: `./tests/integration/run_tests.sh` Expected: the `runtime:` asserts now PASS; everything else still green. - [ ] **Step 8: Commit** ```bash hg commit src/supervisor.reef src/socket.reef tests/integration/run_tests.sh \ -m "supervisor: refuse start when a conflicting peer is running" ``` --- ## Task 4: Symmetry verification The symmetric relation is already implemented in `conflicts_with` (it checks both directions). This task adds a regression test proving one-sided declaration is enforced both ways — no implementation change expected. **Files:** - Modify: `tests/integration/run_tests.sh` (extend the "Service conflicts" section) - [ ] **Step 1: Add the symmetry test** In the "Service conflicts" section, before its final `stop_daemon`, append: ```bash # --- Symmetry: declare the conflict on ONE side only; both still fail at boot. stop_daemon rm -rf "$CONFIG_DIR" mkdir -p "$CONFIG_DIR" "$ENABLED_DIR" # only one-side declares the conflict create_service_with_conflicts "sym-a" "daemon" "$BIN_DIR/sleeper.sh" "\"sym-b\"" create_service "sym-b" "daemon" "$BIN_DIR/sleeper.sh" enable_service "sym-a" enable_service "sym-b" start_daemon output=$(zygctl status sym-a) assert_contains "$output" "failed" "symmetry: sym-a failed (declared side)" output=$(zygctl status sym-b) assert_contains "$output" "failed" "symmetry: sym-b failed (non-declared side)" stop_daemon ``` - [ ] **Step 2: Run the suite — symmetry asserts PASS** Run: `./tests/integration/run_tests.sh` Expected: both `symmetry:` asserts PASS with no code change (the boot section's `mailer-*` services already declared one-sided, but this makes symmetry explicit). All tests green. - [ ] **Step 3: Commit** ```bash hg commit tests/integration/run_tests.sh \ -m "test: conflicts symmetry — one-sided declaration enforced both ways" ``` --- ## Task 5: Documentation **Files:** - Modify: `docs/DESIGN.md` (`:103-105`), `docs/man/man5/zyginit.5` (`:160`), `CLAUDE.md` (Service Definition Schema `[dependencies]`), `ROADMAP.md` (`:150`) - [ ] **Step 1: `docs/DESIGN.md` schema reference** In the `[dependencies]` example (`:103-105`): ``` [dependencies] requires = ["network", "filesystem"] after = ["name-services"] ``` add a `conflicts` line: ``` [dependencies] requires = ["network", "filesystem"] after = ["name-services"] conflicts = ["other-mta"] # mutually exclusive; never run together ``` - [ ] **Step 2: `zyginit.5` man page** In `docs/man/man5/zyginit.5`, the `[dependencies]` list ends at `:160-161`: ``` .Dl after = ["syslog", "name-services"] .El ``` Insert a `conflicts` entry before `.El`: ``` .Dl after = ["syslog", "name-services"] .It Sy conflicts Pq array of strings Services that must never run at the same time as this one. The relation is symmetric: declaring the conflict on either service is enough. If two conflicting services are both enabled at boot, .Em both are marked failed (zyginit never auto-selects a winner). At runtime, .Xr zygctl 8 .Cm start is refused if a conflicting service is already running. .Pp Example: .Dl conflicts = ["sendmail"] .El ``` - [ ] **Step 3: `CLAUDE.md` quick-reference schema** In `CLAUDE.md`, the Service Definition Schema `[dependencies]` block: ``` [dependencies] requires = ["svc1", "svc2"] # must be running after = ["svc3"] # ordering only (not hard dependency) ``` add: ``` [dependencies] requires = ["svc1", "svc2"] # must be running after = ["svc3"] # ordering only (not hard dependency) conflicts = ["svc4"] # mutually exclusive — both fail if both enabled; start refused if peer running ``` - [ ] **Step 4: `ROADMAP.md` — check the box** At `ROADMAP.md:150`, change: ``` - [ ] **`conflicts`** — prevent two services from running simultaneously (e.g., sendmail vs postfix) ``` to: ``` - [x] **`conflicts`** — prevent two services from running simultaneously (e.g., sendmail vs postfix). Symmetric; both fail if both enabled at boot, runtime start refused if a conflicting peer is running. ``` - [ ] **Step 5: Commit** ```bash hg commit docs/DESIGN.md docs/man/man5/zyginit.5 CLAUDE.md ROADMAP.md \ -m "docs: document [dependencies] conflicts field" ``` --- ## Final verification - [ ] **Full suite green** Run: `./tests/integration/run_tests.sh` Expected: all suites pass, including the new "Service conflicts" section (boot fail-both, runtime refusal, symmetry). Note the new total in the summary line. - [ ] **Clean build from scratch** Run: ```bash rm -f build/zyginit clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o ``` Expected: `build/zyginit` builds with no warnings about `conflicts`. --- ## Notes / decisions carried from the spec - **Two mechanisms, non-overlapping:** after `resolve_conflicts` runs at boot, no conflicting pair both survive as `WAITING`, so the `start_service` guard never trips spuriously during the boot tier loop. - **Runlevel transitions** use the runtime guard (refuse), not the boot pre-flight — consistent with the "refuse at runtime" semantic. - **`skip_reason` reuse:** the field stores the conflict reason for `FAILED` services. Kept the field name to minimize diff; `ui_render` now surfaces it for `FAILED` as well as `SKIPPED`. If a future change wants a cleaner name, rename `skip_reason` → `status_reason` across `supervisor.reef` + `ui_render.reef` in one pass. - **Cascade is intended:** a service that `requires` a conflict-failed service will itself fail to start (existing dependency behavior). Documented, not worked around. - **`conflicts` is never a graph edge** — `depgraph.reef` is untouched.