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 testssrc/condition.reef— new module: condition evaluation (evaluate_conditions,ConditionResult)
Modify:
src/config.reef— renameSERVICE_TYPE_TRANSIENT→SERVICE_TYPE_TASK, add[condition]parser, changescan_servicesto walkservices/*/service.toml, changescan_enabledto dereference directory symlinks with escape checksrc/supervisor.reef— addtaskbranch inhandle_contract_event; addSTATE_SKIPPEDconstant +skip_reasonfield onServiceRuntime; wireevaluate_conditionsintostart_servicesrc/ui.reef— addg_skippedcounter +ui_event_skippedproc; updatefmt_headerandui_boot_completeto show skipped count; updaterender_boot_screenprogress-bar denominatorsrc/ui_render.reef— addSTATE_SKIPPEDmapping tostate_color/state_glyph/state_label; showskip_reasonin NOTES columnsrc/main.reef— fatal error whenservices/directory missing; resetg_skippedalongside other counterstools/zygctl/src/main.reef—enable/disablecreate/remove symlinks targeting../services/<name>/(directory), not<name>.toml(file)services/hammerhead/— migrated to new layout in one commitexamples/— migrated to new layout (or relocated under services/) in same committests/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 neededreef.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 viascripts/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
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 <name>.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:
#!/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/<name>/service.toml"
else
fail "apply creates services/<name>/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:
#!/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 <<EOF
Usage: $0 [--dry-run|--apply] [--config-dir <path>]
--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 <name>.toml at top level
# AND services/<name>/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 <name>.toml and services/<name>/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
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 <config_dir>/*.toml and uses the basename as the service name. New behavior: scan <config_dir>/services/<name>/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:
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 (<svc_dir>/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 <svc_dir>/. Absolute paths (starting with /) are unchanged.
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
<config_dir>/enabled.d/ - For each entry,
readlinkit (useos.fs.readlinkor whatever Reef stdlib provides; add a C helperzyginit_readlinkif needed) - Resolve to an absolute path
- Verify it points inside
<config_dir>/services/ - Reject (with warning log) if it escapes or is dangling
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:
/* in src/helpers.c */
#include <stdlib.h>
#include <limits.h>
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:
extern "C" fn zyginit_readlink_resolved(path: string): string
- Step 5: Build + verify
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
hg commit -m "config: scan services/<name>/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
./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
./scripts/migrate-layout.sh --apply --config-dir services/hammerhead/
Verify each service is now services/hammerhead/services/<name>/service.toml plus any helper scripts in the same directory. The services/hammerhead/services/<name> 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
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 <TMP>/<name>.toml and update it to write <TMP>/services/<name>/service.toml. Use a small helper at the top of the test script:
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/<name>.toml" to use create_service and every ln -s in enabled.d/ to use enable_service.
- Step 5: Build + run all tests
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
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 possiblysrc/socket.reeffor 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 <name>. Currently it creates a symlink <config_dir>/enabled.d/<name> → ../<name>.toml.
- Step 2: Add a failing integration test
In tests/integration/run_tests.sh, add (in the appropriate test suite around enable/disable):
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
./tests/integration/run_tests.sh
Expected: the new test fails (current zygctl creates a <name>.toml symlink, not a directory).
- Step 4: Update the symlink target
In tools/zygctl/src/main.reef, change the symlink-create call:
// Old:
let target = str.concat("../", str.concat(name, ".toml"))
// New:
let target = str.concat("../services/", name)
The link path stays the same (enabled.d/<name>).
- Step 5: Build + run
(cd tools/zygctl && reefc build --obj build/symlink_wrapper.o)
./tests/integration/run_tests.sh
Test should now pass.
- Step 6: Commit
hg commit -m "zygctl: enable/disable target services/<name>/ directory instead of <name>.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):
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
./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
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 inservice_type_nameand the parser →"task"
In the parser:
// 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:
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:
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
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
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:
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:
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]:
// 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:
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_conditionsin condition.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:
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>
/* 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:
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
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:
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:
skip_reason: string
Initialize to "" in the existing constructor / table init logic.
- Step 2: Add
ui_event_skippedto ui.reef
In src/ui.reef, alongside other lifecycle procs:
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:
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
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:
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
./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
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:
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:
out = str.concat(out, fmt_progress_bar(g_done, g_num_svc))
g_done already counts STARTED + FAILED. Add g_skipped:
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:
"17 services — 16 online, 1 failed"
Modify the BootStats struct (in ui.reef) to include skipped: int. Update the line to:
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:
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:
// 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:
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
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:
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
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:
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
./tests/integration/ui_tests.sh
Expected: 5 unknown scenarios.
- Step 3: Implement the demo scenarios in ui.reef's ui_demo
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
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
Inspect each new snapshot:
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
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
./scripts/bump-version.sh 0.2.0
hg diff --stat
Confirm 5 files changed.
- Step 2: Regenerate version-stamped snapshots
grep -l "0\.1\.8" tests/integration/snapshots/*.txt | xargs rm 2>/dev/null
- Step 3: Rebuild + full test pass
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
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:
## 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/<name>` symlinks target `../services/<name>/` (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_skippedmay need scaffolding that doesn't yet exist; the implementer should document it as a deferral if needed rather than guessing. - Type consistency:
ConditionResultdefined in Task 6 step 1, used in Task 7.BootStats.skipped: intadded 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_reasonaccessor 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_conditionsfactored once; banner skipped counter added once and consumed byfmt_header,ui_render_status, andui_boot_complete.