diff --git a/.yoi/tickets/00001KVNEKH9Q/item.md b/.yoi/tickets/00001KVNEKH9Q/item.md index 7e9bdb4c..efb624db 100644 --- a/.yoi/tickets/00001KVNEKH9Q/item.md +++ b/.yoi/tickets/00001KVNEKH9Q/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace backend: expose local host and worker list' -state: 'inprogress' +state: 'closed' created_at: '2026-06-21T16:00:49Z' -updated_at: '2026-06-21T16:32:22Z' +updated_at: '2026-06-21T16:39:43Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-21T16:09:10Z' diff --git a/.yoi/tickets/00001KVNEKH9Q/resolution.md b/.yoi/tickets/00001KVNEKH9Q/resolution.md new file mode 100644 index 00000000..8cf9929f --- /dev/null +++ b/.yoi/tickets/00001KVNEKH9Q/resolution.md @@ -0,0 +1,22 @@ +Workspace backend に local Host / Worker read API を追加し、Web UI に Host / Worker list を表示する変更を統合した。 + +主な成果: +- Workspace backend に `/api/hosts`, `/api/workers`, `/api/hosts/{host_id}/workers` を追加。 +- `/api/runners` placeholder を削除 / Host-Worker naming に移行。 +- Local Pod metadata を read-only bridge として Worker domain object に投影。 +- Pod は primary domain ではなく `implementation: { kind: "local_pod", pod_name: ... }` として表現。 +- Worker response includes bounded `worker_id`, `host_id`, label/pod name, role/profile when known, `workspace_root`, state/status, implementation detail, and diagnostics。 +- Missing/unreadable Pod metadata root は process/server failure ではなく empty workers + bounded diagnostics / unavailable capability に degrade。 +- Session transcript / tool result / prompt contents / raw session JSONL は読まない。 +- Web UI static SPA に Host / Worker list を追加。 +- Existing Ticket / Objective canonical workflows remain unchanged。 + +統合・検証: +- Merge commit: `c884d517 merge: workspace host workers` +- Implementation commit: `58143ead feat: expose workspace hosts and workers` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Remote/cloud runner registration, scheduling, start/stop/attach/notify operations, and full run-worker correlation were not implemented。 +- Frontend remains static SPA and does not own backend business/lifecycle authority。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVNEKH9Q/thread.md b/.yoi/tickets/00001KVNEKH9Q/thread.md index d3a21767..75227953 100644 --- a/.yoi/tickets/00001KVNEKH9Q/thread.md +++ b/.yoi/tickets/00001KVNEKH9Q/thread.md @@ -213,3 +213,181 @@ Reviewer focus: Orchestrator will wait for reviewer verdict before integration。 --- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket authority: + - `.yoi/tickets/00001KVNEKH9Q/item.md` + - `.yoi/tickets/00001KVNEKH9Q/thread.md` +- Diff/changed areas `e1f02ffc..58143ead`: + - `crates/workspace-server/src/hosts.rs` + - `crates/workspace-server/src/server.rs` + - `crates/workspace-server/src/store.rs` + - `crates/workspace-server/src/lib.rs` + - `crates/workspace-server/src/main.rs` + - `crates/workspace-server/Cargo.toml` + - `Cargo.lock` + - `package.nix` + - `web/workspace/src/routes/+page.svelte` + - `web/workspace/svelte.config.js` +- Supporting inspection: + - `crates/pod-store/src/lib.rs` + - `runners` references and session/prompt/tool-result exposure grep。 + +Blocking issues: +- None。 + +Acceptance verification: +- API domain naming is Host / Worker: + - Routes added for `/api/hosts`, `/api/workers`, and `/api/hosts/{host_id}/workers`。 + - `/api/runners` placeholder is removed and test-covered as `404`。 +- Local Pods are implementation detail: + - Worker model exposes domain fields (`worker_id`, `host_id`, `label`, `state`, `status`, etc.)。 + - Pod-specific detail is limited to `pod_name` and `implementation: { kind: "local_pod", pod_name: ... }`。 +- Local Pod bridge is read-only: + - Reads local Pod `metadata.json` under configured local data dir。 + - No mutation of Pod runtime metadata, session logs, Tickets, or Objectives observed。 +- Redaction / sensitive data: + - No session JSONL/transcript/tool-result/prompt content reads introduced。 + - `resolved_manifest_snapshot` is not serialized wholesale; role/profile extraction is bounded/sanitized。 + - Tests include a snapshot containing `secret_token` / `system_prompt` and assert they are not returned。 +- Missing/unreadable metadata degradation: + - Missing Pod metadata root returns empty workers plus bounded diagnostics。 + - Host capability degrades to unavailable instead of failing the server。 +- Frontend: + - Static Svelte page fetches/displays Hosts and Workers。 + - No SSR/business authority addition observed。 +- Existing Ticket/Objective workflows remain preserved; `ticket doctor` passes。 + +Non-blocking concerns: +- None。 + +Reviewer validation: +- `cargo fmt --check`: passed +- `git diff --check e1f02ffc..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`6 passed`) +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && deno task check && deno task build`: passed (`svelte-check found 0 errors and 0 warnings`; static build completed) +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed + +Generated artifacts: +- Frontend validation produced ignored generated artifacts only: + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` + - `web/workspace/node_modules/` +- Reviewer did not clean them due review-only scope; they are ignored and not tracked。 + +Worktree status: +- Tracked/untracked status is clean on `impl/00001KVNEKH9Q-workspace-host-workers`。 +- Ignored generated frontend artifacts remain as listed above。 + +--- + + + +## Implementation report + +Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。 + +統合: +- Merge commit: `c884d517 merge: workspace host workers` +- Merged branch: `impl/00001KVNEKH9Q-workspace-host-workers` +- Implementation commit: + - `58143ead feat: expose workspace hosts and workers` + +Orchestrator worktree validation: +- `cargo fmt --check`: passed +- `git diff --check HEAD^1..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`) +- `cd web/workspace && deno task build`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed + +Validation generated ignored frontend artifacts in Orchestrator worktree: +- `web/workspace/node_modules/` +- `web/workspace/.svelte-kit/` +- `web/workspace/build/` + +These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `c884d517`。 + +--- + + + +## State changed + +Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workspace backend に local Host / Worker read API を追加し、Web UI に Host / Worker list を表示する変更を統合した。 + +主な成果: +- Workspace backend に `/api/hosts`, `/api/workers`, `/api/hosts/{host_id}/workers` を追加。 +- `/api/runners` placeholder を削除 / Host-Worker naming に移行。 +- Local Pod metadata を read-only bridge として Worker domain object に投影。 +- Pod は primary domain ではなく `implementation: { kind: "local_pod", pod_name: ... }` として表現。 +- Worker response includes bounded `worker_id`, `host_id`, label/pod name, role/profile when known, `workspace_root`, state/status, implementation detail, and diagnostics。 +- Missing/unreadable Pod metadata root は process/server failure ではなく empty workers + bounded diagnostics / unavailable capability に degrade。 +- Session transcript / tool result / prompt contents / raw session JSONL は読まない。 +- Web UI static SPA に Host / Worker list を追加。 +- Existing Ticket / Objective canonical workflows remain unchanged。 + +統合・検証: +- Merge commit: `c884d517 merge: workspace host workers` +- Implementation commit: `58143ead feat: expose workspace hosts and workers` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Remote/cloud runner registration, scheduling, start/stop/attach/notify operations, and full run-worker correlation were not implemented。 +- Frontend remains static SPA and does not own backend business/lifecycle authority。 + +--- + + + +## Implementation report + +Post-close cleanup completed。 + +- Stopped child Pods and reclaimed scope: + - `yoi-coder-00001KVNEKH9Q` + - `yoi-reviewer-00001KVNEKH9Q-r1` +- Removed ignored frontend validation artifacts from child worktree before worktree removal: + - `web/workspace/node_modules/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` +- Removed implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNEKH9Q-workspace-host-workers` +- Deleted implementation branch: + - `impl/00001KVNEKH9Q-workspace-host-workers` +- Orchestrator worktree remains clean on `orchestration` at `a4ed5fb0`。 + +Root/original workspace was not used for merge/validation/cleanup operations。 + +Note for related active work: +- `00001KVNG9B9Z` sidebar UI work was branched before this merge and may need to integrate the Host/Worker UI/API changes from `c884d517` during review/merge。 + +--- diff --git a/.yoi/tickets/00001KVNG9B9Z/item.md b/.yoi/tickets/00001KVNG9B9Z/item.md index 22562bbb..5b0cc560 100644 --- a/.yoi/tickets/00001KVNG9B9Z/item.md +++ b/.yoi/tickets/00001KVNG9B9Z/item.md @@ -2,7 +2,7 @@ title: 'Workspace web UI: add sidebar navigation panel' state: 'inprogress' created_at: '2026-06-21T16:30:12Z' -updated_at: '2026-06-21T16:36:47Z' +updated_at: '2026-06-21T16:37:34Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-21T16:35:06Z' diff --git a/.yoi/tickets/00001KVNG9B9Z/thread.md b/.yoi/tickets/00001KVNG9B9Z/thread.md index 9b71b72e..838523fd 100644 --- a/.yoi/tickets/00001KVNG9B9Z/thread.md +++ b/.yoi/tickets/00001KVNG9B9Z/thread.md @@ -107,3 +107,31 @@ Validation plan: Human authorized queue routing from Workspace Dashboard. Ticket has concrete frontend acceptance criteria and no blocking relation; related Host/Worker API work is in review and creates merge-conflict risk but not an authority blocker. Orchestrator accepts implementation with narrow frontend scope. --- + + + +## Implementation report + +Implementation start report: + +- Created child implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar` +- Created branch: + - `impl/00001KVNG9B9Z-workspace-sidebar` +- Base commit: + - `2c7ef24a ticket: accept workspace sidebar ui` +- Confirmed tracked Ticket project records are visible in the child worktree。 +- Confirmed `.yoi/memory` has no tracked/untracked entries in the child worktree check。 +- Spawned sibling Coder Pod: + - `yoi-coder-00001KVNG9B9Z` +- Coder scope: + - read: `/home/hare/Projects/yoi` + - write: `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar` + +Parallelization note: +- `00001KVNEKH9Q` remains active in review and may merge Host/Worker API plus `web/workspace/src/routes/+page.svelte` changes. Coder was instructed to keep sidebar work narrow/componentized and report if conflicts become non-trivial。 + +Next action: +- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。 + +--- diff --git a/.yoi/tickets/00001KVNGJPRG/artifacts/.gitkeep b/.yoi/tickets/00001KVNGJPRG/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..5f2c9ee2 --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260621-164056-1","ticket_id":"00001KVNGJPRG","kind":"do_not_parallelize","related_ticket":"00001KVNG9B9Z","note":"Repository/objective pages and sidebar navigation both modify the Workspace SPA navigation/layout surface, especially `web/workspace/src/routes/+page.svelte` and route/component structure. Start this Ticket after `00001KVNG9B9Z` lands or after its implementation diff is known enough to route a non-conflicting continuation.","author":"yoi-orchestrator","at":"2026-06-21T16:40:56Z"} diff --git a/.yoi/tickets/00001KVNGJPRG/artifacts/relations.json b/.yoi/tickets/00001KVNGJPRG/artifacts/relations.json new file mode 100644 index 00000000..b1d54b4a --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/artifacts/relations.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "relations": [ + { + "ticket_id": "00001KVNGJPRG", + "kind": "related", + "target": "00001KVMFFYVX", + "note": "Extends workspace web bootstrap with repository/objective pages", + "author": "yoi ticket", + "at": "2026-06-21T16:36:06Z" + }, + { + "ticket_id": "00001KVNGJPRG", + "kind": "related", + "target": "00001KVNG9B9Z", + "note": "Sidebar navigation should link to repository and objective pages", + "author": "yoi ticket", + "at": "2026-06-21T16:36:06Z" + } + ] +} diff --git a/.yoi/tickets/00001KVNGJPRG/item.md b/.yoi/tickets/00001KVNGJPRG/item.md new file mode 100644 index 00000000..797bd272 --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/item.md @@ -0,0 +1,83 @@ +--- +title: 'Workspace web: repository and objective pages' +state: 'queued' +created_at: '2026-06-21T16:35:19Z' +updated_at: '2026-06-21T16:41:09Z' +assignee: null +queued_by: 'workspace-panel' +queued_at: '2026-06-21T16:40:35Z' +--- + +## 背景 + +Workspace web control plane の初期段階では、Ticket / Objective の操作系が Web で十分に実装され、移行できる状態になるまでは、既存 `.yoi/tickets` / `.yoi/objectives` の filesystem record を read-through authority として扱う。 + +SQLite は Workspace server の runtime/projection/store seam として使うが、Ticket / Objective の canonical write path を中途半端に DB へ移さない。少なくとも Ticket 作成・コメント・状態遷移・close、Objective 作成/更新、validation/audit が Web/API 側で成立するまでは、Web UI は filesystem records を読む方向で進める。 + +次の UI slice として、Workspace sidebar から遷移できる Repository page と Objective list page を追加する。 + +Repository page では、当面は backend が動いている Workspace root の Git repository を primary Repository として扱う。Git repository である場合は、簡易的な Git 情報、直近 log、Repository に関係する Ticket の Kanban view を表示する。Repository target metadata がまだ Ticket schema に十分無い場合は、初期実装では workspace-local tickets 全体または既存 metadata から安全に導ける範囲を表示し、target selector 対応は follow-up にできる。 + +## 方針 + +- Ticket / Objective は当面 filesystem read-through で表示する。 +- Web UI からの mutation / DB migration は、この Ticket の主目的にしない。 +- Repository は Git 専売の概念ではないが、初期 page は Git Repository の read-only summary から始める。 +- Repository page は将来の Repository provider / RepositoryPoint / target selector model に繋がる形にする。 +- Ticket Kanban は Ticket state を column として表示する。 +- Objective list は existing `/api/objectives` を使い、Objective の title/state/summary を一覧できるようにする。 + +## 要件 + +### Backend / API + +- Repository list/detail の read-only API を追加する、または既存 `/api/workspace` に必要最小限の Repository summary を追加する。 + - 初期は current workspace root を 1 つの local Repository として返してよい。 + - Git repository の場合は branch/head/root/dirty status/remote URL summary などを bounded に返す。 + - Git でない場合は `kind = "local"` / `git = unavailable` 相当の diagnostic を返す。 +- Git log summary API を追加する。 + - 直近 N 件だけ返す。 + - commit hash、subject、author name/email の扱い、timestamp を bounded にする。 + - full diff / patch / file contents は返さない。 +- Repository Ticket Kanban 用の read model を追加する。 + - 初期は Ticket state ごとに group した bounded list でよい。 + - Ticket target Repository metadata がない場合の fallback を明記する。 + - 将来 target selector が入ったら Repository ごとの filter に差し替えられる形にする。 +- Objective list/detail は既存 filesystem read-through API を継続利用する。 + +### Frontend + +- Sidebar の `repositories` section から Repository page に遷移できる。 +- Repository page を追加する。 + - Repository summary。 + - Git summary / recent log。 + - Ticket Kanban columns。 + - API failure / non-Git / empty tickets を section 単位で表示。 +- Objective list page を追加する。 + - Objective title/state/updated_at などの一覧。 + - Objective detail への遷移は可能なら行う。無理なら placeholder でよい。 +- UI は static SPA のまま実装し、frontend に authority logic を持たせない。 + +## Non-goals + +- Ticket / Objective の DB canonical migration。 +- Web からの Ticket mutation / Objective mutation。 +- Repository CRUD / remote Git hosting integration。 +- Full Git diff viewer / file browser / blame。 +- Ticket target selector schema の完成。 +- Multi-repository selection UI の完成。 +- Kanban drag-and-drop / state mutation。 + +## 受け入れ条件 + +- Ticket / Objective は引き続き filesystem read-through authority から表示される。 +- Repository page が表示できる。 +- Git repository の場合、Repository summary と recent log が bounded に表示される。 +- Repository Ticket Kanban が state columns で表示される。 +- Ticket target metadata が未整備でも安全な fallback 表示になる。 +- Objective list page が表示できる。 +- Sidebar から repositories / objectives に遷移できる。 +- API failure、non-Git repository、empty state が UI で壊れず表示される。 +- `deno task check` と `deno task build` が通る。 +- backend 変更がある場合は `cargo test -p yoi-workspace-server` が通る。 +- `cargo fmt --check`、`cargo check`、`git diff --check`、`yoi ticket doctor`、`nix build .#yoi --no-link` が通る。 diff --git a/.yoi/tickets/00001KVNGJPRG/thread.md b/.yoi/tickets/00001KVNGJPRG/thread.md new file mode 100644 index 00000000..0122decf --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/thread.md @@ -0,0 +1,55 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `workspace-panel` が queued にしました。 + + +--- + + + +## Decision + +Routing decision: `wait_do_not_parallelize_with_sidebar` + +Reason: +- Ticket body is implementation-ready in isolation, but it is directly coupled to the currently active sidebar navigation work `00001KVNG9B9Z`。 +- Relations show this Ticket is related to `00001KVNG9B9Z` with note: “Sidebar navigation should link to repository and objective pages”。 +- `00001KVNG9B9Z` is already `inprogress`, has a live Coder Pod, and is expected to change `web/workspace/src/routes/+page.svelte` / sidebar component structure。 +- Starting this Ticket in parallel now would likely produce overlapping route/layout/sidebar changes and non-trivial semantic merge work, rather than independent implementation。 +- Host/Worker API work `00001KVNEKH9Q` just landed, further increasing the need to base the page/navigation work on current SPA state。 + +Decision: +- Do not create a worktree or spawn Coder for `00001KVNGJPRG` yet。 +- Leave Ticket `queued` and record an orchestration `do_not_parallelize` note with `00001KVNG9B9Z`。 +- Re-route this Ticket after sidebar work lands, or if sidebar implementation report shows a stable component boundary that makes repository/objective pages non-conflicting。 + +No implementation side effects were performed for this Ticket。 + +--- diff --git a/Cargo.lock b/Cargo.lock index 7d492b65..da305388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6027,6 +6027,8 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "manifest", + "pod-store", "project-record", "rusqlite", "serde", diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 444f36b0..515bd2a4 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -8,6 +8,8 @@ publish = false [dependencies] async-trait.workspace = true axum.workspace = true +manifest = { workspace = true } +pod-store = { workspace = true } project-record.workspace = true rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs new file mode 100644 index 00000000..3fc82d02 --- /dev/null +++ b/crates/workspace-server/src/hosts.rs @@ -0,0 +1,552 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use pod_store::{PodMetadata, validate_pod_name}; +use serde::{Deserialize, Serialize}; + +const MAX_DIAGNOSTICS: usize = 20; +const MAX_LABEL_LEN: usize = 120; +const MAX_PATH_LEN: usize = 512; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeDiagnostic { + pub code: String, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostSummary { + pub host_id: String, + pub label: String, + pub kind: String, + pub status: String, + pub observed_at: String, + pub last_seen_at: String, + pub capabilities: HostCapabilitySummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostCapabilitySummary { + pub local_pod_inspection: String, + pub workspace_root: String, + pub os: String, + pub arch: String, + pub max_workers: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerSummary { + pub worker_id: String, + pub host_id: String, + pub label: String, + pub pod_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + pub state: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen_at: Option, + pub implementation: WorkerImplementation, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerImplementation { + pub kind: String, + pub pod_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalRuntimeBridge { + workspace_id: String, + workspace_root: PathBuf, + data_dir: Option, +} + +impl LocalRuntimeBridge { + pub fn new( + workspace_id: impl Into, + workspace_root: impl Into, + data_dir: Option, + ) -> Self { + Self { + workspace_id: workspace_id.into(), + workspace_root: workspace_root.into(), + data_dir, + } + } + + pub fn host_id(&self) -> String { + stable_local_host_id(&self.workspace_id) + } + + pub fn list_hosts(&self, limit: usize) -> (Vec, Vec) { + if limit == 0 { + return (Vec::new(), Vec::new()); + } + + let observed_at = unix_timestamp(SystemTime::now()); + let mut diagnostics = pod_root_diagnostics(self.pod_root().as_deref()); + let local_pod_inspection = if diagnostics.is_empty() { + "available" + } else { + "unavailable" + } + .to_string(); + let status = if local_pod_inspection == "available" { + "available" + } else { + "degraded" + } + .to_string(); + truncate_diagnostics(&mut diagnostics); + + let host = HostSummary { + host_id: self.host_id(), + label: format!( + "Local host ({})", + self.workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + ), + kind: "local_host".to_string(), + status, + observed_at: observed_at.clone(), + last_seen_at: observed_at, + capabilities: HostCapabilitySummary { + local_pod_inspection, + workspace_root: bounded_path(&self.workspace_root), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + max_workers: limit.min(200), + }, + diagnostics: diagnostics.clone(), + }; + + (vec![host], diagnostics) + } + + pub fn list_workers(&self, limit: usize) -> (Vec, Vec) { + let limit = limit.min(200); + let Some(pod_root) = self.pod_root() else { + return ( + Vec::new(), + vec![RuntimeDiagnostic::new( + "local_yoi_data_dir_unavailable", + "warning", + "local Yoi data directory is not configured; local Pod workers cannot be inspected", + )], + ); + }; + + let mut diagnostics = Vec::new(); + if !pod_root.exists() { + diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_root_missing", + "info", + "local Pod metadata directory is absent; no local workers were discovered", + )); + return (Vec::new(), diagnostics); + } + + let entries = match fs::read_dir(&pod_root) { + Ok(entries) => entries, + Err(error) => { + diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_root_unreadable", + "warning", + format!("local Pod metadata directory cannot be read: {error}"), + )); + return (Vec::new(), diagnostics); + } + }; + + let mut workers = Vec::new(); + let mut candidate_names = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_metadata_entry_unreadable", + "warning", + format!("one local Pod metadata entry cannot be read: {error}"), + ), + ); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_metadata_entry_type_unreadable", + "warning", + format!("one local Pod metadata entry type cannot be read: {error}"), + ), + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_name_non_utf8", + "warning", + "one local Pod metadata directory has a non-UTF-8 name and was skipped", + ), + ); + continue; + }; + if validate_pod_name(&name).is_err() || name.len() > MAX_LABEL_LEN { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_name_invalid", + "warning", + "one local Pod metadata directory has an invalid or oversized name and was skipped", + ), + ); + continue; + } + if entry.path().join("metadata.json").exists() { + candidate_names.push(name); + } + } + + candidate_names.sort(); + candidate_names.truncate(limit); + for pod_name in candidate_names { + match read_worker(&pod_root, &pod_name, &self.host_id()) { + Ok(worker) => workers.push(worker), + Err(diagnostic) => push_diagnostic(&mut diagnostics, diagnostic), + } + } + truncate_diagnostics(&mut diagnostics); + (workers, diagnostics) + } + + fn pod_root(&self) -> Option { + self.data_dir.as_ref().map(|data_dir| data_dir.join("pods")) + } +} + +impl RuntimeDiagnostic { + pub fn new( + code: impl Into, + severity: impl Into, + message: impl Into, + ) -> Self { + Self { + code: truncate_string(&code.into(), MAX_LABEL_LEN), + severity: truncate_string(&severity.into(), MAX_LABEL_LEN), + message: truncate_string(&message.into(), 240), + } + } +} + +fn read_worker( + pod_root: &Path, + pod_name: &str, + host_id: &str, +) -> Result { + let metadata_path = pod_root.join(pod_name).join("metadata.json"); + let last_seen_at = metadata_path + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(unix_timestamp); + let raw = fs::read_to_string(&metadata_path).map_err(|error| { + RuntimeDiagnostic::new( + "local_pod_metadata_unreadable", + "warning", + format!("local Pod metadata for `{pod_name}` cannot be read: {error}"), + ) + })?; + let metadata: PodMetadata = serde_json::from_str(&raw).map_err(|error| { + RuntimeDiagnostic::new( + "local_pod_metadata_invalid", + "warning", + format!("local Pod metadata for `{pod_name}` is invalid: {error}"), + ) + })?; + + let mut worker_diagnostics = Vec::new(); + if metadata.pod_name != pod_name { + worker_diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_name_mismatch", + "warning", + "metadata pod_name differed from its directory name; the directory name was used", + )); + } + + let state = if metadata.active.is_some() { + "active" + } else { + "inactive" + } + .to_string(); + let status = match metadata.active.as_ref() { + Some(active) if active.segment_id.is_some() => "active_segment_known", + Some(_) => "active_session_pending_segment", + None => "metadata_only", + } + .to_string(); + let (role, profile) = extract_safe_role_profile(metadata.resolved_manifest_snapshot.as_ref()); + + Ok(WorkerSummary { + worker_id: format!("local-pod-{}", sanitize_identifier(pod_name, MAX_LABEL_LEN)), + host_id: host_id.to_string(), + label: truncate_string(pod_name, MAX_LABEL_LEN), + pod_name: truncate_string(pod_name, MAX_LABEL_LEN), + role, + profile, + workspace_root: metadata + .workspace_root + .as_ref() + .map(|path| bounded_path(path)), + state, + status, + last_seen_at, + implementation: WorkerImplementation { + kind: "local_pod".to_string(), + pod_name: truncate_string(pod_name, MAX_LABEL_LEN), + }, + diagnostics: worker_diagnostics, + }) +} + +fn pod_root_diagnostics(pod_root: Option<&Path>) -> Vec { + let Some(pod_root) = pod_root else { + return vec![RuntimeDiagnostic::new( + "local_yoi_data_dir_unavailable", + "warning", + "local Yoi data directory is not configured; local Pod inspection is unavailable", + )]; + }; + if !pod_root.exists() { + return vec![RuntimeDiagnostic::new( + "local_pod_metadata_root_missing", + "info", + "local Pod metadata directory is absent; local Pod inspection found no workers", + )]; + } + match fs::read_dir(pod_root) { + Ok(_) => Vec::new(), + Err(error) => vec![RuntimeDiagnostic::new( + "local_pod_metadata_root_unreadable", + "warning", + format!("local Pod metadata directory cannot be read: {error}"), + )], + } +} + +fn extract_safe_role_profile( + snapshot: Option<&serde_json::Value>, +) -> (Option, Option) { + let Some(snapshot) = snapshot else { + return (None, None); + }; + let role = snapshot + .get("role") + .and_then(|value| value.as_str()) + .and_then(safe_metadata_label); + let profile = snapshot + .get("profile") + .and_then(|profile| { + profile + .get("name") + .or_else(|| profile.get("selector")) + .or_else(|| profile.get("id")) + }) + .and_then(|value| value.as_str()) + .and_then(safe_metadata_label); + (role, profile) +} + +fn safe_metadata_label(value: &str) -> Option { + if value.is_empty() + || value.len() > MAX_LABEL_LEN + || value.contains('/') + || value.contains('\\') + || value.contains('\0') + || value.chars().any(|ch| ch.is_control()) + { + return None; + } + Some(value.to_string()) +} + +fn stable_local_host_id(workspace_id: &str) -> String { + format!("local-{}", sanitize_identifier(workspace_id, 96)) +} + +fn sanitize_identifier(value: &str, max_len: usize) -> String { + let mut output = String::new(); + for ch in value.chars() { + if output.len() >= max_len { + break; + } + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if !output.ends_with('-') { + output.push('-'); + } + } + let output = output.trim_matches('-'); + if output.is_empty() { + "local".to_string() + } else { + output.to_string() + } +} + +fn bounded_path(path: &Path) -> String { + truncate_string(&path.to_string_lossy(), MAX_PATH_LEN) +} + +fn truncate_string(value: &str, max_len: usize) -> String { + if value.len() <= max_len { + return value.to_string(); + } + let mut end = max_len; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn push_diagnostic(diagnostics: &mut Vec, diagnostic: RuntimeDiagnostic) { + if diagnostics.len() < MAX_DIAGNOSTICS { + diagnostics.push(diagnostic); + } +} + +fn truncate_diagnostics(diagnostics: &mut Vec) { + diagnostics.truncate(MAX_DIAGNOSTICS); +} + +fn unix_timestamp(time: SystemTime) -> String { + match time.duration_since(UNIX_EPOCH) { + Ok(duration) => format!("unix:{}", duration.as_secs()), + Err(_) => "unix:0".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn lists_workers_from_local_pod_metadata_without_exposing_snapshot_contents() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let worker_dir = data_dir.join("pods/coder"); + fs::create_dir_all(&worker_dir).unwrap(); + fs::write( + worker_dir.join("metadata.json"), + serde_json::to_vec_pretty(&json!({ + "pod_name": "coder", + "active": { + "session_id": "018f4b8e-7c8a-7b41-8d66-111111111111", + "segment_id": "018f4b8e-7c8a-7b41-8d66-222222222222" + }, + "workspace_root": "/workspace/project", + "resolved_manifest_snapshot": { + "role": "coder", + "profile": { "name": "builtin-coder" }, + "secret_token": "do-not-return", + "system_prompt": "do-not-return" + } + })) + .unwrap(), + ) + .unwrap(); + + let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir)); + let (workers, diagnostics) = bridge.list_workers(20); + assert!(diagnostics.is_empty()); + assert_eq!(workers.len(), 1); + let worker = &workers[0]; + assert_eq!(worker.worker_id, "local-pod-coder"); + assert_eq!(worker.host_id, "local-local-test"); + assert_eq!(worker.pod_name, "coder"); + assert_eq!(worker.role.as_deref(), Some("coder")); + assert_eq!(worker.profile.as_deref(), Some("builtin-coder")); + assert_eq!(worker.workspace_root.as_deref(), Some("/workspace/project")); + assert_eq!(worker.state, "active"); + assert_eq!(worker.status, "active_segment_known"); + assert_eq!(worker.implementation.kind, "local_pod"); + assert_eq!(worker.implementation.pod_name, "coder"); + + let response_json = serde_json::to_string(&workers).unwrap(); + assert!(!response_json.contains("do-not-return")); + assert!(!response_json.contains("system_prompt")); + assert!(!response_json.contains("session_id")); + assert!(!response_json.contains("segment_id")); + } + + #[test] + fn missing_local_pod_data_dir_degrades_to_empty_workers_and_diagnostic() { + let temp = tempfile::tempdir().unwrap(); + let bridge = LocalRuntimeBridge::new( + "local:test", + temp.path(), + Some(temp.path().join("missing-data")), + ); + let (workers, diagnostics) = bridge.list_workers(20); + assert!(workers.is_empty()); + assert_eq!(diagnostics[0].code, "local_pod_metadata_root_missing"); + + let (hosts, host_diagnostics) = bridge.list_hosts(20); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].status, "degraded"); + assert_eq!(hosts[0].capabilities.local_pod_inspection, "unavailable"); + assert_eq!(host_diagnostics[0].code, "local_pod_metadata_root_missing"); + } + + #[test] + fn worker_list_and_diagnostics_are_bounded() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let pod_root = data_dir.join("pods"); + fs::create_dir_all(&pod_root).unwrap(); + for index in 0..250 { + let worker_dir = pod_root.join(format!("worker-{index:03}")); + fs::create_dir_all(&worker_dir).unwrap(); + fs::write( + worker_dir.join("metadata.json"), + serde_json::to_vec_pretty(&json!({ + "pod_name": format!("worker-{index:03}"), + "workspace_root": "/workspace/project" + })) + .unwrap(), + ) + .unwrap(); + } + + let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir)); + let (workers, diagnostics) = bridge.list_workers(5); + assert_eq!(workers.len(), 5); + assert!(diagnostics.len() <= MAX_DIAGNOSTICS); + assert_eq!(workers[0].pod_name, "worker-000"); + assert_eq!(workers[4].pod_name, "worker-004"); + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 502ca47d..e8a5a282 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -4,6 +4,7 @@ //! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! remain the canonical project records and are read through bounded bridge APIs. +pub mod hosts; pub mod records; pub mod server; pub mod store; @@ -30,6 +31,8 @@ pub enum Error { InvalidRecordId(String), #[error("record `{0}` is missing frontmatter")] MissingFrontmatter(String), + #[error("unknown local host `{0}`")] + UnknownHost(String), #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 19d5f27d..6c2ea706 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -10,8 +10,9 @@ use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; +use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail}; -use crate::store::{ControlPlaneStore, RunSummary, RunnerSummary, WorkspaceRecord}; +use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; use crate::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -28,6 +29,7 @@ pub struct ServerConfig { pub static_assets_dir: Option, pub auth: AuthConfig, pub max_records: usize, + pub local_runtime_data_dir: Option, } impl ServerConfig { @@ -45,6 +47,7 @@ impl ServerConfig { token_configured: false, }, max_records: 200, + local_runtime_data_dir: manifest::paths::data_dir(), } } } @@ -84,6 +87,14 @@ impl WorkspaceApi { pub fn workspace_id(&self) -> &str { self.config.workspace_id.as_str() } + + fn local_runtime_bridge(&self) -> LocalRuntimeBridge { + LocalRuntimeBridge::new( + self.config.workspace_id.clone(), + self.config.workspace_root.clone(), + self.config.local_runtime_data_dir.clone(), + ) + } } pub fn build_router(api: WorkspaceApi) -> Router { @@ -94,7 +105,9 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/objectives", get(list_objectives)) .route("/api/objectives/{id}", get(get_objective)) .route("/api/runs", get(list_runs)) - .route("/api/runners", get(list_runners)) + .route("/api/hosts", get(list_hosts)) + .route("/api/workers", get(list_workers)) + .route("/api/hosts/{host_id}/workers", get(list_host_workers)) .fallback(get(static_or_spa_fallback)) .with_state(api) } @@ -124,7 +137,7 @@ pub struct WorkspaceResponse { pub struct ExtensionPoints { pub store: String, pub event_stream: ExtensionPointState, - pub runner_connection: ExtensionPointState, + pub host_worker_bridge: ExtensionPointState, } #[derive(Debug, Serialize, Deserialize)] @@ -148,6 +161,7 @@ pub struct RuntimeListResponse { pub limit: usize, pub items: Vec, pub source: String, + pub diagnostics: Vec, } async fn get_workspace(State(api): State) -> ApiResult> { @@ -177,9 +191,9 @@ async fn get_workspace(State(api): State) -> ApiResult, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); - let items = api.store.list_runners(api.workspace_id(), limit).await?; + let bridge = api.local_runtime_bridge(); + let (items, diagnostics) = bridge.list_hosts(limit); Ok(Json(RuntimeListResponse { workspace_id: api.config.workspace_id, limit, items, - source: "sqlite_runtime_tables".to_string(), + source: "local_pod_metadata".to_string(), + diagnostics, })) } +async fn list_workers( + State(api): State, +) -> ApiResult>> { + workers_response(api).map(Json) +} + +async fn list_host_workers( + State(api): State, + AxumPath(host_id): AxumPath, +) -> ApiResult>> { + let bridge = api.local_runtime_bridge(); + if host_id != bridge.host_id() { + return Err(Error::UnknownHost(host_id).into()); + } + workers_response(api).map(Json) +} + +fn workers_response(api: WorkspaceApi) -> ApiResult> { + let limit = api.config.max_records.min(200); + let bridge = api.local_runtime_bridge(); + let (items, diagnostics) = bridge.list_workers(limit); + Ok(RuntimeListResponse { + workspace_id: api.config.workspace_id, + limit, + items, + source: "local_pod_metadata".to_string(), + diagnostics, + }) +} + async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -360,7 +407,9 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.0 { - Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) => StatusCode::NOT_FOUND, + Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) => { + StatusCode::NOT_FOUND + } Error::Ticket(_) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -401,6 +450,7 @@ mod tests { let mut config = ServerConfig::local_dev(dir.path()); config.workspace_id = "local:test".to_string(); config.static_assets_dir = Some(static_dir); + config.local_runtime_data_dir = Some(dir.path().join("data")); let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); let app = build_router(api); @@ -408,8 +458,8 @@ mod tests { assert_eq!(workspace["workspace_id"], "local:test"); assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!( - workspace["extension_points"]["runner_connection"]["status"], - "reserved" + workspace["extension_points"]["host_worker_bridge"]["status"], + "read_only_local" ); let tickets = get_json(app.clone(), "/api/tickets").await; @@ -419,8 +469,35 @@ mod tests { let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"][0]["id"], "00000000001J3"); - let runners = get_json(app.clone(), "/api/runners").await; - assert!(runners["items"].as_array().unwrap().is_empty()); + let hosts = get_json(app.clone(), "/api/hosts").await; + assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); + assert_eq!(hosts["items"][0]["kind"], "local_host"); + assert_eq!( + hosts["items"][0]["capabilities"]["local_pod_inspection"], + "unavailable" + ); + + let workers = get_json(app.clone(), "/api/workers").await; + assert!(workers["items"].as_array().unwrap().is_empty()); + assert_eq!( + workers["diagnostics"][0]["code"], + "local_pod_metadata_root_missing" + ); + + let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; + assert!(host_workers["items"].as_array().unwrap().is_empty()); + + let runners_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/runners") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(runners_response.status(), StatusCode::NOT_FOUND); let static_response = app .clone() diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3b4a6334..7c0c4ee6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -50,14 +50,6 @@ CREATE TABLE IF NOT EXISTS objective_projections ( PRIMARY KEY (workspace_id, objective_id) ); -CREATE TABLE IF NOT EXISTS runners ( - runner_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - label TEXT NOT NULL, - status TEXT NOT NULL, - last_seen_at TEXT -); - CREATE TABLE IF NOT EXISTS runs ( run_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, @@ -106,22 +98,12 @@ pub struct RunSummary { pub updated_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RunnerSummary { - pub runner_id: String, - pub workspace_id: String, - pub label: String, - pub status: String, - pub last_seen_at: Option, -} - #[async_trait] pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result>; - async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result>; } #[derive(Clone)] @@ -229,27 +211,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { rows.collect::>>().map_err(Error::from) }) } - - async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result> { - self.with_conn(|conn| { - let limit = limit.min(200) as i64; - let mut stmt = conn.prepare( - r#"SELECT runner_id, workspace_id, label, status, last_seen_at - FROM runners WHERE workspace_id = ?1 ORDER BY runner_id ASC LIMIT ?2"#, - )?; - let rows = stmt.query_map(params![workspace_id, limit], |row| { - Ok(RunnerSummary { - runner_id: row.get(0)?, - workspace_id: row.get(1)?, - label: row.get(2)?, - status: row.get(3)?, - last_seen_at: row.get(4)?, - }) - })?; - rows.collect::>>() - .map_err(Error::from) - }) - } } fn configure_sqlite(conn: &Connection) -> Result<()> { @@ -330,12 +291,5 @@ mod tests { .unwrap() .is_empty() ); - assert!( - reopened - .list_runners("local-dev", 20) - .await - .unwrap() - .is_empty() - ); } } diff --git a/package.nix b/package.nix index a0d9e420..82da2056 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-cZxkmM42kbDp1Rv9gn4sCD5WIQLc0wCbjj4GbKjuA9Q="; + cargoHash = "sha256-dKkAFUfTAMxSRHq9iNmwRXjQVSBHQBtb0+v8VHkgAGM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index 90a7f19d..0b81f0b6 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,11 +1,11 @@ + +
+
+

