merge: sync orchestration before queue 00001KVNGJPRG
This commit is contained in:
commit
ae2d80ba5a
|
|
@ -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'
|
||||
|
|
|
|||
22
.yoi/tickets/00001KVNEKH9Q/resolution.md
Normal file
22
.yoi/tickets/00001KVNEKH9Q/resolution.md
Normal 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。
|
||||
|
|
@ -213,3 +213,181 @@ Reviewer focus:
|
|||
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。
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"id":"orch-plan-20260621-163647-1","ticket_id":"00001KVNG9B9Z","kind":"accepted_plan","accepted_plan":{"summary":"Add left sidebar navigation skeleton to `web/workspace` static SPA with workspace header/settings placeholder, repositories/objectives/workers sections, objectives API read, workers placeholder/API boundary, responsive layout, and section-level error/empty states while avoiding backend authority changes.","branch":"impl/00001KVNG9B9Z-workspace-sidebar","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope frontend Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, resolving any conflict with Host/Worker work if it has merged, validates Deno/frontend/Nix as needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T16:36:47Z"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Workspace web UI: add sidebar navigation panel'
|
||||
state: 'queued'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-06-21T16:30:12Z'
|
||||
updated_at: '2026-06-21T16:35:06Z'
|
||||
updated_at: '2026-06-21T16:37:34Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-21T16:35:06Z'
|
||||
|
|
|
|||
|
|
@ -30,4 +30,108 @@ Marked ready by `yoi ticket state`.
|
|||
Ticket を `workspace-panel` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T16:36:30Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Routing decision: `implementation_ready_parallel_with_conflict_risk`
|
||||
|
||||
Reason:
|
||||
- Ticket body は Workspace web UI に左 sidebar navigation panel を追加する UX skeleton として、header / repositories / objectives / workers sections、responsive behavior、section-level error handling、Deno validation まで具体化されている。
|
||||
- Relations are `related` only to `00001KVMFFYVX` and `00001KVNEKH9Q`; no blocking dependency is recorded。
|
||||
- `00001KVMFFYVX` is closed/integrated。
|
||||
- `00001KVNEKH9Q` is currently in review and touches Host/Worker API plus `web/workspace/src/routes/+page.svelte`; this creates merge-conflict risk but not an authority blocker. Ticket body explicitly allows Workers section to be placeholder before Host/Worker API exists and asks for component boundary that can later connect to that API。
|
||||
- Current queued Ticket is this Ticket only。
|
||||
- Orchestrator worktree is clean on `orchestration` at `d4de8e26`; target worktree / branch is not present。
|
||||
|
||||
Evidence checked:
|
||||
- Ticket body via direct `item.md` read。
|
||||
- `relations.json`: related to `00001KVMFFYVX` and `00001KVNEKH9Q` only。
|
||||
- `TicketOrchestrationPlanQuery(00001KVNG9B9Z)`: no records。
|
||||
- `TicketList(state=queued)`: this Ticket is the only queued Ticket。
|
||||
- `00001KVNEKH9Q` current state is `inprogress` / reviewer running; no approval/merge yet。
|
||||
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
|
||||
|
||||
IntentPacket:
|
||||
|
||||
Intent:
|
||||
- Add a left sidebar navigation skeleton to Workspace web SPA that surfaces Workspace / Repository / Objective / Worker navigation without adding deep editing or business authority。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- Frontend-only UI skeleton unless a minimal API read is already available; do not change backend authority without need。
|
||||
- Keep static SPA; no SSR or frontend lifecycle/business authority。
|
||||
- Objective section should read existing `/api/objectives` and display bounded title/state data。
|
||||
- Worker section should use a component/data boundary that can connect to Host/Worker API, but must safely show placeholder/empty/error if API is absent or in flux。
|
||||
- Do not expose Pod as primary UI/domain naming; use `workers`。
|
||||
- API failures are section-local and must not take down the whole page。
|
||||
- Avoid broad design-system churn; keep current skeleton style direction。
|
||||
- Be aware that `00001KVNEKH9Q` may merge Host/Worker UI/API changes concurrently; keep sidebar changes narrow and componentized to reduce conflict。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- Left sidebar is visible in Workspace web UI。
|
||||
- Sidebar header displays workspace name/label plus settings placeholder/icon/button。
|
||||
- Sections present: `repositories`, `objectives`, `workers`。
|
||||
- Objectives section fetches existing `/api/objectives` and displays title/state minimal info。
|
||||
- Workers section safely displays placeholder/empty/error if Host/Worker API is not yet integrated; if API is available in branch, it may use it through a component boundary。
|
||||
- Main content and sidebar layout are separated and responsive enough to avoid narrow-viewport breakage / horizontal overflow。
|
||||
- Section-level error/empty states are bounded。
|
||||
- Deno check/build passes。
|
||||
|
||||
Implementation latitude:
|
||||
- Split Svelte components under `web/workspace/src/lib` if useful。
|
||||
- Keep repository section placeholder/local summary if no Repository API exists。
|
||||
- Use simple CSS layout; no full design system。
|
||||
- If concurrent `00001KVNEKH9Q` merge creates conflict, report rather than broad rewrite。
|
||||
|
||||
Escalate if:
|
||||
- Implementing sidebar requires backend API redesign beyond minimal frontend skeleton。
|
||||
- Workers section cannot be safely decoupled from unmerged Host/Worker API。
|
||||
- Deno/Svelte tooling fails for reasons unrelated to this Ticket。
|
||||
- Merge conflict with `00001KVNEKH9Q` becomes non-trivial and requires orchestration sequencing。
|
||||
|
||||
Validation plan:
|
||||
- `cd web/workspace && deno task check && deno task build`
|
||||
- `git diff --check`
|
||||
- `cargo run -p yoi -- ticket doctor`
|
||||
- `nix build .#yoi --no-link` preferred because Workspace frontend package surface is touched。
|
||||
- `cargo test -p yoi-workspace-server` only if backend code changes。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T16:36:37Z from: queued to: inprogress reason: human_authorized_unblocked_sidebar_ui_implementation field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
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。
|
||||
|
||||
---
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -6027,6 +6027,8 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"manifest",
|
||||
"pod-store",
|
||||
"project-record",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
552
crates/workspace-server/src/hosts.rs
Normal file
552
crates/workspace-server/src/hosts.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
pub max_records: usize,
|
||||
pub local_runtime_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<T> {
|
|||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
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(),
|
||||
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(),
|
||||
},
|
||||
runner_connection: ExtensionPointState {
|
||||
status: "reserved".to_string(),
|
||||
note: "Runner connections are modeled, but no job dispatch or scheduler is implemented.".to_string(),
|
||||
host_worker_bridge: ExtensionPointState {
|
||||
status: "read_only_local".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,
|
||||
items,
|
||||
source: "sqlite_runtime_tables".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_runners(
|
||||
async fn list_hosts(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RunnerSummary>>> {
|
||||
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
|
||||
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<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 {
|
||||
if uri.path().starts_with("/api/") || uri.path() == "/api" {
|
||||
return (
|
||||
|
|
@ -360,7 +407,9 @@ impl From<Error> 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()
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
async fn schema_version(&self) -> Result<i64>;
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||
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_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -229,27 +211,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
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<()> {
|
||||
|
|
@ -330,12 +291,5 @@ mod tests {
|
|||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
reopened
|
||||
.list_runners("local-dev", 20)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,33 +5,98 @@
|
|||
record_authority: string;
|
||||
extension_points: {
|
||||
event_stream: { status: string; note: string };
|
||||
runner_connection: { status: string; note: string };
|
||||
host_worker_bridge: { status: string; note: string };
|
||||
};
|
||||
};
|
||||
|
||||
type Diagnostic = {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
type ListResponse<T> = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: T[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
const endpoints = [
|
||||
{ label: 'Workspace', path: '/api/workspace' },
|
||||
{ label: 'Tickets', path: '/api/tickets' },
|
||||
{ label: 'Objectives', path: '/api/objectives' },
|
||||
{ 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 hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let loadError = $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() {
|
||||
try {
|
||||
const response = await fetch('/api/workspace');
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET /api/workspace failed: ${response.status}`);
|
||||
}
|
||||
workspace = await response.json();
|
||||
const [workspaceResponse, hostResponse, workerResponse] = await Promise.all([
|
||||
getJson<WorkspaceResponse>('/api/workspace'),
|
||||
getJson<ListResponse<Host>>('/api/hosts'),
|
||||
getJson<ListResponse<Worker>>('/api/workers')
|
||||
]);
|
||||
workspace = workspaceResponse;
|
||||
hosts = hostResponse;
|
||||
workers = workerResponse;
|
||||
loadError = null;
|
||||
} catch (error) {
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
|
||||
return groups.flatMap((group) => group ?? []);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadWorkspace();
|
||||
});
|
||||
|
|
@ -51,8 +116,9 @@
|
|||
<h1>Yoi Workspace Control Plane</h1>
|
||||
<p>
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||
through bounded backend APIs. Ticket and Objective lifecycle authority stays
|
||||
in the existing local record workflow.
|
||||
and the local Host / Worker execution view through bounded backend APIs.
|
||||
Ticket and Objective lifecycle authority stays in the existing local record
|
||||
workflow.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -72,6 +138,10 @@
|
|||
<dt>Record authority</dt>
|
||||
<dd>{workspace.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host / Worker bridge</dt>
|
||||
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
|
|
@ -93,12 +163,118 @@
|
|||
<div class="card">
|
||||
<h2>Reserved seams</h2>
|
||||
<p>
|
||||
Event streams and runner connections are represented as extension-point
|
||||
state in the backend response, but no scheduler, write API, or hosted
|
||||
multi-tenant behavior is implemented in this slice.
|
||||
Event streams remain represented as extension-point state in the backend
|
||||
response. Hosts and Workers are read-only local observations; no
|
||||
scheduler, lifecycle control, or hosted multi-tenant behavior is
|
||||
implemented in this slice.
|
||||
</p>
|
||||
</div>
|
||||
</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 Pod 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 loadError}
|
||||
<p class="error">{loadError}</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}: {worker.implementation.pod_name}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</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>
|
||||
|
||||
<style>
|
||||
|
|
@ -111,7 +287,7 @@
|
|||
}
|
||||
|
||||
.shell {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
|
@ -145,6 +321,11 @@
|
|||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.runtime {
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
|
|
@ -155,6 +336,33 @@
|
|||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -168,6 +376,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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user