Merge branch 'orchestration' into impl/00001KVNG9B9Z-workspace-sidebar

# Conflicts:
#	web/workspace/src/routes/+page.svelte
This commit is contained in:
Keisuke Hirata 2026-06-22 01:52:53 +09:00
commit 4ab696b434
No known key found for this signature in database
20 changed files with 1339 additions and 140 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Workspace backend: expose local host and worker list' title: 'Workspace backend: expose local host and worker list'
state: 'inprogress' state: 'closed'
created_at: '2026-06-21T16:00:49Z' created_at: '2026-06-21T16:00:49Z'
updated_at: '2026-06-21T16:32:22Z' updated_at: '2026-06-21T16:39:43Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-21T16:09:10Z' queued_at: '2026-06-21T16:09:10Z'

View File

@ -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。

View File

@ -213,3 +213,181 @@ Reviewer focus:
Orchestrator will wait for reviewer verdict before integration。 Orchestrator will wait for reviewer verdict before integration。
--- ---
<!-- event: review author: yoi-reviewer-00001KVNEKH9Q-r1 at: 2026-06-21T16:38:05Z status: approve -->
## 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。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:38:57Z -->
## 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`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T16:39:02Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T16:39:15Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T16:39:15Z status: 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。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:39:43Z -->
## 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。
---

View File

@ -2,7 +2,7 @@
title: 'Workspace web UI: add sidebar navigation panel' title: 'Workspace web UI: add sidebar navigation panel'
state: 'inprogress' state: 'inprogress'
created_at: '2026-06-21T16:30:12Z' created_at: '2026-06-21T16:30:12Z'
updated_at: '2026-06-21T16:36:47Z' updated_at: '2026-06-21T16:37:34Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-21T16:35:06Z' queued_at: '2026-06-21T16:35:06Z'

View File

@ -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. 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.
--- ---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:37:34Z -->
## 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。
---

View File

@ -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"}

View File

@ -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"
}
]
}

View File

