From 4ba751113e219d74210def945cd068f73f39e3ab Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Tue, 17 Mar 2026 22:28:48 -0500 Subject: [PATCH 1/2] feat: add doctor command, enhance logs/status/dashboard, complete skill docs - Add `symphony doctor` pre-flight check (WORKFLOW.md, env vars, binaries, daemon, workspace) - Enhance `symphony logs` with `--level` and `--since` filtering + TTY pretty-print - Enhance `symphony status` with token breakdown (in/out/total) and config metrics - Implement `symphony workspace --clean` with actual directory removal - Improve dashboard HTML: auto-refresh (5s), retrying table, token breakdown, config summary - Add `get_metrics()` to SymphonyClient - Complete SKILL.md commands table (7 missing commands) - Add Arcan runtime section to SKILL.md - Create operator-runbook.md with 11 executable diagnostic recipes - Add Arcan + doctor troubleshooting entries - Move canonical skill to .agents/skills/symphony/ (git-tracked), remove .claude/skills/ tracking - Add symphony-arcan crate with HTTP client, event mapper, and runner Co-Authored-By: Claude Opus 4.6 (1M context) --- {.claude => .agents}/skills/symphony/SKILL.md | 26 ++ .../symphony/references/operator-runbook.md | 91 ++++ .../symphony/references/troubleshooting.md | 19 + .../symphony/references/workflow-config.md | 0 .gitignore | 10 +- Cargo.lock | 131 ++++++ Cargo.toml | 2 + crates/symphony-arcan/Cargo.toml | 22 + crates/symphony-arcan/src/client.rs | 396 ++++++++++++++++++ crates/symphony-arcan/src/event_mapper.rs | 131 ++++++ crates/symphony-arcan/src/lib.rs | 13 + crates/symphony-arcan/src/runner.rs | 390 +++++++++++++++++ crates/symphony-config/src/loader.rs | 27 ++ crates/symphony-config/src/types.rs | 32 ++ crates/symphony-observability/src/server.rs | 84 +++- crates/symphony-orchestrator/Cargo.toml | 1 + crates/symphony-orchestrator/src/scheduler.rs | 106 +++-- src/cli/client.rs | 19 + src/cli/doctor.rs | 171 ++++++++ src/cli/logs.rs | 178 +++++++- src/cli/mod.rs | 19 + src/cli/status.rs | 28 +- src/cli/workspaces.rs | 37 +- src/main.rs | 10 +- tests/cli_integration.rs | 59 +++ 25 files changed, 1933 insertions(+), 69 deletions(-) rename {.claude => .agents}/skills/symphony/SKILL.md (78%) create mode 100644 .agents/skills/symphony/references/operator-runbook.md rename {.claude => .agents}/skills/symphony/references/troubleshooting.md (84%) rename {.claude => .agents}/skills/symphony/references/workflow-config.md (100%) create mode 100644 crates/symphony-arcan/Cargo.toml create mode 100644 crates/symphony-arcan/src/client.rs create mode 100644 crates/symphony-arcan/src/event_mapper.rs create mode 100644 crates/symphony-arcan/src/lib.rs create mode 100644 crates/symphony-arcan/src/runner.rs create mode 100644 src/cli/doctor.rs diff --git a/.claude/skills/symphony/SKILL.md b/.agents/skills/symphony/SKILL.md similarity index 78% rename from .claude/skills/symphony/SKILL.md rename to .agents/skills/symphony/SKILL.md index 61d6564..130d4d1 100644 --- a/.claude/skills/symphony/SKILL.md +++ b/.agents/skills/symphony/SKILL.md @@ -39,9 +39,18 @@ symphony start WORKFLOW.md # run daemon | `symphony stop` | Graceful shutdown | | `symphony validate WORKFLOW.md` | Validate config + template | | `symphony config WORKFLOW.md` | Show resolved config | +| `symphony logs [--follow] [--level LEVEL] [--since TIME]` | Tail daemon log file (filter by level/time) | +| `symphony workspaces` | List workspace directories | +| `symphony workspace STI-123 [--clean]` | Show/manage a workspace | +| `symphony check` | Run `make smoke` equivalent | +| `symphony audit` | Full control audit | +| `symphony test [--crate-name NAME]` | Run tests with filtering | +| `symphony doctor` | Pre-flight environment check | Flags: `--port`, `--host`, `--token`, `--format json`, `--concurrency`, `--turns`, `--once`, `--tickets STI-1,STI-2` +> `--format json` is supported by: `status`, `issues`, `workspaces`, `workspace`, `config`. + ## WORKFLOW.md YAML frontmatter (config) + Liquid template body (agent prompt). For complete reference: [references/workflow-config.md](references/workflow-config.md). @@ -86,6 +95,23 @@ Set up: create `CONTROL.md` with setpoints, add sensors in `Makefile`, reference Implement `TrackerClient` trait (4 methods: `fetch_candidate_issues`, `fetch_issues_by_states`, `fetch_issue_states_by_ids`, `set_issue_state`). Register in `create_tracker()` factory. +## Arcan Runtime + +When `runtime.kind: arcan` is configured, Symphony dispatches work through the Arcan HTTP daemon instead of spawning local subprocesses. + +```yaml +runtime: + kind: arcan + base_url: http://localhost:3000 # Arcan daemon URL + policy: + allow_capabilities: [read, write, shell] + gate_capabilities: [network] +``` + +**Flow:** health check (`GET /health`) → create session (`POST /sessions`) → execute run (`POST /sessions/{id}/runs`) → poll state until complete. + +Arcan session IDs follow the pattern `symphony-{issue_identifier}`. + ## Key Environment Variables | Variable | Purpose | diff --git a/.agents/skills/symphony/references/operator-runbook.md b/.agents/skills/symphony/references/operator-runbook.md new file mode 100644 index 0000000..4605938 --- /dev/null +++ b/.agents/skills/symphony/references/operator-runbook.md @@ -0,0 +1,91 @@ +# Symphony Operator Runbook + +Executable diagnostic recipes for operating Symphony runtimes. + +## Pre-flight Check + +```bash +symphony doctor # checks WORKFLOW.md, env vars, binaries, daemon +``` + +## Check Daemon Health + +```bash +curl -s http://localhost:8080/healthz # 200 = alive +curl -s http://localhost:8080/readyz # 200 = ready, 503 = initializing +symphony status # full daemon state +symphony status --format json | jq . # machine-readable +``` + +## Debug Stuck Issue + +```bash +symphony issue STI-123 # check state, turn count, tokens +symphony logs --id STI-123 # filter logs for this issue +symphony logs --id STI-123 --level error # errors only +symphony logs --since 30m --id STI-123 # last 30 minutes +``` + +## Analyze Token Usage + +```bash +symphony status # total tokens in status output +curl -s http://localhost:8080/api/v1/metrics | jq .totals +curl -s http://localhost:8080/metrics | grep symphony_tokens # Prometheus +``` + +## Inspect Workspaces + +```bash +symphony workspaces # list all +symphony workspace STI-123 # detail for one +symphony workspace STI-123 --clean # remove workspace directory +ls ~/symphony-workspaces/project/ # manual inspection +``` + +## Troubleshoot Hooks + +```bash +# Test hooks in isolation: +export SYMPHONY_ISSUE_ID="STI-123" +export SYMPHONY_ISSUE_TITLE="Test issue" +bash -x -c 'git add -A && git commit -m "test"' # test after_run +bash -x -c 'gh pr create --title "test" --body ""' # test PR creation +``` + +## Force Immediate Poll + +```bash +symphony refresh # trigger poll now +curl -X POST http://localhost:8080/api/v1/refresh # direct API +``` + +## Stop Runaway Issue + +```bash +# Move issue to terminal state in tracker (stops retries): +# Linear: set to "Done" or "Cancelled" +# GitHub: close the issue + +# Or shut down the daemon entirely: +symphony stop +``` + +## View Prometheus Metrics + +```bash +curl -s http://localhost:8080/metrics | grep -v '^#' # values only +# Key metrics: +# symphony_sessions_running — active agent count +# symphony_sessions_retrying — retry queue depth +# symphony_tokens_total — cumulative token spend +# symphony_issues_completed — lifetime completions +``` + +## Arcan Runtime Diagnostics + +```bash +curl -s http://localhost:3000/health # Arcan daemon health +symphony status # shows arcan sessions +# If arcan is unreachable, check base_url in WORKFLOW.md runtime section +``` diff --git a/.claude/skills/symphony/references/troubleshooting.md b/.agents/skills/symphony/references/troubleshooting.md similarity index 84% rename from .claude/skills/symphony/references/troubleshooting.md rename to .agents/skills/symphony/references/troubleshooting.md index 15c3db2..e00a283 100644 --- a/.claude/skills/symphony/references/troubleshooting.md +++ b/.agents/skills/symphony/references/troubleshooting.md @@ -61,6 +61,25 @@ after_run: | mkdir -p ~/symphony-workspaces/project ``` +### Arcan daemon unreachable +**Cause**: `runtime.kind: arcan` configured but Arcan daemon not running. +**Fix**: Start the Arcan daemon or verify `runtime.base_url` in WORKFLOW.md: +```bash +curl http://localhost:3000/health # should return 200 +# If using a different URL: +# runtime: +# kind: arcan +# base_url: http://your-host:3000 +``` + +### Pre-flight check failures +**Cause**: Missing binaries, env vars, or config. +**Fix**: Run `symphony doctor` for a full pre-flight diagnostic: +```bash +symphony doctor +# Checks: WORKFLOW.md, env vars, binaries (claude, gh, git), daemon connectivity +``` + ## Monitoring ### HTTP Dashboard diff --git a/.claude/skills/symphony/references/workflow-config.md b/.agents/skills/symphony/references/workflow-config.md similarity index 100% rename from .claude/skills/symphony/references/workflow-config.md rename to .agents/skills/symphony/references/workflow-config.md diff --git a/.gitignore b/.gitignore index 144113f..3f82c55 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,13 @@ .obsidian/core-plugins-migration.json # Skills CLI — agent symlinks created by `npx skills add` -# The source skill lives in .claude/skills/symphony/ (tracked) +# The canonical skill source lives in .agents/skills/symphony/ (tracked) +# .claude/skills/symphony is a symlink → ../../.agents/skills/symphony # These are auto-generated per-agent symlinks (not tracked) -.agents/ +.agents/* +!.agents/skills/ +.agents/skills/* +!.agents/skills/symphony/ .agent/ .augment/ .codebuddy/ @@ -38,7 +42,7 @@ .trae/ .windsurf/ .zencoder/ -skills/ +/skills/ skills-lock.json # Claude Code worktrees and local state diff --git a/Cargo.lock b/Cargo.lock index bb2043c..0d0b3ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "assert_cmd" version = "2.2.0" @@ -343,6 +353,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deranged" version = "0.5.8" @@ -493,6 +521,21 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -500,6 +543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -508,6 +552,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -526,8 +598,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -606,6 +683,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -1206,6 +1289,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1825,6 +1918,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "symphony-arcan" +version = "0.2.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "symphony-core", + "thiserror", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "symphony-cli" version = "0.2.0" @@ -1906,6 +2013,7 @@ dependencies = [ "serde", "serde_json", "symphony-agent", + "symphony-arcan", "symphony-config", "symphony-core", "symphony-tracker", @@ -2749,6 +2857,29 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 8b26403..edd2854 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/symphony-agent", "crates/symphony-orchestrator", "crates/symphony-observability", + "crates/symphony-arcan", ] resolver = "3" @@ -64,6 +65,7 @@ symphony-workspace = { path = "crates/symphony-workspace", version = "0.2.0" } symphony-agent = { path = "crates/symphony-agent", version = "0.2.0" } symphony-orchestrator = { path = "crates/symphony-orchestrator", version = "0.2.0" } symphony-observability = { path = "crates/symphony-observability", version = "0.2.0" } +symphony-arcan = { path = "crates/symphony-arcan", version = "0.2.0" } # The main binary (published as symphony-cli; installs the `symphony` command) [package] diff --git a/crates/symphony-arcan/Cargo.toml b/crates/symphony-arcan/Cargo.toml new file mode 100644 index 0000000..82dcac0 --- /dev/null +++ b/crates/symphony-arcan/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "symphony-arcan" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Arcan runtime adapter for Symphony — replaces CLI subprocess spawning with Arcan HTTP sessions" + +[dependencies] +symphony-core.workspace = true +reqwest.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +thiserror.workspace = true + +[dev-dependencies] +wiremock = "0.6" +tokio = { workspace = true, features = ["full"] } diff --git a/crates/symphony-arcan/src/client.rs b/crates/symphony-arcan/src/client.rs new file mode 100644 index 0000000..19f4634 --- /dev/null +++ b/crates/symphony-arcan/src/client.rs @@ -0,0 +1,396 @@ +// Copyright 2026 Carlos Escobar-Valbuena +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP client for the Arcan agent runtime daemon. + +use serde::{Deserialize, Serialize}; +use tracing::info; + +/// Arcan HTTP client configuration. +#[derive(Debug, Clone)] +pub struct ArcanClientConfig { + pub base_url: String, + pub timeout_secs: u64, +} + +impl Default for ArcanClientConfig { + fn default() -> Self { + Self { + base_url: "http://localhost:3000".to_string(), + timeout_secs: 10, + } + } +} + +/// HTTP client for the Arcan daemon. +pub struct ArcanHttpClient { + client: reqwest::Client, + base_url: String, +} + +/// Error type for Arcan client operations. +#[derive(Debug, thiserror::Error)] +pub enum ArcanClientError { + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("arcan error: {status} — {message}")] + ArcanError { status: u16, message: String }, + #[error("session not found: {0}")] + SessionNotFound(String), +} + +// --- Local mirror types (no dependency on Arcan internals) --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSessionRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyConfig { + pub allow_capabilities: Vec, + #[serde(default)] + pub gate_capabilities: Vec, + #[serde(default = "default_max_tool_runtime")] + pub max_tool_runtime_secs: u64, + #[serde(default = "default_max_events")] + pub max_events_per_turn: u64, +} + +fn default_max_tool_runtime() -> u64 { + 120 +} +fn default_max_events() -> u64 { + 256 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionManifest { + pub session_id: String, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunRequest { + pub objective: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunResponse { + pub session_id: String, + pub mode: String, + pub events_emitted: u64, + pub last_sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateResponse { + pub session_id: String, + pub mode: String, + pub version: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveApprovalRequest { + pub approved: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor: Option, +} + +impl ArcanHttpClient { + pub fn new(config: ArcanClientConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(config.timeout_secs)) + .build() + .expect("failed to build reqwest client"); + Self { + client, + base_url: config.base_url, + } + } + + /// Create a new session on the Arcan daemon. + pub async fn create_session( + &self, + request: &CreateSessionRequest, + ) -> Result { + let url = format!("{}/sessions", self.base_url); + let resp = self.client.post(&url).json(request).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(ArcanClientError::ArcanError { + status, + message: body, + }); + } + Ok(resp.json().await?) + } + + /// Execute a run on a session. + pub async fn run( + &self, + session_id: &str, + request: &RunRequest, + ) -> Result { + let url = format!("{}/sessions/{}/runs", self.base_url, session_id); + info!(session_id, "executing arcan run"); + let resp = self.client.post(&url).json(request).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(ArcanClientError::ArcanError { + status, + message: body, + }); + } + Ok(resp.json().await?) + } + + /// Get session state. + pub async fn get_state(&self, session_id: &str) -> Result { + let url = format!("{}/sessions/{}/state", self.base_url, session_id); + let resp = self.client.get(&url).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(ArcanClientError::ArcanError { + status, + message: body, + }); + } + Ok(resp.json().await?) + } + + /// Resolve an approval request. + pub async fn resolve_approval( + &self, + session_id: &str, + approval_id: &str, + approved: bool, + ) -> Result<(), ArcanClientError> { + let url = format!( + "{}/sessions/{}/approvals/{}", + self.base_url, session_id, approval_id + ); + let request = ResolveApprovalRequest { + approved, + actor: Some("symphony".to_string()), + }; + let resp = self.client.post(&url).json(&request).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(ArcanClientError::ArcanError { + status, + message: body, + }); + } + Ok(()) + } + + /// Check if the Arcan daemon is healthy. + pub async fn health(&self) -> Result { + let url = format!("{}/health", self.base_url); + match self.client.get(&url).send().await { + Ok(resp) => Ok(resp.status().is_success()), + Err(_) => Ok(false), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn health_returns_true_when_daemon_is_up() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + assert!(client.health().await.unwrap()); + } + + #[tokio::test] + async fn health_returns_false_when_daemon_is_down() { + // Use a port that is (almost certainly) not listening + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: "http://127.0.0.1:19999".to_string(), + timeout_secs: 1, + }); + + assert!(!client.health().await.unwrap()); + } + + #[tokio::test] + async fn create_session_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "sess-123", + "owner": "symphony" + }))) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + let req = CreateSessionRequest { + session_id: Some("sess-123".into()), + owner: Some("symphony".into()), + policy: None, + }; + let manifest = client.create_session(&req).await.unwrap(); + assert_eq!(manifest.session_id, "sess-123"); + assert_eq!(manifest.owner, "symphony"); + } + + #[tokio::test] + async fn create_session_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(409).set_body_string("session already exists")) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + let req = CreateSessionRequest { + session_id: Some("sess-123".into()), + owner: None, + policy: None, + }; + let err = client.create_session(&req).await.unwrap_err(); + match err { + ArcanClientError::ArcanError { status, message } => { + assert_eq!(status, 409); + assert!(message.contains("already exists")); + } + other => panic!("expected ArcanError, got: {other:?}"), + } + } + + #[tokio::test] + async fn run_session_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions/sess-1/runs")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "sess-1", + "mode": "autonomous", + "events_emitted": 42, + "last_sequence": 41 + }))) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + let req = RunRequest { + objective: "Fix the bug".into(), + branch: None, + }; + let resp = client.run("sess-1", &req).await.unwrap(); + assert_eq!(resp.session_id, "sess-1"); + assert_eq!(resp.events_emitted, 42); + assert_eq!(resp.last_sequence, 41); + assert_eq!(resp.mode, "autonomous"); + } + + #[tokio::test] + async fn get_state_success() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sessions/sess-1/state")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "sess-1", + "mode": "autonomous", + "version": 5 + }))) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + let state = client.get_state("sess-1").await.unwrap(); + assert_eq!(state.session_id, "sess-1"); + assert_eq!(state.version, 5); + } + + #[tokio::test] + async fn resolve_approval_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions/sess-1/approvals/appr-1")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + client + .resolve_approval("sess-1", "appr-1", true) + .await + .unwrap(); + } + + #[tokio::test] + async fn run_error_returns_arcan_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions/sess-bad/runs")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal server error")) + .mount(&server) + .await; + + let client = ArcanHttpClient::new(ArcanClientConfig { + base_url: server.uri(), + timeout_secs: 5, + }); + + let req = RunRequest { + objective: "test".into(), + branch: None, + }; + let err = client.run("sess-bad", &req).await.unwrap_err(); + match err { + ArcanClientError::ArcanError { status, .. } => assert_eq!(status, 500), + other => panic!("expected ArcanError, got: {other:?}"), + } + } +} diff --git a/crates/symphony-arcan/src/event_mapper.rs b/crates/symphony-arcan/src/event_mapper.rs new file mode 100644 index 0000000..b490987 --- /dev/null +++ b/crates/symphony-arcan/src/event_mapper.rs @@ -0,0 +1,131 @@ +// Copyright 2026 Carlos Escobar-Valbuena +// SPDX-License-Identifier: Apache-2.0 + +//! Maps Arcan EventRecord to Symphony AgentEvent. + +use serde_json::Value; + +/// A simplified event from the Arcan SSE stream. +#[derive(Debug, Clone)] +pub struct ArcanEvent { + pub sequence: u64, + pub kind: String, + pub data: Value, +} + +impl ArcanEvent { + /// Parse an SSE data line into an ArcanEvent. + pub fn from_sse_data(data: &str) -> Option { + let value: Value = serde_json::from_str(data).ok()?; + let sequence = value.get("sequence")?.as_u64()?; + let kind = Self::extract_kind(&value)?; + Some(Self { + sequence, + kind, + data: value, + }) + } + + /// Extract the event kind from the JSON value. + /// + /// Handles both string kinds (`"kind": "RunCompleted"`) and tagged + /// union kinds (`"kind": { "Text": { "content": "..." } }`). + fn extract_kind(value: &Value) -> Option { + let kind_val = value.get("kind")?; + // Try string first + if let Some(s) = kind_val.as_str() { + return Some(s.to_string()); + } + // Try tagged union — take the first key + kind_val + .as_object() + .and_then(|obj| obj.keys().next().cloned()) + } + + /// Check if this event represents a run completion. + pub fn is_terminal(&self) -> bool { + matches!( + self.kind.as_str(), + "RunCompleted" | "RunFailed" | "RunCancelled" + ) + } + + /// Extract text content if this is a text event. + pub fn text_content(&self) -> Option<&str> { + self.data + .get("kind") + .and_then(|k| k.get("Text")) + .and_then(|t| t.get("content")) + .and_then(|c| c.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_string_kind_event() { + let data = r#"{"sequence": 1, "kind": "RunCompleted", "payload": {}}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert_eq!(event.sequence, 1); + assert_eq!(event.kind, "RunCompleted"); + assert!(event.is_terminal()); + } + + #[test] + fn parse_tagged_union_kind_event() { + let data = + r#"{"sequence": 5, "kind": {"Text": {"content": "Hello world"}}, "session_id": "s1"}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert_eq!(event.sequence, 5); + assert_eq!(event.kind, "Text"); + assert!(!event.is_terminal()); + assert_eq!(event.text_content(), Some("Hello world")); + } + + #[test] + fn parse_run_failed_is_terminal() { + let data = r#"{"sequence": 10, "kind": "RunFailed"}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert!(event.is_terminal()); + } + + #[test] + fn parse_run_cancelled_is_terminal() { + let data = r#"{"sequence": 3, "kind": "RunCancelled"}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert!(event.is_terminal()); + } + + #[test] + fn non_terminal_event() { + let data = r#"{"sequence": 2, "kind": "ToolInvoked"}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert!(!event.is_terminal()); + } + + #[test] + fn invalid_json_returns_none() { + assert!(ArcanEvent::from_sse_data("not json").is_none()); + } + + #[test] + fn missing_sequence_returns_none() { + let data = r#"{"kind": "RunCompleted"}"#; + assert!(ArcanEvent::from_sse_data(data).is_none()); + } + + #[test] + fn missing_kind_returns_none() { + let data = r#"{"sequence": 1}"#; + assert!(ArcanEvent::from_sse_data(data).is_none()); + } + + #[test] + fn text_content_none_for_non_text_event() { + let data = r#"{"sequence": 1, "kind": "RunCompleted"}"#; + let event = ArcanEvent::from_sse_data(data).unwrap(); + assert!(event.text_content().is_none()); + } +} diff --git a/crates/symphony-arcan/src/lib.rs b/crates/symphony-arcan/src/lib.rs new file mode 100644 index 0000000..01c0b39 --- /dev/null +++ b/crates/symphony-arcan/src/lib.rs @@ -0,0 +1,13 @@ +// Copyright 2026 Carlos Escobar-Valbuena +// SPDX-License-Identifier: Apache-2.0 + +//! Arcan runtime adapter for Symphony. +//! +//! Replaces CLI subprocess spawning with Arcan HTTP session API. +//! When `runtime.kind: arcan` is configured in WORKFLOW.md, +//! Symphony dispatches work through the Arcan daemon instead of +//! spawning local subprocesses. + +pub mod client; +pub mod event_mapper; +pub mod runner; diff --git a/crates/symphony-arcan/src/runner.rs b/crates/symphony-arcan/src/runner.rs new file mode 100644 index 0000000..bb89990 --- /dev/null +++ b/crates/symphony-arcan/src/runner.rs @@ -0,0 +1,390 @@ +// Copyright 2026 Carlos Escobar-Valbuena +// SPDX-License-Identifier: Apache-2.0 + +//! Arcan-based agent runner for Symphony. +//! +//! Replaces CLI subprocess spawning with Arcan HTTP session API calls. + +use std::path::Path; + +use tracing::info; + +use crate::client::{ + ArcanClientConfig, ArcanHttpClient, CreateSessionRequest, PolicyConfig, RunRequest, +}; + +/// Configuration for the Arcan runtime. +#[derive(Debug, Clone)] +pub struct ArcanRuntimeConfig { + pub base_url: String, + pub policy: Option, + pub timeout_secs: u64, +} + +/// Policy configuration for Arcan sessions. +#[derive(Debug, Clone)] +pub struct ArcanPolicyConfig { + pub allow_capabilities: Vec, + pub gate_capabilities: Vec, +} + +impl Default for ArcanRuntimeConfig { + fn default() -> Self { + Self { + base_url: "http://localhost:3000".to_string(), + policy: None, + timeout_secs: 3600, + } + } +} + +/// Errors from Arcan runner operations. +#[derive(Debug, thiserror::Error)] +pub enum ArcanRunnerError { + #[error("arcan unavailable: {0}")] + Unavailable(String), + #[error("session creation failed: {0}")] + SessionCreation(String), + #[error("run failed: {0}")] + RunFailed(String), + #[error("client error: {0}")] + Client(#[from] crate::client::ArcanClientError), +} + +/// Agent runner that dispatches work through the Arcan HTTP daemon. +/// +/// Drop-in alternative to Symphony's subprocess-based `AgentRunner`. +/// Uses Arcan's session/run API instead of spawning a CLI process. +pub struct ArcanAgentRunner { + client: ArcanHttpClient, + config: ArcanRuntimeConfig, +} + +impl ArcanAgentRunner { + pub fn new(config: ArcanRuntimeConfig) -> Self { + let client_config = ArcanClientConfig { + base_url: config.base_url.clone(), + timeout_secs: config.timeout_secs, + }; + Self { + client: ArcanHttpClient::new(client_config), + config, + } + } + + /// Run an agent session via Arcan for a given issue. + /// + /// Creates an Arcan session, executes a run with the given prompt, + /// and returns when the run completes. + pub async fn run_session( + &self, + _workspace_path: &Path, + prompt: &str, + issue_identifier: &str, + _issue_title: &str, + _attempt: Option, + _max_turns: u32, + ) -> Result { + // Check health first + let healthy = self.client.health().await.unwrap_or(false); + if !healthy { + return Err(ArcanRunnerError::Unavailable(format!( + "Arcan daemon not reachable at {}", + self.config.base_url + ))); + } + + // Create session with policy + let session_id = format!("symphony-{issue_identifier}"); + let policy = self.config.policy.as_ref().map(|p| PolicyConfig { + allow_capabilities: p.allow_capabilities.clone(), + gate_capabilities: p.gate_capabilities.clone(), + max_tool_runtime_secs: 120, + max_events_per_turn: 256, + }); + + let create_request = CreateSessionRequest { + session_id: Some(session_id.clone()), + owner: Some("symphony".to_string()), + policy, + }; + + let manifest = self + .client + .create_session(&create_request) + .await + .map_err(|e| ArcanRunnerError::SessionCreation(e.to_string()))?; + + info!( + session_id = %manifest.session_id, + identifier = %issue_identifier, + "arcan session created" + ); + + // Execute run + let run_request = RunRequest { + objective: prompt.to_string(), + branch: None, + }; + + let run_response = self + .client + .run(&manifest.session_id, &run_request) + .await + .map_err(|e| ArcanRunnerError::RunFailed(e.to_string()))?; + + info!( + session_id = %manifest.session_id, + events = run_response.events_emitted, + mode = %run_response.mode, + "arcan run completed" + ); + + Ok(ArcanSessionResult { + session_id: manifest.session_id, + events_emitted: run_response.events_emitted, + last_sequence: run_response.last_sequence, + mode: run_response.mode, + }) + } +} + +/// Result of an Arcan-based agent session. +#[derive(Debug, Clone)] +pub struct ArcanSessionResult { + pub session_id: String, + pub events_emitted: u64, + pub last_sequence: u64, + pub mode: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn run_session_full_flow() { + let server = MockServer::start().await; + + // Mock health check + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + // Mock session creation + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "symphony-T-42", + "owner": "symphony" + }))) + .mount(&server) + .await; + + // Mock run execution + Mock::given(method("POST")) + .and(path("/sessions/symphony-T-42/runs")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "symphony-T-42", + "mode": "autonomous", + "events_emitted": 15, + "last_sequence": 14 + }))) + .mount(&server) + .await; + + let runner = ArcanAgentRunner::new(ArcanRuntimeConfig { + base_url: server.uri(), + timeout_secs: 5, + policy: None, + }); + + let result = runner + .run_session( + Path::new("/tmp/workspace"), + "Fix the bug in parser.rs", + "T-42", + "Fix parser bug", + None, + 10, + ) + .await + .unwrap(); + + assert_eq!(result.session_id, "symphony-T-42"); + assert_eq!(result.events_emitted, 15); + assert_eq!(result.last_sequence, 14); + assert_eq!(result.mode, "autonomous"); + } + + #[tokio::test] + async fn run_session_unhealthy_daemon() { + // Use a port that is not listening + let runner = ArcanAgentRunner::new(ArcanRuntimeConfig { + base_url: "http://127.0.0.1:19998".to_string(), + timeout_secs: 1, + policy: None, + }); + + let err = runner + .run_session( + Path::new("/tmp/workspace"), + "test prompt", + "T-1", + "Test", + None, + 5, + ) + .await + .unwrap_err(); + + match err { + ArcanRunnerError::Unavailable(msg) => { + assert!(msg.contains("not reachable")); + } + other => panic!("expected Unavailable, got: {other:?}"), + } + } + + #[tokio::test] + async fn run_session_creation_failure() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) + .mount(&server) + .await; + + let runner = ArcanAgentRunner::new(ArcanRuntimeConfig { + base_url: server.uri(), + timeout_secs: 5, + policy: None, + }); + + let err = runner + .run_session( + Path::new("/tmp/workspace"), + "prompt", + "T-1", + "Test", + None, + 5, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ArcanRunnerError::SessionCreation(_))); + } + + #[tokio::test] + async fn run_session_run_failure() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "symphony-T-1", + "owner": "symphony" + }))) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/sessions/symphony-T-1/runs")) + .respond_with(ResponseTemplate::new(422).set_body_string("bad objective")) + .mount(&server) + .await; + + let runner = ArcanAgentRunner::new(ArcanRuntimeConfig { + base_url: server.uri(), + timeout_secs: 5, + policy: None, + }); + + let err = runner + .run_session( + Path::new("/tmp/workspace"), + "prompt", + "T-1", + "Test", + None, + 5, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ArcanRunnerError::RunFailed(_))); + } + + #[tokio::test] + async fn run_session_with_policy() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "symphony-T-99", + "owner": "symphony" + }))) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/sessions/symphony-T-99/runs")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "session_id": "symphony-T-99", + "mode": "supervised", + "events_emitted": 3, + "last_sequence": 2 + }))) + .mount(&server) + .await; + + let runner = ArcanAgentRunner::new(ArcanRuntimeConfig { + base_url: server.uri(), + timeout_secs: 5, + policy: Some(ArcanPolicyConfig { + allow_capabilities: vec!["read".into(), "write".into()], + gate_capabilities: vec!["shell".into()], + }), + }); + + let result = runner + .run_session( + Path::new("/tmp/workspace"), + "Implement feature X", + "T-99", + "Feature X", + Some(2), + 20, + ) + .await + .unwrap(); + + assert_eq!(result.session_id, "symphony-T-99"); + assert_eq!(result.mode, "supervised"); + } +} diff --git a/crates/symphony-config/src/loader.rs b/crates/symphony-config/src/loader.rs index 6821fb8..6e416b8 100644 --- a/crates/symphony-config/src/loader.rs +++ b/crates/symphony-config/src/loader.rs @@ -169,6 +169,11 @@ pub fn extract_config(def: &WorkflowDefinition) -> ServiceConfig { config.server_port = Some(port as u16); } + // Runtime + if let Some(runtime) = map.get(serde_yaml::Value::String("runtime".into())) { + config.runtime = extract_runtime(runtime); + } + config } @@ -255,6 +260,28 @@ fn extract_codex(v: &serde_yaml::Value) -> CodexConfig { codex } +fn extract_runtime(v: &serde_yaml::Value) -> crate::types::RuntimeConfig { + let mut runtime = crate::types::RuntimeConfig::default(); + if let Some(kind) = get_str(v, "kind") { + runtime.kind = kind; + } + if let Some(base_url) = get_str(v, "base_url") { + runtime.base_url = resolve_env(&base_url); + } + if let Some(policy) = v + .as_mapping() + .and_then(|m| m.get(serde_yaml::Value::String("policy".into()))) + { + if let Some(allow) = get_string_list(policy, "allow_capabilities") { + runtime.policy.allow_capabilities = allow; + } + if let Some(gate) = get_string_list(policy, "gate_capabilities") { + runtime.policy.gate_capabilities = gate; + } + } + runtime +} + // YAML value helpers fn get_str(v: &serde_yaml::Value, key: &str) -> Option { diff --git a/crates/symphony-config/src/types.rs b/crates/symphony-config/src/types.rs index 25ce438..b02caa4 100644 --- a/crates/symphony-config/src/types.rs +++ b/crates/symphony-config/src/types.rs @@ -25,6 +25,9 @@ pub struct ServiceConfig { pub hooks: HooksConfig, pub agent: AgentConfig, pub codex: CodexConfig, + /// Runtime configuration for agent execution (subprocess vs. Arcan). + #[serde(default)] + pub runtime: RuntimeConfig, /// Extension: optional HTTP server port. pub server_port: Option, } @@ -84,6 +87,35 @@ pub struct CodexConfig { pub stall_timeout_ms: i64, } +/// Runtime configuration for agent execution. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RuntimeConfig { + /// Runtime kind: "subprocess" (default) or "arcan" + #[serde(default = "default_runtime_kind")] + pub kind: String, + /// Base URL for the Arcan daemon (only used when kind = "arcan") + #[serde(default = "default_arcan_url")] + pub base_url: String, + /// Policy for Arcan sessions + #[serde(default)] + pub policy: RuntimePolicyConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RuntimePolicyConfig { + #[serde(default)] + pub allow_capabilities: Vec, + #[serde(default)] + pub gate_capabilities: Vec, +} + +fn default_runtime_kind() -> String { + "subprocess".to_string() +} +fn default_arcan_url() -> String { + "http://localhost:3000".to_string() +} + impl Default for TrackerConfig { fn default() -> Self { Self { diff --git a/crates/symphony-observability/src/server.rs b/crates/symphony-observability/src/server.rs index 6449029..544d966 100644 --- a/crates/symphony-observability/src/server.rs +++ b/crates/symphony-observability/src/server.rs @@ -169,15 +169,39 @@ async fn auth_middleware(State(state): State, request: Request, next: async fn dashboard(State(state): State) -> Html { let snapshot = state.orchestrator.lock().await; - let (running_count, retrying_count, totals) = match snapshot.as_ref() { - Some(s) => (s.running.len(), s.retry_attempts.len(), &s.codex_totals), - None => { - return Html( - "

