diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 55a935386..7feae9d77 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -372,7 +372,7 @@ A macOS system prompt could come later for single root-only lines from R11, neve Each is small enough for one PR. 1. **R1** Exists: `hack version --json`: `{version, channel, install_kind, binary_path}`; see [Version identity](../../docs/reference/version.md). -2. **R2** Backend-aware project state in `/v1/projects` and `hack projects --json` (full and summary rows): `backend`, `state` (`running | degraded | starting | stopping | stopped | failed | unknown`), `started_at`, and per service `{state, ready, completed, health, exit_code, started_at}`; native projects included. +2. **R2** Exists (see [project state](../../docs/reference/project-state.md)): backend-aware project state in `/v1/projects` and `hack projects --json` (full and summary rows): `backend`, `state` (`running | degraded | starting | stopping | stopped | failed | unknown`), `started_at`, and per service `{state, ready, completed, health, exit_code, started_at}`; native projects included. 3. **R2b** Native lifecycle the app can drive: a `--json` contract for native `restart` and authored `down`/`restart`, and qualification of the existing detached `up --json` for app use. 4. **R3** Per-service usage: `hack usage --json --path --by-service` with `cpuPercent`, `memUsedBytes`. Exists for Compose ([Usage by service](../../docs/reference/usage-by-service.md), `schema: "hack.usage.by-service.v1"`); native projects are not measured yet. 5. **R4** Logs: document the `--json` ndjson as stable; `--services a,b` honoured by every backend; `--max-line-bytes ` setting `entry.truncated`. Compose and Loki exist ([Logs JSON stream](../../docs/reference/logs-json.md): `schema: "hack.logs.v1"` on `start`, one closing `end`); native multi-service, native `--max-line-bytes` and native `--no-follow` as the stream are open. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d58169cd7..0b556cf40 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -31,6 +31,16 @@ cargo run -p hack-desktop -- --list-fixtures cargo run -p hack-desktop -- --fixtures failed-start --theme dark ``` +The core reads version identity through `hack version --json` and validates its +`hack.version.v1` schema inside the success envelope. New schema-tagged commands +use the strict parser; daemon and published legacy payloads keep their existing +parsers. Shared boundary fixtures are checked against the CLI producers. + +Dropping a log stream retries killing its owned process group for up to two +seconds before detaching a reader still waiting for EOF. A descendant that +leaves that group cannot be retired by group signals; it no longer blocks the +dropping thread, and its reader exits when the descendant closes stdout. + ## Check the live data path without a window ```sh diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index e10b1720c..a86a9738e 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -275,19 +275,41 @@ impl CliRunner { }) } - /// `hack --version` → `"5.0.0"` (text output until request R1). + /// Run a HACK-2218 command with a required envelope and payload schema. + /// Legacy JSON commands continue to use `run_json`. + pub fn run_schema_json( + &self, + args: &[&str], + path: Option<&Path>, + timeout: Duration, + schema: &str, + ) -> Result { + let (stdout, stderr, code) = self.run_raw(args, path, timeout)?; + let value = parse_schema_result(&stdout, &stderr, code, schema)?; + serde_json::from_value(value).map_err(|e| RunError::BadJson { + error: e.to_string(), + stdout_head: head(&stdout), + }) + } + + /// Read the running CLI version through the schema-tagged JSON contract. /// /// # Errors - /// Any [`RunError`]. + /// Any [`RunError`], including an unsupported or missing schema. pub fn version(&self, timeout: Duration) -> Result { - let (stdout, stderr, code) = self.run_raw(&["--version"], None, timeout)?; - if code != Some(0) { - return Err(RunError::Exit { code, stderr }); + #[derive(serde::Deserialize)] + struct Version { + version: String, } - parse_version(&stdout).ok_or_else(|| RunError::BadJson { - error: "no version in output".into(), - stdout_head: head(&stdout), - }) + let report: Version = + self.run_schema_json(&["version", "--json"], None, timeout, "hack.version.v1")?; + if report.version.is_empty() { + return Err(RunError::BadJson { + error: "empty version".into(), + stdout_head: String::new(), + }); + } + Ok(report.version) } /// Start a long-running ndjson stream (`hack logs --json ...`). @@ -323,7 +345,8 @@ impl CliRunner { } } -/// A running log stream. Dropping it kills the process group. +/// A running log stream. Drop retries group cleanup for at most two seconds, +/// then detaches a reader still held open by a descendant outside that group. pub struct LogStream { child: Child, rx: mpsc::Receiver, @@ -344,10 +367,23 @@ impl LogStream { impl Drop for LogStream { fn drop(&mut self) { - kill_group(&mut self.child); + signal_kill_group(&mut self.child); if let Some(r) = self.reader.take() { - let _ = r.join(); + // A group signal can race a descendant fork on macOS. Keep the + // leader unreaped while retrying, preserving ownership of its PID + // and catching late members of the same process group. + let until = Instant::now() + Duration::from_secs(2); + while !r.is_finished() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + signal_kill_group(&mut self.child); + } + // A descendant may have left our group while keeping stdout open. + // Detach that reader rather than block the dropping UI thread. + if r.is_finished() { + let _ = r.join(); + } } + let _ = self.child.wait(); } } @@ -477,6 +513,18 @@ fn kill_group(child: &mut Child) { let _ = child.wait(); } +/// Only for the log-drop path, which has not reaped its child. Avoid +/// Child::kill here: its status check may reap and release the PID reservation. +fn signal_kill_group(child: &mut Child) { + if let Ok(pid) = i32::try_from(child.id()) { + // SAFETY: plain syscall on the group we created with process_group(0). + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::kill(pid, libc::SIGKILL); + } + } +} + fn head(s: &str) -> String { s.chars().take(200).collect() } @@ -536,6 +584,53 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option) -> Result, + schema: &str, +) -> Result { + let invalid = || RunError::BadJson { + error: format!("expected CLI envelope with schema {schema}"), + stdout_head: head(stdout), + }; + let value: Value = serde_json::from_str(stdout).map_err(|_| invalid())?; + if value.as_object().is_none_or(|object| object.len() != 2) { + return Err(invalid()); + } + match value.get("ok").and_then(Value::as_bool) { + Some(false) if code.is_some_and(|code| code != 0) => { + let error = value.get("error").ok_or_else(invalid)?; + if !error + .get("code") + .and_then(Value::as_str) + .is_some_and(|code| { + code.strip_prefix("E_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix.bytes().all(|byte| { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' + }) + }) + }) + || error.get("message").and_then(Value::as_str).is_none() + { + return Err(invalid()); + } + parse_result(stdout, stderr, code) + } + Some(true) if code == Some(0) => { + let data = value.get("data").ok_or_else(invalid)?; + if data.get("schema").and_then(Value::as_str) != Some(schema) { + return Err(invalid()); + } + Ok(data.clone()) + } + _ => Err(invalid()), + } +} + /// `hack v5.0.0` / `5.0.0-next.3` → the version string. #[must_use] pub fn parse_version(text: &str) -> Option { @@ -903,17 +998,24 @@ wait $!"#, Some("5.0.0-next.3") ); assert_eq!(parse_version("hack"), None); - let f = FakeHack::new("echo 'hack v5.0.0'"); + let f = FakeHack::new( + r#"echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}'"#, + ); assert_eq!(f.runner().version(T).unwrap(), "5.0.0"); } #[test] fn log_stream_parses_ndjson_and_stops_on_drop() { + // Establish the pipe-holding descendant before publishing events. If + // sleep starts after the last event, drop can race that fork rather + // than exercise cleanup of an already-running process group. let f = FakeHack::new( - r#"echo '{"type":"start","backend":"compose"}' + r#"sleep 300 & +echo $! > "$HACK_HOME/holder" +echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' -exec sleep 300"#, +wait $!"#, ); let s = f.runner().stream_logs(&["logs", "--json"], None).unwrap(); let mut got = Vec::new(); @@ -924,6 +1026,12 @@ exec sleep 300"#, assert_eq!(got[1].entry.as_ref().unwrap().message, "hi"); assert_eq!(got[2].message.as_deref(), Some("plain text")); let pid = i32::try_from(s.child.id()).unwrap(); + let holder: i32 = std::fs::read_to_string(f.dir.join("holder")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(!process_gone(holder), "pipe holder is alive"); assert!( !process_gone(pid), "positive control: stream child is alive" @@ -934,5 +1042,54 @@ exec sleep 300"#, ); completes(move || drop(s)); assert_retired(pid); + assert_retired(holder); + } + + #[test] + #[ignore = "subprocess fixture for log_stream_drop_is_bounded_with_an_escaped_writer"] + fn escaped_log_pipe_helper() { + // SAFETY: this fixture subprocess leaves its parent's group so the + // parent can test a writer that its owned-group signal cannot reach. + assert_eq!(unsafe { libc::setpgid(0, 0) }, 0); + let path = std::env::var("HACK_TEST_ESCAPED_LOG_PID").unwrap(); + std::fs::write(path, std::process::id().to_string()).unwrap(); + println!("escaped-ready"); + thread::sleep(Duration::from_secs(30)); + } + + #[test] + fn log_stream_drop_is_bounded_with_an_escaped_writer() { + struct EscapedWriter(i32); + impl Drop for EscapedWriter { + fn drop(&mut self) { + // SAFETY: PID supplied only by our fixture subprocess, which + // remains alive until this guard retires it. + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } + } + let executable = std::env::current_exe().unwrap(); + let quoted = executable.to_string_lossy().replace('\'', "'\\''"); + let f = FakeHack::new(&format!( + "'{quoted}' --exact runner::tests::escaped_log_pipe_helper --ignored --nocapture &\nwait $!" + )); + let pid_file = f.dir.join("escaped.pid"); + let runner = f + .runner() + .with_env("HACK_TEST_ESCAPED_LOG_PID", &pid_file.to_string_lossy()); + let s = runner.stream_logs(&["logs", "--json"], None).unwrap(); + loop { + let event = s.next_timeout(T).expect("escaped writer ready"); + if event.message.as_deref() == Some("escaped-ready") { + break; + } + } + let escaped = EscapedWriter(std::fs::read_to_string(pid_file).unwrap().parse().unwrap()); + let t = Instant::now(); + drop(s); + assert!(t.elapsed() < Duration::from_secs(5)); + // The fixture still holds stdout: bounded return did not require EOF. + // SAFETY: signal zero only checks our still-owned fixture process. + assert_eq!(unsafe { libc::kill(escaped.0, 0) }, 0); + drop(escaped); } } diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 0c9351031..207c7a82a 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -631,14 +631,14 @@ mod tests { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo '{"runtime_ok":true,"projects":[{"name":"svc","status":"running","repo_root":"/work/svc"},{"name":"nat","status":"unavailable","repo_root":"/work/nat","input_diagnostic":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}]}' ;; ps) if [ "$HACK_RUNTIME_BACKEND" = native ]; then echo '{"ok":true,"data":{"backend":"native","status":"pending","items":[]}}'; else echo '{"ok":false,"error":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}'; exit 1; fi ;; esac "#, ); // Complete macOS first-launch scanning before the fake daemon's - // bounded accept window starts. --version has no fixture effects. + // bounded accept window starts. version --json has no fixture effects. CliRunner::new(&hack) .version(Duration::from_secs(60)) .unwrap(); @@ -687,7 +687,7 @@ esac let hack = dir.join("hack"); crate::test_support::script( &hack, - "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0' ;; projects) echo '{}' ;; esac\n", + "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; projects) echo '{}' ;; esac\n", ); CliRunner::new(&hack) .version(Duration::from_secs(60)) diff --git a/apps/desktop/core/tests/dump_state.rs b/apps/desktop/core/tests/dump_state.rs index c32c9fa59..1ecf7ca20 100644 --- a/apps/desktop/core/tests/dump_state.rs +++ b/apps/desktop/core/tests/dump_state.rs @@ -72,7 +72,7 @@ fn dump_state_reads_the_cli_like_the_app_and_prints_view_models() { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo called > "$HACK_HOME/unsafe-listing"; exit 2 ;; *) echo "unexpected: $*" >&2; exit 2 ;; esac @@ -144,7 +144,7 @@ fn missing_daemon_never_runs_the_registry_writing_cli_listing() { let hack = home.join("hack"); test_support::script( &hack, - "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n", + "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n", ); let out = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) .env("PATH", &home) diff --git a/apps/desktop/core/tests/fixtures/machine-output/activity.json b/apps/desktop/core/tests/fixtures/machine-output/activity.json new file mode 100644 index 000000000..0c762508e --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/activity.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "data": { + "version": 1, + "entries": [], + "schema": "hack.activity.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json new file mode 100644 index 000000000..a32920e8c --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json new file mode 100644 index 000000000..027202bba --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "data": { + "observe": true, + "docker": { + "state": "not_running" + }, + "summary": { + "ok": "unknown" + }, + "schema": "hack.global-status.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json new file mode 100644 index 000000000..1b6bf94a5 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json new file mode 100644 index 000000000..e930570d7 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json @@ -0,0 +1,18 @@ +{ + "ok": false, + "error": { + "code": "E_PLAN_INCOMPLETE", + "message": "Administrator plan is incomplete.", + "detail": { + "plan": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": false, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } + } + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json new file mode 100644 index 000000000..87802e7ce --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "observe": true, + "runtime_requested": false, + "runtime_ok": false, + "projects": [], + "schema": "hack.projects.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/version.json b/apps/desktop/core/tests/fixtures/machine-output/version.json new file mode 100644 index 000000000..440c4288a --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/version.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "version": "5.0.0", + "channel": "stable", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} diff --git a/apps/desktop/core/tests/machine_output.rs b/apps/desktop/core/tests/machine_output.rs new file mode 100644 index 000000000..3abaa24bd --- /dev/null +++ b/apps/desktop/core/tests/machine_output.rs @@ -0,0 +1,72 @@ +//! The same fixtures are checked against the CLI producers by Bun tests. +use hack_desktop_core::runner::{RunError, parse_schema_result}; +use serde_json::Value; + +#[test] +fn migrated_payloads_require_the_expected_envelope_and_schema() { + let cases = [ + ( + include_str!("fixtures/machine-output/activity.json"), + "hack.activity.v1", + ), + ( + include_str!("fixtures/machine-output/version.json"), + "hack.version.v1", + ), + ( + include_str!("fixtures/machine-output/projects-observe.json"), + "hack.projects.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-status-observe.json"), + "hack.global-status.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-install-plan.json"), + "hack.global-admin-plan.v1", + ), + ( + include_str!("fixtures/machine-output/global-trust-plan.json"), + "hack.global-admin-plan.v1", + ), + ]; + for (text, schema) in cases { + let value = parse_schema_result(text, "", Some(0), schema).unwrap(); + assert_eq!(value["schema"], schema); + // Bare payloads, wrong schemas and success with a failing process are refused. + assert!(parse_schema_result(&value.to_string(), "", Some(0), schema).is_err()); + assert!(parse_schema_result(text, "", Some(0), "unknown.schema").is_err()); + assert!(parse_schema_result(text, "", Some(1), schema).is_err()); + } +} + +#[test] +fn incomplete_admin_plan_is_an_error_with_preserved_recovery_evidence() { + let text = include_str!("fixtures/machine-output/plan-incomplete.json"); + let error = parse_schema_result(text, "", Some(3), "hack.global-admin-plan.v1").unwrap_err(); + match error { + RunError::Cli(error) => { + assert_eq!(error.code, "E_PLAN_INCOMPLETE"); + assert_eq!( + error.detail.unwrap()["plan"]["schema"], + "hack.global-admin-plan.v1" + ); + } + other => panic!("unexpected result: {other:?}"), + } + assert!(parse_schema_result(text, "", Some(0), "hack.global-admin-plan.v1").is_err()); +} + +#[test] +fn malformed_envelopes_cannot_fall_back_to_legacy_parsing() { + for value in [ + serde_json::json!({"ok": true, "data": {}}), + serde_json::json!({"ok": false, "error": "legacy message"}), + serde_json::json!({"ok": false, "error": {"code": "BAD", "message": "message"}}), + serde_json::json!({"ok": false, "error": {"code": "E_", "message": "message"}}), + serde_json::json!({"ok": true, "data": {"schema": "hack.version.v1"}, "extra": true}), + Value::Null, + ] { + assert!(parse_schema_result(&value.to_string(), "", Some(1), "hack.version.v1").is_err()); + } +} diff --git a/docs/cli.md b/docs/cli.md index 4aadf0743..6dc1419c5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,7 +132,8 @@ ps/inspect reads, which can start a socket-activated engine (for example a systemd Docker socket or an on-demand macOS engine). Both modes bypass hackd, including when a daemon is available. `--all` discovers runtime-only projects only with `--runtime`. -Observe returns the normal listing fields plus `observe: true` and +Observe returns a success envelope with `data.schema: "hack.projects.observe.v1"`. +The `data` payload contains the normal listing fields plus `observe: true` and `runtime_requested: false|true`. Without runtime queries, `runtime_error` and `runtime_checked_at` are null rather than claiming Docker failed. Each full or summary row adds `backend`, `state`, `state_reason`, `started_at` and `services`. diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 11af785e2..039a6f04c 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -35,6 +35,17 @@ no longer removes legacy inputs in the same step. The full upgrade steps are in [Upgrade to v5](upgrade-to-v5.md#preview-resume-and-undo-env-migration). +### Breaking change: next.17 machine output moves into the JSON envelope + +Five `--json` outputs that first shipped in next.17 now use the documented +envelope (HACK-2238). Plain `hack projects --json` and other older outputs keep +their shapes. See [machine-readable output](../reference/machine-readable-output.md). + +| Change | What to do | +| --- | --- | +| **`hack version --json`, `hack activity --json`, `hack projects --json --observe`, `hack global status --json --observe` and `hack global install\|trust --plan --json` print `{"ok":true,"data":{...}}`.** The payload is under `data` and carries a string `data.schema` (for example `hack.version.v1`). | Read the payload from `data` and check `data.schema` before using it. | +| **Their failures print `{"ok":false,"error":{"code":"E_...","message":"..."}}` and exit nonzero.** A refused or incomplete administrator plan is `E_PLAN_REFUSED` (exit 1) or `E_PLAN_INCOMPLETE` (exit 3), with the partial plan at `error.detail.plan`. | Branch on `ok` and `error.code`, not on top-level payload fields. | + ## next.17: what changed and what to do These are source changes intended for `5.0.0-next.17`, not a claim that the diff --git a/docs/reference.md b/docs/reference.md index 696fb696e..bbfdcb582 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -10,7 +10,9 @@ ## Environment & sessions +- [Project state](./reference/project-state.md) — shared backend-aware CLI and daemon project/service observations - [Version identity](./reference/version.md) — `hack version --json` describes the running CLI without starting services +- [Machine-readable output](./reference/machine-readable-output.md) — envelope rule, schema identifiers and the complete JSON command inventory - [Environment model](./env.md) - [Lifecycle & startup](./lifecycle.md) - [Admin plans](./reference/global-admin-plan.md) — `hack global install|trust --plan --json`: the ordered root and user steps and exact root argv, without running anything diff --git a/docs/reference/activity-log.md b/docs/reference/activity-log.md index ee584c4c9..c3c9b41ef 100644 --- a/docs/reference/activity-log.md +++ b/docs/reference/activity-log.md @@ -18,7 +18,7 @@ hack activity --json --project shop --limit 100 Historical entries remain selectable after a project leaves the registry. - `--limit `: at most `n` entries, newest first (default 50, at most 10,000). -- `--json`: the standard envelope, `{"ok": true, "data": {"version": 1, +- `--json`: the standard envelope, `{"ok": true, "data": {"schema": "hack.activity.v1", "version": 1, "entries": [...]}}`. A log that exists but cannot be read (not this user's regular file, a symlink, or a read error) is `{"ok": false, "error": {"code": "E_PATH_CONFLICT", ...}}` with exit 1, never an empty history. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7d8a66765..7cbcfebae 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4,7 +4,7 @@ hack — Local development without the port-collision tax -Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout. +Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md). ## Project diff --git a/docs/reference/global-admin-plan.md b/docs/reference/global-admin-plan.md index 456612238..b38872060 100644 --- a/docs/reference/global-admin-plan.md +++ b/docs/reference/global-admin-plan.md @@ -40,30 +40,79 @@ to run the listed commands. ```json { - "schema": "hack.global-admin-plan.v1", - "command": "trust", - "platform": "linux", - "refused": null, - "complete": true, - "blocked": [], - "steps": [ - { - "order": 1, - "privilege": "root", - "id": "system-trust-install", - "description": "Install the CA (SHA-256 …) into the debian trust anchors; root accepts only these exact bytes", - "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C", "LC_ALL=C", "/bin/sh", "-c", "", "hack-install", "", "", "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", "0644"], - "shell": "/usr/bin/sudo /usr/bin/env -i … 0644", - "stdin": { "bytes": 652, "sha256": "…", "content": "the verified Caddy Local CA (PEM)" }, - "when": "after you confirm the system-wide trust prompt on an interactive terminal; …" - }, - { "order": 2, "privilege": "root", "id": "system-trust-refresh", "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "…", "/usr/sbin/update-ca-certificates"], "…": "…" }, - { "order": 3, "privilege": "user", "id": "nss-trust", "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", "when": "…" }, - { "order": 4, "privilege": "user", "id": "host-tls-env", "description": "…", "when": "…" } - ] + "ok": true, + "data": { + "schema": "hack.global-admin-plan.v1", + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [ + { + "order": 1, + "privilege": "root", + "id": "system-trust-install", + "description": "Install the CA (SHA-256 \u2026) into the debian trust anchors; root accepts only these exact bytes", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "LANG=C", + "LC_ALL=C", + "/bin/sh", + "-c", + "", + "hack-install", + "", + "", + "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", + "0644" + ], + "shell": "/usr/bin/sudo /usr/bin/env -i \u2026 0644", + "stdin": { + "bytes": 652, + "sha256": "\u2026", + "content": "the verified Caddy Local CA (PEM)" + }, + "when": "after you confirm the system-wide trust prompt on an interactive terminal; \u2026" + }, + { + "order": 2, + "privilege": "root", + "id": "system-trust-refresh", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "\u2026", + "/usr/sbin/update-ca-certificates" + ], + "\u2026": "\u2026" + }, + { + "order": 3, + "privilege": "user", + "id": "nss-trust", + "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", + "when": "\u2026" + }, + { + "order": 4, + "privilege": "user", + "id": "host-tls-env", + "description": "\u2026", + "when": "\u2026" + } + ] + } } ``` +Fields below are inside `data` on success. Refused or incomplete plans use an +error envelope with `error.detail.plan` retaining the same plan payload. + | Field | Meaning | |---|---| | `refused` | the real command would refuse (native runtime, slim mode, an invalid CA); no steps | @@ -72,7 +121,8 @@ to run the listed commands. | `steps[].when` | when the real command takes the step: after a confirmation, only interactively, only if a check passes | | `steps[].gates` | on a probe: the step it can skip (macOS non-interactive runs check `sudo -n true` before the keychain step) | -Exit code: 0 for a complete plan, 1 when `refused`, 3 when incomplete; `--plan` +Exit code: 0 for a complete success envelope, 1 with `E_PLAN_REFUSED` when +refused, 3 with `E_PLAN_INCOMPLETE` when incomplete; `--plan` without `--json` exits 2. `--json` without `--plan` also exits 2 before any effect. ## What `hack global trust` plans diff --git a/docs/reference/global-status-observe.md b/docs/reference/global-status-observe.md index 6cf8eb5c8..364f43e81 100644 --- a/docs/reference/global-status-observe.md +++ b/docs/reference/global-status-observe.md @@ -49,24 +49,50 @@ could not tell; it is never reported as absent or as a failure of the thing. ```json { - "schema": "hack.global-status.observe.v1", - "generated_at": "2026-11-14T00:00:00.000Z", - "observe": true, - "platform": "linux", - "docker": { "state": "running", "detail": null }, - "caddy": { "ok": true, "error": null, "services": [] }, - "logging": { "ok": true, "error": null, "services": [] }, - "networks": { "ok": true, "missing": [], "networks": [] }, - "proxy_network": { "ok": true, "readable": true, "issues": [] }, - "host_dns": { "platform": "linux", "state": "configured", "ok": true }, - "summary": { - "docker_ok": true, - "caddy_ok": true, - "logging_ok": true, - "networks_ok": true, - "proxy_network_ok": true, - "host_dns_ok": true, - "ok": true + "ok": true, + "data": { + "schema": "hack.global-status.observe.v1", + "generated_at": "2026-11-14T00:00:00.000Z", + "observe": true, + "platform": "linux", + "docker": { + "state": "running", + "detail": null + }, + "caddy": { + "ok": true, + "error": null, + "services": [] + }, + "logging": { + "ok": true, + "error": null, + "services": [] + }, + "networks": { + "ok": true, + "missing": [], + "networks": [] + }, + "proxy_network": { + "ok": true, + "readable": true, + "issues": [] + }, + "host_dns": { + "platform": "linux", + "state": "configured", + "ok": true + }, + "summary": { + "docker_ok": true, + "caddy_ok": true, + "logging_ok": true, + "networks_ok": true, + "proxy_network_ok": true, + "host_dns_ok": true, + "ok": true + } } } ``` @@ -83,8 +109,10 @@ could not tell; it is never reported as absent or as a failure of the thing. | `host_dns` (other) | `state: "unknown"`, `ok: "unknown"` | | `summary.ok` | `false` if any part is `false`; otherwise `"unknown"` if any part is; otherwise `true` | -Exit code: 0 whenever the JSON was printed, whatever it says; read the `ok` -fields. +Exit code 0 means the observation was collected, with the report inside `data` +and `data.schema: "hack.global-status.observe.v1"`. Read `data.summary.ok` and +the individual report parts for health; top-level `ok` is not a health claim. +A command failure uses the standard error envelope and a nonzero exit. Not included: the gateway section of plain `hack global status --json` (it reads the daemon and token store and is not routing state). diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md new file mode 100644 index 000000000..560859956 --- /dev/null +++ b/docs/reference/machine-readable-output.md @@ -0,0 +1,139 @@ +# Machine-readable output + +New `--json` outputs added for HACK-2218 use one success envelope: + +```json +{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}} +``` + +The stable schema identifier belongs inside `data`. A payload schema changes +when its incompatible contract changes. Consumers unwrap `data` and validate +its schema before using it. Human diagnostics go to stderr. +The envelope has exactly two top-level keys; additional metadata belongs inside +the schema-tagged payload, not alongside `ok` and `data`. + +Failures use a stable error code and a nonzero exit status: + +```json +{"ok":false,"error":{"code":"E_USAGE","message":"Use a supported option combination."}} +``` + +Errors may carry `error.detail`. A refused administrator plan uses +`E_PLAN_REFUSED` (exit 1); an incomplete plan uses `E_PLAN_INCOMPLETE` (exit 3). +Both retain the partial plan, including its schema, at `error.detail.plan`. +Unknown/offline state in an observation is still a successful observation; +it is not a claim that the runtime is healthy. + +The version, Activity, observe-only project/status and admin-plan outputs +follow this rule from next.18. They first shipped in 5.0.0-next.17 with other +shapes; the [next.18 release notes](../guides/v5-release-notes.md) say how to +migrate. Plain `projects --json` and other older legacy outputs keep their +shapes. A consumer must not use the new observe-only parser for a plain projects +result. Streaming interfaces remain NDJSON and have their own event contracts. + +![JSON command boundary](./machine-readable-output.svg) + +## Command inventory + +This inventory includes every canonical command exposing `--json`, including +retained unsupported command groups. Aliases have the same contract as their +canonical command. “Legacy” records the existing output; it does not recommend +that shape for a new interface. Backend-dependent entries need their existing +command-specific parser. Do not infer a success shape from an error envelope. + +Regenerate with `bun scripts/generate-machine-output-reference.ts`. Tests compare +the inventory to the command definitions and exercise every command's shared +usage-error boundary without entering its handler. Every listed command that can +succeed without a runtime, engine or project is also run for real and checked +against its row; legacy objects there pin their top-level keys. The remaining +success behavior stays covered by command-specific suites. + + +| Canonical command / variant | Success shape | +|---|---| +| `hack activity --json` | Envelope; versioned `data.schema`: `hack.activity.v1` | +| `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack commands read --json` | NDJSON events / terminal event, not a single envelope | +| `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack config adopt --json` | Legacy bare object (no `data` envelope) | +| `hack config import --json` | Legacy bare object (no `data` envelope) | +| `hack config plan --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack config validate --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack daemon status --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch logs --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch run --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch status --json` | Legacy bare object (no `data` envelope) | +| `hack doctor --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack down --json` | Backend-dependent: envelope or legacy bare object | +| `hack env apply --json` | Legacy bare object (no `data` envelope) | +| `hack env backend status --json` | Legacy bare object (no `data` envelope) | +| `hack env backend use --json` | Legacy bare object (no `data` envelope) | +| `hack env explain --json` | Legacy bare object (no `data` envelope) | +| `hack env hydrate --json` | Legacy bare object (no `data` envelope) | +| `hack env list --json` | Legacy bare object (no `data` envelope) | +| `hack env migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack env protect --json` | Legacy bare object (no `data` envelope) | +| `hack exec --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack exec-sessions --json` | Legacy bare object (no `data` envelope) | +| `hack global dns preview --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack global install --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack global status --json` | Legacy bare object (no `data` envelope) | +| `hack global trust --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack host ps --json` | Legacy bare object (no `data` envelope) | +| `hack logs --json` | NDJSON logs; finite native modes may return a legacy snapshot object | +| `hack native setup --json` | Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope) | +| `hack node add --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer attach --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer down --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer up --json` | Legacy bare object (no `data` envelope) | +| `hack node init --json` | Legacy bare object (no `data` envelope) | +| `hack node list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair --json` | Legacy bare object (no `data` envelope) | +| `hack node pair approve --json` | Legacy bare object (no `data` envelope) | +| `hack node pair cancel --json` | Legacy bare object (no `data` envelope) | +| `hack node pair complete --json` | Legacy bare object (no `data` envelope) | +| `hack node pair fulfill --json` | Legacy bare object (no `data` envelope) | +| `hack node pair list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair request --json` | Legacy bare object (no `data` envelope) | +| `hack node pair start --json` | Legacy bare object (no `data` envelope) | +| `hack node pair walkthrough --json` | Legacy bare object (no `data` envelope) | +| `hack node provider railway bootstrap --json` | Legacy bare object (no `data` envelope) | +| `hack node remove --json` | Legacy bare object (no `data` envelope) | +| `hack node routes repair --json` | Legacy bare object (no `data` envelope) | +| `hack node routes status --json` | Legacy bare object (no `data` envelope) | +| `hack node ssh setup --json` | Legacy bare object (no `data` envelope) | +| `hack node status --json` | Legacy bare object (no `data` envelope) | +| `hack node use --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace attach --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace list --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace remove --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace resolve --json` | Legacy bare object (no `data` envelope) | +| `hack open --json` | Legacy bare object (no `data` envelope) | +| `hack project owner show --json` | Legacy bare object (no `data` envelope) | +| `hack project target --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task list --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack projects --json` | Legacy bare object (no `data` envelope) | +| `hack projects prune --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack ps --json` | Backend-dependent: envelope or legacy bare object | +| `hack restart --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack run --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack session capture --json` | NDJSON events / terminal event, not a single envelope | +| `hack session panes --json` | NDJSON events / terminal event, not a single envelope | +| `hack session tail --json` | NDJSON events / terminal event, not a single envelope | +| `hack setup migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack setup sync --json` | Retired: usage-error envelope, no success payload | +| `hack status --json` | Backend-dependent: envelope or legacy bare object | +| `hack up --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack update --json` | Legacy result (`ok` plus fields, no `data`; error may be a string) | +| `hack upgrade apply --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade check --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade review --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade rollback --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade snapshot --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack usage --json` | Legacy bare object (no `data` envelope) | +| `hack version --json` | Envelope; versioned `data.schema`: `hack.version.v1` | +| `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | +| `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | +| `hack usage --json --by-service` | Envelope; `data.schema: hack.usage.by-service.v1` | + diff --git a/docs/reference/machine-readable-output.mmd b/docs/reference/machine-readable-output.mmd new file mode 100644 index 000000000..2522472fb --- /dev/null +++ b/docs/reference/machine-readable-output.mmd @@ -0,0 +1,7 @@ +flowchart LR + C[New JSON command] --> O{Command outcome} + O -->|success| D[ok true with data and schema] + O -->|failure| E[ok false with stable error code] + D --> V[Client validates schema] + E --> F[Nonzero exit and optional recovery detail] + L[Published legacy command] --> P[Existing parser unchanged] diff --git a/docs/reference/machine-readable-output.svg b/docs/reference/machine-readable-output.svg new file mode 100644 index 000000000..10335d781 --- /dev/null +++ b/docs/reference/machine-readable-output.svg @@ -0,0 +1,66 @@ +successfailureNew JSON commandCommand outcomeok true with data andschemaok false with stable errorcodeClient validates schemaNonzero exit and optionalrecovery detailPublished legacycommandExisting parser unchanged \ No newline at end of file diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md new file mode 100644 index 000000000..df1f248ca --- /dev/null +++ b/docs/reference/project-state.md @@ -0,0 +1,90 @@ +# Backend-aware project state + +Plain `hack projects --json` retains its published top-level object. With +`--observe`, the payload is inside the [machine-output envelope](./machine-readable-output.md) +with `data.schema: "hack.projects.observe.v1"`. The row fields below are shared +by both CLI forms and the daemon payload. + +Full and summary `hack projects --json` rows and hackd's `GET /v1/projects` +share these additional fields. Older `status` and `runtime_status` fields remain +for compatibility; consumers of this contract use `state`. + +```json +{ + "backend": "compose", + "state": "running", + "state_reason": null, + "started_at": "2026-01-01T00:00:00Z", + "services": [{"name":"api","state":"running","ready":true,"completed":null,"health":null,"exit_code":null,"started_at":"2026-01-01T00:00:00Z"}] +} +``` + +`backend` is `compose`, `native` or `unknown`. Project and service `state` is +`running`, `degraded`, `starting`, `stopping`, `stopped`, `failed` or `unknown`. +Profile services without observed containers are not required for project-state +aggregation unless a required service depends on them (transitively; +`required: false` dependencies do not activate them). Their service rows remain +visible. Observed profile containers participate even after they exit. The +listing process's environment is not used to guess a past profile selection. +`paused` and `removing` members remain unknown. Image-provided health checks +can conservatively make a started-role service degraded/not ready even without +a Compose health dependency. Mixed running/terminated replicas are degraded. When no live member remains, +known failure wins over stopped members. Unknown evidence prevents a complete +claim. Successful declared jobs do not degrade other running services. + +`ready` and `completed` are nullable booleans. Unknown is not false: + +- Compose roles come from current service configuration: `disable: true` and + `test: ["NONE"]` both disable a health check. An enabled health check or a + `service_healthy` dependency requires health; a + `service_completed_successfully` dependency declares a completion role; + otherwise the role is started. A successful exit alone never declares a job. +- Native roles come from the owned run's retained readiness declarations, + combined with fresh authenticated member observations. A historical ready + receipt does not prove current readiness. +- A completion role succeeds only when every member has an observed zero exit. + Missing exit codes or required health remain null. Mixed replica exit codes + or health strings are null rather than selecting an arbitrary member. + +`health` and `exit_code` come from current observations, never log text. An exit +code is emitted only for a terminated member. Docker inspect selects status, +exit code, start time and health status, excluding environment values, commands, +error text and health-check logs. The daemon's static inspect cache cannot +supply current health/exit/start evidence; fields are null on a cache-only +refresh. Failed reinspection clears prior state evidence while retaining static +identity. Unchanged running IDs remain query-free. Changed states and non-running IDs +are re-inspected during event refreshes, including repeated exited states: a +restart and second exit can reuse the same container ID. This keeps job completion +and failure known without waiting for the periodic full refresh. + +`started_at` is the earliest valid observed member start time, or null. It is +never the observation time or a historical readiness time. Native status does +not currently expose start timestamps, so native timestamps remain null. + +Registered native projects remain in the listing. A Compose-format project's +backend is chosen by the observing process's runtime selection, not persisted +per project. Without native selection it is observed through Compose and can +report stopped even if that checkout is running on native elsewhere. Hackd uses +its own environment, including when started at login; it does not inherit later +shell selections. Consumers must not interpret a Compose observation as evidence +about a native run. Persisting backend identity is outside this contract. Authored native markers and +explicit native runtime selection choose the native observer. For authored native projects, an absent runtime +selection, unavailable/busy provider, changed provider pin or unconfirmed owner +produces unknown state with `state_reason: "observation-unavailable"`. Raw +provider errors are omitted. An explicitly verified native runtime degradation +reports `degraded` with `state_reason: "native-runtime-degraded"`, without claiming +service readiness. Native observations cover the registered root's +base instance; branch observations remain a separate interface. At most four +native project status reads run at once in one process, across all concurrent +callers (for example parallel `/v1/projects` requests to hackd). Callers that +ask for the same project at the same time share one read; settled results are +not cached. + +Observation boundaries from [observe-only listing](../cli.md) are preserved: +`hack projects --json --observe` reads no engine or native provider and leaves +live state unknown. `--observe --runtime` explicitly opts in to runtime contact, +which can start a socket-activated engine. Both bypass hackd and registration +writes. Ordinary JSON listing and daemon listing use live native reads when +native runtime selection is configured; those reads do not start a VM or graph. + +![Shared state projection](./project-state.svg) diff --git a/docs/reference/project-state.mmd b/docs/reference/project-state.mmd new file mode 100644 index 000000000..aa721829c --- /dev/null +++ b/docs/reference/project-state.mmd @@ -0,0 +1,14 @@ +flowchart TD + S[Registered project] --> G{Runtime contact allowed} + G -->|no| U[Unknown live state] + G -->|yes| B{Selected backend} + B -->|Compose| D[Current ps and selected inspect fields] + B -->|Native| N[Owned run and current authenticated observation] + D --> P[Shared service projection] + N --> P + P --> J{Declared job and observed zero exit} + J -->|yes| C[Completed and ready] + J -->|no| E[Current state and nullable readiness] + C --> R[Shared full and summary rows] + E --> R + U --> R diff --git a/docs/reference/project-state.svg b/docs/reference/project-state.svg new file mode 100644 index 000000000..afd5ef274 --- /dev/null +++ b/docs/reference/project-state.svg @@ -0,0 +1,66 @@ +noyesComposeNativeyesnoRegistered projectRuntime contact allowedUnknown live stateSelected backendCurrent ps and selectedinspect fieldsOwned run and currentauthenticated observationShared service projectionDeclared job and observedzero exitCompleted and readyCurrent state and nullablereadinessShared full and summaryrows \ No newline at end of file diff --git a/docs/reference/version.md b/docs/reference/version.md index f88e84202..90ac8b500 100644 --- a/docs/reference/version.md +++ b/docs/reference/version.md @@ -5,7 +5,16 @@ lookup, project discovery, registry writes or starting Docker, a VM or hackd. Plain `hack version` and `hack --version` keep their text output. ```json -{"version":"5.0.0-next.1","channel":"next","install_kind":"standalone","binary_path":"/example/bin/hack"} +{ + "ok": true, + "data": { + "version": "5.0.0-next.1", + "channel": "next", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} ``` - `version` is the running CLI's embedded version. @@ -23,8 +32,8 @@ Plain `hack version` and `hack --version` keep their text output. The OS may already have resolved a launch symlink, so its original spelling is not guaranteed. -The JSON is a bare object with these four fields, without an `ok` or `data` -envelope. +The four identity fields and `schema: "hack.version.v1"` are inside `data` +in the standard success envelope. See [Machine-readable output](./machine-readable-output.md). The report inspects only local executable metadata and, for compiled installs, its initial bytes. It does not check whether a newer version exists. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 22aa34933..131d16b4a 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -117,3 +117,9 @@ This stops the project. It does not delete its named volumes or stop shared routing services. See [Run and inspect](guides/run-project.md) for service selection, log history and restarts. When this Compose run works, try native only on a [disposable project](native/start.md). + +Project JSON rows and hackd project snapshots share `backend`, `state`, +`started_at` and per-service readiness, completion, health and exit fields. +Unavailable evidence stays unknown/null. A zero exit is a completed job only +with a declared completion role. Default `projects --json --observe` still +contacts no runtime; live observations require `--runtime`. diff --git a/docs/versions/v5/guides/agent-first-setup.md b/docs/versions/v5/guides/agent-first-setup.md index 2e7c00acd..b5a992b85 100644 --- a/docs/versions/v5/guides/agent-first-setup.md +++ b/docs/versions/v5/guides/agent-first-setup.md @@ -44,3 +44,8 @@ install kind and executable path without starting services or checking the network. The JSON fields are `version`, `channel`, `install_kind` and `binary_path`; a source run reports Bun as its executable and `source` as its install kind. + +New machine-readable contracts return `{ok:true,data:{schema,...}}`; failures +return `{ok:false,error:{code,message}}` with a nonzero exit. Version identity, +observe-only projects/global status and administrator plans use this envelope. +Published plain project listing and existing streaming formats keep their shapes. diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md index 55b83353b..a0fa33c14 100644 --- a/plugins/hack/skills/hack-diagnose/references/error-codes.md +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -19,6 +19,8 @@ stay within the request. | `E_COMPOSE_FAILED` | A Compose operation failed or left a stop incomplete. | `hack logs --json --no-follow --tail 200` | | `E_COMPOSE_OUTPUT_INVALID` | Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version. | `docker compose version` | | `E_STARTUP_INCOMPLETE` | After start or restart, a service is neither running nor a successful one-shot. | `hack ps --json` | +| `E_PLAN_REFUSED` | A read-only administrator plan established that the command would refuse to run. | Read `error.detail.plan.refused`; no planned step ran. | +| `E_PLAN_INCOMPLETE` | An administrator plan cannot yet determine every required phase. | Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied. | | `E_NATIVE_SETUP_REQUIRED` | The selected native home needs runtime setup before project startup. | `hack doctor --native-preflight --json` | | `E_DEPENDENCY_BOOTSTRAP_FAILED` | A dependency installer service did not complete successfully. | `hack logs --json --no-follow --tail 200` | | `E_STARTUP_TIMEOUT` | Bounded startup ran out of time before services settled. | `hack ps --json` | @@ -44,4 +46,4 @@ stay within the request. --- -Content revision: `12ec120ab5ff`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `d54c6edc4aa7`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/scripts/generate-machine-output-reference.ts b/scripts/generate-machine-output-reference.ts new file mode 100644 index 000000000..2f81ec397 --- /dev/null +++ b/scripts/generate-machine-output-reference.ts @@ -0,0 +1,30 @@ +import { renderJsonInventory } from "../src/cli/json-reference.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { machineOutputFixtures } from "./lib/machine-output-fixtures.ts"; + +const path = new URL( + "../docs/reference/machine-readable-output.md", + import.meta.url +); +const text = await Bun.file(path).text(); +const start = ""; +const end = ""; +if ( + text.split(start).length !== 2 || + text.split(end).length !== 2 || + text.indexOf(end) <= text.indexOf(start) +) { + throw new Error("Machine output inventory markers must occur once, in order"); +} +const next = `${text.slice(0, text.indexOf(start) + start.length)}\n${renderJsonInventory(CLI_SPEC)}${text.slice(text.indexOf(end))}`; +await Bun.write(path, next); + +for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + await Bun.write( + new URL( + `../apps/desktop/core/tests/fixtures/machine-output/${name}`, + import.meta.url + ), + `${JSON.stringify(fixture, null, 2)}\n` + ); +} diff --git a/scripts/lib/machine-output-fixtures.ts b/scripts/lib/machine-output-fixtures.ts new file mode 100644 index 000000000..a213655a8 --- /dev/null +++ b/scripts/lib/machine-output-fixtures.ts @@ -0,0 +1,60 @@ +import { errorResult } from "../../src/lib/cli-result.ts"; +import { + MACHINE_SCHEMAS, + machineSuccess, +} from "../../src/lib/machine-output.ts"; + +/** Minimal payloads for cross-language boundary tests, not runtime-state fixtures. */ +export function machineOutputFixtures() { + const plan = { + command: "install", + platform: "linux", + refused: null, + complete: true, + blocked: [], + steps: [], + }; + const success = ( + schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS], + data: object + ) => machineSuccess({ schema, data }); + return { + "activity.json": success(MACHINE_SCHEMAS.activity, { + version: 1, + entries: [], + }), + "version.json": success(MACHINE_SCHEMAS.version, { + version: "5.0.0", + channel: "stable", + install_kind: "standalone", + binary_path: "/example/bin/hack", + }), + "projects-observe.json": success(MACHINE_SCHEMAS.projectsObserve, { + observe: true, + runtime_requested: false, + runtime_ok: false, + projects: [], + }), + "global-status-observe.json": success(MACHINE_SCHEMAS.globalStatusObserve, { + observe: true, + docker: { state: "not_running" }, + summary: { ok: "unknown" }, + }), + "global-install-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, plan), + "global-trust-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, { + ...plan, + command: "trust", + }), + "plan-incomplete.json": errorResult({ + code: "E_PLAN_INCOMPLETE", + message: "Administrator plan is incomplete.", + detail: { + plan: { + ...plan, + schema: MACHINE_SCHEMAS.globalAdminPlan, + complete: false, + }, + }, + }), + }; +} diff --git a/scripts/lib/native-plan-home.ts b/scripts/lib/native-plan-home.ts index fed8de9f9..b7b89f009 100644 --- a/scripts/lib/native-plan-home.ts +++ b/scripts/lib/native-plan-home.ts @@ -19,6 +19,10 @@ export async function assertNativePlanHome(opts: { const expectedHome = expectedHackFiles.length > 0 ? [".hack"] : []; const homeEntries = await readdir(opts.home); assertNames(homeEntries, expectedHome); + assertNames(await readdir(opts.home, { recursive: true }), [ + ...expectedHome, + ...expectedHackFiles.map((name) => `.hack/${name}`), + ]); if (expectedHackFiles.length === 0) { return; } @@ -40,7 +44,20 @@ export async function assertNativePlanHome(opts: { !isRecord(activity) || activity.action !== "up" || activity.outcome !== "failed" || - activity.project !== null + activity.project !== null || + JSON.stringify(Object.keys(activity).sort()) !== + JSON.stringify([ + "action", + "branch", + "duration_ms", + "failed_services", + "origin", + "outcome", + "project", + "ts", + "v", + ]) || + JSON.stringify(activity).includes(opts.home) ) { throw new Error("Expected failed unregistered up activity"); } diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts index 64a851b9f..46efe24c2 100644 --- a/src/agents/error-code-reference.ts +++ b/src/agents/error-code-reference.ts @@ -70,6 +70,16 @@ const ERROR_CODE_GUIDES: Readonly> = { "Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version.", readFirst: "`docker compose version`", }, + E_PLAN_REFUSED: { + meaning: + "A read-only administrator plan established that the command would refuse to run.", + readFirst: "Read `error.detail.plan.refused`; no planned step ran.", + }, + E_PLAN_INCOMPLETE: { + meaning: "An administrator plan cannot yet determine every required phase.", + readFirst: + "Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied.", + }, E_STARTUP_INCOMPLETE: { meaning: "After start or restart, a service is neither running nor a successful one-shot.", diff --git a/src/backends/native-authored-project-observe.ts b/src/backends/native-authored-project-observe.ts index ce0ecb9d1..c71d776cb 100644 --- a/src/backends/native-authored-project-observe.ts +++ b/src/backends/native-authored-project-observe.ts @@ -28,6 +28,7 @@ type Item = { readonly state: string; readonly health: string | null; readonly exitCode: number | null; + readonly readiness?: "started" | "healthy" | "completed"; }; export type NativeAuthoredProjectStatus = { readonly backend: "native"; @@ -52,6 +53,7 @@ export async function nativeAuthoredProjectPs(input: { readonly runtime: NativeRuntimeSelection; readonly scope: NativeAuthoredProjectRunScope; readonly signal?: AbortSignal; + readonly includeReadiness?: boolean; readonly invoke?: typeof invokeNativeRuntime; /** Control seam; production always uses the held saved-status owner. */ readonly withStatus?: typeof withNativeAuthoredProjectStatus; @@ -145,6 +147,9 @@ export async function nativeAuthoredProjectPs(input: { } return { service, + ...(input.includeReadiness + ? { readiness: current.receipt.readiness[service] } + : {}), container: resource.id, state: observation?.state ?? "absent", health: diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts new file mode 100644 index 000000000..1bd5047de --- /dev/null +++ b/src/cli/json-contracts.ts @@ -0,0 +1,115 @@ +/** Canonical --json inventory. New command paths require an explicit shape decision. */ +export const JSON_COMMAND_SHAPES = { + "global install": "schema-envelope", + "global dns preview": "envelope", + "global status": "legacy-object", + "global trust": "schema-envelope", + status: "backend-dependent", + usage: "legacy-object", + projects: "legacy-object", + "projects prune": "envelope", + "project owner show": "legacy-object", + "project target": "envelope", + "project task list": "envelope", + "project task inspect": "envelope", + up: "envelope", + down: "backend-dependent", + restart: "envelope", + ps: "backend-dependent", + run: "envelope", + exec: "envelope", + "exec-sessions": "legacy-object", + "commands read": "ndjson", + "commands show": "envelope", + "commands cancel": "envelope", + logs: "logs", + open: "legacy-object", + activity: "schema-envelope", + doctor: "envelope", + "daemon status": "legacy-object", + "dispatch run": "legacy-object", + "dispatch status": "legacy-object", + "dispatch logs": "legacy-object", + "env list": "legacy-object", + "env migrate": "envelope", + "env protect": "legacy-object", + "env explain": "legacy-object", + "env apply": "legacy-object", + "env hydrate": "legacy-object", + "env backend status": "legacy-object", + "env backend use": "legacy-object", + "host ps": "legacy-object", + "config validate": "transport", + "config plan": "transport", + "config import": "legacy-object", + "config adopt": "legacy-object", + "setup sync": "error-only", + "setup migrate": "envelope", + "native setup": "runtime-passthrough", + "session panes": "ndjson", + "session capture": "ndjson", + "session tail": "ndjson", + "node init": "legacy-object", + "node pair": "legacy-object", + "node pair start": "legacy-object", + "node pair request": "legacy-object", + "node pair approve": "legacy-object", + "node pair complete": "legacy-object", + "node pair cancel": "legacy-object", + "node pair walkthrough": "legacy-object", + "node pair list": "legacy-object", + "node pair fulfill": "legacy-object", + "node ssh setup": "legacy-object", + "node add": "legacy-object", + "node list": "legacy-object", + "node status": "legacy-object", + "node use": "legacy-object", + "node remove": "legacy-object", + "node workspace list": "legacy-object", + "node workspace resolve": "legacy-object", + "node workspace remove": "legacy-object", + "node workspace attach": "legacy-object", + "node routes status": "legacy-object", + "node routes repair": "legacy-object", + "node provider railway bootstrap": "legacy-object", + "node devcontainer up": "legacy-object", + "node devcontainer down": "legacy-object", + "node devcontainer attach": "legacy-object", + update: "legacy-result", + "upgrade check": "envelope", + "upgrade apply": "envelope", + "upgrade review": "envelope", + "upgrade snapshot": "envelope", + "upgrade rollback": "envelope", + version: "schema-envelope", +} as const; + +export const SCHEMA_JSON_VARIANTS = [ + { command: "activity", flags: [], schema: "hack.activity.v1" }, + { command: "version", flags: [], schema: "hack.version.v1" }, + { + command: "projects", + flags: ["--observe"], + schema: "hack.projects.observe.v1", + }, + { + command: "global status", + flags: ["--observe"], + schema: "hack.global-status.observe.v1", + }, + { + command: "global install", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, + { + command: "global trust", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, + { + command: "usage", + flags: ["--by-service"], + schema: "hack.usage.by-service.v1", + }, +] as const; diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts new file mode 100644 index 000000000..e4367baf3 --- /dev/null +++ b/src/cli/json-reference.ts @@ -0,0 +1,70 @@ +import type { AnyCommandSpec, CliSpec } from "./command.ts"; +import { JSON_COMMAND_SHAPES, SCHEMA_JSON_VARIANTS } from "./json-contracts.ts"; + +export function jsonCommandPaths(cli: CliSpec): readonly string[] { + const out: string[] = []; + function visit(command: AnyCommandSpec, parents: readonly string[]): void { + const path = [...parents, command.name]; + if (command.options.some((option) => option.name === "json")) { + out.push(path.join(" ")); + } + for (const child of command.subcommands) { + visit(child, path); + } + } + for (const command of cli.commands) { + visit(command, []); + } + return out.sort(); +} + +const SHAPES: Record< + (typeof JSON_COMMAND_SHAPES)[keyof typeof JSON_COMMAND_SHAPES], + string +> = { + "schema-envelope": "Envelope; versioned `data.schema`", + envelope: + "Envelope (`ok`, `data` / `error`); legacy payload has no required schema", + "legacy-object": "Legacy bare object (no `data` envelope)", + "legacy-result": + "Legacy result (`ok` plus fields, no `data`; error may be a string)", + "backend-dependent": "Backend-dependent: envelope or legacy bare object", + ndjson: "NDJSON events / terminal event, not a single envelope", + logs: "NDJSON logs; finite native modes may return a legacy snapshot object", + transport: + "Native compiler protocol object (`transport_version`, `ok`, result / diagnostics)", + "error-only": "Retired: usage-error envelope, no success payload", + "runtime-passthrough": + "Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope)", +}; + +export function renderJsonInventory(cli: CliSpec): string { + const rows = jsonCommandPaths(cli).map((command) => { + const kind = + JSON_COMMAND_SHAPES[command as keyof typeof JSON_COMMAND_SHAPES]; + if (kind === undefined) { + throw new Error(`Missing JSON shape decision: ${command}`); + } + const variant = SCHEMA_JSON_VARIANTS.find( + (entry) => entry.command === command && kind === "schema-envelope" + ); + const flags = variant?.flags.join(" ") ?? ""; + return `| \`hack ${command}${flags ? ` ${flags}` : ""} --json\` | ${SHAPES[kind]}${variant ? `: \`${variant.schema}\`` : ""} |`; + }); + // A flag variant whose base command keeps another shape gets its own row. + for (const variant of SCHEMA_JSON_VARIANTS.filter( + (entry) => + JSON_COMMAND_SHAPES[entry.command as keyof typeof JSON_COMMAND_SHAPES] !== + "schema-envelope" + )) { + rows.push( + `| \`hack ${variant.command} --json ${variant.flags.join(" ")}\` | Envelope; \`data.schema: ${variant.schema}\` |` + ); + } + return [ + "| Canonical command / variant | Success shape |", + "|---|---|", + ...rows, + "", + ].join("\n"); +} diff --git a/src/cli/reference.ts b/src/cli/reference.ts index 29ebb189f..cfc75047d 100644 --- a/src/cli/reference.ts +++ b/src/cli/reference.ts @@ -46,7 +46,7 @@ export function renderCliReferenceMarkdown(opts: { "", `${opts.cli.name} — ${opts.cli.summary}`, "", - "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout.", + "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md).", "", ]; diff --git a/src/commands/activity.ts b/src/commands/activity.ts index b5094f03f..f0b39644b 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -12,7 +12,8 @@ import { type ActivityRecord, readActivity, } from "../lib/activity-log.ts"; -import { emitCliResult, errorResult, okResult } from "../lib/cli-result.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; const WHOLE_NUMBER = /^[1-9][0-9]*$/; @@ -97,7 +98,10 @@ async function handleActivity({ } if (json) { emitCliResult({ - result: okResult({ data: { version: ACTIVITY_SCHEMA_VERSION, entries } }), + result: machineSuccess({ + schema: MACHINE_SCHEMAS.activity, + data: { version: ACTIVITY_SCHEMA_VERSION, entries }, + }), }); return 0; } diff --git a/src/commands/global-admin-plan.ts b/src/commands/global-admin-plan.ts index 05d11ec42..973277d3c 100644 --- a/src/commands/global-admin-plan.ts +++ b/src/commands/global-admin-plan.ts @@ -35,6 +35,7 @@ import type { LinuxTrustHost, PreparedLinuxSystemTrust, } from "../lib/linux-trust-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { trustedSudoInvocation } from "../lib/privileged-sudo.ts"; import { type DockerBackend, @@ -54,7 +55,7 @@ import { type PreparedMacTrustPlan, } from "./global-mac-trust.ts"; -export const ADMIN_PLAN_SCHEMA = "hack.global-admin-plan.v1"; +export const ADMIN_PLAN_SCHEMA = MACHINE_SCHEMAS.globalAdminPlan; /** * A bounded `docker info` result as the plan reads it: exit 0 answers; diff --git a/src/commands/global-status-observe.ts b/src/commands/global-status-observe.ts index f1b752a55..5c7a37476 100644 --- a/src/commands/global-status-observe.ts +++ b/src/commands/global-status-observe.ts @@ -13,6 +13,7 @@ * reported as absent or as a failure of the thing. */ import type { LinuxDnsInspection } from "../lib/linux-dns-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import type { ExecResult } from "../lib/shell.ts"; import type { ObservedCheck } from "./global-mac-dns.ts"; import type { @@ -21,7 +22,7 @@ import type { ProxyObservation, } from "./global-status-observe-host.ts"; -export const OBSERVE_STATUS_SCHEMA = "hack.global-status.observe.v1"; +export const OBSERVE_STATUS_SCHEMA = MACHINE_SCHEMAS.globalStatusObserve; /** A yes/no answer, or "unknown" when it could not be determined. */ export type Tri = true | false | "unknown"; diff --git a/src/commands/global.ts b/src/commands/global.ts index b20048838..68d07801a 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -11,7 +11,6 @@ import type { CommandHandlerFor, } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; - import { optFollow, optJson, @@ -67,6 +66,7 @@ import { inspectCaddyCaIdentity, } from "../lib/caddy-ca-identity.ts"; import { observeGlobalCaddyIp } from "../lib/caddy-hosts.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; import { resolveGlobalConfigPath, resolveGlobalHackDir, @@ -107,6 +107,7 @@ import { resolveHackLocalCaCertPath, } from "../lib/local-ca.ts"; import { inspectMacCaEligibility } from "../lib/mac-ca-trust.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -483,8 +484,12 @@ export const globalCommand = defineCommand({ subcommands: [ withHandler(globalInstallSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global install --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: + "Use --json with --plan: hack global install --plan --json", + }), }); return 2; } @@ -505,8 +510,11 @@ export const globalCommand = defineCommand({ withHandler(globalCertSpec, handleGlobalCert), withHandler(globalTrustSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global trust --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: "Use --json with --plan: hack global trust --plan --json", + }), }); return 2; } @@ -2572,7 +2580,12 @@ async function globalStatusObserve(): Promise { observeMacDns: observeMacHostDns, now: () => new Date(), }); - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalStatusObserve, + data: payload, + }), + }); return 0; } @@ -3382,11 +3395,34 @@ async function printAdminPlan(opts: { return 2; } const result = await opts.build(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (result.refused !== null) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_REFUSED", + message: result.refused, + detail: { plan: result }, + }), + }); return 1; } - return result.complete ? 0 : 3; + if (!result.complete) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_INCOMPLETE", + message: + "Administrator plan is incomplete; inspect error.detail.plan.blocked before proceeding.", + detail: { plan: result }, + }), + }); + return 3; + } + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalAdminPlan, + data: result, + }), + }); + return 0; } /** Install's read-only snapshot. Never enter the mutating install handler. */ diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 191aa8b0e..28a58bc4a 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -27,6 +27,7 @@ import { import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { confirmSafe } from "../lib/interactivity.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; import { createOperationTimings, @@ -43,7 +44,6 @@ import { AmbiguousProjectNameError, normalizeProjectName, } from "../lib/project-name.ts"; -import { projectObservation } from "../lib/project-observation.ts"; import { assertPruneRuntimeOwnership } from "../lib/project-prune-ownership.ts"; import { findMissingRegistryEntries, @@ -621,6 +621,7 @@ async function runProjects(opts: { const views = await profiler.measure("project_views_ms", () => buildProjectViews({ + observeNative: !opts.observe || opts.observeRuntime === true, registryProjects: registry.projects, runtime: runtime.runtime, runtimeOk: runtime.ok, @@ -851,14 +852,6 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), - ...(opts.observe - ? projectObservation({ - view, - runtimeOk: opts.runtime.ok, - nativeSelected: - process.env.HACK_RUNTIME_BACKEND?.trim() === "native", - }) - : {}), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), @@ -866,7 +859,12 @@ function outputProjectsJson(opts: { ), }; writeProfiledProjects({ - payload, + payload: opts.observe + ? machineSuccess({ + schema: MACHINE_SCHEMAS.projectsObserve, + data: payload, + }) + : payload, profiler: opts.profiler, started: opts.started, enabled: opts.timings, diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 84001c938..0add0619b 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -22,6 +22,7 @@ import { readHostCommandRecords, readObservedProcesses, } from "../lib/host-command-observation.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { MissingProjectContextError } from "../lib/missing-project-error.ts"; import { findProjectContext, @@ -90,7 +91,7 @@ const optByService = defineOption({ } as const); /** Schema id of `hack usage --json --by-service`; see docs/reference/usage-by-service.md. */ -export const USAGE_BY_SERVICE_SCHEMA = "hack.usage.by-service.v1"; +export const USAGE_BY_SERVICE_SCHEMA = MACHINE_SCHEMAS.usageByService; const options = [ optProject, diff --git a/src/commands/version.ts b/src/commands/version.ts index 8c4cabf76..a74d2a1a1 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,7 +1,8 @@ import type { CommandHandlerFor } from "../cli/command.ts"; import { defineCommand, withHandler } from "../cli/command.ts"; - import { optJson } from "../cli/options.ts"; +import { emitCliResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { buildVersionReport } from "../lib/version-report.ts"; const versionSpec = defineCommand({ @@ -23,7 +24,9 @@ const handleVersion: CommandHandlerFor = async ({ executable: process.execPath, compiled: Bun.main.startsWith("/$bunfs/"), }); - console.log(JSON.stringify(report)); + emitCliResult({ + result: machineSuccess({ schema: MACHINE_SCHEMAS.version, data: report }), + }); return 0; } console.log(`${ctx.cli.name} v${ctx.cli.version}`); diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index d8cb76dac..3720470e2 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -318,6 +318,7 @@ export function createRuntimeCache(opts: { }); const views = await profiler.measure("project_views_ms", () => deps.buildProjectViews({ + observeNative: true, registryProjects: registry.projects, runtime, runtimeOk: health.ok, diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 488f84605..94285a692 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -1,10 +1,11 @@ /** * Machine-readable CLI result envelope. * - * Commands that support `--json` print exactly one envelope object to stdout: + * New structured `--json` contracts print one envelope object to stdout: * `{ok: true, data}` on success or `{ok: false, error: {code, message, - * detail?}}` on failure. Exit codes keep their existing 0/1 semantics; the - * envelope only adds structure, never changes them. + * detail?}}` on failure. New HACK-2218 successes also carry `data.schema`. + * Errors exit nonzero. Published legacy and streaming formats are inventoried + * in docs/reference/machine-readable-output.md and are not silently wrapped. */ /** @@ -28,6 +29,8 @@ export const HACK_ERROR_CODES = [ "E_COMPOSE_FAILED", "E_COMPOSE_OUTPUT_INVALID", "E_STARTUP_INCOMPLETE", + "E_PLAN_REFUSED", + "E_PLAN_INCOMPLETE", "E_NATIVE_SETUP_REQUIRED", "E_DEPENDENCY_BOOTSTRAP_FAILED", "E_STARTUP_TIMEOUT", diff --git a/src/lib/compose-service-activation.ts b/src/lib/compose-service-activation.ts new file mode 100644 index 000000000..2d56db87e --- /dev/null +++ b/src/lib/compose-service-activation.ts @@ -0,0 +1,67 @@ +import { isRecord } from "./guards.ts"; + +export type ComposeServiceActivation = { + readonly profiles: Readonly>; + readonly requiredDependencies: Readonly>; +}; + +/** Definition metadata only; a listing's environment does not prove which profiles ran. */ +export function composeServiceActivation( + document: unknown +): ComposeServiceActivation { + const profiles: Record = {}; + const requiredDependencies: Record = {}; + if (!(isRecord(document) && isRecord(document.services))) { + return { profiles, requiredDependencies }; + } + for (const [name, service] of Object.entries(document.services)) { + if (!isRecord(service)) { + continue; + } + profiles[name] = Array.isArray(service.profiles) + ? service.profiles.filter( + (profile): profile is string => + typeof profile === "string" && profile.length > 0 + ) + : []; + requiredDependencies[name] = dependencies(service.depends_on); + } + return { profiles, requiredDependencies }; +} + +function dependencies(value: unknown): readonly string[] { + if (Array.isArray(value)) { + return value.filter((name): name is string => typeof name === "string"); + } + if (!isRecord(value)) { + return []; + } + return Object.entries(value) + .filter( + ([, dependency]) => + !(isRecord(dependency) && dependency.required === false) + ) + .map(([name]) => name); +} + +/** Unprofiled, observed, and transitively required services contribute to project state. */ +export function requiredObservedServices(opts: { + readonly names: readonly string[]; + readonly observed: readonly string[]; + readonly activation?: ComposeServiceActivation; +}): ReadonlySet { + const required = new Set( + opts.names.filter((name) => !opts.activation?.profiles[name]?.length) + ); + for (const name of opts.observed) { + required.add(name); + } + // Set iteration visits newly inserted dependencies, including transitive ones. + for (const name of required) { + for (const dependency of opts.activation?.requiredDependencies[name] ?? + []) { + required.add(dependency); + } + } + return required; +} diff --git a/src/lib/compose-service-readiness.ts b/src/lib/compose-service-readiness.ts new file mode 100644 index 000000000..957fe065f --- /dev/null +++ b/src/lib/compose-service-readiness.ts @@ -0,0 +1,51 @@ +import { isRecord } from "./guards.ts"; +import type { ServiceReadiness } from "./project-state.ts"; + +/** Current declared readiness roles; exit zero alone never declares a job. */ +export function composeServiceReadiness( + document: unknown +): Readonly> { + if (!(isRecord(document) && isRecord(document.services))) { + return {}; + } + const roles: Record = {}; + for (const [name, service] of Object.entries(document.services)) { + if (!isRecord(service)) { + continue; + } + const health = service.healthcheck; + const disabled = + isRecord(health) && + (health.disable === true || + (Array.isArray(health.test) && + health.test.length === 1 && + health.test[0] === "NONE")); + roles[name] = isRecord(health) && !disabled ? "healthy" : "started"; + } + applyDependencyRoles(document.services, roles); + return roles; +} + +function applyDependencyRoles( + services: Readonly>, + roles: Record +): void { + for (const service of Object.values(services)) { + if (!(isRecord(service) && isRecord(service.depends_on))) { + continue; + } + for (const [name, dependency] of Object.entries(service.depends_on)) { + if (!(roles[name] && isRecord(dependency))) { + continue; + } + if (dependency.condition === "service_completed_successfully") { + roles[name] = "completed"; + } else if ( + roles[name] !== "completed" && + dependency.condition === "service_healthy" + ) { + roles[name] = "healthy"; + } + } + } +} diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts new file mode 100644 index 000000000..1b8561c5c --- /dev/null +++ b/src/lib/machine-output.ts @@ -0,0 +1,18 @@ +import { type CliOkResult, okResult } from "./cli-result.ts"; + +/** HACK-2218 machine contracts use an envelope and a versioned payload schema. */ +export const MACHINE_SCHEMAS = { + activity: "hack.activity.v1", + version: "hack.version.v1", + projectsObserve: "hack.projects.observe.v1", + globalAdminPlan: "hack.global-admin-plan.v1", + globalStatusObserve: "hack.global-status.observe.v1", + usageByService: "hack.usage.by-service.v1", +} as const; + +export function machineSuccess(opts: { + readonly schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS]; + readonly data: T; +}): CliOkResult { + return okResult({ data: { ...opts.data, schema: opts.schema } }); +} diff --git a/src/lib/project-native-observation.ts b/src/lib/project-native-observation.ts new file mode 100644 index 000000000..2e0caa785 --- /dev/null +++ b/src/lib/project-native-observation.ts @@ -0,0 +1,158 @@ +import { nativeAuthoredProjectPs } from "../backends/native-authored-project-observe.ts"; +import { nativeProjectPs } from "../backends/native-project-observe.ts"; +import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import type { ProjectObservation } from "./project-observation.ts"; +import { + earliestStart, + observedProjectState, + observeService, +} from "./project-state.ts"; +import type { ProjectView } from "./project-views.ts"; + +type ObserveOptions = { + readonly view: ProjectView; + readonly ps?: typeof nativeProjectPs; + readonly authoredPs?: typeof nativeAuthoredProjectPs; +}; + +/** + * Native status reads own subprocesses. The limit is process-wide, not per + * request, so parallel `/v1/projects` callers cannot multiply the fan-out. + */ +export const NATIVE_OBSERVATION_LIMIT = 4; +let active = 0; +const waiting: (() => void)[] = []; +/** Concurrent reads of the same project share one subprocess and result. */ +const inFlight = new WeakMap< + object, + Map> +>(); + +async function withObservationSlot(run: () => Promise): Promise { + if (active >= NATIVE_OBSERVATION_LIMIT) { + await new Promise((resolve) => waiting.push(resolve)); + } else { + active++; + } + try { + return await run(); + } finally { + const next = waiting.shift(); + if (next) { + // Hand the slot over directly; `active` is unchanged. + next(); + } else { + active--; + } + } +} + +/** Optional live native reads. The default observe-only CLI never calls this boundary. */ +export function observeNativeProject( + opts: ObserveOptions +): Promise { + // Share only reads that would run the same reader for the same project. + const reader: object = + opts.view.inputDiagnostic?.code === "E_NATIVE_PROJECT_UNSUPPORTED" + ? (opts.authoredPs ?? nativeAuthoredProjectPs) + : (opts.ps ?? nativeProjectPs); + let shared = inFlight.get(reader); + if (!shared) { + shared = new Map(); + inFlight.set(reader, shared); + } + const key = JSON.stringify([ + opts.view.repoRoot, + opts.view.projectDir, + opts.view.inputDiagnostic?.code ?? null, + ]); + const existing = shared.get(key); + if (existing) { + return existing; + } + const pending = withObservationSlot(() => observeOnce(opts)).finally(() => + shared.delete(key) + ); + shared.set(key, pending); + return pending; +} + +async function observeOnce(opts: ObserveOptions): Promise { + const unknown: ProjectObservation = { + backend: "native", + state: "unknown", + state_reason: "observation-unavailable", + started_at: null, + services: [], + }; + const { view } = opts; + if (!(view.repoRoot && view.projectDir)) { + return unknown; + } + try { + const runtime = resolveNativeRuntimeSelection(); + if (!runtime) { + return unknown; + } + const scope = { + projectRoot: view.repoRoot, + projectDir: view.projectDir, + nativeHome: runtime.home, + branch: null, + }; + const result = + view.inputDiagnostic?.code === "E_NATIVE_PROJECT_UNSUPPORTED" + ? await (opts.authoredPs ?? nativeAuthoredProjectPs)({ + runtime, + scope, + includeReadiness: true, + }) + : await (opts.ps ?? nativeProjectPs)({ + runtime, + scope, + includeReadiness: true, + }); + if (result.status === "not_started" || result.status === "pending") { + return { + ...unknown, + state: result.status === "pending" ? "starting" : "stopped", + state_reason: null, + }; + } + if (result.status === "runtime_degraded") { + return { + ...unknown, + state: "degraded", + state_reason: "native-runtime-degraded", + }; + } + if (result.status !== "observed") { + return unknown; + } + const services = result.items.map((item) => + observeService({ + name: item.service, + available: true, + readiness: item.readiness ?? null, + members: [ + { + state: item.state, + health: item.health, + exitCode: item.exitCode, + startedAt: null, + }, + ], + }) + ); + return { + backend: "native", + state: observedProjectState(services), + state_reason: null, + started_at: earliestStart(services.map((service) => service.started_at)), + services, + }; + } catch { + // Status refusals are missing evidence, never raw provider diagnostics. + return unknown; + } +} diff --git a/src/lib/project-observation.ts b/src/lib/project-observation.ts index a558f9b1a..226190bac 100644 --- a/src/lib/project-observation.ts +++ b/src/lib/project-observation.ts @@ -1,25 +1,14 @@ +import { requiredObservedServices } from "./compose-service-activation.ts"; +import { + earliestStart, + observedProjectState, + observeService, + type ProjectState, + type ServiceObservation, +} from "./project-state.ts"; import type { ProjectView } from "./project-views.ts"; -import type { RuntimeContainer } from "./runtime-projects.ts"; - -/** Shared vocabulary for the desktop listing; unknown evidence is never a stopped claim. */ -export type ObservedProjectState = - | "running" - | "degraded" - | "starting" - | "stopping" - | "stopped" - | "failed" - | "unknown"; - -export type ObservedService = { - readonly name: string; - readonly state: ObservedProjectState; - readonly ready: boolean | null; - readonly completed: boolean | null; - readonly health: string | null; - readonly exit_code: number | null; - readonly started_at: string | null; -}; +export type ObservedProjectState = ProjectState; +export type ObservedService = ServiceObservation; export type ProjectObservation = { readonly backend: "compose" | "native" | "unknown"; @@ -27,15 +16,15 @@ export type ProjectObservation = { readonly state_reason: | "native-observer-required" | "observation-unavailable" + | "native-runtime-degraded" | null; readonly started_at: string | null; readonly services: readonly ObservedService[]; }; /** - * Read-only listing projection. Docker's listing does not carry authenticated - * exit codes, readiness roles or start times: retain null until the R2 owner - * supplies them. Native inputs need their own observer, never Docker inference. + * Shared listing projection. Fresh inspect data supplies state details; static + * cached metadata never supplies current health, exit codes or start time. */ export function projectObservation(input: { readonly view: ProjectView; @@ -66,68 +55,40 @@ export function projectObservation(input: { ...(view.definedServices ?? []), ...(observable ? (view.runtime?.services.keys() ?? []) : []), ]); - const services: ObservedService[] = [...names].sort().map((name) => ({ - name, - state: observable - ? serviceState(view.runtime?.services.get(name)?.containers ?? []) - : "unknown", - ready: null, - completed: null, - health: null, - exit_code: null, - started_at: null, - })); + const services: ObservedService[] = [...names].sort().map((name) => { + const containers = view.runtime?.services.get(name)?.containers ?? []; + return observeService({ + name, + available: observable, + readiness: view.serviceReadiness?.[name] ?? null, + members: containers.map( + (container) => + container.observation ?? { + state: container.state, + health: null, + exitCode: null, + startedAt: null, + } + ), + }); + }); + const required = requiredObservedServices({ + names: [...names], + observed: observable ? [...(view.runtime?.services.keys() ?? [])] : [], + activation: view.serviceActivation, + }); + const requiredServices = services.filter((service) => + required.has(service.name) + ); + const state = + requiredServices.length === 0 && services.length > 0 + ? "stopped" + : observedProjectState(requiredServices); return { backend, - state: observable ? projectState(services) : "unknown", + state: observable ? state : "unknown", state_reason: reason, - started_at: null, + started_at: earliestStart(services.map((service) => service.started_at)), services, }; } - -function serviceState( - containers: readonly RuntimeContainer[] -): ObservedProjectState { - if (containers.length === 0) { - return "stopped"; - } - if (containers.every((container) => container.state === "running")) { - return "running"; - } - if (containers.some((container) => container.state === "running")) { - return "degraded"; - } - if (containers.some((container) => container.state === "restarting")) { - return "starting"; - } - if (containers.every((container) => container.state === "created")) { - return "stopped"; - } - return "unknown"; -} - -function projectState( - services: readonly ObservedService[] -): ObservedProjectState { - if (services.length === 0) { - return "unknown"; - } - if (services.every((service) => service.state === "running")) { - return "running"; - } - if (services.some((service) => service.state === "unknown")) { - return "unknown"; - } - if ( - services.some( - (service) => service.state === "running" || service.state === "degraded" - ) - ) { - return "degraded"; - } - if (services.some((service) => service.state === "starting")) { - return "starting"; - } - return "stopped"; -} diff --git a/src/lib/project-state.ts b/src/lib/project-state.ts new file mode 100644 index 000000000..239a84c15 --- /dev/null +++ b/src/lib/project-state.ts @@ -0,0 +1,200 @@ +/** Shared public state vocabulary. Absence of trustworthy evidence is unknown. */ +export type ProjectState = + | "running" + | "degraded" + | "starting" + | "stopping" + | "stopped" + | "failed" + | "unknown"; + +export type ServiceObservation = { + readonly name: string; + readonly state: ProjectState; + readonly ready: boolean | null; + readonly completed: boolean | null; + readonly health: string | null; + readonly exit_code: number | null; + readonly started_at: string | null; +}; + +/** Current member evidence, separate from historical run/ready receipts. */ +export type ServiceMemberEvidence = { + readonly state: string; + readonly health: string | null; + readonly exitCode: number | null; + readonly startedAt: string | null; +}; + +export type ServiceReadiness = "started" | "healthy" | "completed"; + +function memberState(member: ServiceMemberEvidence): ProjectState { + if (member.state === "running") { + if (member.health === "unhealthy") { + return "degraded"; + } + return member.health === "starting" ? "starting" : "running"; + } + if (member.state === "restarting" || member.state === "starting") { + return "starting"; + } + if (member.state === "stopping") { + return "stopping"; + } + if (member.state === "dead" || member.state === "failed") { + return "failed"; + } + if (member.state === "exited") { + if (member.exitCode === null) { + return "unknown"; + } + return member.exitCode === 0 ? "stopped" : "failed"; + } + if (member.state === "stopped" || member.state === "absent") { + return "stopped"; + } + return member.state === "created" ? "stopped" : "unknown"; +} + +/** A completed job can satisfy readiness, but exit zero without its role cannot. */ +export function observeService(opts: { + readonly name: string; + readonly available: boolean; + readonly members: readonly ServiceMemberEvidence[]; + readonly readiness: ServiceReadiness | null; +}): ServiceObservation { + const unknown: ServiceObservation = { + name: opts.name, + state: "unknown", + ready: null, + completed: null, + health: null, + exit_code: null, + started_at: null, + }; + if (!opts.available) { + return unknown; + } + if (opts.members.length === 0) { + return { ...unknown, state: "stopped", ready: false, completed: false }; + } + const states = opts.members.map(memberState); + const mixedLive = + opts.members.some((member) => member.state === "running") && + opts.members.some((member) => + ["exited", "dead", "stopped", "absent"].includes(member.state) + ); + const completed = + opts.readiness === "completed" + ? allEvidence( + opts.members.map((member) => { + const state = memberState(member); + if (state === "unknown") { + return null; + } + if ( + state !== "stopped" || + (member.state !== "exited" && member.state !== "stopped") + ) { + return false; + } + return member.exitCode === null ? null : member.exitCode === 0; + }) + ) + : null; + let ready: boolean | null = null; + if (opts.readiness === "completed") { + ready = completed; + } else if (opts.readiness === "healthy") { + ready = allEvidence( + opts.members.map((member) => { + if (memberState(member) === "unknown") { + return null; + } + if (member.state !== "running") { + return false; + } + return member.health === null ? null : member.health === "healthy"; + }) + ); + } else if (opts.readiness === "started") { + ready = allEvidence( + states.map((state) => (state === "unknown" ? null : state === "running")) + ); + } + const health = sameValue(opts.members.map((member) => member.health)); + const exitCode = sameValue(opts.members.map((member) => member.exitCode)); + return { + name: opts.name, + state: mixedLive ? "degraded" : aggregateProjectState(states), + ready, + completed, + health, + exit_code: exitCode, + started_at: earliestStart(opts.members.map((member) => member.startedAt)), + }; +} + +/** Unknown prevents a complete claim; mixed live replicas are degraded. */ +export function aggregateProjectState( + states: readonly ProjectState[] +): ProjectState { + if (states.length === 0 || states.includes("unknown")) { + return "unknown"; + } + const first = states[0]; + if (first && states.every((state) => state === first)) { + return first; + } + if (states.includes("running") || states.includes("degraded")) { + return "degraded"; + } + if (states.includes("failed")) { + return "failed"; + } + if (states.includes("stopping")) { + return "stopping"; + } + if (states.includes("starting")) { + return "starting"; + } + return "unknown"; +} + +function allEvidence(values: readonly (boolean | null)[]): boolean | null { + if (values.includes(false)) { + return false; + } + return values.includes(null) ? null : true; +} + +function sameValue(values: readonly (T | null)[]): T | null { + const first = values[0] ?? null; + return values.every((value) => value === first) ? first : null; +} + +/** Missing timestamps remain missing; never substitute the receipt or observation time. */ +export function earliestStart( + values: readonly (string | null)[] +): string | null { + const known = values.filter( + (value): value is string => + value !== null && + Number.isFinite(Date.parse(value)) && + !value.startsWith("0001-") + ); + return ( + known.sort((left, right) => Date.parse(left) - Date.parse(right))[0] ?? null + ); +} + +/** Successful declared jobs do not degrade otherwise-ready long-running services. */ +export function observedProjectState( + services: readonly ServiceObservation[] +): ProjectState { + const active = services.filter((service) => service.completed !== true); + if (services.length > 0 && active.length === 0) { + return "stopped"; + } + return aggregateProjectState(active.map((service) => service.state)); +} diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index a4428bd57..ea012940c 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -9,6 +9,11 @@ import { } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { parseSessionBase } from "../mux/session-names.ts"; +import { + type ComposeServiceActivation, + composeServiceActivation, +} from "./compose-service-activation.ts"; +import { composeServiceReadiness } from "./compose-service-readiness.ts"; import { pathExists, readTextFile } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { @@ -23,6 +28,12 @@ import { ProjectInputSelectionError, } from "./project-input-selection.ts"; import { normalizeProjectName } from "./project-name.ts"; +import { observeNativeProject } from "./project-native-observation.ts"; +import { + type ProjectObservation, + projectObservation, +} from "./project-observation.ts"; +import type { ServiceReadiness } from "./project-state.ts"; import type { RegisteredProject, RegisteredProjectWorktree, @@ -35,6 +46,8 @@ import { } from "./runtime-projects.ts"; import { exec, findExecutableInPath } from "./shell.ts"; +const NATIVE_OBSERVATION_CONCURRENCY = 4; + export type BranchRuntime = { readonly branch: string; readonly runtime: RuntimeProject; @@ -83,6 +96,9 @@ export type ProjectLifecycleView = { }; export type ProjectView = { + readonly observation?: ProjectObservation; + readonly serviceActivation?: ComposeServiceActivation; + readonly serviceReadiness?: Readonly>; readonly projectId?: string; readonly name: string; readonly devHost: string | null; @@ -123,6 +139,7 @@ export type ProjectRuntimeStatus = | "not_configured"; type BuildProjectViewsOptions = { + readonly observeNative?: boolean; readonly registryProjects: readonly RegisteredProject[]; readonly runtime: readonly RuntimeProject[]; readonly runtimeOk: boolean; @@ -225,7 +242,34 @@ export async function buildProjectViews( } } - return out; + const nativeSelected = process.env.HACK_RUNTIME_BACKEND?.trim() === "native"; + const observed: ProjectView[] = []; + // Native status reads own subprocesses; keep registry size from becoming fan-out. + for ( + let offset = 0; + offset < out.length; + offset += NATIVE_OBSERVATION_CONCURRENCY + ) { + observed.push( + ...(await Promise.all( + out + .slice(offset, offset + NATIVE_OBSERVATION_CONCURRENCY) + .map(async (view) => { + const base = projectObservation({ + view, + runtimeOk: opts.runtimeOk, + nativeSelected, + }); + const observation = + opts.observeNative && base.backend === "native" + ? await observeNativeProject({ view }) + : base; + return { ...view, observation }; + }) + )) + ); + } + return observed; } function resolveRegisteredRuntime(opts: { @@ -270,9 +314,10 @@ async function buildRegisteredProjectView(opts: { PROJECT_COMPOSE_FILENAME ); const composeExists = projectDirOk && (await pathExists(composeFile)); - const definedServices = composeExists + const composeDefinition = composeExists ? await readComposeServices({ composeFile }) : null; + const definedServices = composeDefinition?.names ?? null; const serviceHosts = composeExists ? await readComposeServiceHosts({ composeFile }) : null; @@ -318,6 +363,8 @@ async function buildRegisteredProjectView(opts: { projectDir: opts.registration.projectDir, ownership: config && !config.parseError ? config.ownership : null, definedServices, + serviceReadiness: composeDefinition?.readiness, + serviceActivation: composeDefinition?.activation, extensionsEnabled: extensions?.enabled ?? null, features: extensions?.features ?? null, serviceHosts, @@ -406,6 +453,14 @@ export function serializeProjectSummary( [...runtime.services.values()].flatMap((service) => service.containers) ); return { + ...(view.observation ?? + projectObservation({ + view, + runtimeOk: + view.runtimeStatus !== "unknown" && + view.runtimeStatus !== "unavailable", + nativeSelected: false, + })), project_id: view.projectId ?? null, name: view.name, repo_root: view.repoRoot, @@ -434,6 +489,14 @@ export function serializeProjectView( view: ProjectView ): Record { return { + ...(view.observation ?? + projectObservation({ + view, + runtimeOk: + view.runtimeStatus !== "unknown" && + view.runtimeStatus !== "unavailable", + nativeSelected: false, + })), project_id: view.projectId ?? null, name: view.name, dev_host: view.devHost ?? null, @@ -826,7 +889,11 @@ function parseTmuxSessionFields( async function readComposeServices(opts: { readonly composeFile: string; -}): Promise { +}): Promise<{ + names: readonly string[]; + readiness: Readonly>; + activation: ComposeServiceActivation; +} | null> { const text = await readTextFile(opts.composeFile); if (!text) { return null; @@ -843,11 +910,13 @@ async function readComposeServices(opts: { } const servicesRaw = parsed.services; - if (!isRecord(servicesRaw)) { - return []; - } - - return Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)); + return { + names: isRecord(servicesRaw) + ? Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)) + : [], + readiness: composeServiceReadiness(parsed), + activation: composeServiceActivation(parsed), + }; } async function readComposeServiceHosts(opts: { diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index d85a30822..b5ea948b9 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -18,10 +18,14 @@ import { inspectProjectInputsAtRoot, ProjectInputSelectionError, } from "./project-input-selection.ts"; +import type { ServiceMemberEvidence } from "./project-state.ts"; import { upsertProjectRegistration } from "./projects-registry.ts"; +import { readContainerState } from "./runtime-service-state.ts"; import { exec, findExecutableInPath } from "./shell.ts"; export type RuntimeContainer = { + /** Fresh inspect evidence only; cached static metadata never proves current health/exit. */ + readonly observation?: ServiceMemberEvidence | null; readonly id: string; readonly project: string; readonly service: string; @@ -79,6 +83,7 @@ export type RuntimeProjectsResult = }; export type ContainerInspectData = { + readonly observation?: ServiceMemberEvidence | null; readonly labels: Record; readonly image: string | null; readonly mounts: readonly RuntimeContainerMount[]; @@ -211,6 +216,12 @@ export async function readRuntimeProjects(opts: { ? await readCachedContainerInspectData({ cache: opts.inspectCache, forceInspect: opts.forceInspect ?? true, + states: new Map( + baseRows.map((row) => [ + getString(row, "ID") ?? getString(row, "Id") ?? "", + getString(row, "State") ?? "", + ]) + ), ids, }) : await readContainerInspectData({ ids }) @@ -248,6 +259,8 @@ export async function readRuntimeProjects(opts: { } containers.push({ + observation: + inspect?.observation?.state === state ? inspect.observation : null, id, project, service, @@ -616,7 +629,7 @@ export async function readContainerLabels(opts: { // Request only fields used by the runtime model; Docker must not return env values or command arguments. const RUNTIME_INSPECT_FORMAT = - '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; + '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"State":{"Status":{{json .State.Status}},"ExitCode":{{json .State.ExitCode}},"StartedAt":{{json .State.StartedAt}},"Health":{{with index .State "Health"}}{"Status":{{json .Status}}}{{else}}null{{end}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; async function readContainerInspectData(opts: { readonly ids: readonly string[]; @@ -663,6 +676,7 @@ async function readContainerInspectData(opts: { const networks = parseInspectNetworks(item.NetworkSettings); const detail: ContainerInspectData = { + observation: readContainerState(item.State), labels, image, mounts, @@ -680,6 +694,7 @@ async function readContainerInspectData(opts: { async function readCachedContainerInspectData(opts: { readonly cache: RuntimeInspectCache; readonly forceInspect: boolean; + readonly states: ReadonlyMap; readonly ids: readonly string[]; }): Promise> { const currentIds = new Set(opts.ids); @@ -693,29 +708,37 @@ async function readCachedContainerInspectData(opts: { opts.cache.diagnostics.fullRefreshes += 1; } const missingIds = opts.ids.filter((id) => !opts.cache.entries.has(id)); - const idsToInspect = opts.forceInspect ? opts.ids : missingIds; + const idsToInspect = opts.forceInspect + ? opts.ids + : opts.ids.filter((id) => { + const previous = opts.cache.entries.get(id)?.observation; + const state = opts.states.get(id); + return !previous || state !== "running" || previous.state !== state; + }); opts.cache.diagnostics.cacheHits += opts.forceInspect ? 0 - : opts.ids.length - missingIds.length; + : opts.ids.length - idsToInspect.length; opts.cache.diagnostics.cacheMisses += missingIds.length; if (idsToInspect.length > 0) { opts.cache.diagnostics.inspectCalls += 1; opts.cache.diagnostics.inspectIds += idsToInspect.length; const inspected = await readContainerInspectData({ ids: idsToInspect }); - for (const id of idsToInspect) { - const detail = inspected.get(id); - if (detail) { - opts.cache.entries.set(id, detail); - } - } + reconcileInspectEvidence({ + cache: opts.cache, + inspected, + ids: idsToInspect, + }); } const out = new Map(); for (const id of opts.ids) { const detail = opts.cache.entries.get(id); if (detail) { - out.set(id, detail); + out.set( + id, + idsToInspect.includes(id) ? detail : { ...detail, observation: null } + ); } } return out; @@ -846,3 +869,20 @@ function formatDockerError(opts: { } return `docker ps failed (exit ${opts.exitCode})`; } + +/** Failed reinspection can retain static labels, never a previous health result. */ +function reconcileInspectEvidence(opts: { + readonly cache: RuntimeInspectCache; + readonly inspected: ReadonlyMap; + readonly ids: readonly string[]; +}): void { + for (const id of opts.ids) { + const detail = opts.inspected.get(id); + const previous = opts.cache.entries.get(id); + if (detail) { + opts.cache.entries.set(id, detail); + } else if (previous) { + opts.cache.entries.set(id, { ...previous, observation: null }); + } + } +} diff --git a/src/lib/runtime-service-state.ts b/src/lib/runtime-service-state.ts new file mode 100644 index 000000000..59e45c516 --- /dev/null +++ b/src/lib/runtime-service-state.ts @@ -0,0 +1,35 @@ +import { getString, isRecord } from "./guards.ts"; +import type { ServiceMemberEvidence } from "./project-state.ts"; + +/** Docker's inspect State is current evidence; never parse prose from ps Status. */ +export function readContainerState( + value: unknown +): ServiceMemberEvidence | null { + if (!isRecord(value)) { + return null; + } + const state = getString(value, "Status") ?? null; + if (state === null) { + return null; + } + const health = isRecord(value.Health) + ? (getString(value.Health, "Status") ?? null) + : null; + const exited = state === "exited" || state === "dead"; + const code = value.ExitCode; + const started = getString(value, "StartedAt") ?? null; + return { + state, + health, + exitCode: + exited && typeof code === "number" && Number.isSafeInteger(code) + ? code + : null, + startedAt: + started !== null && + !started.startsWith("0001-") && + Number.isFinite(Date.parse(started)) + ? started + : null, + }; +} diff --git a/tests/activity-cli.test.ts b/tests/activity-cli.test.ts index 68a6a9a4b..3d99ac034 100644 --- a/tests/activity-cli.test.ts +++ b/tests/activity-cli.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; /** @@ -129,13 +130,9 @@ type Entry = Record; async function activity(args: readonly string[] = []): Promise { const result = await runCli({ args: ["activity", "--json", ...args] }); expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as { - ok: boolean; - data: { version: number; entries: Entry[] }; - }; - expect(envelope.ok).toBe(true); - expect(envelope.data.version).toBe(1); - return envelope.data.entries; + const data = machineData(result.stdout, "hack.activity.v1"); + expect(data.version).toBe(1); + return data.entries; } test("up, a failed up, restart and down are recorded with names only", async () => { diff --git a/tests/admin-plan-json-contract.test.ts b/tests/admin-plan-json-contract.test.ts new file mode 100644 index 000000000..e3a497fef --- /dev/null +++ b/tests/admin-plan-json-contract.test.ts @@ -0,0 +1,114 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdminPlan } from "../src/commands/global-admin-plan.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +let plan: AdminPlan; +let root: string; +let env: Record; +const planner = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/commands/global-admin-plan.ts", + overrides: { + planGlobalInstall: async () => plan, + planGlobalTrust: async () => plan, + }, +}); +beforeAll(() => planner.activate()); +afterAll(() => planner.deactivate()); +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plan-json-")); + const keys = [ + "HOME", + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_CANDIDATE_ENTRYPOINT", + "HACK_LOGGER", + "HACK_DAEMON_AUTOSTART_DISABLE", + ]; + env = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) { + Reflect.deleteProperty(process.env, key); + } + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +for (const command of ["install", "trust"] as const) { + test.each([ + "complete", + "refused", + "incomplete", + ] as const)(`${command} plan %s follows its JSON result contract`, async (outcome) => { + plan = { + schema: MACHINE_SCHEMAS.globalAdminPlan, + command, + platform: "linux", + complete: outcome === "complete", + refused: outcome === "refused" ? "Host selection refused" : null, + blocked: + outcome === "incomplete" + ? [ + { + phase: "host", + reason: "Unknown", + prerequisite: "Re-check host state", + }, + ] + : [], + steps: [], + }; + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("plan contract executed a child"); + }); + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", command, "--plan", "--json"]); + expect(code).toBe( + outcome === "complete" ? 0 : outcome === "refused" ? 1 : 3 + ); + if (outcome === "complete") { + expect(machineData(stdout, MACHINE_SCHEMAS.globalAdminPlan)).toEqual( + plan + ); + } else { + expect( + machineError( + stdout, + outcome === "refused" ? "E_PLAN_REFUSED" : "E_PLAN_INCOMPLETE" + ).detail.plan + ).toEqual(plan); + } + expect(spawn).not.toHaveBeenCalled(); + }); +} diff --git a/tests/compose-service-activation.test.ts b/tests/compose-service-activation.test.ts new file mode 100644 index 000000000..96ca2e4f6 --- /dev/null +++ b/tests/compose-service-activation.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { + composeServiceActivation, + requiredObservedServices, +} from "../src/lib/compose-service-activation.ts"; + +test("inactive profiles stay optional while observed and transitively required services participate", () => { + const activation = composeServiceActivation({ + services: { + api: { depends_on: ["helper"] }, + helper: { + profiles: ["tools"], + depends_on: { init: {}, extra: { required: false } }, + }, + init: { profiles: ["tools"], depends_on: ["helper"] }, + extra: { profiles: ["tools"] }, + docs: { profiles: ["docs"] }, + }, + }); + const names = Object.keys(activation.profiles); + expect( + [...requiredObservedServices({ names, observed: [], activation })].sort() + ).toEqual(["api", "helper", "init"]); + expect( + [ + ...requiredObservedServices({ names, observed: ["docs"], activation }), + ].sort() + ).toEqual(["api", "docs", "helper", "init"]); +}); diff --git a/tests/compose-service-readiness.test.ts b/tests/compose-service-readiness.test.ts new file mode 100644 index 000000000..3446f3237 --- /dev/null +++ b/tests/compose-service-readiness.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { composeServiceReadiness } from "../src/lib/compose-service-readiness.ts"; +import { observeService } from "../src/lib/project-state.ts"; + +test("completion is declared by a dependency, never inferred from a stopped service", () => { + expect( + composeServiceReadiness({ + services: { + api: { + depends_on: { + migrate: { condition: "service_completed_successfully" }, + db: { condition: "service_healthy" }, + }, + }, + migrate: {}, + db: {}, + idle: {}, + checked: { healthcheck: { test: ["CMD", "true"] } }, + disabled: { healthcheck: { disable: true } }, + }, + }) + ).toEqual({ + api: "started", + migrate: "completed", + db: "healthy", + idle: "started", + checked: "healthy", + disabled: "started", + }); + expect(composeServiceReadiness(null)).toEqual({}); +}); + +test("NONE disables the health requirement while explicit dependency roles still apply", () => { + const roles = composeServiceReadiness({ + services: { + plain: { healthcheck: { test: ["NONE"] } }, + dependency: { healthcheck: { test: ["NONE"] } }, + consumer: { + depends_on: { dependency: { condition: "service_healthy" } }, + }, + }, + }); + expect(roles.plain).toBe("started"); + expect(roles.dependency).toBe("healthy"); + expect( + observeService({ + name: "plain", + available: true, + readiness: roles.plain ?? null, + members: [ + { state: "running", health: null, exitCode: null, startedAt: null }, + ], + }).ready + ).toBe(true); +}); diff --git a/tests/e2e/scenarios/cache-prune.ts b/tests/e2e/scenarios/cache-prune.ts index 290dda491..cd784b60f 100644 --- a/tests/e2e/scenarios/cache-prune.ts +++ b/tests/e2e/scenarios/cache-prune.ts @@ -167,6 +167,10 @@ function renderCacheCompose(opts: { readonly name: string }): string { " web:", " image: oven/bun:1", " command: bun -e 'setInterval(() => {}, 60000)'", + // An absent State.Health must not make the runtime inspect template lose + // mount/ownership evidence required by the cache-pruning assertions. + " healthcheck:", + " disable: true", " volumes:", ` - ${DISPOSABLE_VOLUME}:/app/.turbo`, ` - ${DURABLE_VOLUME}:/app/data`, diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index cefc00a39..0c900a6d3 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -26,6 +26,7 @@ import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { CURRENT_CA_PEM, OLD_CA_PEM } from "./helpers/ca-certificates.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; import { asExecStdout, tarArchive } from "./helpers/tar.ts"; @@ -440,7 +441,10 @@ test("global install plan root argv equals the command's fake-root execution, in value: true, }); } - const planned = JSON.parse(chunks.join("")) as AdminPlan; + const planned = machineData( + chunks.join(""), + "hack.global-admin-plan.v1" + ) as AdminPlan; expect(planCode).toBe(0); expect(planned.complete).toBe(true); expect(runCalls).toEqual([]); @@ -475,7 +479,8 @@ test("global install plan keeps JSON stdout pure when helper ownership refuses r } finally { stdout.mockRestore(); } - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ command: "install", steps: expect.arrayContaining([ diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index 89f32e555..b0a5945b6 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -10,7 +10,6 @@ import { import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; - import { DEFAULT_CADDY_IP, DEFAULT_COREDNS_IP, @@ -28,6 +27,7 @@ import { resolveGlobalConfigPath } from "../src/lib/config-paths.ts"; import * as interactivity from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let tempDir: string | null = null; @@ -187,6 +187,11 @@ test.each([ "install", "trust", ])("global %s --json without --plan refuses before any effect", async (command) => { + const chunks: string[] = []; + const output = spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); const confirm = spyOn(interactivity, "confirmSafe").mockImplementation(() => { throw new Error("No confirmation expected"); }); @@ -197,8 +202,10 @@ test.each([ expect(runCalls).toEqual([]); expect(linuxDnsPrivileged).toEqual([]); expect(confirm).not.toHaveBeenCalled(); + machineError(chunks.join(""), "E_USAGE"); } finally { confirm.mockRestore(); + output.mockRestore(); } }); @@ -221,7 +228,9 @@ test.skipIf(process.platform !== "linux")( } finally { stdout.mockRestore(); } - expect(JSON.parse(chunks.join(""))).toMatchObject({ + expect( + machineData(chunks.join(""), "hack.global-admin-plan.v1") + ).toMatchObject({ schema: "hack.global-admin-plan.v1", command: "install", complete: true, @@ -962,7 +971,8 @@ test.skipIf(process.platform !== "linux")( } expect(dockerStarted).toBe(false); expect(runCalls).toEqual([]); - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ steps: expect.arrayContaining([ expect.objectContaining({ diff --git a/tests/global-status-observe.test.ts b/tests/global-status-observe.test.ts index f59b73cc8..81e83db74 100644 --- a/tests/global-status-observe.test.ts +++ b/tests/global-status-observe.test.ts @@ -32,6 +32,7 @@ import type { LinuxDnsInspection } from "../src/lib/linux-dns-setup.ts"; import * as shell from "../src/lib/shell.ts"; import { isolatedDockerClientEnv } from "./helpers/docker-client.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData } from "./helpers/machine-output.ts"; // `hack global status --json --observe` (HACK-2218, R13): never starts // anything, and never reports what it could not read as absent or failed. @@ -1137,7 +1138,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly summary: Record; }; @@ -1173,7 +1177,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly networks: { readonly ok: unknown }; }; @@ -1201,7 +1208,10 @@ test( const result = await runObserve(base); expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly host_dns: { readonly ok: unknown }; readonly summary: { readonly host_dns_ok: unknown }; }; @@ -1211,7 +1221,10 @@ test( // state. await writeFile(join(hackHome, "hack.config.json"), "{}\n"); const control = await runObserve(base); - const known = JSON.parse(control.stdout) as { + const known = machineData( + control.stdout, + "hack.global-status.observe.v1" + ) as { readonly summary: { readonly host_dns_ok: unknown }; }; expect(known.summary.host_dns_ok).not.toBe("unknown"); diff --git a/tests/helpers/machine-output.ts b/tests/helpers/machine-output.ts new file mode 100644 index 000000000..940e8909e --- /dev/null +++ b/tests/helpers/machine-output.ts @@ -0,0 +1,19 @@ +import { expect } from "bun:test"; + +/** Assert the public boundary before tests inspect command-specific payload fields. */ +export function machineData(text: string, schema: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data.schema).toBe(schema); + return result.data; +} + +export function machineError(text: string, code: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["error", "ok"]); + expect(result.ok).toBe(false); + expect(result.error.code).toBe(code); + expect(result.error.message).toBeString(); + return result.error; +} diff --git a/tests/machine-output-contract.test.ts b/tests/machine-output-contract.test.ts new file mode 100644 index 000000000..f45039edd --- /dev/null +++ b/tests/machine-output-contract.test.ts @@ -0,0 +1,154 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { machineOutputFixtures } from "../scripts/lib/machine-output-fixtures.ts"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { + jsonCommandPaths, + renderJsonInventory, +} from "../src/cli/json-reference.ts"; +import { runCli } from "../src/cli/run.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const runtime = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/runtime-projects.ts", + overrides: { readRuntimeProjects: async () => ({ ok: true, runtime: [] }) }, +}); +beforeAll(() => runtime.activate()); +afterAll(() => runtime.deactivate()); + +let root: string; +let original: Record; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-json-contract-")); + original = Object.fromEntries( + ["HOME", "HACK_HOME", "HACK_LOGGER", "HACK_DAEMON_AUTOSTART_DISABLE"].map( + (key) => [key, process.env[key]] + ) + ); + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +test("every canonical JSON command has an explicit inventory shape decision", () => { + expect(Object.keys(JSON_COMMAND_SHAPES).sort()).toEqual([ + ...jsonCommandPaths(CLI_SPEC), + ]); + for (const variant of SCHEMA_JSON_VARIANTS) { + expect(JSON_COMMAND_SHAPES).toHaveProperty(variant.command); + } +}); + +test.each([ + { flags: [] }, + { flags: ["--dry-run"] }, +])("projects prune %j matches its envelope inventory", async ({ flags }) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const code = await runCli(["projects", "prune", "--json", ...flags]); + expect(code).toBe(0); + const result = JSON.parse(stdout); + expect(JSON_COMMAND_SHAPES["projects prune"]).toBe("envelope"); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); +}); + +test("machine-readable reference table is generated from the command inventory", async () => { + const doc = await Bun.file( + join(import.meta.dir, "..", "docs/reference/machine-readable-output.md") + ).text(); + const table = doc + .split("\n")[1] + ?.split("")[0]; + expect(table).toBe(renderJsonInventory(CLI_SPEC)); +}); + +test.each( + Object.keys(JSON_COMMAND_SHAPES) +)("%s keeps its pre-handler JSON usage-error contract", async (command) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("contract probe entered a runtime handler"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("contract probe entered a synchronous handler"); + }); + const fetch = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + () => { + throw new Error("contract probe entered a network handler"); + }, + { + preconnect: () => { + throw new Error("unexpected preconnect"); + }, + } + ) + ); + const code = await runCli([ + ...command.split(" "), + "--json", + "--__contract_refuse", + ]); + expect(code).not.toBe(0); + machineError(stdout, "E_USAGE"); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("desktop boundary fixtures and schema inventory agree with CLI producers", async () => { + expect( + [...new Set(SCHEMA_JSON_VARIANTS.map((variant) => variant.schema))].sort() + ).toEqual(Object.values(MACHINE_SCHEMAS).sort()); + for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + const text = await Bun.file( + join( + import.meta.dir, + "..", + "apps/desktop/core/tests/fixtures/machine-output", + name + ) + ).text(); + expect(text).toBe(`${JSON.stringify(fixture, null, 2)}\n`); + } +}); diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts new file mode 100644 index 000000000..4c6b84cb0 --- /dev/null +++ b/tests/machine-output-success-shapes.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * Real success output of every listed command that can succeed hermetically + * (temp HOME, empty working directory, no daemon, engine or network). Each case + * checks the shape its inventory row declares, so changing either the output + * or the row without the other fails. Legacy objects pin their top-level keys: + * adding, removing or wrapping one is a contract change that must be deliberate. + * Commands needing a runtime, Docker engine, project or macOS (such as + * `usage`, `doctor` and admin plans) keep their command-specific + * suites; the usage-error boundary for all of them is in + * machine-output-contract.test.ts. + */ +const entrypoint = resolve(import.meta.dir, "..", "index.ts"); + +type Case = { + readonly command: string; + readonly flags?: readonly string[]; + readonly keys?: readonly string[]; +}; + +const CASES: readonly Case[] = [ + { command: "version" }, + { command: "activity" }, + { command: "projects", flags: ["--observe"] }, + { command: "global status", flags: ["--observe"] }, + { command: "projects prune" }, + { command: "upgrade check" }, + { + command: "projects", + keys: [ + "filter", + "generated_at", + "include_global", + "include_meta", + "include_unregistered", + "projects", + "runtime_checked_at", + "runtime_error", + "runtime_last_ok_at", + "runtime_last_repair_at", + "runtime_next_step", + "runtime_ok", + "runtime_repair_action", + "runtime_repair_outcome", + "runtime_reset_at", + "runtime_reset_changes", + "runtime_reset_count", + "runtime_reset_summary", + ], + }, + { + command: "host ps", + keys: ["commands", "generatedAt", "lifecycle", "snapshotAvailable"], + }, + { command: "node list", keys: ["defaultNodeId", "nodes"] }, + { + command: "env backend status", + keys: [ + "allow_env_auth_refs", + "backend", + "cloud", + "encrypted_file", + "encrypted_file_key_env", + "status", + "store_plaintext_in_backend", + ], + }, +]; + +function schemaFor(item: Case): string | undefined { + const flags = item.flags ?? []; + return SCHEMA_JSON_VARIANTS.find( + (variant) => + variant.command === item.command && + variant.flags.join(" ") === flags.join(" ") + )?.schema; +} + +/** + * The inventory row decides a plain command's shape. Only a flag variant + * (`projects --observe`) may differ from its base row, and then only through + * its own SCHEMA_JSON_VARIANTS entry. + */ +function declaredShape(item: Case): string | undefined { + if ((item.flags ?? []).length > 0) { + return schemaFor(item) ? "schema-envelope" : undefined; + } + return JSON_COMMAND_SHAPES[item.command as keyof typeof JSON_COMMAND_SHAPES]; +} + +async function runJson(item: Case): Promise<{ code: number; json: unknown }> { + const root = await mkdtemp(join(tmpdir(), "hack-json-shape-")); + try { + const cwd = join(root, "work"); + await mkdir(cwd); + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + ...item.command.split(" "), + ...(item.flags ?? []), + "--json", + ], + { + cwd, + env: privateCliEnv({ + root, + env: { HACK_HOME: join(root, "state"), HACK_LOGGER: "console" }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, stderr).toBe(0); + return { code, json: JSON.parse(stdout) }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test.each( + CASES.map((item) => [ + [item.command, ...(item.flags ?? []), "--json"].join(" "), + item, + ]) +)("%s keeps its inventory success shape", async (_label, item) => { + const shape = declaredShape(item); + const { json } = await runJson(item); + expect(json).toBeObject(); + const result = json as Record; + const keys = Object.keys(result).sort(); + if (shape === "schema-envelope" || shape === "envelope") { + expect(item.keys).toBeUndefined(); + expect(keys).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); + const data = result.data as Record; + if (shape === "schema-envelope") { + expect(data.schema).toBe(schemaFor(item)); + } + return; + } + expect(shape).toBe("legacy-object"); + expect(item.keys).toBeDefined(); + expect(keys).toEqual([...(item.keys ?? [])].sort()); +}); diff --git a/tests/native-plan-home.test.ts b/tests/native-plan-home.test.ts index 4d4bfd8b7..172833828 100644 --- a/tests/native-plan-home.test.ts +++ b/tests/native-plan-home.test.ts @@ -14,7 +14,17 @@ afterEach(async () => { await rm(home, { recursive: true, force: true }); }); -const failedUp = { action: "up", outcome: "failed", project: null }; +const failedUp = { + v: 1, + ts: "2026-01-01T00:00:00Z", + project: null, + branch: null, + action: "up", + origin: "cli", + outcome: "failed", + failed_services: [], + duration_ms: 1, +}; async function writeActivity(records: readonly unknown[]): Promise { await mkdir(join(home, ".hack"), { recursive: true }); @@ -66,6 +76,7 @@ for (const records of [ [{ ...failedUp, outcome: "ok" }], [{ ...failedUp, action: "down" }], [{ ...failedUp, project: "registered" }], + [{ ...failedUp, extra: "unexpected" }], [null], ]) { test(`rejects missing, duplicate or unrelated activity: ${JSON.stringify(records)}`, async () => { @@ -84,6 +95,11 @@ test("rejects a torn activity record", async () => { ); }); +test("rejects private paths in known activity fields", async () => { + await writeActivity([{ ...failedUp, branch: home }]); + await expect(assertNativePlanHome({ home, failedUps: 1 })).rejects.toThrow(); +}); + test("rejects private fixture inputs in otherwise valid activity", async () => { const privateInput = "synthetic-private-plan-home-value"; await writeActivity([{ ...failedUp, unexpected: privateInput }]); diff --git a/tests/project-native-observation.test.ts b/tests/project-native-observation.test.ts new file mode 100644 index 000000000..ed7f745e0 --- /dev/null +++ b/tests/project-native-observation.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import type { NativeProjectPsResult } from "../src/backends/native-project-observe.ts"; +import { + NATIVE_OBSERVATION_LIMIT, + observeNativeProject, +} from "../src/lib/project-native-observation.ts"; +import type { ProjectView } from "../src/lib/project-views.ts"; + +let original: Record; +beforeEach(() => { + original = { + HACK_RUNTIME_BACKEND: process.env.HACK_RUNTIME_BACKEND, + HACK_NATIVE_BINARY: process.env.HACK_NATIVE_BINARY, + HACK_NATIVE_HOME: process.env.HACK_NATIVE_HOME, + }; + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = "/example/native"; + process.env.HACK_NATIVE_HOME = "/example/native-home"; +}); +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); +const view: ProjectView = { + name: "sample", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + devHost: null, + ownership: null, + definedServices: ["api", "task"], + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: "unknown", + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + worktrees: null, + kind: "registered", + status: "unknown", +}; + +test("native projection requires fresh current state and the retained readiness role", async () => { + let calls = 0; + const report = await observeNativeProject({ + view, + ps: async (opts) => { + calls++; + expect(opts.includeReadiness).toBe(true); + expect(opts.scope.projectRoot).toBe("/example/project"); + return { + backend: "native", + status: "observed", + run: "run", + phase: "ready-observed", + items: [ + { + service: "api", + container: "member", + phase: "ready", + state: "running", + health: "healthy", + exitCode: null, + readiness: "healthy", + }, + { + service: "task", + container: "task", + phase: "ready", + state: "exited", + health: null, + exitCode: 0, + readiness: "completed", + }, + ], + }; + }, + }); + expect(calls).toBe(1); + expect(report.backend).toBe("native"); + expect(report.state).toBe("running"); + expect(report.services[1]?.completed).toBe(true); + expect(report.started_at).toBeNull(); +}); + +test.each([ + "runtime_not_running", + "runtime_busy", + "owner_unconfirmed", +] as const)("native %s is unknown, never stopped or a stale ready claim", async (status) => { + const report = await observeNativeProject({ + view, + ps: async () => ({ + backend: "native", + status, + run: "run", + phase: "ready-observed", + items: [], + }), + }); + expect(report.state).toBe("unknown"); + expect(report.state_reason).toBe("observation-unavailable"); +}); + +test.each([ + "pending", + "not_started", +] as const)("native %s comes from the owned run observer", async (status) => { + const result: NativeProjectPsResult = { + backend: "native", + status, + run: null, + phase: null, + items: [], + }; + const report = await observeNativeProject({ view, ps: async () => result }); + expect(report.state).toBe(status === "pending" ? "starting" : "stopped"); +}); + +test("native refusal omits raw provider diagnostics", async () => { + const report = await observeNativeProject({ + view, + ps: async () => { + throw new Error("private diagnostic placeholder"); + }, + }); + expect(report.state).toBe("unknown"); + expect(JSON.stringify(report)).not.toContain("diagnostic placeholder"); +}); + +test("verified native runtime degradation is distinct from unavailable observation", async () => { + const report = await observeNativeProject({ + view, + ps: async () => ({ + backend: "native", + status: "runtime_degraded", + run: "run", + phase: "ready-observed", + items: [], + }), + }); + expect(report.state).toBe("degraded"); + expect(report.state_reason).toBe("native-runtime-degraded"); +}); + +const notStarted = (): NativeProjectPsResult => + ({ backend: "native", status: "not_started" }) as NativeProjectPsResult; + +test("native reads are bounded process-wide across concurrent callers", async () => { + let running = 0; + let peak = 0; + const ps = async () => { + running++; + peak = Math.max(peak, running); + await Bun.sleep(5); + running--; + return notStarted(); + }; + // Separate "requests", each with its own projects, all at once. + const reports = await Promise.all( + Array.from({ length: 3 * NATIVE_OBSERVATION_LIMIT + 1 }, (_, index) => + observeNativeProject({ + view: { + ...view, + repoRoot: `/example/project-${index}`, + projectDir: `/example/project-${index}/.hack`, + }, + ps, + }) + ) + ); + expect(peak).toBe(NATIVE_OBSERVATION_LIMIT); + expect(reports.every((report) => report.state === "stopped")).toBe(true); +}); + +test("concurrent reads of one project share a single native read", async () => { + let calls = 0; + const ps = async () => { + calls++; + await Bun.sleep(5); + return notStarted(); + }; + const reports = await Promise.all( + [1, 2, 3].map(() => observeNativeProject({ view, ps })) + ); + expect(calls).toBe(1); + expect(new Set(reports).size).toBe(1); + // Settled reads are not cached: the next caller reads fresh state. + await observeNativeProject({ view, ps }); + expect(calls).toBe(2); +}); diff --git a/tests/project-open-loopback.test.ts b/tests/project-open-loopback.test.ts index cbe1469d9..ae222cf21 100644 --- a/tests/project-open-loopback.test.ts +++ b/tests/project-open-loopback.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn, test } from "bun:test"; +import { JSON_COMMAND_SHAPES } from "../src/cli/json-contracts.ts"; import { emitOpenOrigin } from "../src/commands/project.ts"; test("open refuses a confirmed loopback failure before JSON or browser output", async () => { @@ -25,6 +26,7 @@ test("open refuses a confirmed loopback failure before JSON or browser output", }); test("native loopback origin bypasses Compose DNS, Docker and CA probes even if a proxy failure would be reported", async () => { + expect(JSON_COMMAND_SHAPES.open).toBe("legacy-object"); const write = spyOn(process.stdout, "write").mockImplementation(() => true); let checks = 0; try { diff --git a/tests/project-state.test.ts b/tests/project-state.test.ts new file mode 100644 index 000000000..dbd0b92c5 --- /dev/null +++ b/tests/project-state.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { + aggregateProjectState, + earliestStart, + observeService, + type ServiceMemberEvidence, +} from "../src/lib/project-state.ts"; + +const running: ServiceMemberEvidence = { + state: "running", + health: null, + exitCode: null, + startedAt: null, +}; +const exited = { ...running, state: "exited", exitCode: 0 }; + +test("an ordinary exit zero does not invent a completion role or readiness", () => { + const service = observeService({ + name: "task", + available: true, + members: [exited], + readiness: null, + }); + expect(service.state).toBe("stopped"); + expect(service.ready).toBeNull(); + expect(service.completed).toBeNull(); + const job = observeService({ + name: "task", + available: true, + members: [exited], + readiness: "completed", + }); + expect(job.completed).toBe(true); + expect(job.ready).toBe(true); +}); + +test("unavailable evidence cannot turn stale running or exited members into current state", () => { + const service = observeService({ + name: "api", + available: false, + members: [running, exited], + readiness: "completed", + }); + expect(service.state).toBe("unknown"); + expect(service.ready).toBeNull(); + expect(service.completed).toBeNull(); + expect(service.exit_code).toBeNull(); +}); + +test("readiness follows current health rather than a historical ready phase", () => { + const healthy = observeService({ + name: "api", + available: true, + members: [{ ...running, health: "healthy" }], + readiness: "healthy", + }); + expect(healthy.ready).toBe(true); + const unhealthy = observeService({ + name: "api", + available: true, + members: [{ ...running, health: "unhealthy" }], + readiness: "healthy", + }); + expect(unhealthy.ready).toBe(false); + expect(unhealthy.state).toBe("degraded"); +}); + +test("failure and partial replica sets never become all running or complete", () => { + const failed = observeService({ + name: "task", + available: true, + members: [{ ...exited, exitCode: 2 }], + readiness: "completed", + }); + expect(failed.state).toBe("failed"); + expect(failed.completed).toBe(false); + expect( + observeService({ + name: "api", + available: true, + members: [running, exited], + readiness: "started", + }).state + ).toBe("degraded"); + expect(aggregateProjectState(["running", "unknown"])).toBe("unknown"); + expect(aggregateProjectState([])).toBe("unknown"); +}); + +test("start time uses only valid observed timestamps, never a zero date", () => { + expect(earliestStart([null, "0001-01-01T00:00:00Z", "invalid"])).toBeNull(); + expect(earliestStart(["2026-01-02T00:00:00Z", "2026-01-01T00:00:00Z"])).toBe( + "2026-01-01T00:00:00Z" + ); +}); + +test("missing exit or health evidence remains unknown even with a declared role", () => { + expect( + observeService({ + name: "task", + available: true, + members: [{ ...exited, exitCode: null }], + readiness: "completed", + }).completed + ).toBeNull(); + expect( + observeService({ + name: "api", + available: true, + members: [running], + readiness: "healthy", + }).ready + ).toBeNull(); +}); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index eaec104eb..4cd1443ee 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -32,6 +32,39 @@ let tempDir: string | null = null; let originalGlobalConfigPath: string | undefined; let blockedReadRoot: string | null = null; const blockedReadPaths: string[] = []; +let nativeReadCount = 0; +let nativeActive = 0; +let nativePeak = 0; +const nativeObservationMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/project-native-observation.ts", + overrides: { + observeNativeProject: async () => { + nativeReadCount++; + nativeActive++; + nativePeak = Math.max(nativePeak, nativeActive); + await new Promise((resolve) => setTimeout(resolve, 5)); + nativeActive--; + return { + backend: "native", + state: "running", + state_reason: null, + started_at: null, + services: [ + { + name: "api", + state: "running", + ready: true, + completed: null, + health: "healthy", + exit_code: null, + started_at: null, + }, + ], + }; + }, + }, +}); const projectReadMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -53,13 +86,18 @@ const projectReadMock = await registerScopedModuleMock({ beforeAll(() => { projectReadMock.activate(); + nativeObservationMock.activate(); }); afterAll(() => { projectReadMock.deactivate(); + nativeObservationMock.deactivate(); }); beforeEach(async () => { + nativeReadCount = 0; + nativeActive = 0; + nativePeak = 0; blockedReadRoot = null; blockedReadPaths.length = 0; tempDir = await mkdtemp(join(tmpdir(), "hack-views-")); @@ -765,3 +803,209 @@ test("project views accept an unambiguous legacy alias without rewriting runtime }) ).rejects.toThrow("Ambiguous project name"); }); + +test("full and summary rows share Compose state, readiness, completion and timestamps", async () => { + const registration = await createProject({ + name: "state-fixture", + services: ["api", "task"], + }); + await writeFile( + join(registration.projectDir, PROJECT_COMPOSE_FILENAME), + "services:\n api:\n depends_on:\n task: {condition: service_completed_successfully}\n task: {}\n" + ); + const api = { + ...makeContainer({ + project: registration.name, + service: "api", + name: "api", + state: "running", + }), + observation: { + state: "running", + health: null, + exitCode: null, + startedAt: "2026-01-01T00:00:00Z", + }, + }; + const task = { + ...makeContainer({ + project: registration.name, + service: "task", + name: "task", + state: "exited", + }), + observation: { + state: "exited", + health: null, + exitCode: 0, + startedAt: "2026-01-01T00:00:01Z", + }, + }; + const views = await buildProjectViews({ + registryProjects: [registration], + runtime: [ + makeRuntimeProject({ + name: registration.name, + workingDir: registration.projectDir, + containersByService: { api: [api], task: [task] }, + }), + ], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + const view = views[0]; + if (!view) { + throw new Error("missing fixture view"); + } + const full = serializeProjectView(view); + const summary = serializeProjectSummary(view); + for (const key of ["backend", "state", "services", "started_at"]) { + expect(summary[key]).toEqual(full[key]); + } + expect(full.backend).toBe("compose"); + expect(full.state).toBe("running"); + expect(full.started_at).toBe("2026-01-01T00:00:00Z"); + expect(full.services).toMatchObject([ + { name: "api", ready: true }, + { name: "task", completed: true, exit_code: 0 }, + ]); +}); + +test("registered authored native rows use the observer only with explicit live-read admission", async () => { + const registration = await createProject({ + name: "native-observed", + services: ["api"], + }); + await rm(join(registration.projectDir, PROJECT_COMPOSE_FILENAME)); + await writeFile(join(registration.projectDir, "hack.project.json"), "{}\n"); + blockedReadRoot = registration.repoRoot; + const options = { + registryProjects: [registration], + runtime: [], + runtimeOk: false, + filter: null, + includeUnregistered: false, + muxSessions: [], + }; + const passive = await buildProjectViews(options); + expect(nativeReadCount).toBe(0); + expect(passive[0]?.observation?.state).toBe("unknown"); + const live = await buildProjectViews({ ...options, observeNative: true }); + expect(nativeReadCount).toBe(1); + const row = live[0]; + if (!row) { + throw new Error("missing native row"); + } + expect(serializeProjectView(row)).toMatchObject({ + backend: "native", + state: "running", + }); + expect(serializeProjectSummary(row).services).toEqual( + serializeProjectView(row).services + ); + expect(blockedReadPaths).toEqual([]); +}); + +test("native listing bounds status subprocess fan-out regardless of registry size", async () => { + const registrations: RegisteredProject[] = []; + for (let index = 0; index < 9; index++) { + const registration = await createProject({ + name: `native-${index}`, + services: [], + }); + await rm(join(registration.projectDir, PROJECT_COMPOSE_FILENAME)); + await writeFile(join(registration.projectDir, "hack.project.json"), "{}\n"); + registrations.push(registration); + } + const views = await buildProjectViews({ + registryProjects: registrations, + runtime: [], + runtimeOk: false, + filter: null, + includeUnregistered: false, + muxSessions: [], + observeNative: true, + }); + expect(views).toHaveLength(9); + expect(nativeReadCount).toBe(9); + expect(nativePeak).toBe(4); + expect(nativeActive).toBe(0); +}); + +test.each([ + { dependency: "", member: "absent", expected: "running" }, + { + dependency: " depends_on: [tool]\n", + member: "absent", + expected: "degraded", + }, + { + dependency: " depends_on:\n tool: {required: false}\n", + member: "absent", + expected: "running", + }, + { dependency: "", member: "exited", expected: "degraded" }, +])("profile activation keeps required aggregation honest: %j", async ({ + dependency, + member, + expected, +}) => { + const registration = await createProject({ + name: "profiles-fixture", + services: ["api", "tool"], + }); + await writeFile( + join(registration.projectDir, PROJECT_COMPOSE_FILENAME), + `services:\n api:\n image: example\n${dependency} tool:\n image: example\n profiles: [tools]\n` + ); + const api = makeContainer({ + project: registration.name, + service: "api", + name: "api", + state: "running", + }); + const tools = + member === "absent" + ? [] + : [ + { + ...makeContainer({ + project: registration.name, + service: "tool", + name: "tool", + state: member, + }), + observation: { + state: member, + health: null, + exitCode: 0, + startedAt: null, + }, + }, + ]; + const [view] = await buildProjectViews({ + registryProjects: [registration], + runtime: [ + makeRuntimeProject({ + name: registration.name, + workingDir: registration.projectDir, + containersByService: { + api: [api], + ...(tools.length ? { tool: tools } : {}), + }, + }), + ], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + if (!view) { + throw new Error("missing fixture view"); + } + expect(serializeProjectView(view).state).toBe(expected); + expect(serializeProjectSummary(view).state).toBe(expected); + expect(view.observation?.services).toHaveLength(2); +}); diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index 0a5592b35..c8fe28470 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; let root = ""; @@ -146,7 +147,7 @@ test("observe default preserves HOME and Hack home and never contacts Docker", a ]) { const result = await run(["--json", "--observe", ...flags]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.observe).toBe(true); expect(payload.runtime_requested).toBe(false); expect(payload.runtime_error).toBeNull(); @@ -188,8 +189,21 @@ test("observe runtime explicitly opts in to Docker reads without registering dis ...flags, ]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_requested).toBe(true); + // R2's observer has run even without a native selection. Re-projecting + // here would replace its result with native-observer-required (and discard + // live service evidence when a native selection is available). + expect( + payload.projects.find( + (row: { name: string }) => row.name === "native-app" + ) + ).toMatchObject({ + backend: "native", + state: "unknown", + state_reason: "observation-unavailable", + services: [], + }); expect( payload.projects.find( (row: { name: string }) => row.name === "unregistered-app" @@ -219,7 +233,7 @@ test("observe keeps native rows and unknown Compose services when Docker is unav OFFLINE: "1", }); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_ok).toBe(false); const compose = payload.projects.find( (row: { name: string }) => row.name === "compose-app" @@ -249,7 +263,10 @@ test("mixed running and exited replicas keep service and project degraded", asyn { MIXED: "1" } ); expect(result.code).toBe(0); - const row = JSON.parse(result.stdout).projects.find( + const row = machineData( + result.stdout, + "hack.projects.observe.v1" + ).projects.find( (project: { name: string }) => project.name === "unregistered-app" ); expect(row.services[0].state).toBe("degraded"); @@ -261,7 +278,9 @@ test("observe never creates a missing registry or registers the current checkout await rm(registry); const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toEqual([]); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toEqual([]); expect(await stat(registry).catch(() => null)).toBeNull(); }); @@ -293,9 +312,21 @@ test("observe does not query an available daemon cache", async () => { try { const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toHaveLength(2); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toHaveLength(2); expect(requests).toBe(0); } finally { await server.stop(true); } }); + +test("published plain projects JSON retains its unwrapped shape", async () => { + const result = await run(["--json", "--no-daemon"]); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.projects).toBeArray(); + expect(payload).not.toHaveProperty("ok"); + expect(payload).not.toHaveProperty("data"); + expect(payload).not.toHaveProperty("schema"); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 38c19b0b5..77f73fae2 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; import { HACK_PROJECT_DIR_PRIMARY } from "../src/constants.ts"; import type { ProjectMeta } from "../src/lib/project-meta.ts"; -import type { ProjectView } from "../src/lib/project-views.ts"; +import { + type ProjectView, + serializeProjectSummary, + serializeProjectView, +} from "../src/lib/project-views.ts"; import type { RegisteredProject } from "../src/lib/projects-registry.ts"; import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; @@ -804,3 +808,156 @@ test("summary payload keeps project identity and omits detailed runtime payloads "auto_register_ms" ); }); + +test("daemon full and summary preserve the shared backend/service observation", async () => { + const observation = { + backend: "native", + state: "running", + state_reason: null, + started_at: null, + services: [ + { + name: "api", + state: "running", + ready: true, + completed: null, + health: "healthy", + exit_code: null, + started_at: null, + }, + ], + } as const; + const view: ProjectView = { + name: "native-fixture", + devHost: null, + repoRoot: "/fixture", + projectDir: "/fixture/.hack", + definedServices: ["api"], + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: "unknown", + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + ownership: null, + worktrees: null, + kind: "registered", + status: "unknown", + observation, + }; + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ version: 1, projects: [] }), + buildProjectViews: async (opts) => { + expect(opts.observeNative).toBe(true); + return [view]; + }, + }, + }); + for (const summary of [false, true]) { + const payload = await cache.getProjectsPayload({ + filter: null, + includeGlobal: false, + includeUnregistered: false, + includeMeta: false, + summary, + }); + const cli = summary + ? serializeProjectSummary(view) + : serializeProjectView(view); + expect(payload.projects[0]).toEqual(cli); + expect(payload.projects[0]).toMatchObject(observation); + } +}); + +test("an event refresh retains known job completion and publishes a subsequent crash", async () => { + function runtime(code: number): RuntimeProject { + const member = (service: string, state: string) => ({ + id: service, + project: "event-fixture", + service, + state, + status: state, + name: service, + ports: "", + workingDir: "/fixture/.hack", + image: null, + labels: null, + mounts: [], + networks: [], + observation: { + state, + health: null, + exitCode: state === "exited" ? code : null, + startedAt: null, + }, + }); + return { + project: "event-fixture", + workingDir: "/fixture/.hack", + isGlobal: false, + services: new Map([ + ["api", { service: "api", containers: [member("api", "running")] }], + ["task", { service: "task", containers: [member("task", "exited")] }], + ]), + }; + } + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ version: 1, projects: [] }), + buildProjectViews: async (opts) => [ + { + name: "event-fixture", + devHost: null, + repoRoot: "/fixture", + projectDir: "/fixture/.hack", + definedServices: ["api", "task"], + serviceReadiness: { api: "started", task: "completed" }, + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: opts.runtimeOk ? "running" : "unknown", + runtime: opts.runtime[0] ?? null, + branchRuntime: [], + sessions: [], + lifecycle: null, + ownership: null, + worktrees: null, + kind: "registered", + status: "running", + }, + ], + }, + }); + for (const [index, code] of [0, 0, 2].entries()) { + runtimeQueue.push({ + ok: true, + runtime: [runtime(code)], + error: null, + checkedAtMs: Date.now(), + }); + await cache.refresh({ reason: "docker-event", forceInspect: index === 0 }); + const payload = await cache.getProjectsPayload({ + filter: null, + includeGlobal: false, + includeUnregistered: false, + includeMeta: false, + }); + expect(payload.projects[0]?.state).toBe( + code === 0 ? "running" : "degraded" + ); + expect(payload.projects[0]?.services).toMatchObject([ + { name: "api" }, + { name: "task", completed: code === 0, exit_code: code }, + ]); + } + expect(runtimeReadCalls.map((call) => call.forceInspect)).toEqual([ + true, + false, + false, + ]); +}); diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index 06b69b7c2..072e3acaa 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -18,6 +18,8 @@ import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let dockerAvailable = false; let currentIds: string[] = []; let inspectExitCode = 0; +const memberStates = new Map(); +const memberExitCodes = new Map(); const inspectCalls: string[][] = []; let tempDir: string | null = null; let originalHackHome: string | undefined; @@ -61,6 +63,9 @@ const shellMock = await registerScopedModuleMock({ if (command[1] === "inspect") { expect(command[2]).toBe("--format"); expect(command[3]).not.toContain(".Env"); + expect(command[3]).not.toContain(".Log"); + expect(command[3]).not.toContain(".Error"); + expect(command[3]).toContain(".State.StartedAt"); const ids = [...command.slice(4)]; inspectCalls.push(ids); const returnedIds = inspectExitCode === 0 ? ids : ids.slice(0, 1); @@ -102,6 +107,8 @@ beforeEach(async () => { dockerAvailable = false; currentIds = []; inspectExitCode = 0; + memberStates.clear(); + memberExitCodes.clear(); inspectCalls.length = 0; }); @@ -254,7 +261,7 @@ test("runtime auto-registration skips an input family that changes before regist function makePsRow(opts: { readonly id: string }): Record { return { ID: opts.id, - State: "running", + State: memberStates.get(opts.id) ?? "running", Status: "Up 10 seconds", Names: `container-${opts.id}`, Ports: "3000/tcp", @@ -271,6 +278,12 @@ function makeInspectRow(opts: { }): Record { return { Id: `${opts.id}${"0".repeat(52)}`, + State: { + Status: memberStates.get(opts.id) ?? "running", + ExitCode: memberExitCodes.get(opts.id) ?? 0, + StartedAt: "2026-01-01T00:00:00Z", + Health: { Status: "healthy" }, + }, Config: { Image: `image:${opts.id}`, Labels: { @@ -291,3 +304,86 @@ function makeInspectRow(opts: { }, }; } + +test("cached static metadata never becomes fresh service health or start evidence", async () => { + dockerAvailable = true; + currentIds = ["aaaaaaaaaaaa"]; + const cache = createRuntimeInspectCache(); + const first = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + const cached = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + const member = (result: typeof first) => + [...(result.runtime[0]?.services.values() ?? [])][0]?.containers[0]; + expect(member(first)?.observation?.health).toBe("healthy"); + expect(member(first)?.observation?.exitCode).toBeNull(); + expect(member(cached)?.observation).toBeNull(); + expect(inspectCalls).toHaveLength(1); +}); + +test("failed reinspection keeps static identity but clears old current-state evidence", async () => { + dockerAvailable = true; + currentIds = ["aaaaaaaaaaaa", "bbbbbbbbbbbb"]; + const cache = createRuntimeInspectCache(); + await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + inspectExitCode = 1; + const result = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + const services = result.runtime[0]?.services; + expect( + services?.get("service-aaaaaaaaaaaa")?.containers[0]?.observation?.health + ).toBe("healthy"); + const second = services?.get("service-bbbbbbbbbbbb")?.containers[0]; + expect(second?.image).toBe("image:bbbbbbbbbbbb"); + expect(second?.observation).toBeNull(); +}); + +test("event refresh reinspects changed and terminated IDs without polling unchanged running peers", async () => { + dockerAvailable = true; + const job = "aaaaaaaaaaaa"; + const api = "bbbbbbbbbbbb"; + currentIds = [job, api]; + const cache = createRuntimeInspectCache(); + await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + memberStates.set(job, "exited"); + const finished = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + const completed = finished.runtime[0]?.services.get(`service-${job}`) + ?.containers[0]; + expect(completed?.observation?.exitCode).toBe(0); + expect( + finished.runtime[0]?.services.get(`service-${api}`)?.containers[0] + ?.observation + ).toBeNull(); + memberExitCodes.set(job, 2); + const failed = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + expect( + failed.runtime[0]?.services.get(`service-${job}`)?.containers[0] + ?.observation?.exitCode + ).toBe(2); + expect(inspectCalls).toEqual([[job, api], [job], [job]]); +}); diff --git a/tests/runtime-service-state.test.ts b/tests/runtime-service-state.test.ts new file mode 100644 index 000000000..1af8d6c04 --- /dev/null +++ b/tests/runtime-service-state.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readContainerState } from "../src/lib/runtime-service-state.ts"; + +test("inspect state exposes exit codes only for terminated containers", () => { + expect( + readContainerState({ + Status: "running", + ExitCode: 0, + Health: { Status: "healthy" }, + StartedAt: "2026-01-01T00:00:00Z", + }) + ).toEqual({ + state: "running", + health: "healthy", + exitCode: null, + startedAt: "2026-01-01T00:00:00Z", + }); + expect(readContainerState({ Status: "exited", ExitCode: 2 })?.exitCode).toBe( + 2 + ); +}); + +test("missing or invalid metadata stays unknown instead of successful or epoch-started", () => { + expect(readContainerState(undefined)).toBeNull(); + expect(readContainerState({})).toBeNull(); + expect( + readContainerState({ + Status: "exited", + ExitCode: "0", + StartedAt: "0001-01-01T00:00:00Z", + }) + ).toEqual({ state: "exited", health: null, exitCode: null, startedAt: null }); + expect( + readContainerState({ + Status: "exited", + ExitCode: Number.NaN, + StartedAt: "invalid", + })?.exitCode + ).toBeNull(); +}); diff --git a/tests/version-report.test.ts b/tests/version-report.test.ts index 62d7ad27f..ec8e41ea3 100644 --- a/tests/version-report.test.ts +++ b/tests/version-report.test.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildVersionReport } from "../src/lib/version-report.ts"; +import { machineData } from "./helpers/machine-output.ts"; const owned: string[] = []; afterEach(async () => { @@ -74,11 +75,12 @@ test("source CLI JSON is pure, local and leaves a private home unchanged", async const stderr = await new Response(child.stderr).text(); expect(await child.exited).toBe(0); expect(stderr).toBe(""); - const report = JSON.parse(stdout); + const report = machineData(stdout, "hack.version.v1"); expect(Object.keys(report).sort()).toEqual([ "binary_path", "channel", "install_kind", + "schema", "version", ]); expect(report.install_kind).toBe("source");