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..503485b --- /dev/null +++ b/docs/specs/event-schema.md @@ -0,0 +1,187 @@ +# 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 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": "healthy", + "port": 8001, + "ownership": "owned", + "message": "Backend started (port 8001)" +} +``` + +| 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), `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" +} +``` + +--- + +## 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 | + +> **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. The `result` envelope format is defined in `events::emit_result` but not yet wired to CLI commands. + +```json +{ + "v": 1, + "ts": "2026-06-14T12:01:30Z", + "kind": "result", + "event": "completed", + "path": "~/Music/openloop/generation-abc123.wav", + "duration_ms": 45200, + "seed": 12345 +} +``` + +| 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 | + +> **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. + +--- + +## 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 | + +> **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 + +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 | — | + +> **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 + +``` +{"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"} +``` + +--- + +## 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. +- 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 e894fb5..a68411f 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,86 @@ 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)) + } + } +} + +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 // --------------------------------------------------------------------------- @@ -78,10 +161,8 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { let provision_info = read_backend_manifest(&state.app_data_dir); if json { - let mut output: serde_json::Value = - serde_json::to_value(&status).map_err(|e| cli_error(e.to_string()))?; + let mut output = lifecycle_event(&status, ownership, status_lifecycle_message(&status)); if let Some(obj) = output.as_object_mut() { - obj.insert("ownership".to_owned(), serde_json::json!(ownership)); match &provision_info { Some(manifest) => { obj.insert( @@ -97,16 +178,12 @@ fn execute_status(state: &AppState, args: &[String]) -> AppResult<()> { None => { obj.insert( "backendCode".to_owned(), - serde_json::json!({ - "installed": false, - }), + serde_json::json!({ "installed": false }), ); } } } - super::json_output( - &serde_json::to_string_pretty(&output).map_err(|e| cli_error(e.to_string()))?, - ); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -147,11 +224,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 output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let output = lifecycle_event(&status, &ownership, start_lifecycle_message(&status)); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -182,11 +276,28 @@ 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 = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let output = lifecycle_event(&status, &ownership, stop_lifecycle_message(&status)); + print_json_value(&output)?; } else { events::human_success("Backend stopped"); } @@ -202,11 +313,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 output = serde_json::to_string_pretty(&status).map_err(|e| cli_error(e.to_string()))?; - super::json_output(&output); + let output = lifecycle_event(&status, &ownership, restart_lifecycle_message(&status)); + print_json_value(&output)?; } else { match &status { BackendStatus::Healthy { port } => { @@ -239,9 +367,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) => { @@ -554,3 +680,46 @@ 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" + ); + } + + #[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" + ); + } +} 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 // ---------------------------------------------------------------------------