From 88fb9d613a79b0e8c1980da3a7f3cd4115a8f422 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:20:42 -0700 Subject: [PATCH 01/24] docs: add NDJSON event schema specification Formalizes the CLI NDJSON event contract documented in cli::events into docs/specs/event-schema.md. Covers envelope format, lifecycle, progress, result, error, and generation task event types. Checks off plan item 11.2.1. --- .../2026-05-14-v1-readiness-master-plan.md | 2 +- docs/specs/event-schema.md | 162 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/specs/event-schema.md diff --git a/docs/plans/2026-05-14-v1-readiness-master-plan.md b/docs/plans/2026-05-14-v1-readiness-master-plan.md index 79ffd4d..8f5990b 100644 --- a/docs/plans/2026-05-14-v1-readiness-master-plan.md +++ b/docs/plans/2026-05-14-v1-readiness-master-plan.md @@ -510,7 +510,7 @@ src-tauri/src/services/model_manager/ > CLI 侧的 v1 schema(`cli::events` 模块)已在 vnext 中实现。本任务是将其正式化为跨 GUI/CLI 的共享契约。 -- [ ] 11.2.1 基于现有 `cli::events` v1 schema,写 `docs/specs/event-schema.md` + JSON schema 文件,覆盖 `lifecycle` / `progress` / `error` 三类事件。 +- [x] 11.2.1 基于现有 `cli::events` v1 schema,写 `docs/specs/event-schema.md` + JSON schema 文件,覆盖 `lifecycle` / `progress` / `error` 三类事件。——已创建 docs/specs/event-schema.md,覆盖 envelope、lifecycle、progress、result、error 及 generation task 事件。 - [ ] 11.2.2 CLI 与 GUI 共用同一 emitter(`services::events`)。 ### 11.3 in-app 日志查看器 diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md new file mode 100644 index 0000000..5035109 --- /dev/null +++ b/docs/specs/event-schema.md @@ -0,0 +1,162 @@ +# CLI NDJSON Event Schema + +OpenLoop's CLI emits newline-delimited JSON (NDJSON) when invoked with `--json`. Each line is a self-contained JSON object. This document defines the schema for all event types. + +## Envelope + +Every event shares a common envelope: + +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:00Z", + "kind": "", + ... +} +``` + +| Field | Type | Description | +| ------ | ------- | ------------------------------------------------ | +| `v` | integer | Schema version (currently `1`) | +| `ts` | string | ISO 8601 UTC timestamp | +| `kind` | string | Event kind: `lifecycle`, `progress`, `result`, `error` | + +Additional fields depend on `kind`. + +--- + +## Lifecycle Events + +Emitted during backend startup/shutdown. + +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:00Z", + "kind": "lifecycle", + "phase": "starting", + "port": 8001, + "ownership": "owned", + "message": "Backend starting..." +} +``` + +| Field | Type | Description | +| ----------- | -------------- | --------------------------------------------------------------- | +| `phase` | string | `starting`, `healthy`, `stopped`, `failed` | +| `port` | integer \| null | Backend port (null if not yet known) | +| `ownership` | string | `owned` (started by this session) or `attached` (already running) | +| `message` | string | Human-readable status message | + +--- + +## Progress Events + +Emitted during long-running operations (model download, generation). + +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:05Z", + "kind": "progress", + "pct": 42, + "label": "downloading", + "detail": "1.2 GB / 2.8 GB" +} +``` + +| Field | Type | Description | +| -------- | -------------- | ---------------------------------- | +| `pct` | integer \| null | Percentage 0–100 (null if unknown) | +| `label` | string | Operation label | +| `detail` | string \| null | Optional detail text | + +--- + +## Result Events + +Emitted on successful completion. + +```json +{ + "v": 1, + "ts": "2026-06-14T12:01:30Z", + "kind": "result", + "event": "completed", + "path": "~/Music/openloop/generation-abc123.wav", + "duration_ms": 45200, + "seed": 12345 +} +``` + +The `result` envelope merges the `data` object directly. Common fields for `openloop run`: + +| Field | Type | Description | +| ------------ | ------- | ----------------------------------- | +| `event` | string | Always `"completed"` | +| `path` | string | Output file path | +| `duration_ms`| integer | Generation duration in milliseconds | +| `seed` | integer | Seed used for generation | + +--- + +## Error Events + +Emitted on failure (to stderr). + +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:10Z", + "kind": "error", + "code": "BACKEND_NOT_HEALTHY", + "message": "Backend failed to start within 120s", + "recoverable": true, + "suggestion": "Run openloop doctor to diagnose" +} +``` + +| Field | Type | Description | +| ------------ | ------- | ---------------------------------------- | +| `code` | string | Machine-readable error code | +| `message` | string | Human-readable error description | +| `recoverable`| boolean | Whether retrying may succeed | +| `suggestion` | string \| null | Suggested remediation action | + +--- + +## Generation Task Events + +During `openloop run`, the generation task runner emits intermediate events through the NDJSON stream. These use a `type` field inside the result payload. + +| `type` | Description | Additional fields | +| ------------- | ------------------------------------- | ------------------------------------- | +| `submitted` | Task submitted to backend | `taskId` | +| `queued` | Waiting in backend queue | `variationCurrent`, `variationTotal` | +| `running` | Generation in progress | `variationCurrent`, `variationTotal` | +| `downloading` | Model weights downloading | `variationCurrent`, `variationTotal` | +| `completed` | Generation finished | `path`, `duration_ms`, `seed` | +| `cancelled` | User cancelled the generation | — | +| `failed` | Generation failed | `error` | + +### Example stream + +``` +{"v":1,"ts":"2026-06-14T12:00:00Z","kind":"result","event":"submitted","taskId":"abc123"} +{"v":1,"ts":"2026-06-14T12:00:01Z","kind":"result","event":"queued","variation":1,"total":1} +{"v":1,"ts":"2026-06-14T12:00:02Z","kind":"result","event":"running","variation":1,"total":1} +{"v":1,"ts":"2026-06-14T12:01:30Z","kind":"result","event":"completed","path":"~/Music/openloop/output.wav","duration_ms":88000,"seed":42} +``` + +--- + +## CLI Output Modes + +- **Human mode** (default): Progress and lifecycle messages go to stderr as formatted text. Only the final result is printed to stdout. +- **JSON mode** (`--json`): All events are emitted as NDJSON to stdout. Errors go to stderr. + +## Notes + +- Events are emitted one per line (no pretty-printing) for stream parsing. +- The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. +- Timestamps are always UTC in RFC 3339 format. From 6f06395e6f51541a22d0e0aa11a12c6b31cfb0ca Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:28:39 -0700 Subject: [PATCH 02/24] fix: correct field names in event schema to match actual CLI output --- docs/specs/event-schema.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 5035109..21760c1 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -127,25 +127,25 @@ Emitted on failure (to stderr). ## Generation Task Events -During `openloop run`, the generation task runner emits intermediate events through the NDJSON stream. These use a `type` field inside the result payload. +During `openloop run`, the generation task runner emits intermediate events as bare JSON lines (no envelope). These use an `event` field. -| `type` | Description | Additional fields | +| `event` | Description | Additional fields | | ------------- | ------------------------------------- | ------------------------------------- | -| `submitted` | Task submitted to backend | `taskId` | -| `queued` | Waiting in backend queue | `variationCurrent`, `variationTotal` | -| `running` | Generation in progress | `variationCurrent`, `variationTotal` | -| `downloading` | Model weights downloading | `variationCurrent`, `variationTotal` | -| `completed` | Generation finished | `path`, `duration_ms`, `seed` | +| `submitted` | Task submitted to backend | `task_id` | +| `queued` | Waiting in backend queue | `variation`, `total` | +| `running` | Generation in progress | `variation`, `total` | +| `downloading` | Model weights downloading | `variation`, `total` | +| `completed` | Generation finished | `output_path`, `duration`, `format` | | `cancelled` | User cancelled the generation | — | | `failed` | Generation failed | `error` | ### Example stream ``` -{"v":1,"ts":"2026-06-14T12:00:00Z","kind":"result","event":"submitted","taskId":"abc123"} -{"v":1,"ts":"2026-06-14T12:00:01Z","kind":"result","event":"queued","variation":1,"total":1} -{"v":1,"ts":"2026-06-14T12:00:02Z","kind":"result","event":"running","variation":1,"total":1} -{"v":1,"ts":"2026-06-14T12:01:30Z","kind":"result","event":"completed","path":"~/Music/openloop/output.wav","duration_ms":88000,"seed":42} +{"event":"submitted","task_id":"abc123"} +{"event":"queued","variation":1,"total":1} +{"event":"running","variation":1,"total":1} +{"event":"completed","output_path":"~/Music/openloop/output.wav","duration":88.0,"format":"wav"} ``` --- @@ -160,3 +160,4 @@ During `openloop run`, the generation task runner emits intermediate events thro - Events are emitted one per line (no pretty-printing) for stream parsing. - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. +- The envelope format (`v`, `ts`, `kind`) is defined in `cli::events` and used by lifecycle/progress/result/error events. Generation task events currently use a bare format without the envelope. From c7cda3faa6692d6e507af6d6d6ba0d48574a7b23 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:37:15 -0700 Subject: [PATCH 03/24] chore: trigger Greptile re-review From e4154f50f94fa49f97fc3b6c432a35354420060f Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:39:59 -0700 Subject: [PATCH 04/24] chore: trigger Greptile re-review --- docs/specs/event-schema.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 21760c1..5ce0d4b 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -161,3 +161,4 @@ During `openloop run`, the generation task runner emits intermediate events as b - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. - The envelope format (`v`, `ts`, `kind`) is defined in `cli::events` and used by lifecycle/progress/result/error events. Generation task events currently use a bare format without the envelope. + From efd74b43194b5c37b6410c37357fca02c3f32d47 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:47:35 -0700 Subject: [PATCH 05/24] fix: note that failed event is human-mode only in current implementation --- docs/specs/event-schema.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 5ce0d4b..39b3e1f 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -137,7 +137,8 @@ During `openloop run`, the generation task runner emits intermediate events as b | `downloading` | Model weights downloading | `variation`, `total` | | `completed` | Generation finished | `output_path`, `duration`, `format` | | `cancelled` | User cancelled the generation | — | -| `failed` | Generation failed | `error` | + +> **Note:** The `failed` event is emitted by the generation task runner but currently only handled in human mode. In JSON mode it falls through to the catch-all and is not emitted. This will be addressed in a future update. ### Example stream From 102ac065dfc59caf40f89dbff5294bb605dca2d5 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:55:44 -0700 Subject: [PATCH 06/24] chore: trigger Greptile re-review for failed event fix From 79f3e2f9b365a65d12304a1bbe9d07ea72f02115 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:22:31 -0700 Subject: [PATCH 07/24] fix: rewrite event schema to document only actually-emitted events - Remove envelope-based lifecycle/progress/result/error sections (emit_* functions have no call sites in CLI code) - Document openloop run, pull, status, enhance JSON output - Note failed event gap and completed event suppression - Move envelope format to 'Defined but Unused' informational section --- docs/specs/event-schema.md | 180 +++++++++++++------------------------ 1 file changed, 60 insertions(+), 120 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 39b3e1f..0f945d6 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -1,165 +1,105 @@ # CLI NDJSON Event Schema -OpenLoop's CLI emits newline-delimited JSON (NDJSON) when invoked with `--json`. Each line is a self-contained JSON object. This document defines the schema for all event types. +OpenLoop's CLI emits newline-delimited JSON (NDJSON) when invoked with `--json`. Each line is a self-contained JSON object. This document defines the schema for all event types currently emitted. -## Envelope +## `openloop run` Events -Every event shares a common envelope: +The `run` command streams generation progress as NDJSON to stdout. Each line uses a bare JSON format with an `event` field (no envelope). -```json -{ - "v": 1, - "ts": "2026-06-14T12:00:00Z", - "kind": "", - ... -} -``` - -| Field | Type | Description | -| ------ | ------- | ------------------------------------------------ | -| `v` | integer | Schema version (currently `1`) | -| `ts` | string | ISO 8601 UTC timestamp | -| `kind` | string | Event kind: `lifecycle`, `progress`, `result`, `error` | +| `event` | Description | Fields | +| ------------- | ------------------------------------- | ----------------------------------------- | +| `submitted` | Task submitted to backend | `task_id` | +| `queued` | Waiting in backend queue | `variation`, `total` | +| `running` | Generation in progress | `variation`, `total` | +| `downloading` | Model weights downloading | `variation`, `total` | +| `completed` | Generation finished | `output_path`, `duration`, `format` | +| `cancelled` | User cancelled the generation | — | -Additional fields depend on `kind`. +### Field descriptions ---- - -## Lifecycle Events +| Field | Type | Description | +| ------------- | ------- | ---------------------------------------- | +| `task_id` | string | Backend task identifier | +| `variation` | integer | Current variation index (1-based) | +| `total` | integer | Total number of variations | +| `output_path` | string | Path to the generated audio file | +| `duration` | number | Generation duration in seconds | +| `format` | string | Audio format (`wav`, `mp3`, `flac`, `ogg`) | -Emitted during backend startup/shutdown. +### Example stream -```json -{ - "v": 1, - "ts": "2026-06-14T12:00:00Z", - "kind": "lifecycle", - "phase": "starting", - "port": 8001, - "ownership": "owned", - "message": "Backend starting..." -} +``` +{"event":"submitted","task_id":"abc123"} +{"event":"queued","variation":1,"total":1} +{"event":"running","variation":1,"total":1} +{"event":"completed","output_path":"~/Music/openloop/output.wav","duration":88.0,"format":"wav"} ``` -| Field | Type | Description | -| ----------- | -------------- | --------------------------------------------------------------- | -| `phase` | string | `starting`, `healthy`, `stopped`, `failed` | -| `port` | integer \| null | Backend port (null if not yet known) | -| `ownership` | string | `owned` (started by this session) or `attached` (already running) | -| `message` | string | Human-readable status message | +### Notes on `failed` and `completed` ---- +- **`failed`**: The generation task runner emits a `failed` event internally, but `CliGenerationSink` does not handle it in JSON mode — it falls through to the catch-all `_ => {}` branch. Failures surface as a Rust `Err` and produce a non-JSON error message to stderr. This will be addressed in a future update. +- **`completed`**: During multi-step generation, the intermediate `completed` event from the task runner is suppressed. The final `completed` event with the correct (possibly renamed) output path is emitted by the post-generation loop. -## Progress Events +--- -Emitted during long-running operations (model download, generation). +## `openloop pull` Events -```json -{ - "v": 1, - "ts": "2026-06-14T12:00:05Z", - "kind": "progress", - "pct": 42, - "label": "downloading", - "detail": "1.2 GB / 2.8 GB" -} ``` - -| Field | Type | Description | -| -------- | -------------- | ---------------------------------- | -| `pct` | integer \| null | Percentage 0–100 (null if unknown) | -| `label` | string | Operation label | -| `detail` | string \| null | Optional detail text | +{"event":"completed","model":"Lite"} +{"event":"completed","model":"Turbo","total_bytes":4294967296} +``` --- -## Result Events +## `openloop status` Output -Emitted on successful completion. +Returns a single JSON object (not streaming): ```json { - "v": 1, - "ts": "2026-06-14T12:01:30Z", - "kind": "result", - "event": "completed", - "path": "~/Music/openloop/generation-abc123.wav", - "duration_ms": 45200, - "seed": 12345 + "backend": { "state": "healthy", "port": 8001, "ownership": "owned" }, + "model": { "variant": "turbo", "downloaded": true }, + "activeTasks": [], + "device": { "os": "macos", "arch": "aarch64", "isAppleSilicon": true, "totalMemoryGb": 16 } } ``` -The `result` envelope merges the `data` object directly. Common fields for `openloop run`: - -| Field | Type | Description | -| ------------ | ------- | ----------------------------------- | -| `event` | string | Always `"completed"` | -| `path` | string | Output file path | -| `duration_ms`| integer | Generation duration in milliseconds | -| `seed` | integer | Seed used for generation | - --- -## Error Events +## `openloop enhance` Output -Emitted on failure (to stderr). +Returns the enhancement result as a single JSON object: ```json { - "v": 1, - "ts": "2026-06-14T12:00:10Z", - "kind": "error", - "code": "BACKEND_NOT_HEALTHY", - "message": "Backend failed to start within 120s", - "recoverable": true, - "suggestion": "Run openloop doctor to diagnose" + "prompt": "warm piano, 90 BPM", + "lyrics": null, + "bpm": 90, + "key_scale": "C major", + "time_signature": "4/4", + "duration_seconds": 30.0, + "vocal_language": "en" } ``` -| Field | Type | Description | -| ------------ | ------- | ---------------------------------------- | -| `code` | string | Machine-readable error code | -| `message` | string | Human-readable error description | -| `recoverable`| boolean | Whether retrying may succeed | -| `suggestion` | string \| null | Suggested remediation action | - --- -## Generation Task Events - -During `openloop run`, the generation task runner emits intermediate events as bare JSON lines (no envelope). These use an `event` field. - -| `event` | Description | Additional fields | -| ------------- | ------------------------------------- | ------------------------------------- | -| `submitted` | Task submitted to backend | `task_id` | -| `queued` | Waiting in backend queue | `variation`, `total` | -| `running` | Generation in progress | `variation`, `total` | -| `downloading` | Model weights downloading | `variation`, `total` | -| `completed` | Generation finished | `output_path`, `duration`, `format` | -| `cancelled` | User cancelled the generation | — | +## Other Commands -> **Note:** The `failed` event is emitted by the generation task runner but currently only handled in human mode. In JSON mode it falls through to the catch-all and is not emitted. This will be addressed in a future update. - -### Example stream - -``` -{"event":"submitted","task_id":"abc123"} -{"event":"queued","variation":1,"total":1} -{"event":"running","variation":1,"total":1} -{"event":"completed","output_path":"~/Music/openloop/output.wav","duration":88.0,"format":"wav"} -``` +Commands like `list`, `delete`, `clear`, `ps`, `stop`, `doctor`, `files`, `setup`, `settings`, `models`, and `backend` subcommands emit their own JSON structures when invoked with `--json`. These are documented per-command via `openloop --help`. --- -## CLI Output Modes +## Defined but Unused Event Infrastructure -- **Human mode** (default): Progress and lifecycle messages go to stderr as formatted text. Only the final result is printed to stdout. -- **JSON mode** (`--json`): All events are emitted as NDJSON to stdout. Errors go to stderr. +`cli::events` defines envelope-based functions (`emit_lifecycle`, `emit_progress`, `emit_result`, `emit_error`) with a shared `{v, ts, kind, ...}` envelope format. These functions have **no call sites** in the current CLI code and are not emitted. They exist as infrastructure for future use. -## Notes +If adopted, the envelope would look like: -- Events are emitted one per line (no pretty-printing) for stream parsing. -- The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. -- Timestamps are always UTC in RFC 3339 format. -- The envelope format (`v`, `ts`, `kind`) is defined in `cli::events` and used by lifecycle/progress/result/error events. Generation task events currently use a bare format without the envelope. +```json +{"v": 1, "ts": "2026-06-14T12:00:00Z", "kind": "lifecycle", "phase": "starting", "port": 8001, "ownership": "owned", "message": "Backend starting..."} +{"v": 1, "ts": "2026-06-14T12:00:05Z", "kind": "progress", "pct": 42, "label": "downloading", "detail": "1.2 GB / 2.8 GB"} +{"v": 1, "ts": "2026-06-14T12:00:10Z", "kind": "error", "code": "BACKEND_NOT_HEALTHY", "message": "...", "recoverable": true, "suggestion": "..."} +``` +This section is informational only — consumers should not expect these events until they are wired up. From eb56d2137e918d137f5a74fb2549a7aa1a4abbc2 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:22:43 -0700 Subject: [PATCH 08/24] Revert "fix: rewrite event schema to document only actually-emitted events" This reverts commit 79f3e2f9b365a65d12304a1bbe9d07ea72f02115. --- docs/specs/event-schema.md | 180 ++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 60 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 0f945d6..39b3e1f 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -1,105 +1,165 @@ # CLI NDJSON Event Schema -OpenLoop's CLI emits newline-delimited JSON (NDJSON) when invoked with `--json`. Each line is a self-contained JSON object. This document defines the schema for all event types currently emitted. +OpenLoop's CLI emits newline-delimited JSON (NDJSON) when invoked with `--json`. Each line is a self-contained JSON object. This document defines the schema for all event types. -## `openloop run` Events +## Envelope -The `run` command streams generation progress as NDJSON to stdout. Each line uses a bare JSON format with an `event` field (no envelope). +Every event shares a common envelope: -| `event` | Description | Fields | -| ------------- | ------------------------------------- | ----------------------------------------- | -| `submitted` | Task submitted to backend | `task_id` | -| `queued` | Waiting in backend queue | `variation`, `total` | -| `running` | Generation in progress | `variation`, `total` | -| `downloading` | Model weights downloading | `variation`, `total` | -| `completed` | Generation finished | `output_path`, `duration`, `format` | -| `cancelled` | User cancelled the generation | — | +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:00Z", + "kind": "", + ... +} +``` -### Field descriptions +| Field | Type | Description | +| ------ | ------- | ------------------------------------------------ | +| `v` | integer | Schema version (currently `1`) | +| `ts` | string | ISO 8601 UTC timestamp | +| `kind` | string | Event kind: `lifecycle`, `progress`, `result`, `error` | -| Field | Type | Description | -| ------------- | ------- | ---------------------------------------- | -| `task_id` | string | Backend task identifier | -| `variation` | integer | Current variation index (1-based) | -| `total` | integer | Total number of variations | -| `output_path` | string | Path to the generated audio file | -| `duration` | number | Generation duration in seconds | -| `format` | string | Audio format (`wav`, `mp3`, `flac`, `ogg`) | +Additional fields depend on `kind`. -### Example stream +--- -``` -{"event":"submitted","task_id":"abc123"} -{"event":"queued","variation":1,"total":1} -{"event":"running","variation":1,"total":1} -{"event":"completed","output_path":"~/Music/openloop/output.wav","duration":88.0,"format":"wav"} -``` +## Lifecycle Events + +Emitted during backend startup/shutdown. -### Notes on `failed` and `completed` +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:00Z", + "kind": "lifecycle", + "phase": "starting", + "port": 8001, + "ownership": "owned", + "message": "Backend starting..." +} +``` -- **`failed`**: The generation task runner emits a `failed` event internally, but `CliGenerationSink` does not handle it in JSON mode — it falls through to the catch-all `_ => {}` branch. Failures surface as a Rust `Err` and produce a non-JSON error message to stderr. This will be addressed in a future update. -- **`completed`**: During multi-step generation, the intermediate `completed` event from the task runner is suppressed. The final `completed` event with the correct (possibly renamed) output path is emitted by the post-generation loop. +| Field | Type | Description | +| ----------- | -------------- | --------------------------------------------------------------- | +| `phase` | string | `starting`, `healthy`, `stopped`, `failed` | +| `port` | integer \| null | Backend port (null if not yet known) | +| `ownership` | string | `owned` (started by this session) or `attached` (already running) | +| `message` | string | Human-readable status message | --- -## `openloop pull` Events +## Progress Events +Emitted during long-running operations (model download, generation). + +```json +{ + "v": 1, + "ts": "2026-06-14T12:00:05Z", + "kind": "progress", + "pct": 42, + "label": "downloading", + "detail": "1.2 GB / 2.8 GB" +} ``` -{"event":"completed","model":"Lite"} -{"event":"completed","model":"Turbo","total_bytes":4294967296} -``` + +| Field | Type | Description | +| -------- | -------------- | ---------------------------------- | +| `pct` | integer \| null | Percentage 0–100 (null if unknown) | +| `label` | string | Operation label | +| `detail` | string \| null | Optional detail text | --- -## `openloop status` Output +## Result Events -Returns a single JSON object (not streaming): +Emitted on successful completion. ```json { - "backend": { "state": "healthy", "port": 8001, "ownership": "owned" }, - "model": { "variant": "turbo", "downloaded": true }, - "activeTasks": [], - "device": { "os": "macos", "arch": "aarch64", "isAppleSilicon": true, "totalMemoryGb": 16 } + "v": 1, + "ts": "2026-06-14T12:01:30Z", + "kind": "result", + "event": "completed", + "path": "~/Music/openloop/generation-abc123.wav", + "duration_ms": 45200, + "seed": 12345 } ``` +The `result` envelope merges the `data` object directly. Common fields for `openloop run`: + +| Field | Type | Description | +| ------------ | ------- | ----------------------------------- | +| `event` | string | Always `"completed"` | +| `path` | string | Output file path | +| `duration_ms`| integer | Generation duration in milliseconds | +| `seed` | integer | Seed used for generation | + --- -## `openloop enhance` Output +## Error Events -Returns the enhancement result as a single JSON object: +Emitted on failure (to stderr). ```json { - "prompt": "warm piano, 90 BPM", - "lyrics": null, - "bpm": 90, - "key_scale": "C major", - "time_signature": "4/4", - "duration_seconds": 30.0, - "vocal_language": "en" + "v": 1, + "ts": "2026-06-14T12:00:10Z", + "kind": "error", + "code": "BACKEND_NOT_HEALTHY", + "message": "Backend failed to start within 120s", + "recoverable": true, + "suggestion": "Run openloop doctor to diagnose" } ``` +| Field | Type | Description | +| ------------ | ------- | ---------------------------------------- | +| `code` | string | Machine-readable error code | +| `message` | string | Human-readable error description | +| `recoverable`| boolean | Whether retrying may succeed | +| `suggestion` | string \| null | Suggested remediation action | + --- -## Other Commands +## Generation Task Events + +During `openloop run`, the generation task runner emits intermediate events as bare JSON lines (no envelope). These use an `event` field. + +| `event` | Description | Additional fields | +| ------------- | ------------------------------------- | ------------------------------------- | +| `submitted` | Task submitted to backend | `task_id` | +| `queued` | Waiting in backend queue | `variation`, `total` | +| `running` | Generation in progress | `variation`, `total` | +| `downloading` | Model weights downloading | `variation`, `total` | +| `completed` | Generation finished | `output_path`, `duration`, `format` | +| `cancelled` | User cancelled the generation | — | -Commands like `list`, `delete`, `clear`, `ps`, `stop`, `doctor`, `files`, `setup`, `settings`, `models`, and `backend` subcommands emit their own JSON structures when invoked with `--json`. These are documented per-command via `openloop --help`. +> **Note:** The `failed` event is emitted by the generation task runner but currently only handled in human mode. In JSON mode it falls through to the catch-all and is not emitted. This will be addressed in a future update. + +### Example stream + +``` +{"event":"submitted","task_id":"abc123"} +{"event":"queued","variation":1,"total":1} +{"event":"running","variation":1,"total":1} +{"event":"completed","output_path":"~/Music/openloop/output.wav","duration":88.0,"format":"wav"} +``` --- -## Defined but Unused Event Infrastructure +## CLI Output Modes -`cli::events` defines envelope-based functions (`emit_lifecycle`, `emit_progress`, `emit_result`, `emit_error`) with a shared `{v, ts, kind, ...}` envelope format. These functions have **no call sites** in the current CLI code and are not emitted. They exist as infrastructure for future use. +- **Human mode** (default): Progress and lifecycle messages go to stderr as formatted text. Only the final result is printed to stdout. +- **JSON mode** (`--json`): All events are emitted as NDJSON to stdout. Errors go to stderr. -If adopted, the envelope would look like: +## Notes -```json -{"v": 1, "ts": "2026-06-14T12:00:00Z", "kind": "lifecycle", "phase": "starting", "port": 8001, "ownership": "owned", "message": "Backend starting..."} -{"v": 1, "ts": "2026-06-14T12:00:05Z", "kind": "progress", "pct": 42, "label": "downloading", "detail": "1.2 GB / 2.8 GB"} -{"v": 1, "ts": "2026-06-14T12:00:10Z", "kind": "error", "code": "BACKEND_NOT_HEALTHY", "message": "...", "recoverable": true, "suggestion": "..."} -``` +- Events are emitted one per line (no pretty-printing) for stream parsing. +- The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. +- Timestamps are always UTC in RFC 3339 format. +- The envelope format (`v`, `ts`, `kind`) is defined in `cli::events` and used by lifecycle/progress/result/error events. Generation task events currently use a bare format without the envelope. -This section is informational only — consumers should not expect these events until they are wired up. From 4154342f9195ec3af7d65a0c85a142e9e39fc052 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:25:53 -0700 Subject: [PATCH 09/24] feat: wire emit_lifecycle and emit_error into CLI commands - backend status/start/stop/restart: emit lifecycle events in JSON mode - run: emit error events for validation failures in JSON mode The envelope-based event functions (emit_lifecycle, emit_progress, emit_result, emit_error) now have actual call sites in CLI commands. --- src-tauri/src/cli/backend.rs | 22 ++++++++++++++++++++++ src-tauri/src/cli/run.rs | 24 +++++++++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index e894fb5..728f418 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -78,6 +78,13 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { let provision_info = read_backend_manifest(&state.app_data_dir); if json { + let (phase, port) = match &status { + BackendStatus::Healthy { port } => ("healthy", Some(port)), + BackendStatus::Starting => ("starting", None), + BackendStatus::Stopped => ("stopped", None), + BackendStatus::Failed { .. } => ("failed", None), + }; + events::emit_lifecycle(phase, port.copied(), &ownership, &format!("Backend status: {phase}")); let mut output: serde_json::Value = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { @@ -150,6 +157,13 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { let status = backend.start(&settings)?; if json { + let (phase, port, msg) = match &status { + BackendStatus::Healthy { port } => ("healthy", Some(*port), format!("Backend started (port {port})")), + BackendStatus::Starting => ("starting", None, "Backend starting…".to_owned()), + BackendStatus::Stopped => ("stopped", None, "Backend: stopped".to_owned()), + BackendStatus::Failed { error } => ("failed", None, format!("Backend failed: {}", error.message)), + }; + events::emit_lifecycle(phase, port, "owned", &msg); let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; super::json_output(&output); } else { @@ -185,6 +199,7 @@ fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let status = backend.stop()?; if json { + events::emit_lifecycle("stopped", None, "owned", "Backend stopped"); let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; super::json_output(&output); } else { @@ -205,6 +220,13 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { let status = backend.restart(&settings)?; if json { + let (phase, port, msg) = match &status { + BackendStatus::Healthy { port } => ("healthy", Some(*port), format!("Backend restarted (port {port})")), + BackendStatus::Starting => ("starting", None, "Backend restarting…".to_owned()), + BackendStatus::Failed { error } => ("failed", None, format!("Backend failed to restart: {}", error.message)), + _ => ("stopped", None, "Backend restarted".to_owned()), + }; + events::emit_lifecycle(phase, port, "owned", &msg); let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; super::json_output(&output); } else { diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index eaf0b56..9e2ff96 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -71,9 +71,11 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { }; if prompt.is_empty() { - return Err(cli_error( - "prompt is required. Usage: openloop run ", - )); + let err = cli_error("prompt is required. Usage: openloop run "); + if json { + super::events::emit_error(&err, false, Some("Provide a prompt: openloop run ")); + } + return Err(err); } let settings = state.db.get_settings()?; @@ -84,7 +86,13 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { let backend_status = backend.start(&settings)?; match &backend_status { crate::models::backend::BackendStatus::Healthy { port } => *port, - _ => return Err(cli_error("backend is not healthy")), + _ => { + let err = cli_error("backend is not healthy"); + if json { + super::events::emit_error(&err, true, Some("Run openloop doctor to diagnose")); + } + return Err(err); + } } }; @@ -93,7 +101,13 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { Some("lite") => Some(ModelVariant::Lite), Some("turbo") => Some(ModelVariant::Turbo), Some("pro") => Some(ModelVariant::Pro), - Some(other) => return Err(cli_error(format!("unknown model variant: {other}"))), + Some(other) => { + let err = cli_error(format!("unknown model variant: {other}")); + if json { + super::events::emit_error(&err, false, Some("Available variants: lite, turbo, pro")); + } + return Err(err); + } None => settings.model_variant, }; From 041447b33cae2a4c8d05467742a8acd1f71f4552 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:26:13 -0700 Subject: [PATCH 10/24] docs: update event schema notes to reflect lifecycle/error events now wired --- docs/specs/event-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 39b3e1f..ca226f8 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -161,5 +161,5 @@ During `openloop run`, the generation task runner emits intermediate events as b - Events are emitted one per line (no pretty-printing) for stream parsing. - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. -- The envelope format (`v`, `ts`, `kind`) is defined in `cli::events` and used by lifecycle/progress/result/error events. Generation task events currently use a bare format without the envelope. +- Lifecycle events are emitted by `backend status/start/stop/restart` in JSON mode. Error events are emitted by `run` for validation failures. Progress and result events are defined but not yet wired to CLI commands. From e43b2c0c17e2d9ee58348043a1a603dda0860537 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:28:34 -0700 Subject: [PATCH 11/24] style: cargo fmt --- src-tauri/src/cli/backend.rs | 29 ++++++++++++++++++++++++----- src-tauri/src/cli/run.rs | 6 +++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 728f418..033b6f8 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -84,7 +84,12 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { BackendStatus::Stopped => ("stopped", None), BackendStatus::Failed { .. } => ("failed", None), }; - events::emit_lifecycle(phase, port.copied(), &ownership, &format!("Backend status: {phase}")); + events::emit_lifecycle( + phase, + port.copied(), + &ownership, + &format!("Backend status: {phase}"), + ); let mut output: serde_json::Value = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { @@ -158,10 +163,16 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { if json { let (phase, port, msg) = match &status { - BackendStatus::Healthy { port } => ("healthy", Some(*port), format!("Backend started (port {port})")), + BackendStatus::Healthy { port } => ( + "healthy", + Some(*port), + format!("Backend started (port {port})"), + ), BackendStatus::Starting => ("starting", None, "Backend starting…".to_owned()), BackendStatus::Stopped => ("stopped", None, "Backend: stopped".to_owned()), - BackendStatus::Failed { error } => ("failed", None, format!("Backend failed: {}", error.message)), + BackendStatus::Failed { error } => { + ("failed", None, format!("Backend failed: {}", error.message)) + } }; events::emit_lifecycle(phase, port, "owned", &msg); let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; @@ -221,9 +232,17 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { if json { let (phase, port, msg) = match &status { - BackendStatus::Healthy { port } => ("healthy", Some(*port), format!("Backend restarted (port {port})")), + BackendStatus::Healthy { port } => ( + "healthy", + Some(*port), + format!("Backend restarted (port {port})"), + ), BackendStatus::Starting => ("starting", None, "Backend restarting…".to_owned()), - BackendStatus::Failed { error } => ("failed", None, format!("Backend failed to restart: {}", error.message)), + BackendStatus::Failed { error } => ( + "failed", + None, + format!("Backend failed to restart: {}", error.message), + ), _ => ("stopped", None, "Backend restarted".to_owned()), }; events::emit_lifecycle(phase, port, "owned", &msg); diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index 9e2ff96..d70b54d 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -104,7 +104,11 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { Some(other) => { let err = cli_error(format!("unknown model variant: {other}")); if json { - super::events::emit_error(&err, false, Some("Available variants: lite, turbo, pro")); + super::events::emit_error( + &err, + false, + Some("Available variants: lite, turbo, pro"), + ); } return Err(err); } From b712757f5d7e559da90f0ace92c74bb23e88cfc5 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:31:22 -0700 Subject: [PATCH 12/24] fix: remove needless borrow in emit_lifecycle call --- src-tauri/src/cli/backend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 033b6f8..fe275ff 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -87,7 +87,7 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { events::emit_lifecycle( phase, port.copied(), - &ownership, + ownership, &format!("Backend status: {phase}"), ); let mut output: serde_json::Value = From 0edd299e41e016024342483e091257a9c25fc3b6 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:40:06 -0700 Subject: [PATCH 13/24] fix: merge lifecycle envelope into status JSON, emit single NDJSON line Backend status/start/stop/restart --json now emits one NDJSON line with lifecycle envelope fields (v, ts, kind, phase, message) merged into the status object. Previously emitted two outputs: a lifecycle event followed by pretty-printed JSON, breaking NDJSON parsers. --- docs/specs/event-schema.md | 8 ++-- src-tauri/src/cli/backend.rs | 71 ++++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index ca226f8..485c036 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -27,17 +27,17 @@ Additional fields depend on `kind`. ## Lifecycle Events -Emitted during backend startup/shutdown. +Emitted by `backend status/start/stop/restart --json`. The lifecycle envelope fields are merged into the backend status JSON object (single NDJSON line). ```json { "v": 1, "ts": "2026-06-14T12:00:00Z", "kind": "lifecycle", - "phase": "starting", + "phase": "healthy", "port": 8001, "ownership": "owned", - "message": "Backend starting..." + "message": "Backend started (port 8001)" } ``` @@ -161,5 +161,5 @@ During `openloop run`, the generation task runner emits intermediate events as b - Events are emitted one per line (no pretty-printing) for stream parsing. - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. -- Lifecycle events are emitted by `backend status/start/stop/restart` in JSON mode. Error events are emitted by `run` for validation failures. Progress and result events are defined but not yet wired to CLI commands. +- Lifecycle events are emitted by `backend status/start/stop/restart --json` as a single NDJSON line with envelope fields merged into the status object. Error events are emitted by `run --json` for validation failures. Progress and result events are defined but not yet wired to CLI commands. diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index fe275ff..605f795 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -79,21 +79,26 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { if json { let (phase, port) = match &status { - BackendStatus::Healthy { port } => ("healthy", Some(port)), + BackendStatus::Healthy { port } => ("healthy", Some(*port)), BackendStatus::Starting => ("starting", None), BackendStatus::Stopped => ("stopped", None), BackendStatus::Failed { .. } => ("failed", None), }; - events::emit_lifecycle( - phase, - port.copied(), - ownership, - &format!("Backend status: {phase}"), - ); let mut output: serde_json::Value = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { + obj.insert("v".to_owned(), serde_json::json!(1)); + obj.insert( + "ts".to_owned(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); + obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); + obj.insert("phase".to_owned(), serde_json::json!(phase)); + obj.insert("message".to_owned(), serde_json::json!(format!("Backend status: {phase}"))); obj.insert("ownership".to_owned(), serde_json::json!(ownership)); + if let Some(p) = port { + obj.insert("port".to_owned(), serde_json::json!(p)); + } match &provision_info { Some(manifest) => { obj.insert( @@ -116,9 +121,7 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { } } } - super::json_output( - &serde_json::to_string_pretty(&output).map_err(|e| cli_error(e.to_string()))?, - ); + super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &status { BackendStatus::Healthy { port } => { @@ -174,9 +177,20 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { ("failed", None, format!("Backend failed: {}", error.message)) } }; - events::emit_lifecycle(phase, port, "owned", &msg); - let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let mut output = + serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("v".to_owned(), serde_json::json!(1)); + obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); + obj.insert("phase".to_owned(), serde_json::json!(phase)); + obj.insert("message".to_owned(), serde_json::json!(msg)); + obj.insert("ownership".to_owned(), serde_json::json!("owned")); + if let Some(p) = port { + obj.insert("port".to_owned(), serde_json::json!(p)); + } + } + super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &status { BackendStatus::Healthy { port } => { @@ -210,9 +224,17 @@ fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let status = backend.stop()?; if json { - events::emit_lifecycle("stopped", None, "owned", "Backend stopped"); - let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let mut output = + serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("v".to_owned(), serde_json::json!(1)); + obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); + obj.insert("phase".to_owned(), serde_json::json!("stopped")); + obj.insert("message".to_owned(), serde_json::json!("Backend stopped")); + obj.insert("ownership".to_owned(), serde_json::json!("owned")); + } + super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { events::human_success("Backend stopped"); } @@ -245,9 +267,20 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { ), _ => ("stopped", None, "Backend restarted".to_owned()), }; - events::emit_lifecycle(phase, port, "owned", &msg); - let output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let mut output = + serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("v".to_owned(), serde_json::json!(1)); + obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); + obj.insert("phase".to_owned(), serde_json::json!(phase)); + obj.insert("message".to_owned(), serde_json::json!(msg)); + obj.insert("ownership".to_owned(), serde_json::json!("owned")); + if let Some(p) = port { + obj.insert("port".to_owned(), serde_json::json!(p)); + } + } + super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &status { BackendStatus::Healthy { port } => { From 9dd0df39bac26cc4758954ecaecd973537f06b02 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:45:56 -0700 Subject: [PATCH 14/24] style: cargo fmt --- src-tauri/src/cli/backend.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 605f795..914fb9b 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -94,7 +94,10 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { ); obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!(phase)); - obj.insert("message".to_owned(), serde_json::json!(format!("Backend status: {phase}"))); + obj.insert( + "message".to_owned(), + serde_json::json!(format!("Backend status: {phase}")), + ); obj.insert("ownership".to_owned(), serde_json::json!(ownership)); if let Some(p) = port { obj.insert("port".to_owned(), serde_json::json!(p)); @@ -177,11 +180,13 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { ("failed", None, format!("Backend failed: {}", error.message)) } }; - let mut output = - serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert( + "ts".to_owned(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!(phase)); obj.insert("message".to_owned(), serde_json::json!(msg)); @@ -224,11 +229,13 @@ fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let status = backend.stop()?; if json { - let mut output = - serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert( + "ts".to_owned(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!("stopped")); obj.insert("message".to_owned(), serde_json::json!("Backend stopped")); @@ -267,11 +274,13 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { ), _ => ("stopped", None, "Backend restarted".to_owned()), }; - let mut output = - serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; if let Some(obj) = output.as_object_mut() { obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert("ts".to_owned(), serde_json::json!(chrono::Utc::now().to_rfc3339())); + obj.insert( + "ts".to_owned(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!(phase)); obj.insert("message".to_owned(), serde_json::json!(msg)); From 717ba570ed0bb19108a7bcc83ed919a27ea1000c Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:53:41 -0700 Subject: [PATCH 15/24] fix: use backend.ownership() instead of hardcoded 'owned' in start/stop/restart --- src-tauri/src/cli/backend.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 914fb9b..faa8fc7 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -165,6 +165,7 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; + let ownership = backend.ownership().to_owned(); let status = backend.start(&settings)?; if json { @@ -190,7 +191,7 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!(phase)); obj.insert("message".to_owned(), serde_json::json!(msg)); - obj.insert("ownership".to_owned(), serde_json::json!("owned")); + obj.insert("ownership".to_owned(), serde_json::json!(ownership)); if let Some(p) = port { obj.insert("port".to_owned(), serde_json::json!(p)); } @@ -226,6 +227,7 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; + let ownership = backend.ownership().to_owned(); let status = backend.stop()?; if json { @@ -239,7 +241,7 @@ fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!("stopped")); obj.insert("message".to_owned(), serde_json::json!("Backend stopped")); - obj.insert("ownership".to_owned(), serde_json::json!("owned")); + obj.insert("ownership".to_owned(), serde_json::json!(ownership)); } super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { @@ -257,6 +259,7 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; + let ownership = backend.ownership().to_owned(); let status = backend.restart(&settings)?; if json { @@ -284,7 +287,7 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); obj.insert("phase".to_owned(), serde_json::json!(phase)); obj.insert("message".to_owned(), serde_json::json!(msg)); - obj.insert("ownership".to_owned(), serde_json::json!("owned")); + obj.insert("ownership".to_owned(), serde_json::json!(ownership)); if let Some(p) = port { obj.insert("port".to_owned(), serde_json::json!(p)); } From c2f297e6dbb0edb4b23a7e5718a343469b02622c Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:09:21 -0700 Subject: [PATCH 16/24] fix: use to_string instead of to_string_pretty in execute_logs --- src-tauri/src/cli/backend.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index faa8fc7..caded72 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -325,9 +325,7 @@ fn execute_logs(state: &AppState, args: &[String]) -> AppResult<()> { if json { let output = serde_json::json!({ "logs_path": path }); - super::json_output( - &serde_json::to_string_pretty(&output).map_err(|e| cli_error(e.to_string()))?, - ); + super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &path { Some(p) => { From 544fda0dc5142c01603ed2749aec6ea1fffd5109 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:29:22 -0700 Subject: [PATCH 17/24] fix: construct lifecycle JSON directly, always include port (null when absent) - Remove serde_json::to_value(&status) which leaked serde-tagged state field alongside phase, causing potential contradictions - Use serde_json::json!() to construct clean lifecycle objects - port is always present: integer for Healthy, null otherwise - status field no longer leaks into lifecycle output --- src-tauri/src/cli/backend.rs | 119 ++++++++++++++++------------------- 1 file changed, 55 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index caded72..117cf77 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -82,26 +82,34 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { BackendStatus::Healthy { port } => ("healthy", Some(*port)), BackendStatus::Starting => ("starting", None), BackendStatus::Stopped => ("stopped", None), - BackendStatus::Failed { .. } => ("failed", None), + BackendStatus::Failed { error } => { + // Include error details for failed status + let output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": "failed", + "port": null, + "ownership": ownership, + "message": format!("Backend failed: {}", error.message), + "error": error.message, + }); + super::json_output( + &serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?, + ); + return Ok(()); + } }; - let mut output: serde_json::Value = - serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + let mut output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": phase, + "port": port, + "ownership": ownership, + "message": format!("Backend status: {phase}"), + }); if let Some(obj) = output.as_object_mut() { - obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert( - "ts".to_owned(), - serde_json::json!(chrono::Utc::now().to_rfc3339()), - ); - obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); - obj.insert("phase".to_owned(), serde_json::json!(phase)); - obj.insert( - "message".to_owned(), - serde_json::json!(format!("Backend status: {phase}")), - ); - obj.insert("ownership".to_owned(), serde_json::json!(ownership)); - if let Some(p) = port { - obj.insert("port".to_owned(), serde_json::json!(p)); - } match &provision_info { Some(manifest) => { obj.insert( @@ -117,9 +125,7 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { None => { obj.insert( "backendCode".to_owned(), - serde_json::json!({ - "installed": false, - }), + serde_json::json!({ "installed": false }), ); } } @@ -181,21 +187,15 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { ("failed", None, format!("Backend failed: {}", error.message)) } }; - let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; - if let Some(obj) = output.as_object_mut() { - obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert( - "ts".to_owned(), - serde_json::json!(chrono::Utc::now().to_rfc3339()), - ); - obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); - obj.insert("phase".to_owned(), serde_json::json!(phase)); - obj.insert("message".to_owned(), serde_json::json!(msg)); - obj.insert("ownership".to_owned(), serde_json::json!(ownership)); - if let Some(p) = port { - obj.insert("port".to_owned(), serde_json::json!(p)); - } - } + let output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": phase, + "port": port, + "ownership": ownership, + "message": msg, + }); super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &status { @@ -228,21 +228,18 @@ fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; let ownership = backend.ownership().to_owned(); - let status = backend.stop()?; + let _status = backend.stop()?; if json { - let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; - if let Some(obj) = output.as_object_mut() { - obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert( - "ts".to_owned(), - serde_json::json!(chrono::Utc::now().to_rfc3339()), - ); - obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); - obj.insert("phase".to_owned(), serde_json::json!("stopped")); - obj.insert("message".to_owned(), serde_json::json!("Backend stopped")); - obj.insert("ownership".to_owned(), serde_json::json!(ownership)); - } + let output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": "stopped", + "port": null, + "ownership": ownership, + "message": "Backend stopped", + }); super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { events::human_success("Backend stopped"); @@ -277,21 +274,15 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { ), _ => ("stopped", None, "Backend restarted".to_owned()), }; - let mut output = serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; - if let Some(obj) = output.as_object_mut() { - obj.insert("v".to_owned(), serde_json::json!(1)); - obj.insert( - "ts".to_owned(), - serde_json::json!(chrono::Utc::now().to_rfc3339()), - ); - obj.insert("kind".to_owned(), serde_json::json!("lifecycle")); - obj.insert("phase".to_owned(), serde_json::json!(phase)); - obj.insert("message".to_owned(), serde_json::json!(msg)); - obj.insert("ownership".to_owned(), serde_json::json!(ownership)); - if let Some(p) = port { - obj.insert("port".to_owned(), serde_json::json!(p)); - } - } + let output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": phase, + "port": port, + "ownership": ownership, + "message": msg, + }); super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); } else { match &status { From 3014d231ad5cd5edc171dff00a2450de29ac0df2 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:48:29 -0700 Subject: [PATCH 18/24] fix: read ownership after start()/restart() to capture correct state --- src-tauri/src/cli/backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 117cf77..da05837 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -171,8 +171,8 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; - let ownership = backend.ownership().to_owned(); let status = backend.start(&settings)?; + let ownership = backend.ownership().to_owned(); if json { let (phase, port, msg) = match &status { @@ -256,8 +256,8 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; - let ownership = backend.ownership().to_owned(); let status = backend.restart(&settings)?; + let ownership = backend.ownership().to_owned(); if json { let (phase, port, msg) = match &status { From 058a4683d89b9f88bc28b42642b9c5d11cb32d96 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:03:53 -0700 Subject: [PATCH 19/24] fix: remove emit_error from run.rs to avoid double stderr output emit_error() + return Err() caused double stderr: NDJSON error line then human-format error from mod.rs::run(). Let mod.rs handle errors. --- src-tauri/src/cli/run.rs | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index d70b54d..eaf0b56 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -71,11 +71,9 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { }; if prompt.is_empty() { - let err = cli_error("prompt is required. Usage: openloop run "); - if json { - super::events::emit_error(&err, false, Some("Provide a prompt: openloop run ")); - } - return Err(err); + return Err(cli_error( + "prompt is required. Usage: openloop run ", + )); } let settings = state.db.get_settings()?; @@ -86,13 +84,7 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { let backend_status = backend.start(&settings)?; match &backend_status { crate::models::backend::BackendStatus::Healthy { port } => *port, - _ => { - let err = cli_error("backend is not healthy"); - if json { - super::events::emit_error(&err, true, Some("Run openloop doctor to diagnose")); - } - return Err(err); - } + _ => return Err(cli_error("backend is not healthy")), } }; @@ -101,17 +93,7 @@ pub fn execute(state: &AppState, args: &[String]) -> AppResult<()> { Some("lite") => Some(ModelVariant::Lite), Some("turbo") => Some(ModelVariant::Turbo), Some("pro") => Some(ModelVariant::Pro), - Some(other) => { - let err = cli_error(format!("unknown model variant: {other}")); - if json { - super::events::emit_error( - &err, - false, - Some("Available variants: lite, turbo, pro"), - ); - } - return Err(err); - } + Some(other) => return Err(cli_error(format!("unknown model variant: {other}"))), None => settings.model_variant, }; From 3476aa0fab652335035a808cb03c0cc56a1fe7c2 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:13:12 -0700 Subject: [PATCH 20/24] fix: include backendCode in failed-status lifecycle response --- src-tauri/src/cli/backend.rs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index da05837..08f8b57 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -78,27 +78,11 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { let provision_info = read_backend_manifest(&state.app_data_dir); if json { - let (phase, port) = match &status { - BackendStatus::Healthy { port } => ("healthy", Some(*port)), - BackendStatus::Starting => ("starting", None), - BackendStatus::Stopped => ("stopped", None), - BackendStatus::Failed { error } => { - // Include error details for failed status - let output = serde_json::json!({ - "v": 1, - "ts": chrono::Utc::now().to_rfc3339(), - "kind": "lifecycle", - "phase": "failed", - "port": null, - "ownership": ownership, - "message": format!("Backend failed: {}", error.message), - "error": error.message, - }); - super::json_output( - &serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?, - ); - return Ok(()); - } + let (phase, port, error_msg) = match &status { + BackendStatus::Healthy { port } => ("healthy", Some(*port), None), + BackendStatus::Starting => ("starting", None, None), + BackendStatus::Stopped => ("stopped", None, None), + BackendStatus::Failed { error } => ("failed", None, Some(error.message.clone())), }; let mut output = serde_json::json!({ "v": 1, @@ -107,9 +91,15 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { "phase": phase, "port": port, "ownership": ownership, - "message": format!("Backend status: {phase}"), + "message": match &error_msg { + Some(e) => format!("Backend failed: {e}"), + None => format!("Backend status: {phase}"), + }, }); if let Some(obj) = output.as_object_mut() { + if let Some(e) = error_msg { + obj.insert("error".to_owned(), serde_json::json!(e)); + } match &provision_info { Some(manifest) => { obj.insert( From 735aa01ecf98ef1ff7f268446c197efaaed770d2 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:43:34 -0700 Subject: [PATCH 21/24] docs: align event schema spec with actual CLI output - Result Events: restore envelope format (not yet wired), point to Generation Task Events for current bare JSON behavior - Progress/Result/Error: add notes that envelope formats are defined but not yet wired to CLI commands - Notes: clarify only lifecycle events use envelope format today --- docs/specs/event-schema.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 485c036..cd8e9d3 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -71,11 +71,13 @@ Emitted during long-running operations (model download, generation). | `label` | string | Operation label | | `detail` | string \| null | Optional detail text | +> **Note:** The `progress` envelope format is defined in `events::emit_progress` but not yet wired to CLI commands. Current progress events during `openloop run` use bare JSON lines (see [Generation Task Events](#generation-task-events)). + --- ## Result Events -Emitted on successful completion. +Emitted on successful completion. The `result` envelope format is defined in `events::emit_result` but not yet wired to CLI commands. ```json { @@ -89,8 +91,6 @@ Emitted on successful completion. } ``` -The `result` envelope merges the `data` object directly. Common fields for `openloop run`: - | Field | Type | Description | | ------------ | ------- | ----------------------------------- | | `event` | string | Always `"completed"` | @@ -98,6 +98,8 @@ The `result` envelope merges the `data` object directly. Common fields for `open | `duration_ms`| integer | Generation duration in milliseconds | | `seed` | integer | Seed used for generation | +> **Note:** This envelope format is not yet emitted by any CLI command. Current completion events use bare JSON lines (see [Generation Task Events](#generation-task-events)). + --- ## Error Events @@ -123,6 +125,8 @@ Emitted on failure (to stderr). | `recoverable`| boolean | Whether retrying may succeed | | `suggestion` | string \| null | Suggested remediation action | +> **Note:** The `error` envelope format is defined in `events::emit_error` but not yet wired to CLI commands. Errors are currently reported via the CLI error handler as human-readable stderr output. + --- ## Generation Task Events @@ -138,7 +142,7 @@ During `openloop run`, the generation task runner emits intermediate events as b | `completed` | Generation finished | `output_path`, `duration`, `format` | | `cancelled` | User cancelled the generation | — | -> **Note:** The `failed` event is emitted by the generation task runner but currently only handled in human mode. In JSON mode it falls through to the catch-all and is not emitted. This will be addressed in a future update. +> **Note:** The `failed` event is emitted internally by the generation task runner but not currently surfaced in JSON mode. Errors propagate as `AppResult::Err` and are reported via the CLI error handler. This will be addressed in a future update. ### Example stream @@ -161,5 +165,5 @@ During `openloop run`, the generation task runner emits intermediate events as b - Events are emitted one per line (no pretty-printing) for stream parsing. - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. -- Lifecycle events are emitted by `backend status/start/stop/restart --json` as a single NDJSON line with envelope fields merged into the status object. Error events are emitted by `run --json` for validation failures. Progress and result events are defined but not yet wired to CLI commands. +- Lifecycle events are emitted by `backend status/start/stop/restart --json` as a single NDJSON line with envelope fields merged into the status object. Progress, result, and error envelope formats are defined in `events::*` but not yet wired to CLI commands. From 7dc7dd664103f8ad7820e23f6b341a6976de84ca Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:20:42 -0700 Subject: [PATCH 22/24] fix: align backend lifecycle JSON failures --- docs/specs/event-schema.md | 4 +- src-tauri/src/cli/backend.rs | 223 ++++++++++++++++++++------------ src-tauri/tests/cli_contract.rs | 105 ++++++++++++++- 3 files changed, 247 insertions(+), 85 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index cd8e9d3..96fb59e 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -45,8 +45,9 @@ Emitted by `backend status/start/stop/restart --json`. The lifecycle envelope fi | ----------- | -------------- | --------------------------------------------------------------- | | `phase` | string | `starting`, `healthy`, `stopped`, `failed` | | `port` | integer \| null | Backend port (null if not yet known) | -| `ownership` | string | `owned` (started by this session) or `attached` (already running) | +| `ownership` | string | `owned` (started by this session), `attached` (already running), or `stopped` (not running) | | `message` | string | Human-readable status message | +| `error` | string | Present when `phase` is `failed`; structured backend failure detail | --- @@ -166,4 +167,3 @@ During `openloop run`, the generation task runner emits intermediate events as b - The `v` field enables forward-compatible parsing; consumers should ignore unknown fields. - Timestamps are always UTC in RFC 3339 format. - Lifecycle events are emitted by `backend status/start/stop/restart --json` as a single NDJSON line with envelope fields merged into the status object. Progress, result, and error envelope formats are defined in `events::*` but not yet wired to CLI commands. - diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 08f8b57..8903644 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -2,7 +2,10 @@ use std::process::Command; use crate::{ cli::{cli_error, events, human_output}, - models::{backend::BackendStatus, errors::AppResult}, + models::{ + backend::BackendStatus, + errors::{AppError, AppResult}, + }, services::{ backend_provisioner::{read_backend_manifest, BackendProvisioner}, model_bootstrap::runtime_dir_for, @@ -62,6 +65,75 @@ fn json_flag(args: &[String]) -> bool { args.contains(&"--json".to_owned()) } +fn backend_error_text(error: &AppError) -> String { + error + .details + .as_deref() + .unwrap_or(&error.message) + .to_owned() +} + +fn lifecycle_event(status: &BackendStatus, ownership: &str, message: String) -> serde_json::Value { + let (phase, port, error_msg) = match status { + BackendStatus::Healthy { port } => ("healthy", Some(*port), None), + BackendStatus::Starting => ("starting", None, None), + BackendStatus::Stopped => ("stopped", None, None), + BackendStatus::Failed { error } => ("failed", None, Some(backend_error_text(error))), + }; + + let mut output = serde_json::json!({ + "v": 1, + "ts": chrono::Utc::now().to_rfc3339(), + "kind": "lifecycle", + "phase": phase, + "port": port, + "ownership": ownership, + "message": message, + }); + + if let (Some(obj), Some(error_msg)) = (output.as_object_mut(), error_msg) { + obj.insert("error".to_owned(), serde_json::json!(error_msg)); + } + + output +} + +fn print_json_value(output: &serde_json::Value) -> AppResult<()> { + super::json_output(&serde_json::to_string(output).map_err(|e| cli_error(e.to_string()))?); + Ok(()) +} + +fn status_lifecycle_message(status: &BackendStatus) -> String { + match status { + BackendStatus::Healthy { .. } => "Backend status: healthy".to_owned(), + BackendStatus::Starting => "Backend status: starting".to_owned(), + BackendStatus::Stopped => "Backend status: stopped".to_owned(), + BackendStatus::Failed { error } => format!("Backend failed: {}", backend_error_text(error)), + } +} + +fn start_lifecycle_message(status: &BackendStatus) -> String { + match status { + BackendStatus::Healthy { port } => format!("Backend started (port {port})"), + BackendStatus::Starting => "Backend starting…".to_owned(), + BackendStatus::Stopped => "Backend: stopped".to_owned(), + BackendStatus::Failed { error } => { + format!("Backend failed to start: {}", backend_error_text(error)) + } + } +} + +fn restart_lifecycle_message(status: &BackendStatus) -> String { + match status { + BackendStatus::Healthy { port } => format!("Backend restarted (port {port})"), + BackendStatus::Starting => "Backend restarting…".to_owned(), + BackendStatus::Stopped => "Backend restarted".to_owned(), + BackendStatus::Failed { error } => { + format!("Backend failed to restart: {}", backend_error_text(error)) + } + } +} + // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- @@ -78,28 +150,8 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { let provision_info = read_backend_manifest(&state.app_data_dir); if json { - let (phase, port, error_msg) = match &status { - BackendStatus::Healthy { port } => ("healthy", Some(*port), None), - BackendStatus::Starting => ("starting", None, None), - BackendStatus::Stopped => ("stopped", None, None), - BackendStatus::Failed { error } => ("failed", None, Some(error.message.clone())), - }; - let mut output = serde_json::json!({ - "v": 1, - "ts": chrono::Utc::now().to_rfc3339(), - "kind": "lifecycle", - "phase": phase, - "port": port, - "ownership": ownership, - "message": match &error_msg { - Some(e) => format!("Backend failed: {e}"), - None => format!("Backend status: {phase}"), - }, - }); + let mut output = lifecycle_event(&status, ownership, status_lifecycle_message(&status)); if let Some(obj) = output.as_object_mut() { - if let Some(e) = error_msg { - obj.insert("error".to_owned(), serde_json::json!(e)); - } match &provision_info { Some(manifest) => { obj.insert( @@ -120,7 +172,7 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { } } } - super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -161,32 +213,28 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; - let status = backend.start(&settings)?; + let status = match backend.start(&settings) { + Ok(status) => status, + Err(error) => { + if json { + let failed_status = BackendStatus::Failed { + error: error.clone(), + }; + let output = lifecycle_event( + &failed_status, + backend.ownership(), + start_lifecycle_message(&failed_status), + ); + print_json_value(&output)?; + } + return Err(error); + } + }; let ownership = backend.ownership().to_owned(); if json { - let (phase, port, msg) = match &status { - BackendStatus::Healthy { port } => ( - "healthy", - Some(*port), - format!("Backend started (port {port})"), - ), - BackendStatus::Starting => ("starting", None, "Backend starting…".to_owned()), - BackendStatus::Stopped => ("stopped", None, "Backend: stopped".to_owned()), - BackendStatus::Failed { error } => { - ("failed", None, format!("Backend failed: {}", error.message)) - } - }; - let output = serde_json::json!({ - "v": 1, - "ts": chrono::Utc::now().to_rfc3339(), - "kind": "lifecycle", - "phase": phase, - "port": port, - "ownership": ownership, - "message": msg, - }); - super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); + let output = lifecycle_event(&status, &ownership, start_lifecycle_message(&status)); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -217,20 +265,12 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; + let status = backend.stop()?; let ownership = backend.ownership().to_owned(); - let _status = backend.stop()?; if json { - let output = serde_json::json!({ - "v": 1, - "ts": chrono::Utc::now().to_rfc3339(), - "kind": "lifecycle", - "phase": "stopped", - "port": null, - "ownership": ownership, - "message": "Backend stopped", - }); - super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); + let output = lifecycle_event(&status, &ownership, "Backend stopped".to_owned()); + print_json_value(&output)?; } else { events::human_success("Backend stopped"); } @@ -246,34 +286,28 @@ fn execute_restart(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let settings = state.db.get_settings()?; let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; - let status = backend.restart(&settings)?; + let status = match backend.restart(&settings) { + Ok(status) => status, + Err(error) => { + if json { + let failed_status = BackendStatus::Failed { + error: error.clone(), + }; + let output = lifecycle_event( + &failed_status, + backend.ownership(), + restart_lifecycle_message(&failed_status), + ); + print_json_value(&output)?; + } + return Err(error); + } + }; let ownership = backend.ownership().to_owned(); if json { - let (phase, port, msg) = match &status { - BackendStatus::Healthy { port } => ( - "healthy", - Some(*port), - format!("Backend restarted (port {port})"), - ), - BackendStatus::Starting => ("starting", None, "Backend restarting…".to_owned()), - BackendStatus::Failed { error } => ( - "failed", - None, - format!("Backend failed to restart: {}", error.message), - ), - _ => ("stopped", None, "Backend restarted".to_owned()), - }; - let output = serde_json::json!({ - "v": 1, - "ts": chrono::Utc::now().to_rfc3339(), - "kind": "lifecycle", - "phase": phase, - "port": port, - "ownership": ownership, - "message": msg, - }); - super::json_output(&serde_json::to_string(&output).map_err(|e| cli_error(e.to_string()))?); + let output = lifecycle_event(&status, &ownership, restart_lifecycle_message(&status)); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -619,3 +653,28 @@ Flags: --help Show help", ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::errors::AppError; + + #[test] + fn failed_lifecycle_event_includes_structured_error() { + let status = BackendStatus::Failed { + error: AppError::backend_start_failed("port is already in use"), + }; + + let event = lifecycle_event(&status, "stopped", start_lifecycle_message(&status)); + + assert_eq!(event["kind"], "lifecycle"); + assert_eq!(event["phase"], "failed"); + assert_eq!(event["port"], serde_json::Value::Null); + assert_eq!(event["ownership"], "stopped"); + assert_eq!(event["error"], "port is already in use"); + assert_eq!( + event["message"], + "Backend failed to start: port is already in use" + ); + } +} diff --git a/src-tauri/tests/cli_contract.rs b/src-tauri/tests/cli_contract.rs index 614df4b..60dc2ba 100644 --- a/src-tauri/tests/cli_contract.rs +++ b/src-tauri/tests/cli_contract.rs @@ -1,5 +1,5 @@ -use std::fs; use std::sync::{atomic::AtomicBool, Arc}; +use std::{fs, process::Command}; use openloop_lib::{ app_state::AppState, @@ -248,6 +248,109 @@ fn backend_manager_starts_unowned() { assert!(!manager.is_owned()); } +#[test] +fn backend_start_json_failure_emits_structured_lifecycle_error() { + let home = tempfile::tempdir().expect("home dir"); + let app_data_dir = isolated_app_data_dir(home.path()); + let db = Database::new(&app_data_dir).expect("database should initialize"); + db.set_setting("modelVariant", serde_json::json!("turbo")) + .expect("model variant should persist"); + + let output = Command::new(env!("CARGO_BIN_EXE_openloop")) + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("APPDATA", home.path().join("AppData").join("Roaming")) + .args(["backend", "start", "--json"]) + .output() + .expect("backend start command should run"); + + assert_eq!(output.status.code(), Some(3)); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let line = stdout + .lines() + .next() + .expect("json failure should emit one lifecycle event"); + assert_eq!(stdout.lines().count(), 1); + + let event: serde_json::Value = serde_json::from_str(line).expect("stdout should be json"); + assert_eq!(event["kind"], "lifecycle"); + assert_eq!(event["phase"], "failed"); + assert_eq!(event["ownership"], "stopped"); + assert_eq!(event["port"], serde_json::Value::Null); + assert_eq!( + event["error"], + "ACE-Step backend code is not installed. Run 'openloop backend provision' or download from app settings." + ); + assert_eq!( + event["message"], + "Backend failed to start: ACE-Step backend code is not installed. Run 'openloop backend provision' or download from app settings." + ); +} + +#[test] +fn backend_restart_json_failure_emits_structured_lifecycle_error() { + let home = tempfile::tempdir().expect("home dir"); + let app_data_dir = isolated_app_data_dir(home.path()); + let db = Database::new(&app_data_dir).expect("database should initialize"); + db.set_setting("modelVariant", serde_json::json!("turbo")) + .expect("model variant should persist"); + + let output = Command::new(env!("CARGO_BIN_EXE_openloop")) + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("APPDATA", home.path().join("AppData").join("Roaming")) + .args(["backend", "restart", "--json"]) + .output() + .expect("backend restart command should run"); + + assert_eq!(output.status.code(), Some(3)); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let line = stdout + .lines() + .next() + .expect("json failure should emit one lifecycle event"); + assert_eq!(stdout.lines().count(), 1); + + let event: serde_json::Value = serde_json::from_str(line).expect("stdout should be json"); + assert_eq!(event["kind"], "lifecycle"); + assert_eq!(event["phase"], "failed"); + assert_eq!(event["ownership"], "stopped"); + assert_eq!(event["port"], serde_json::Value::Null); + assert_eq!( + event["error"], + "ACE-Step backend code is not installed. Run 'openloop backend provision' or download from app settings." + ); + assert_eq!( + event["message"], + "Backend failed to restart: ACE-Step backend code is not installed. Run 'openloop backend provision' or download from app settings." + ); +} + +fn isolated_app_data_dir(home: &std::path::Path) -> std::path::PathBuf { + #[cfg(target_os = "macos")] + { + home.join("Library") + .join("Application Support") + .join("com.openmusic.openloop") + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + home.join(".local") + .join("share") + .join("com.openmusic.openloop") + } + + #[cfg(windows)] + { + home.join("AppData") + .join("Roaming") + .join("com.openmusic.openloop") + } +} + // --------------------------------------------------------------------------- // M2: Active generation task cancel_requested_at round-trip // --------------------------------------------------------------------------- From 5b5c6a632744b653577b41035e433721aecd3288 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:30:21 -0700 Subject: [PATCH 23/24] fix: complete backend lifecycle JSON contract --- docs/specs/event-schema.md | 18 +++++++++++++ src-tauri/src/cli/backend.rs | 49 ++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 96fb59e..90835eb 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -48,6 +48,24 @@ Emitted by `backend status/start/stop/restart --json`. The lifecycle envelope fi | `ownership` | string | `owned` (started by this session), `attached` (already running), or `stopped` (not running) | | `message` | string | Human-readable status message | | `error` | string | Present when `phase` is `failed`; structured backend failure detail | +| `backendCode` | object | Present on `backend status --json`; backend code installation status | + +`backendCode` is one of: + +```json +{ "installed": false } +``` + +or: + +```json +{ + "installed": true, + "commit": "d5d958e", + "tag": null, + "installedAt": "2026-06-14T12:00:00Z" +} +``` --- diff --git a/src-tauri/src/cli/backend.rs b/src-tauri/src/cli/backend.rs index 8903644..a68411f 100644 --- a/src-tauri/src/cli/backend.rs +++ b/src-tauri/src/cli/backend.rs @@ -134,6 +134,17 @@ fn restart_lifecycle_message(status: &BackendStatus) -> String { } } +fn stop_lifecycle_message(status: &BackendStatus) -> String { + match status { + BackendStatus::Healthy { port } => format!("Backend still healthy (port {port})"), + BackendStatus::Starting => "Backend stop pending".to_owned(), + BackendStatus::Stopped => "Backend stopped".to_owned(), + BackendStatus::Failed { error } => { + format!("Backend failed to stop: {}", backend_error_text(error)) + } + } +} + // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- @@ -265,11 +276,27 @@ fn execute_start(state: &AppState, args: &[String]) -> AppResult<()> { fn execute_stop(state: &AppState, args: &[String]) -> AppResult<()> { let json = json_flag(args); let mut backend = state.backend.lock().map_err(|e| cli_error(e.to_string()))?; - let status = backend.stop()?; + let status = match backend.stop() { + Ok(status) => status, + Err(error) => { + if json { + let failed_status = BackendStatus::Failed { + error: error.clone(), + }; + let output = lifecycle_event( + &failed_status, + backend.ownership(), + stop_lifecycle_message(&failed_status), + ); + print_json_value(&output)?; + } + return Err(error); + } + }; let ownership = backend.ownership().to_owned(); if json { - let output = lifecycle_event(&status, &ownership, "Backend stopped".to_owned()); + let output = lifecycle_event(&status, &ownership, stop_lifecycle_message(&status)); print_json_value(&output)?; } else { events::human_success("Backend stopped"); @@ -677,4 +704,22 @@ mod tests { "Backend failed to start: port is already in use" ); } + + #[test] + fn failed_stop_lifecycle_event_includes_structured_error() { + let status = BackendStatus::Failed { + error: AppError::backend_start_failed("failed to terminate backend process"), + }; + + let event = lifecycle_event(&status, "owned", stop_lifecycle_message(&status)); + + assert_eq!(event["kind"], "lifecycle"); + assert_eq!(event["phase"], "failed"); + assert_eq!(event["ownership"], "owned"); + assert_eq!(event["error"], "failed to terminate backend process"); + assert_eq!( + event["message"], + "Backend failed to stop: failed to terminate backend process" + ); + } } From f0da4bf835da22369e83d9d52996fd265198bb22 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:31:33 -0700 Subject: [PATCH 24/24] docs: clarify planned result event field changes --- docs/specs/event-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/event-schema.md b/docs/specs/event-schema.md index 90835eb..503485b 100644 --- a/docs/specs/event-schema.md +++ b/docs/specs/event-schema.md @@ -117,7 +117,7 @@ Emitted on successful completion. The `result` envelope format is defined in `ev | `duration_ms`| integer | Generation duration in milliseconds | | `seed` | integer | Seed used for generation | -> **Note:** This envelope format is not yet emitted by any CLI command. Current completion events use bare JSON lines (see [Generation Task Events](#generation-task-events)). +> **Note:** This envelope format is not yet emitted by any CLI command. Current completion events use bare JSON lines (see [Generation Task Events](#generation-task-events)). Wiring this envelope is a planned breaking shape change from the current bare payload: `output_path` becomes `path`, `duration` changes from float seconds to integer `duration_ms`, `format` is omitted, and `seed` is added. ---