Symphony Dashboard

Initializing...

" - .into(), - ); - } - }; + let (running_count, retrying_count, totals, retrying_rows, poll_ms, max_conc) = + match snapshot.as_ref() { + Some(s) => { + let retrying: String = s + .retry_attempts + .values() + .map(|r| { + format!( + "{}{}{}{}", + r.identifier, + r.attempt, + r.due_at_ms, + r.error.as_deref().unwrap_or("-") + ) + }) + .collect::>() + .join("\n"); + ( + s.running.len(), + s.retry_attempts.len(), + &s.codex_totals, + retrying, + s.poll_interval_ms, + s.max_concurrent_agents, + ) + } + None => { + return Html( + "

Symphony Dashboard

Initializing...

" + .into(), + ); + } + }; let running_rows: String = snapshot .as_ref() @@ -201,23 +225,32 @@ async fn dashboard(State(state): State) -> Html { Html(format!( r#" Symphony Dashboard +

Symphony Dashboard

Running: {running_count} Retrying: {retrying_count} -Total tokens: {total} +Tokens: {input_tokens} in / {output_tokens} out / {total_tokens} total Runtime: {runtime:.1}s
+
Poll interval: {poll_ms}ms +Max concurrent: {max_conc}

Active Sessions

{running_rows}
IdentifierStateSessionTurns
+