@ -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` が通る。

View File

@ -0,0 +1,55 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T16:35:19Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T16:36:06Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T16:36:06Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T16:40:35Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T16:41:09Z -->
## 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。
---

2
Cargo.lock generated
View File

@ -6027,6 +6027,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"manifest",
"pod-store",
"project-record", "project-record",
"rusqlite", "rusqlite",
"serde", "serde",

View File

@ -8,6 +8,8 @@ publish = false
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum.workspace = true
manifest = { workspace = true }
pod-store = { workspace = true }
project-record.workspace = true project-record.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@ -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<RuntimeDiagnostic>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<String>,
pub state: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<String>,
pub implementation: WorkerImplementation,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[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<PathBuf>,
}
impl LocalRuntimeBridge {
pub fn new(
workspace_id: impl Into<String>,
workspace_root: impl Into<PathBuf>,
data_dir: Option<PathBuf>,
) -> 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<HostSummary>, Vec<RuntimeDiagnostic>) {
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<WorkerSummary>, Vec<RuntimeDiagnostic>) {
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<PathBuf> {
self.data_dir.as_ref().map(|data_dir| data_dir.join("pods"))
}
}
impl RuntimeDiagnostic {
pub fn new(
code: impl Into<String>,
severity: impl Into<String>,
message: impl Into<String>,
) -> 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<WorkerSummary, RuntimeDiagnostic> {
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<RuntimeDiagnostic> {
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<String>, Option<String>) {
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<String> {
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<RuntimeDiagnostic>, diagnostic: RuntimeDiagnostic) {
if diagnostics.len() < MAX_DIAGNOSTICS {
diagnostics.push(diagnostic);
}
}
fn truncate_diagnostics(diagnostics: &mut Vec<RuntimeDiagnostic>) {
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");
}
}

View File

@ -4,6 +4,7 @@
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! 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. //! remain the canonical project records and are read through bounded bridge APIs.
pub mod hosts;
pub mod records; pub mod records;
pub mod server; pub mod server;
pub mod store; pub mod store;
@ -30,6 +31,8 @@ pub enum Error {
InvalidRecordId(String), InvalidRecordId(String),
#[error("record `{0}` is missing frontmatter")] #[error("record `{0}` is missing frontmatter")]
MissingFrontmatter(String), MissingFrontmatter(String),
#[error("unknown local host `{0}`")]
UnknownHost(String),
#[error("store error: {0}")] #[error("store error: {0}")]
Store(String), Store(String),
} }

View File

@ -10,8 +10,9 @@ use axum::{Json, Router};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail};
use crate::store::{ControlPlaneStore, RunSummary, RunnerSummary, WorkspaceRecord}; use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord};
use crate::{Error, Result}; use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -28,6 +29,7 @@ pub struct ServerConfig {
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig, pub auth: AuthConfig,
pub max_records: usize, pub max_records: usize,
pub local_runtime_data_dir: Option<PathBuf>,
} }
impl ServerConfig { impl ServerConfig {
@ -45,6 +47,7 @@ impl ServerConfig {
token_configured: false, token_configured: false,
}, },
max_records: 200, max_records: 200,
local_runtime_data_dir: manifest::paths::data_dir(),
} }
} }
} }
@ -84,6 +87,14 @@ impl WorkspaceApi {
pub fn workspace_id(&self) -> &str { pub fn workspace_id(&self) -> &str {
self.config.workspace_id.as_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 { 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", get(list_objectives))
.route("/api/objectives/{id}", get(get_objective)) .route("/api/objectives/{id}", get(get_objective))
.route("/api/runs", get(list_runs)) .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)) .fallback(get(static_or_spa_fallback))
.with_state(api) .with_state(api)
} }
@ -124,7 +137,7 @@ pub struct WorkspaceResponse {
pub struct ExtensionPoints { pub struct ExtensionPoints {
pub store: String, pub store: String,
pub event_stream: ExtensionPointState, pub event_stream: ExtensionPointState,
pub runner_connection: ExtensionPointState, pub host_worker_bridge: ExtensionPointState,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -148,6 +161,7 @@ pub struct RuntimeListResponse<T> {
pub limit: usize, pub limit: usize,
pub items: Vec<T>, pub items: Vec<T>,
pub source: String, pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
} }
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> { async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
@ -177,9 +191,9 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
status: "reserved".to_string(), status: "reserved".to_string(),
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(), note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(),
}, },
runner_connection: ExtensionPointState { host_worker_bridge: ExtensionPointState {
status: "reserved".to_string(), status: "read_only_local".to_string(),
note: "Runner connections are modeled, but no job dispatch or scheduler is implemented.".to_string(), note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no scheduling or lifecycle control is implemented.".to_string(),
}, },
}, },
})) }))
@ -245,22 +259,55 @@ async fn list_runs(
limit, limit,
items, items,
source: "sqlite_runtime_tables".to_string(), source: "sqlite_runtime_tables".to_string(),
diagnostics: Vec::new(),
})) }))
} }
async fn list_runners( async fn list_hosts(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<RunnerSummary>>> { ) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
let limit = api.config.max_records.min(200); 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 { Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
limit, limit,
items, items,
source: "sqlite_runtime_tables".to_string(), source: "local_pod_metadata".to_string(),
diagnostics,
})) }))
} }
async fn list_workers(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
workers_response(api).map(Json)
}
async fn list_host_workers(
State(api): State<WorkspaceApi>,
AxumPath(host_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
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<RuntimeListResponse<WorkerSummary>> {
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<WorkspaceApi>, uri: Uri) -> Response { async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
if uri.path().starts_with("/api/") || uri.path() == "/api" { if uri.path().starts_with("/api/") || uri.path() == "/api" {
return ( return (
@ -360,7 +407,9 @@ impl From<Error> for ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = match &self.0 { 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, Error::Ticket(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
@ -401,6 +450,7 @@ mod tests {
let mut config = ServerConfig::local_dev(dir.path()); let mut config = ServerConfig::local_dev(dir.path());
config.workspace_id = "local:test".to_string(); config.workspace_id = "local:test".to_string();
config.static_assets_dir = Some(static_dir); 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 api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app = build_router(api); let app = build_router(api);
@ -408,8 +458,8 @@ mod tests {
assert_eq!(workspace["workspace_id"], "local:test"); assert_eq!(workspace["workspace_id"], "local:test");
assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!( assert_eq!(
workspace["extension_points"]["runner_connection"]["status"], workspace["extension_points"]["host_worker_bridge"]["status"],
"reserved" "read_only_local"
); );
let tickets = get_json(app.clone(), "/api/tickets").await; let tickets = get_json(app.clone(), "/api/tickets").await;
@ -419,8 +469,35 @@ mod tests {
let objectives = get_json(app.clone(), "/api/objectives").await; let objectives = get_json(app.clone(), "/api/objectives").await;
assert_eq!(objectives["items"][0]["id"], "00000000001J3"); assert_eq!(objectives["items"][0]["id"], "00000000001J3");
let runners = get_json(app.clone(), "/api/runners").await; let hosts = get_json(app.clone(), "/api/hosts").await;
assert!(runners["items"].as_array().unwrap().is_empty()); 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 let static_response = app
.clone() .clone()

View File

@ -50,14 +50,6 @@ CREATE TABLE IF NOT EXISTS objective_projections (
PRIMARY KEY (workspace_id, objective_id) 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 ( CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY, run_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
@ -106,22 +98,12 @@ pub struct RunSummary {
pub updated_at: String, 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<String>,
}
#[async_trait] #[async_trait]
pub trait ControlPlaneStore: Send + Sync { pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>; async fn schema_version(&self) -> Result<i64>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>; async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>>; async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>>;
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>>;
} }
#[derive(Clone)] #[derive(Clone)]
@ -229,27 +211,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Error::from) rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Error::from)
}) })
} }
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>> {
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::<rusqlite::Result<Vec<_>>>()
.map_err(Error::from)
})
}
} }
fn configure_sqlite(conn: &Connection) -> Result<()> { fn configure_sqlite(conn: &Connection) -> Result<()> {
@ -330,12 +291,5 @@ mod tests {
.unwrap() .unwrap()
.is_empty() .is_empty()
); );
assert!(
reopened
.list_runners("local-dev", 20)
.await
.unwrap()
.is_empty()
);
} }
} }

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-cZxkmM42kbDp1Rv9gn4sCD5WIQLc0wCbjj4GbKjuA9Q="; cargoHash = "sha256-dKkAFUfTAMxSRHq9iNmwRXjQVSBHQBtb0+v8VHkgAGM=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { WorkerSummary } from './types'; import type { ListResponse, Worker } from './types';
const MAX_VISIBLE_WORKERS = 6; const MAX_VISIBLE_WORKERS = 6;
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<WorkerSummary[]>([]); let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null); let placeholder = $state<string | null>(null);
$effect(() => { $effect(() => {
@ -28,8 +28,8 @@
if (!response.ok) { if (!response.ok) {
throw new Error(`workers request failed (${response.status})`); throw new Error(`workers request failed (${response.status})`);
} }
const payload = await response.json(); const payload = (await response.json()) as ListResponse<Worker>;
workers = normalizeWorkers(payload).slice(0, MAX_VISIBLE_WORKERS); workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
if (workers.length === 0) { if (workers.length === 0) {
placeholder = 'No workers reported by the current API.'; placeholder = 'No workers reported by the current API.';
} }
@ -45,47 +45,6 @@
} }
} }
} }
function normalizeWorkers(payload: unknown): WorkerSummary[] {
const items = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.items)
? payload.items
: [];
return items.map((item, index) => normalizeWorker(item, index));
}
function normalizeWorker(item: unknown, index: number): WorkerSummary {
if (!isRecord(item)) {
return {
id: `worker-${index + 1}`,
label: `worker ${index + 1}`,
status: 'unknown'
};
}
const id = readText(item, ['id', 'worker_id', 'name']) ?? `worker-${index + 1}`;
const label = readText(item, ['display_name', 'label', 'name', 'worker_id', 'id']) ?? id;
const status = readText(item, ['status', 'state', 'lifecycle']) ?? 'unknown';
const detail = readText(item, ['role', 'profile', 'note']);
return { id, label, status, detail };
}
function readText(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
</script> </script>
<section class="nav-section" aria-labelledby="workers-heading"> <section class="nav-section" aria-labelledby="workers-heading">
@ -104,11 +63,11 @@
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p> <p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
{:else} {:else}
<ul class="nav-list" aria-label="Workers"> <ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.id)} {#each workers as worker (worker.worker_id)}
<li class="nav-item"> <li class="nav-item">
<span class="item-title">{worker.label}</span> <span class="item-title">{worker.label}</span>
<span class="item-meta"> <span class="item-meta">
{worker.status}{worker.detail ? ` · ${worker.detail}` : ''} {worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
</span> </span>
</li> </li>
{/each} {/each}

View File

@ -1,13 +1,64 @@
export type ExtensionPoint = {
status: string;
note: string;
};
export type WorkspaceResponse = { export type WorkspaceResponse = {
workspace_id: string; workspace_id: string;
display_name: string; display_name: string;
record_authority: string; record_authority: string;
extension_points: { extension_points: {
event_stream: { status: string; note: string }; event_stream: ExtensionPoint;
runner_connection: { status: string; note: string }; host_worker_bridge: ExtensionPoint;
}; };
}; };
export type Diagnostic = {
code: string;
severity: string;
message: string;
};
export type Host = {
host_id: string;
label: string;
kind: string;
status: string;
observed_at: string;
last_seen_at: string;
capabilities: {
local_pod_inspection: string;
workspace_root: string;
os: string;
arch: string;
max_workers: number;
};
diagnostics: Diagnostic[];
};
export type Worker = {
worker_id: string;
host_id: string;
label: string;
pod_name: string;
role?: string;
profile?: string;
workspace_root?: string;
state: string;
status: string;
last_seen_at?: string;
implementation: { kind: string; pod_name: string };
diagnostics: Diagnostic[];
};
export type ListResponse<T> = {
workspace_id: string;
limit: number;
items: T[];
source: string;
diagnostics: Diagnostic[];
};
export type ObjectiveSummary = { export type ObjectiveSummary = {
id: string; id: string;
title: string; title: string;
@ -29,10 +80,3 @@ export type ObjectiveListResponse = {
invalid_records: InvalidProjectRecord[]; invalid_records: InvalidProjectRecord[];
record_authority: string; record_authority: string;
}; };
export type WorkerSummary = {
id: string;
label: string;
status: string;
detail?: string | null;
};

View File

@ -1,33 +1,69 @@
<script lang="ts"> <script lang="ts">
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type { WorkspaceResponse } from '$lib/workspace-sidebar/types'; import type { Diagnostic, Host, ListResponse, Worker, WorkspaceResponse } from '$lib/workspace-sidebar/types';
const endpoints = [ const endpoints = [
{ label: 'Workspace', path: '/api/workspace' }, { label: 'Workspace', path: '/api/workspace' },
{ label: 'Tickets', path: '/api/tickets' }, { label: 'Tickets', path: '/api/tickets' },
{ label: 'Objectives', path: '/api/objectives' }, { label: 'Objectives', path: '/api/objectives' },
{ label: 'Runs', path: '/api/runs' }, { label: 'Runs', path: '/api/runs' },
{ label: 'Runners', path: '/api/runners' } { label: 'Hosts', path: '/api/hosts' },
{ label: 'Workers', path: '/api/workers' }
]; ];
let workspace = $state<WorkspaceResponse | null>(null); let workspace = $state<WorkspaceResponse | null>(null);
let loadError = $state<string | null>(null); let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null);
let workspaceError = $state<string | null>(null);
let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function loadWorkspace() { async function loadWorkspace() {
loadError = null; workspaceError = null;
try { try {
const response = await fetch('/api/workspace'); workspace = await getJson<WorkspaceResponse>('/api/workspace');
if (!response.ok) {
throw new Error(`GET /api/workspace failed: ${response.status}`);
}
workspace = await response.json();
} catch (error) { } catch (error) {
loadError = error instanceof Error ? error.message : String(error); workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
} }
} }
async function loadHosts() {
hostsError = null;
try {
hosts = await getJson<ListResponse<Host>>('/api/hosts');
} catch (error) {
hostsError = error instanceof Error ? error.message : String(error);
hosts = null;
}
}
async function loadWorkers() {
workersError = null;
try {
workers = await getJson<ListResponse<Worker>>('/api/workers');
} catch (error) {
workersError = error instanceof Error ? error.message : String(error);
workers = null;
}
}
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
return groups.flatMap((group) => group ?? []);
}
$effect(() => { $effect(() => {
void loadWorkspace(); void loadWorkspace();
void loadHosts();
void loadWorkers();
}); });
</script> </script>
@ -40,7 +76,7 @@
</svelte:head> </svelte:head>
<div class="workspace-layout"> <div class="workspace-layout">
<WorkspaceSidebar {workspace} workspaceError={loadError} /> <WorkspaceSidebar {workspace} {workspaceError} />
<main class="shell"> <main class="shell">
<section class="hero"> <section class="hero">
@ -48,8 +84,9 @@
<h1>Yoi Workspace Control Plane</h1> <h1>Yoi Workspace Control Plane</h1>
<p> <p>
Static SPA shell for reading canonical <code>.yoi</code> project records Static SPA shell for reading canonical <code>.yoi</code> project records
through bounded backend APIs. Ticket and Objective lifecycle authority stays and the local Host / Worker execution view through bounded backend APIs.
in the existing local record workflow. Ticket and Objective lifecycle authority stays in the existing local record
workflow.
</p> </p>
</section> </section>
@ -69,9 +106,13 @@
<dt>Record authority</dt> <dt>Record authority</dt>
<dd>{workspace.record_authority}</dd> <dd>{workspace.record_authority}</dd>
</div> </div>
<div>
<dt>Host / Worker bridge</dt>
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
</div>
</dl> </dl>
{:else if loadError} {:else if workspaceError}
<p class="error">{loadError}</p> <p class="error">{workspaceError}</p>
{:else} {:else}
<p>Waiting for <code>/api/workspace</code></p> <p>Waiting for <code>/api/workspace</code></p>
{/if} {/if}
@ -90,12 +131,118 @@
<div class="card"> <div class="card">
<h2>Reserved seams</h2> <h2>Reserved seams</h2>
<p> <p>
Event streams and runner connections are represented as extension-point Event streams remain represented as extension-point state in the backend
state in the backend response, but no scheduler, write API, or hosted response. Hosts and Workers are read-only local observations; no
multi-tenant behavior is implemented in this slice. scheduler, lifecycle control, or hosted multi-tenant behavior is
implemented in this slice.
</p> </p>
</div> </div>
</section> </section>
<section class="grid runtime">
<div class="card">
<h2>Hosts</h2>
{#if hosts}
{#if hosts.items.length === 0}
<p>No local Hosts are visible.</p>
{:else}
<div class="stack">
{#each hosts.items as host}
<article class="runtime-card">
<div class="runtime-heading">
<strong>{host.label}</strong>
<span class:warn={host.status !== 'available'}>{host.status}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{host.host_id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{host.kind}</dd>
</div>
<div>
<dt>Local inspection</dt>
<dd>{host.capabilities.local_pod_inspection}</dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
</div>
</dl>
</article>
{/each}
</div>
{/if}
{:else if hostsError}
<p class="error">{hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code></p>
{/if}
</div>
<div class="card">
<h2>Workers</h2>
{#if workers}
{#if workers.items.length === 0}
<p>No local Workers are visible.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Host</th>
<th>State</th>
<th>Workspace</th>
<th>Implementation</th>
</tr>
</thead>
<tbody>
{#each workers.items as worker}
<tr>
<td>
<strong>{worker.label}</strong>
{#if worker.role || worker.profile}
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
{/if}
</td>
<td><code>{worker.host_id}</code></td>
<td>{worker.state} · {worker.status}</td>
<td>{worker.workspace_root ?? 'unknown'}</td>
<td>{worker.implementation.kind}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if workersError}
<p class="error">{workersError}</p>
{:else}
<p>Waiting for <code>/api/workers</code></p>
{/if}
</div>
</section>
{#if hosts || workers}
{@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)}
{#if diagnostics.length > 0}
<section class="card diagnostics">
<h2>Diagnostics</h2>
<ul>
{#each diagnostics as diagnostic}
<li>
<strong>{diagnostic.severity}</strong>
<code>{diagnostic.code}</code>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</main> </main>
</div> </div>
@ -116,7 +263,7 @@
display: grid; display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 24px; gap: 24px;
width: min(1180px, calc(100vw - 32px)); width: min(1240px, calc(100vw - 32px));
margin: 0 auto; margin: 0 auto;
padding: 32px 0; padding: 32px 0;
min-width: 0; min-width: 0;
@ -171,6 +318,10 @@
min-width: 0; min-width: 0;
} }
.runtime {
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
}
.card { .card {
border: 1px solid rgba(148, 163, 184, 0.25); border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 20px; border-radius: 20px;
@ -180,6 +331,33 @@
min-width: 0; 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 { dl {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -193,6 +371,46 @@
dd { dd {
margin: 0; 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 { .error {