Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8b8be79
feat(projects): add registry-preserving observe-only JSON listing
roodboi Nov 14, 2026
b0dcc4d
fix(projects): require explicit engine contact during observation
roodboi Nov 14, 2026
99ce916
fix(projects): retain degraded replica state in project summaries
roodboi Nov 14, 2026
dbde680
feat(projects): retain current service evidence for state projection
roodboi Nov 14, 2026
2e9a690
Merge remote-tracking branch 'origin/codex/hack-2218-projects-observe…
roodboi Nov 14, 2026
bbdfe7d
feat(projects): share backend-aware service state across CLI and daemon
roodboi Nov 14, 2026
811bc74
fix(projects): preserve profile activation and current exit evidence
roodboi Nov 14, 2026
1dd47a6
fix(projects): honor explicitly disabled NONE health checks
roodboi Nov 14, 2026
eeb8def
Merge remote-tracking branch 'origin/next' into feat/hack-2218-projec…
roodboi Nov 14, 2026
47aee20
fix(cli): standardize unpublished machine output contracts
roodboi Nov 14, 2026
bce84af
fix(cli): include activity in schema contract and correct inventory
roodboi Nov 14, 2026
2be5183
test(config): account for both failed lifecycle audit records
roodboi Nov 14, 2026
82de876
test(config): verify audit-only effects across native plan probes
roodboi Nov 14, 2026
30fd64c
Merge remote-tracking branch 'origin/next' into feat/hack-2238-json-c…
roodboi Nov 14, 2026
0c69a6f
docs(cli): classify published open output as a bare object
roodboi Nov 14, 2026
d6372ba
test(desktop): establish log child before stream drop
roodboi Nov 14, 2026
3ffc597
fix(desktop): bound log stream reader cleanup on drop
roodboi Nov 14, 2026
58605b6
Merge remote-tracking branch 'origin/next' into feat/hack-2238-json-c…
roodboi Nov 14, 2026
b68d2dd
Merge remote-tracking branch 'origin/next' into feat/hack-2218-projec…
roodboi Nov 14, 2026
34e8bbc
Merge branch 'feat/hack-2238-json-contract' into feat/hack-2218-proje…
roodboi Nov 14, 2026
6ca7b5e
docs(projects): distinguish observation envelope from legacy listing
roodboi Nov 14, 2026
253a713
fix(projects): retain computed native observation in JSON output
roodboi Nov 14, 2026
d1aa159
fix(runtime): inspect containers without health checks safely
roodboi Nov 14, 2026
6f788b5
Merge remote-tracking branch 'origin/next' into feat/hack-2238-json-c…
roodboi Nov 14, 2026
337f005
test(desktop): keep stream fixture alive past cleanup deadline
roodboi Nov 14, 2026
6303c0b
test(desktop): assert log pipe descendant is retired
roodboi Nov 14, 2026
f4d2b55
test(cli): check real success shapes of hermetic JSON commands
roodboi Nov 14, 2026
cda7980
test(cli): take plain command shapes from their inventory row
roodboi Nov 14, 2026
d7bcd2a
Merge origin/next into feat/hack-2238-json-contract
roodboi Nov 15, 2026
2058a97
docs(cli): list native setup in the machine output inventory
roodboi Nov 15, 2026
6e3509c
Merge feat/hack-2238-json-contract (#835, includes next e47681ea) int…
roodboi Nov 15, 2026
36fba16
fix(projects): bound native status reads across concurrent callers
roodboi Nov 15, 2026
d6d0b89
Merge origin/next (bf74aaf0) into feat/hack-2238-json-contract
roodboi Nov 15, 2026
4e55382
fix(cli)!: record env migrate and usage by-service; note the next.17 …
roodboi Nov 15, 2026
db01be6
docs(desktop): drop the stale unpublished claim from the schema parser
roodboi Nov 15, 2026
5bf815c
Merge feat/hack-2238-json-contract (#835 at db01be63, includes next b…
roodboi Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root> --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 <n>` 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.
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
187 changes: 172 additions & 15 deletions apps/desktop/core/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: DeserializeOwned>(
&self,
args: &[&str],
path: Option<&Path>,
timeout: Duration,
schema: &str,
) -> Result<T, RunError> {
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<String, RunError> {
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 ...`).
Expand Down Expand Up @@ -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<LogEvent>,
Expand All @@ -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();
}
}

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -536,6 +584,53 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option<i32>) -> Result<Val
}
}

/// Strict parser for the envelope contracts migrated by HACK-2238 (changed after next.17).
/// A nonzero result can never be accepted as successful data.
pub fn parse_schema_result(
stdout: &str,
stderr: &str,
code: Option<i32>,
schema: &str,
) -> Result<Value, RunError> {
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<String> {
Expand Down Expand Up @@ -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();
Expand All @@ -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"
Expand All @@ -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);
}
}
6 changes: 3 additions & 3 deletions apps/desktop/core/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/core/tests/dump_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/core/tests/fixtures/machine-output/activity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"ok": true,
"data": {
"version": 1,
"entries": [],
"schema": "hack.activity.v1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ok": true,
"data": {
"command": "install",
"platform": "linux",
"refused": null,
"complete": true,
"blocked": [],
"steps": [],
"schema": "hack.global-admin-plan.v1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"ok": true,
"data": {
"observe": true,
"docker": {
"state": "not_running"
},
"summary": {
"ok": "unknown"
},
"schema": "hack.global-status.observe.v1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ok": true,
"data": {
"command": "trust",
"platform": "linux",
"refused": null,
"complete": true,
"blocked": [],
"steps": [],
"schema": "hack.global-admin-plan.v1"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Loading
Loading