Retrying Issues

+ +{retrying_rows} +
IdentifierAttemptDue AtError

Generated at {time}

"#, - total = totals.total_tokens, + input_tokens = totals.input_tokens, + output_tokens = totals.output_tokens, + total_tokens = totals.total_tokens, runtime = totals.seconds_running, time = chrono::Utc::now().to_rfc3339() )) @@ -962,4 +995,31 @@ mod tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + + #[tokio::test] + async fn dashboard_contains_auto_refresh() { + let state = make_app_state(); + let app = build_router(state); + let req = Request::builder().uri("/").body(Body::empty()).unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1_000_000) + .await + .unwrap(); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains(r#"meta http-equiv="refresh" content="5""#)); + } + + #[tokio::test] + async fn dashboard_contains_retrying_table() { + let state = make_app_state(); + let app = build_router(state); + let req = Request::builder().uri("/").body(Body::empty()).unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1_000_000) + .await + .unwrap(); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("Retrying Issues")); + assert!(text.contains("Attempt")); + } } diff --git a/crates/symphony-orchestrator/Cargo.toml b/crates/symphony-orchestrator/Cargo.toml index 131d7a2..1b76e15 100644 --- a/crates/symphony-orchestrator/Cargo.toml +++ b/crates/symphony-orchestrator/Cargo.toml @@ -13,6 +13,7 @@ symphony-config.workspace = true symphony-tracker.workspace = true symphony-workspace.workspace = true symphony-agent.workspace = true +symphony-arcan.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/symphony-orchestrator/src/scheduler.rs b/crates/symphony-orchestrator/src/scheduler.rs index 6ec32f2..644eb2a 100644 --- a/crates/symphony-orchestrator/src/scheduler.rs +++ b/crates/symphony-orchestrator/src/scheduler.rs @@ -620,56 +620,84 @@ pub async fn run_worker( let prompt = symphony_config::template::render_prompt(&template, issue, attempt) .map_err(|e| anyhow::anyhow!("prompt render failed: {e}"))?; - let runner = if config.tracker.kind == "linear" { - AgentRunner::with_linear_tool( - config.codex.clone(), - LinearToolConfig { - endpoint: config.tracker.endpoint.clone(), - api_key: config.tracker.api_key.clone(), + // Branch on runtime kind: "arcan" dispatches via Arcan HTTP, default uses subprocess + if config.runtime.kind == "arcan" { + let arcan_config = symphony_arcan::runner::ArcanRuntimeConfig { + base_url: config.runtime.base_url.clone(), + policy: if config.runtime.policy.allow_capabilities.is_empty() { + None + } else { + Some(symphony_arcan::runner::ArcanPolicyConfig { + allow_capabilities: config.runtime.policy.allow_capabilities.clone(), + gate_capabilities: config.runtime.policy.gate_capabilities.clone(), + }) }, - ) - } else { - AgentRunner::new(config.codex.clone()) - }; - - let identifier = issue.identifier.clone(); - let max_turns = config.agent.max_turns; - - let on_event: symphony_agent::EventCallback = Box::new(move |event| { - tracing::info!( - identifier = %identifier, - event = ?event, - "agent event" - ); - }); - - let is_app_server = config.codex.command.contains("app-server"); - if is_app_server { - runner + timeout_secs: config.codex.turn_timeout_ms / 1000, + }; + let arcan_runner = symphony_arcan::runner::ArcanAgentRunner::new(arcan_config); + arcan_runner .run_session( &workspace.path, &prompt, &issue.identifier, &issue.title, attempt, - max_turns, - &on_event, + config.agent.max_turns, ) .await - .map_err(|e| anyhow::anyhow!("agent session failed: {e}"))?; + .map_err(|e| anyhow::anyhow!("arcan agent session failed: {e}"))?; } else { - runner - .run_simple_session( - &workspace.path, - &prompt, - &issue.identifier, - &issue.title, - attempt, - max_turns, - &on_event, + let runner = if config.tracker.kind == "linear" { + AgentRunner::with_linear_tool( + config.codex.clone(), + LinearToolConfig { + endpoint: config.tracker.endpoint.clone(), + api_key: config.tracker.api_key.clone(), + }, ) - .await - .map_err(|e| anyhow::anyhow!("agent session failed: {e}"))?; + } else { + AgentRunner::new(config.codex.clone()) + }; + + let identifier = issue.identifier.clone(); + let max_turns = config.agent.max_turns; + + let on_event: symphony_agent::EventCallback = Box::new(move |event| { + tracing::info!( + identifier = %identifier, + event = ?event, + "agent event" + ); + }); + + let is_app_server = config.codex.command.contains("app-server"); + if is_app_server { + runner + .run_session( + &workspace.path, + &prompt, + &issue.identifier, + &issue.title, + attempt, + max_turns, + &on_event, + ) + .await + .map_err(|e| anyhow::anyhow!("agent session failed: {e}"))?; + } else { + runner + .run_simple_session( + &workspace.path, + &prompt, + &issue.identifier, + &issue.title, + attempt, + max_turns, + &on_event, + ) + .await + .map_err(|e| anyhow::anyhow!("agent session failed: {e}"))?; + } } workspace_mgr diff --git a/src/cli/client.rs b/src/cli/client.rs index 5b1d412..69b0002 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -127,6 +127,25 @@ impl SymphonyClient { .map_err(|e| ClientError::Parse(e.to_string())) } + /// GET /api/v1/metrics — usage metrics. + pub async fn get_metrics(&self) -> Result { + let resp = self + .request(reqwest::Method::GET, "/api/v1/metrics") + .send() + .await + .map_err(|e| ClientError::Connection(e.to_string()))?; + let status = resp.status(); + if status.as_u16() == 401 { + return Err(ClientError::Unauthorized); + } + if !status.is_success() { + return Err(ClientError::Http(status.as_u16(), status.to_string())); + } + resp.json() + .await + .map_err(|e| ClientError::Parse(e.to_string())) + } + /// GET /api/v1/workspaces — list workspaces. pub async fn get_workspaces(&self) -> Result { let resp = self diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..1d5f803 --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,171 @@ +// Copyright 2026 Carlos Escobar-Valbuena +// SPDX-License-Identifier: Apache-2.0 + +//! Doctor command — pre-flight environment check. + +use std::path::Path; + +use super::ConnOpts; + +struct Check { + name: &'static str, + passed: bool, + detail: String, +} + +fn check_file_exists(path: &str) -> Check { + let exists = Path::new(path).exists(); + Check { + name: "WORKFLOW.md", + passed: exists, + detail: if exists { + format!("found: {path}") + } else { + format!("not found: {path}") + }, + } +} + +fn check_workflow_valid(path: &str) -> Check { + if !Path::new(path).exists() { + return Check { + name: "WORKFLOW.md valid", + passed: false, + detail: "skipped (file not found)".into(), + }; + } + match std::fs::read_to_string(path) { + Ok(content) => { + let has_frontmatter = content.starts_with("---"); + Check { + name: "WORKFLOW.md valid", + passed: has_frontmatter, + detail: if has_frontmatter { + "has YAML frontmatter".into() + } else { + "missing YAML frontmatter".into() + }, + } + } + Err(e) => Check { + name: "WORKFLOW.md valid", + passed: false, + detail: format!("read error: {e}"), + }, + } +} + +fn check_env_var(name: &str) -> Check { + let set = std::env::var(name).is_ok_and(|v| !v.is_empty()); + Check { + name: "env var", + passed: set, + detail: if set { + format!("{name} is set") + } else { + format!("{name} is not set") + }, + } +} + +fn check_binary(name: &str) -> Check { + let found = std::process::Command::new("which") + .arg(name) + .output() + .is_ok_and(|o| o.status.success()); + Check { + name: "binary", + passed: found, + detail: if found { + format!("{name} found in PATH") + } else { + format!("{name} not found in PATH") + }, + } +} + +/// Run the `doctor` command — pre-flight environment check. +pub async fn run_doctor(conn: &ConnOpts) -> anyhow::Result<()> { + println!("Symphony Doctor"); + println!("===============\n"); + + let mut checks: Vec = Vec::new(); + + // 1. WORKFLOW.md + checks.push(check_file_exists("WORKFLOW.md")); + checks.push(check_workflow_valid("WORKFLOW.md")); + + // 2. Environment variables + checks.push(check_env_var("ANTHROPIC_API_KEY")); + + // Check tracker-specific env vars + let has_linear = std::env::var("LINEAR_API_KEY").is_ok_and(|v| !v.is_empty()); + let has_github = std::env::var("GITHUB_TOKEN").is_ok_and(|v| !v.is_empty()); + checks.push(Check { + name: "tracker auth", + passed: has_linear || has_github, + detail: if has_linear && has_github { + "LINEAR_API_KEY and GITHUB_TOKEN both set".into() + } else if has_linear { + "LINEAR_API_KEY is set".into() + } else if has_github { + "GITHUB_TOKEN is set".into() + } else { + "neither LINEAR_API_KEY nor GITHUB_TOKEN is set".into() + }, + }); + + // 3. Binaries + checks.push(check_binary("claude")); + checks.push(check_binary("gh")); + checks.push(check_binary("git")); + + // 4. Daemon connectivity + let client = conn.client(); + let daemon_running = client.is_running().await; + checks.push(Check { + name: "daemon", + passed: daemon_running, + detail: if daemon_running { + format!("reachable at {}", conn.target()) + } else { + format!("not reachable at {}", conn.target()) + }, + }); + + // 5. Workspace root + let ws_root = std::env::var("HOME") + .map(|h| std::path::PathBuf::from(h).join("symphony-workspaces")) + .unwrap_or_default(); + let ws_exists = ws_root.exists(); + checks.push(Check { + name: "workspace root", + passed: ws_exists, + detail: if ws_exists { + format!("exists: {}", ws_root.display()) + } else { + format!( + "not found: {} (will be created on first run)", + ws_root.display() + ) + }, + }); + + // Print results + let mut pass_count = 0; + let total = checks.len(); + for check in &checks { + let icon = if check.passed { "[ok]" } else { "[!!]" }; + if check.passed { + pass_count += 1; + } + println!(" {icon} {:<20} {}", check.name, check.detail); + } + + println!("\n{pass_count}/{total} checks passed."); + if pass_count < total { + println!("Run `symphony doctor` after fixing issues to verify."); + } + + Ok(()) +} diff --git a/src/cli/logs.rs b/src/cli/logs.rs index 6a3a0a5..1c6546e 100644 --- a/src/cli/logs.rs +++ b/src/cli/logs.rs @@ -1,13 +1,105 @@ // Copyright 2026 Carlos Escobar-Valbuena // SPDX-License-Identifier: Apache-2.0 -//! Logs command — tail daemon log file. +//! Logs command — tail daemon log file with level and time filtering. use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::Path; use super::LogsArgs; +/// Parse a relative duration string ("5m", "1h", "30s") into seconds. +fn parse_relative_duration(s: &str) -> Option { + let s = s.trim(); + if s.len() < 2 { + return None; + } + let (num_str, unit) = s.split_at(s.len() - 1); + let num: i64 = num_str.parse().ok()?; + match unit { + "s" => Some(num), + "m" => Some(num * 60), + "h" => Some(num * 3600), + "d" => Some(num * 86400), + _ => None, + } +} + +/// Parse --since value into a chrono DateTime cutoff. +fn parse_since(since: &str) -> Option> { + // Try relative duration first + if let Some(secs) = parse_relative_duration(since) { + return Some(chrono::Utc::now() - chrono::Duration::seconds(secs)); + } + // Try ISO 8601 + since.parse::>().ok() +} + +/// Extract log level from a JSON log line. +fn extract_level(line: &str) -> Option<&str> { + // Fast path: look for "level":"..." pattern in JSON + let level_key = "\"level\":\""; + if let Some(start) = line.find(level_key) { + let value_start = start + level_key.len(); + if let Some(end) = line[value_start..].find('"') { + return Some(&line[value_start..value_start + end]); + } + } + None +} + +/// Extract timestamp from a JSON log line. +fn extract_timestamp(line: &str) -> Option> { + let key = "\"timestamp\":\""; + if let Some(start) = line.find(key) { + let value_start = start + key.len(); + if let Some(end) = line[value_start..].find('"') { + let ts_str = &line[value_start..value_start + end]; + return ts_str.parse().ok(); + } + } + None +} + +/// Pretty-print a JSON log line for terminal display. +fn pretty_print_line(line: &str) { + // Try to extract fields for pretty display + let level = extract_level(line).unwrap_or("???"); + let msg_key = "\"message\":\""; + let message = if let Some(start) = line.find(msg_key) { + let value_start = start + msg_key.len(); + if let Some(end) = line[value_start..].find('"') { + &line[value_start..value_start + end] + } else { + line + } + } else { + // Not JSON, print as-is + println!("{line}"); + return; + }; + + // Extract time portion + let time = extract_timestamp(line) + .map(|t| t.format("%H:%M:%S").to_string()) + .unwrap_or_else(|| "??:??:??".to_string()); + + // Extract target/span if present + let target_key = "\"target\":\""; + let target = if let Some(start) = line.find(target_key) { + let value_start = start + target_key.len(); + if let Some(end) = line[value_start..].find('"') { + &line[value_start..value_start + end] + } else { + "" + } + } else { + "" + }; + + println!("[{time}] {level:>5} {target}: {message}"); +} + /// Run the `logs` command — read and optionally follow the log file. pub async fn run_logs(args: &LogsArgs) -> anyhow::Result<()> { let log_path = symphony_config::loader::expand_path(&args.path); @@ -18,6 +110,10 @@ pub async fn run_logs(args: &LogsArgs) -> anyhow::Result<()> { std::process::exit(1); } + let since_cutoff = args.since.as_deref().and_then(parse_since); + let level_filter = args.level.as_deref().map(|l| l.to_lowercase()); + let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout()); + let file = std::fs::File::open(log_path)?; let mut reader = BufReader::new(file); @@ -48,11 +144,32 @@ pub async fn run_logs(args: &LogsArgs) -> anyhow::Result<()> { } Ok(_) => { let line = line.trim_end(); - if let Some(filter_id) = &args.id { - // Filter by identifier in JSON log lines - if line.contains(filter_id) { - println!("{line}"); - } + + // Filter by issue identifier + if let Some(filter_id) = &args.id + && !line.contains(filter_id) + { + continue; + } + + // Filter by level + if let Some(ref lvl) = level_filter + && let Some(line_level) = extract_level(line) + && line_level.to_lowercase() != *lvl + { + continue; + } + + // Filter by timestamp + if let Some(cutoff) = since_cutoff + && let Some(ts) = extract_timestamp(line) + && ts < cutoff + { + continue; + } + + if is_tty { + pretty_print_line(line); } else { println!("{line}"); } @@ -66,3 +183,52 @@ pub async fn run_logs(args: &LogsArgs) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_relative_duration_seconds() { + assert_eq!(parse_relative_duration("30s"), Some(30)); + } + + #[test] + fn parse_relative_duration_minutes() { + assert_eq!(parse_relative_duration("5m"), Some(300)); + } + + #[test] + fn parse_relative_duration_hours() { + assert_eq!(parse_relative_duration("2h"), Some(7200)); + } + + #[test] + fn parse_relative_duration_days() { + assert_eq!(parse_relative_duration("1d"), Some(86400)); + } + + #[test] + fn parse_relative_duration_invalid() { + assert_eq!(parse_relative_duration("abc"), None); + assert_eq!(parse_relative_duration(""), None); + } + + #[test] + fn extract_level_from_json() { + let line = r#"{"timestamp":"2026-01-01T00:00:00Z","level":"INFO","message":"hello"}"#; + assert_eq!(extract_level(line), Some("INFO")); + } + + #[test] + fn extract_level_missing() { + assert_eq!(extract_level("plain text line"), None); + } + + #[test] + fn extract_timestamp_from_json() { + let line = r#"{"timestamp":"2026-01-15T10:30:00Z","level":"INFO","message":"test"}"#; + let ts = extract_timestamp(line); + assert!(ts.is_some()); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8cbc632..5d809d1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -9,6 +9,7 @@ pub mod client; pub mod config_cmd; pub mod control; +pub mod doctor; pub mod init; pub mod issues; pub mod logs; @@ -80,6 +81,8 @@ pub enum Command { Logs(LogsArgs), /// Initialize a WORKFLOW.md in the current directory. Init(InitArgs), + /// Pre-flight environment check. + Doctor, } #[derive(clap::Args, Debug)] @@ -149,6 +152,9 @@ pub struct WorkspaceArgs { /// Remove workspace directory. #[arg(long)] pub clean: bool, + /// Path to WORKFLOW.md (used with --clean to resolve workspace root locally). + #[arg(long, default_value = "WORKFLOW.md")] + pub workflow_path: std::path::PathBuf, } #[derive(clap::Args, Debug)] @@ -180,6 +186,12 @@ pub struct LogsArgs { /// Filter by issue identifier. #[arg(long)] pub id: Option, + /// Filter by log level (e.g. info, warn, error). + #[arg(long)] + pub level: Option, + /// Show logs since time (e.g. "5m", "1h", "30s" or ISO 8601). + #[arg(long)] + pub since: Option, /// Log file path. #[arg(default_value = "~/.symphony/symphony.log")] pub path: String, @@ -246,6 +258,7 @@ const SUBCOMMANDS: &[&str] = &[ "run", "logs", "init", + "doctor", "help", ]; @@ -397,6 +410,12 @@ mod tests { assert!(matches!(cli.command, Some(Command::Refresh))); } + #[test] + fn parse_doctor() { + let cli = Cli::parse_from(["symphony", "doctor"]); + assert!(matches!(cli.command, Some(Command::Doctor))); + } + // S46: backward compat — bare `symphony` starts daemon #[test] fn backward_compat_bare_symphony() { diff --git a/src/cli/status.rs b/src/cli/status.rs index ea534dd..8db1f21 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -19,8 +19,14 @@ pub async fn run_status(conn: &ConnOpts, format: OutputFormat) -> anyhow::Result Err(e) => return Err(e.into()), }; + // Best-effort metrics fetch (daemon may not support it yet) + let metrics = client.get_metrics().await.ok(); + if format == OutputFormat::Json { - let json = serde_json::to_value(&state)?; + let mut json = serde_json::to_value(&state)?; + if let Some(m) = &metrics { + json["metrics"] = m.clone(); + } output::print_json(&json); return Ok(()); } @@ -30,6 +36,14 @@ pub async fn run_status(conn: &ConnOpts, format: OutputFormat) -> anyhow::Result output::print_kv("Generated at:", &state.generated_at); output::print_kv("Running:", &state.counts.running.to_string()); output::print_kv("Retrying:", &state.counts.retrying.to_string()); + output::print_kv( + "Input tokens:", + &state.codex_totals.input_tokens.to_string(), + ); + output::print_kv( + "Output tokens:", + &state.codex_totals.output_tokens.to_string(), + ); output::print_kv( "Total tokens:", &state.codex_totals.total_tokens.to_string(), @@ -39,6 +53,18 @@ pub async fn run_status(conn: &ConnOpts, format: OutputFormat) -> anyhow::Result &format!("{:.1}s", state.codex_totals.seconds_running), ); + // Show config from metrics if available + if let Some(m) = &metrics + && let Some(config) = m.get("config") + { + if let Some(poll) = config.get("poll_interval_ms") { + output::print_kv("Poll interval:", &format!("{}ms", poll)); + } + if let Some(max) = config.get("max_concurrent_agents") { + output::print_kv("Max concurrent:", &max.to_string()); + } + } + if !state.running.is_empty() { println!("\nRunning Issues:"); let headers = &["identifier", "state", "session", "turns", "tokens"]; diff --git a/src/cli/workspaces.rs b/src/cli/workspaces.rs index 9bb109f..c41e1f5 100644 --- a/src/cli/workspaces.rs +++ b/src/cli/workspaces.rs @@ -56,20 +56,43 @@ pub async fn run_workspaces(conn: &ConnOpts, format: OutputFormat) -> anyhow::Re pub async fn run_workspace( identifier: &str, clean: bool, + workflow_path: &std::path::Path, conn: &ConnOpts, format: OutputFormat, ) -> anyhow::Result<()> { let client = conn.client(); if clean { - if !client.is_running().await { - eprintln!("daemon not running ({})", conn.target()); - std::process::exit(1); + // Load workflow locally to find workspace root + match symphony_config::loader::load_workflow(workflow_path) { + Ok(def) => { + let config = symphony_config::loader::extract_config(&def); + let ws_root = config.workspace.root; + // Normalize identifier for path (same as orchestrator) + let safe_id: String = identifier + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let ws_path = ws_root.join(&safe_id); + if ws_path.exists() { + std::fs::remove_dir_all(&ws_path)?; + println!("Removed workspace: {}", ws_path.display()); + } else { + println!("Workspace not found: {}", ws_path.display()); + } + } + Err(e) => { + eprintln!("Cannot resolve workspace root: {e}"); + eprintln!("Provide --workflow-path pointing to your WORKFLOW.md"); + std::process::exit(1); + } } - println!("Workspace cleanup for '{identifier}' requested."); - println!( - "Note: Use the daemon's terminal cleanup or manually remove the workspace directory." - ); return Ok(()); } diff --git a/src/main.rs b/src/main.rs index 952df2a..39c96a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,7 +70,14 @@ async fn run_command( Command::Refresh => cli::issues::run_refresh(&conn).await, Command::Workspaces => cli::workspaces::run_workspaces(&conn, format).await, Command::Workspace(args) => { - cli::workspaces::run_workspace(&args.identifier, args.clean, &conn, format).await + cli::workspaces::run_workspace( + &args.identifier, + args.clean, + &args.workflow_path, + &conn, + format, + ) + .await } Command::Validate(args) => cli::control::run_validate(&args.workflow_path, format).await, Command::Config(args) => cli::config_cmd::run_config(&args.workflow_path, format).await, @@ -86,6 +93,7 @@ async fn run_command( cli::init::run_init(&args)?; Ok(()) } + Command::Doctor => cli::doctor::run_doctor(&conn).await, } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 16063af..a1ab2f1 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -301,3 +301,62 @@ fn cli_run_missing_workflow_fails() { .assert() .failure(); } + +// ── Doctor ── + +#[test] +fn cli_doctor_help_shows_preflight() { + symphony() + .args(["doctor", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Pre-flight")); +} + +// ── Logs flags ── + +#[test] +fn cli_logs_help_shows_level_and_since() { + symphony() + .args(["logs", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--level")) + .stdout(predicate::str::contains("--since")); +} + +// ── Workspace clean ── + +#[test] +fn cli_workspace_clean_nonexistent() { + let dir = TempDir::new().unwrap(); + let wf = dir.path().join("WORKFLOW.md"); + fs::write( + &wf, + r#"--- +tracker: + kind: linear + api_key: test-key + project_slug: test +workspace: + root: /tmp/symphony-test-ws-nonexistent +codex: + command: echo +--- +Prompt +"#, + ) + .unwrap(); + + symphony() + .args([ + "workspace", + "TEST-999", + "--clean", + "--workflow-path", + wf.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("not found")); +} From f700359d0ef92aacf08001201950904ee981fc37 Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Tue, 17 Mar 2026 22:33:05 -0500 Subject: [PATCH 2/2] fix: inline format arg for clippy uninlined_format_args Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/status.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/status.rs b/src/cli/status.rs index 8db1f21..12ff198 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -58,7 +58,7 @@ pub async fn run_status(conn: &ConnOpts, format: OutputFormat) -> anyhow::Result && let Some(config) = m.get("config") { if let Some(poll) = config.get("poll_interval_ms") { - output::print_kv("Poll interval:", &format!("{}ms", poll)); + output::print_kv("Poll interval:", &format!("{poll}ms")); } if let Some(max) = config.get("max_concurrent_agents") { output::print_kv("Max concurrent:", &max.to_string());