Hosts

+ {#if hosts} + {#if hosts.items.length === 0} +

No local Hosts are visible.

+ {:else} +
+ {#each hosts.items as host} +
+
+ {host.label} + {host.status} +
+
+
+
ID
+
{host.host_id}
+
+
+
Kind
+
{host.kind}
+
+
+
Local inspection
+
{host.capabilities.local_pod_inspection}
+
+
+
Platform
+
{host.capabilities.os} / {host.capabilities.arch}
+
+
+
+ {/each} +
+ {/if} + {:else if hostsError} +

{hostsError}

+ {:else} +

Waiting for /api/hosts

+ {/if} +
+ +
+

Workers

+ {#if workers} + {#if workers.items.length === 0} +

No local Workers are visible.

+ {:else} +
+ + + + + + + + + + + + {#each workers.items as worker} + + + + + + + + {/each} + +
WorkerHostStateWorkspaceImplementation
+ {worker.label} + {#if worker.role || worker.profile} + {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} + {/if} + {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}
+
+ {/if} + {:else if workersError} +

{workersError}

+ {:else} +

Waiting for /api/workers

+ {/if} +
+
+ + {#if hosts || workers} + {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} + {#if diagnostics.length > 0} +
+

Diagnostics

+
    + {#each diagnostics as diagnostic} +
  • + {diagnostic.severity} + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+
+ {/if} + {/if} @@ -116,7 +263,7 @@ display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 24px; - width: min(1180px, calc(100vw - 32px)); + width: min(1240px, calc(100vw - 32px)); margin: 0 auto; padding: 32px 0; min-width: 0; @@ -171,6 +318,10 @@ min-width: 0; } + .runtime { + grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); + } + .card { border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 20px; @@ -180,6 +331,33 @@ min-width: 0; } + .stack { + display: grid; + gap: 12px; + } + + .runtime-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 16px; + background: rgba(15, 23, 42, 0.55); + } + + .runtime-heading { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .runtime-heading span { + color: #86efac; + } + + .runtime-heading span.warn { + color: #fcd34d; + } + dl { display: grid; gap: 12px; @@ -193,6 +371,46 @@ dd { margin: 0; + overflow-wrap: anywhere; + } + + .table-wrap { + overflow-x: auto; + } + + table { + width: 100%; + border-collapse: collapse; + } + + th, + td { + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + padding: 10px 8px; + text-align: left; + vertical-align: top; + } + + th { + color: #94a3b8; + font-size: 0.85rem; + text-transform: uppercase; + } + + small { + color: #94a3b8; + display: block; + margin-top: 4px; + } + + .diagnostics { + margin-top: 16px; + } + + .diagnostics li { + display: grid; + gap: 4px; + margin-bottom: 12px; } .error {