Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -378,7 +378,7 @@ Each is small enough for one PR.
5. **R4** Logs: document the `--json` ndjson as stable; `--services a,b` honoured by every backend; `--max-line-bytes <n>` setting `entry.truncated`.
6. **R5a** Activity writer: append-only `activity.jsonl` in the Hack home, one line per `up`/`down`/`restart`/autostart (`ts, project, branch, action, origin, outcome, failed_services, duration_ms`), `O_APPEND` writes under one line size limit, rotation at a size cap, `origin` from `HACK_ORIGIN` (the app sets `app`).
7. **R5b** Activity reader: `hack activity --json [--project] [--limit]`.
8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope.
8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. The Compose slice exists; see [Lifecycle progress stream](../../docs/reference/lifecycle-progress.md). Native backend support is open.
9. **R7a — exists.** Doctor audit schema 1 adds per-check `area`, `scope: machine | project` and `fix: {kind: safe | admin | manual, args[], doc_url}` inside the existing `{ok,data}` envelope. Unknown checks default to Other/manual with no executable args. A category is guidance, not permission to run it.
10. **R7b** One fix without prompts: `hack doctor --fix --only <id> --yes --json`, only for `kind: safe`.
11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ hack up [services...] [options]
| `--profile <name[,name...]>` | Enable one or more compose profiles (comma-separated) |
| `--target <auto|local|remote>` | Execution target routing (auto routes to remote when project execution mode requires it) |
| `--json` | Output JSON (machine-readable) |
| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
| `--help, -h` | Show help |
| `--version, -v` | Show version |
Expand Down Expand Up @@ -216,6 +217,7 @@ hack down [options]
| `--remove-data` | Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first) |
| `--yes` | Confirm --prune-caches or --remove-data without prompting |
| `--json` | Output JSON (machine-readable) |
| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) |
| `--recover` | Explicitly reconcile an interrupted native stop, or stop the services of a native run whose foreground owner exited, while retaining data |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
| `--help, -h` | Show help |
Expand Down Expand Up @@ -249,6 +251,7 @@ hack restart [services...] [options]
| `--profile <name[,name...]>` | Enable one or more compose profiles (comma-separated) |
| `--target <auto|local|remote>` | Execution target routing (auto routes to remote when project execution mode requires it) |
| `--json` | Output JSON (machine-readable) |
| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) |
| `--recover-frontend` | Recover an interrupted native frontend after proving its owner and effects are gone |
| `--expect-finalization-attempt <32-hex>` | Require this exact interrupted native frontend attempt |
| `--expect-frontend-pid <pid>` | Previously observed frontend PID (required only for legacy v1 state) |
Expand Down
116 changes: 116 additions & 0 deletions docs/reference/lifecycle-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Lifecycle progress stream

`hack up --json --progress`, `hack down --json --progress` and
`hack restart --json --progress` print a newline-delimited JSON (ndjson) stream on
stdout instead of one pretty-printed envelope. Callers that show progress, such as
the desktop app, read it line by line. Stderr is unchanged and is not part of the
contract.

This is the Compose backend only. The native backend does not support it yet (see
[Refusals](#refusals)).

## The stream

Every line is one JSON object.

1. **Start line**, written once Hack validated the options and resolved the
project:

```json
{"schema":"hack.lifecycle-progress.v1","type":"start","ts":"2026-11-14T17:00:00.000Z","action":"up","project":"shop"}
```

`action` is `up`, `down` or `restart`. `project` is the Compose project name
(`<name>` or `<name>--<branch>`). `schema` appears only on this line.

2. **Progress lines**, zero or more:

```json
{"type":"progress","ts":"2026-11-14T17:00:01.000Z","service":null,"phase":"images"}
{"type":"progress","ts":"2026-11-14T17:00:09.000Z","service":"api","phase":"running"}
```

`service` is `null` for a project-level phase and a Compose service name
otherwise. `ts` is an ISO 8601 UTC timestamp.

3. **Closing line**: the same envelope the command prints with `--json` alone,
written as one compact line. Nothing follows it.

```json
{"ok":true,"data":{"action":"up","project":"shop","branch":null,"composeProject":"shop","services":{"started":["api"],"completed":["migrate"],"stopped":[],"failed":[]},"durationMs":9120}}
```

A failure after the start line also ends with exactly one envelope line,
`{"ok":false,"error":{"code":...,"message":...}}`. Exit codes are the same as
with `--json` alone.

```mermaid
stateDiagram-v2
[*] --> Refused: usage error or project not found
Refused --> [*]: one pretty envelope, no stream
[*] --> Started: start line (schema)
Started --> Progress: progress line
Progress --> Progress: progress line
Started --> Closed: envelope line
Progress --> Closed: envelope line
Closed --> [*]
```

## Phases

Hack reports only signals the Compose path already observes. It does not parse
Compose's terminal output or poll for extra state. A phase that did not happen is
skipped, so a consumer must not wait for any particular phase.

| Phase | `service` | Source |
|---|---|---|
| `images` | `null` | Compose image preparation (`up --no-start`) is starting. |
| `starting` | `null` | Image preparation succeeded; containers are being started (`up -d`). |
| `stopping` | `null` | `down` is about to stop the containers; `restart` is about to run its stop phase (down hooks and host processes). |
| `stopped` | `null` | That stop finished successfully. |
| `waiting`, `installing`, `verifying`, `ready`, `failed` | service | Dependency cache startup markers, also printed to stderr as `Dependency cache <service>: <phase>`. Only services that use the dependency cache report them. |
| `running`, `completed`, `stopped`, `failed` | service | The final state of each service, taken from the closing envelope and written just before it. |

What each command reports:

- `up`: `images`, then `starting` if image preparation succeeded, any dependency
cache phases, then the final states. `up --json` always runs detached, so the
`images` and `starting` signals always fire; a failure during image preparation
stops after `images`.
- `down`: `stopping`, `stopped` when `docker compose down` succeeded, then one
`stopped` line per service that was running before.
- `restart`: `stopping` and `stopped` for the stop phase, then the same phases as
`up`. A restart of named services (`hack restart api`) skips the stop phase.
When a full restart's start fails and Hack repairs it with a second Compose
start, that repair reports no `images` or `starting` lines.

Final per-service lines come from the envelope's `data.services` lists on success
(`started` maps to `running`). On an `E_STARTUP_INCOMPLETE` error they come from
`error.detail.running`, `completed` and `failed`. Other errors have no final
per-service lines.

## Interruption

SIGINT or SIGTERM after the start line closes the stream with one compact
`{"ok":false,"error":{"code":"E_CANCELLED",...}}` line. The existing lifecycle
cleanup still runs, and the process still ends by that signal. Nothing is
written to stdout after the closing line. A stream with no closing line was
killed (for example with SIGKILL).

## Refusals

These end with the ordinary pretty `--json` envelope (or usage text without
`--json`) and no stream, because they happen before the start line:

- `--progress` without `--json`: usage error `--progress requires --json.`
- The native backend (`HACK_RUNTIME_BACKEND=native`): usage error
`Native lifecycle commands do not support --progress yet.` before any work.
- A native-authored project refuses `--progress` as an unsupported option of its
native Compose path.
- A remote target (project lifecycle routed through dispatch): usage error
`--progress is not supported for remote targets.` before dispatch.
- Other usage errors, a missing project, Docker missing from `PATH`, and an
invalid shared helper configuration.

A consumer that needs to tell the two apart can check whether the first line
parses as a complete JSON object with `schema`.
4 changes: 3 additions & 1 deletion docs/versions/v5/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ and [Worktrees and watchers](../guides/development-runtime-safety.md).
## Use Hack from an agent or script

Agents use the same CLI. `--json` returns structured results with stable error codes
and `--no-interactive` prevents a command from waiting for input. No plugin, server
and `--no-interactive` prevents a command from waiting for input. On the Compose
backend, `hack up|down|restart --json --progress` streams ndjson progress lines
before the final envelope. No plugin, server
or account is required. [Agent handoff](agent-handoff.md) and
[Agent integrations](../integrations.md) cover the optional extras.

Expand Down
2 changes: 2 additions & 0 deletions src/backends/runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
resolveComposeStartupTimeoutMs,
} from "../lib/compose-startup-budget.ts";
import { withDependencyStartupProgress } from "../lib/dependency-startup-progress.ts";
import { reportLifecycleProgress } from "../lib/lifecycle-progress.ts";
import { exec, run } from "../lib/shell.ts";
import { readLinesFromStream } from "../ui/lines.ts";
import { createStructuredLogGrouper } from "../ui/log-group.ts";
Expand Down Expand Up @@ -214,6 +215,7 @@ export const composeRuntimeBackend: RuntimeBackend = {
env: opts.env,
onPhase: (service, phase) => {
process.stderr.write(`Dependency cache ${service}: ${phase}\n`);
reportLifecycleProgress({ service, phase });
},
onUnavailable: () => {
process.stderr.write(
Expand Down
17 changes: 17 additions & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
HackCliError,
} from "../lib/cli-result.ts";
import { setNoInteractiveFlag } from "../lib/interactivity.ts";
import { closeLifecycleProgress } from "../lib/lifecycle-progress.ts";
import { nativeDetachedUpFailure } from "../lib/native-detached-up.ts";
import { logger } from "../ui/logger.ts";
import {
Expand Down Expand Up @@ -164,6 +165,19 @@ function resolveSensitiveOutputError(
}
}

/**
* A started `--progress` stream must still end with one compact envelope
* line; the global pretty envelope would break the ndjson stream.
*/
function closeProgressStreamOnError(error: unknown): boolean {
return closeLifecycleProgress({
result:
error instanceof CliUsageError
? errorResult({ code: "E_USAGE", message: error.message })
: errorResultFromUnknown({ error }),
});
}

/**
* Maps a top-level CLI failure to its exit path. Under `--json`, stdout
* stays a single parseable envelope for every error class (usage errors
Expand Down Expand Up @@ -197,6 +211,9 @@ export async function handleRunCliError(opts: {
process.stderr.write(`${JSON.stringify(result)}\n`);
return 1;
}
if (jsonRequested && closeProgressStreamOnError(error)) {
return 1;
}
if (error instanceof CliUsageError) {
if (jsonRequested) {
// Envelope only: the message is inside it, and logger backends may
Expand Down
Loading
Loading