diff --git a/.yoi/tickets/00001KWHEM8YJ/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KWHEM8YJ/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..1eed0012 --- /dev/null +++ b/.yoi/tickets/00001KWHEM8YJ/artifacts/orchestration-plan.jsonl @@ -0,0 +1,3 @@ +{"id":"orch-plan-20260702-164730-1","ticket_id":"00001KWHEM8YJ","kind":"waiting_capacity_note","note":"Dashboard 起動時の queue review。対象 Ticket は未指定で、ユーザー指示により explicit follow-up まで role Pod spawn / queued->inprogress acceptance は行わない human gate として待機する。確認済み: Ticket 本文/最近の thread、TicketRelationQuery(0件)、TicketOrchestrationPlanQuery(0件)、workspace git/worktree 状態、visible Pods、TicketDoctor(0 errors)。","author":"orchestrator","at":"2026-07-02T16:47:30Z"} +{"id":"orch-plan-20260702-170047-2","ticket_id":"00001KWHEM8YJ","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWHHRTM9` と `00001KWHEM8YJ` は workspace Browser の runtime/worker UI/API surface が重なるため、別 worktree 並列ではなく同一 branch/worktree で順序実装する。human gate は明示 follow-up で解除済み。","branch":"work/00001KWHHRTM9-00001KWHEM8YJ","worktree":"/home/hare/Projects/yoi/.worktree/00001KWHHRTM9-00001KWHEM8YJ","role_plan":"単一 sibling Coder Pod に同一 implementation worktree を委譲し、Runtime connection settings/API を先に実装してから Manual Coding Worker launch 導線を実装する。実装後は別 sibling Reviewer Pod で両 Ticket の recorded IntentPacket / invariants / acceptance criteria に照らしてレビューする。"},"author":"orchestrator","at":"2026-07-02T17:00:47Z"} +{"id":"orch-plan-20260702-170054-3","ticket_id":"00001KWHEM8YJ","kind":"after","related_ticket":"00001KWHHRTM9","note":"同一 worktree で順序実装する。Manual Coding Worker launch は Runtime connection settings/API の candidate/projection 境界に合わせて後続実装する。","author":"orchestrator","at":"2026-07-02T17:00:54Z"} diff --git a/.yoi/tickets/00001KWHEM8YJ/item.md b/.yoi/tickets/00001KWHEM8YJ/item.md index 6b24327d..bda02d0b 100644 --- a/.yoi/tickets/00001KWHEM8YJ/item.md +++ b/.yoi/tickets/00001KWHEM8YJ/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace Browserから手動Coding Workerを作成する導線を追加する' -state: 'queued' +state: 'inprogress' created_at: '2026-07-02T12:59:57Z' -updated_at: '2026-07-02T16:13:24Z' +updated_at: '2026-07-02T17:01:11Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-02T16:13:24Z' diff --git a/.yoi/tickets/00001KWHEM8YJ/thread.md b/.yoi/tickets/00001KWHEM8YJ/thread.md index 08fac08d..2b91487c 100644 --- a/.yoi/tickets/00001KWHEM8YJ/thread.md +++ b/.yoi/tickets/00001KWHEM8YJ/thread.md @@ -62,4 +62,97 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Ticket は Manual Coding Worker launch の product-level API / Sidebar New form / sanitized payload / success Console navigation / unsupported runtime diagnostic を具体化しており、observable な acceptance criteria と validation がある。 +- typed relation blocker は 0 件、OrchestrationPlan は prior human-gate waiting note のみで、ユーザーから「2つとも消化して」と明示 follow-up があったため human gate は解除された。 +- `00001KWHHRTM9` の Runtime connection management と UI/API surface が近く、別 worktree 並列では衝突リスクが高いので、同一 implementation worktree/branch で `00001KWHHRTM9` を先に実装し、その Runtime candidate/projection を利用して本 Ticket を続けて実装する。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWHEM8YJ)` は 0 件。 +- `TicketOrchestrationPlanQuery(00001KWHEM8YJ)` は prior human-gate waiting note のみ。 +- queued Ticket 一覧では `00001KWHHRTM9` も queued、ready/inprogress は 0 件。 +- workspace/orchestration git state と worktree 一覧、visible Pods、TicketDoctor(0 errors / 既存 warning のみ)。 +- Bounded code map: `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`、console route under `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/`、existing `/api/workers` list projection、`/api/runtimes/{runtime_id}/workers` internal-ish Runtime spawn path、`crates/workspace-server` runtime/backend/config areas。 + +IntentPacket: + +Intent: +- Workspace Browser sidebar の WORKER heading 横に `New` button と作成 form を追加し、Browser-facing `/api/workers` product-level endpoint から coding Worker を作成し、成功後に Worker Console へ遷移する。 + +Binding decisions / invariants: +- Browser UI は existing Runtime create payload を直接露出しない。 +- Browser-facing request fields は `runtime_id` / `display_name` / `profile` / `initial_text` のみ。`kind` は endpoint/backend launch mode として扱い、form input や request field にしない。 +- Browser-facing payload/response/form に raw workspace path、cwd、tool scope、`ConfigBundleRef`、requested capabilities、secret/token、Runtime endpoint、socket/session/store path を含めない。 +- `profile` は Backend が公開する候補から選ばせ、自由入力にしない。 +- v0 は embedded/local Runtime を primary target とし、remote Runtime の workspace provisioning が未対応なら typed diagnostic で拒否してよい。 +- `/api/runtimes/{runtime_id}/workers` は残してよいが、New Worker UI は新しい product-level `/api/workers` POST を使う。 + +Requirements / acceptance criteria: +- Sidebar WORKER heading 横に `New` button がある。 +- `New` で display name / runtime / profile / initial text だけの form が開く。 +- Form submit は `/api/workers` product-level endpoint を使い、coding Worker を作成できる。 +- Backend は request の `profile` から Profile / ConfigBundle / execution backend を解決する。 +- 作成成功後、Worker list を refresh し、`/runtimes/{runtime_id}/workers/{worker_id}/console` へ遷移する。 +- 作成失敗時、入力値を保持して sanitized diagnostic を form に表示する。 +- focused backend/UI tests を追加し、指定 validation を可能な範囲で実行する。 + +Implementation latitude: +- Runtime candidate は `00001KWHHRTM9` の projection を利用する。v0 で embedded-only fallback が必要なら許容されるが、Browser 自由入力にはしない。 +- Product-level endpoint の internal mapping、response shape、Svelte form placement(inline/sidebar panel/modal)は既存 UX を壊さない範囲で選んでよい。 +- Profile candidate の v0 source は backend-provided static/builtin candidates でもよい。ただし自由入力化しない。 + +Escalate if: +- Browser-facing API に raw path/cwd/scope/secret/runtime internal location を含めないと実装できない場合。 +- Profile/ConfigBundle resolution の既存 boundary を変える必要が出た場合。 +- remote runtime provisioning を完成させないと acceptance を満たせない場合。 + +Validation: +- `cd web/workspace && deno task test` +- `cd web/workspace && deno task check` +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` は時間/依存変更の重さを見て実行、未実行なら理由を report。 + +Current code map: +- Sidebar UI: `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`, `WorkspaceSidebar.svelte`。 +- Worker console/navigation: `web/workspace/src/lib/workspace-console/model`, `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/`。 +- Existing worker list and runtime spawn APIs: `crates/workspace-server` `/api/workers` and `/api/runtimes/{runtime_id}/workers` handlers, worker-runtime `CreateWorkerRequest` / config bundle resolution paths。 +- Runtime candidates/settings base: `00001KWHHRTM9` implementation in the same worktree. + +Critical risks / reviewer focus: +- Product-level endpoint does not deserialize or expose the internal Runtime create request shape. +- Browser-facing surfaces are sanitized and do not leak raw path/cwd/scope/ConfigBundleRef/requested capabilities/secret/runtime endpoint/socket/session/store path. +- Profile/runtime selectors are bounded to backend-published choices. +- Success navigation and worker list refresh are deterministic. +- The combined implementation with `00001KWHHRTM9` keeps runtime connection management and manual worker launch contracts consistent. + +--- + + + +## State changed + +Queued acceptance recorded after explicit user follow-up 「2つとも消化して」。 + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWHEM8YJ)`: blocking relation 0 件。 +- `TicketOrchestrationPlanQuery(00001KWHEM8YJ)`: prior human-gate waiting note を確認し、今回 accepted_plan / after ordering を記録済み。 +- workspace/worktree/visible Pod/TicketDoctor/code-map の bounded check。 + +Acceptance basis: +- concrete missing decision / information は残っていない。 +- `00001KWHHRTM9` と surface が重なるため、同一 implementation worktree で `00001KWHHRTM9` の後に実装する。 +- side effect はこの `queued -> inprogress` acceptance 後に開始する。 + --- diff --git a/.yoi/tickets/00001KWHHRTM9/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KWHHRTM9/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..54c99e75 --- /dev/null +++ b/.yoi/tickets/00001KWHHRTM9/artifacts/orchestration-plan.jsonl @@ -0,0 +1,3 @@ +{"id":"orch-plan-20260702-164730-1","ticket_id":"00001KWHHRTM9","kind":"waiting_capacity_note","note":"Dashboard 起動時の queue review。対象 Ticket は未指定で、ユーザー指示により explicit follow-up まで role Pod spawn / queued->inprogress acceptance は行わない human gate として待機する。確認済み: Ticket 本文/最近の thread、TicketRelationQuery(0件)、TicketOrchestrationPlanQuery(0件)、workspace git/worktree 状態、visible Pods、TicketDoctor(0 errors)。本文上の依存先 00001KWHJ0XH6 は TicketShow で closed を確認済み。","author":"orchestrator","at":"2026-07-02T16:47:30Z"} +{"id":"orch-plan-20260702-170047-2","ticket_id":"00001KWHHRTM9","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWHHRTM9` と `00001KWHEM8YJ` は workspace Browser の runtime/worker UI/API surface が重なるため、別 worktree 並列ではなく同一 branch/worktree で順序実装する。human gate は明示 follow-up で解除済み。","branch":"work/00001KWHHRTM9-00001KWHEM8YJ","worktree":"/home/hare/Projects/yoi/.worktree/00001KWHHRTM9-00001KWHEM8YJ","role_plan":"単一 sibling Coder Pod に同一 implementation worktree を委譲し、Runtime connection settings/API を先に実装してから Manual Coding Worker launch 導線を実装する。実装後は別 sibling Reviewer Pod で両 Ticket の recorded IntentPacket / invariants / acceptance criteria に照らしてレビューする。"},"author":"orchestrator","at":"2026-07-02T17:00:47Z"} +{"id":"orch-plan-20260702-170054-3","ticket_id":"00001KWHHRTM9","kind":"before","related_ticket":"00001KWHEM8YJ","note":"同一 worktree で順序実装する。Runtime connection settings/API が Manual Coding Worker form の runtime candidate/projection 基盤になるため、まずこちらを実装する。","author":"orchestrator","at":"2026-07-02T17:00:54Z"} diff --git a/.yoi/tickets/00001KWHHRTM9/item.md b/.yoi/tickets/00001KWHHRTM9/item.md index 8ebe1eaa..18cfc11b 100644 --- a/.yoi/tickets/00001KWHHRTM9/item.md +++ b/.yoi/tickets/00001KWHHRTM9/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace Backend Runtime接続の管理画面と永続configを追加する' -state: 'queued' +state: 'inprogress' created_at: '2026-07-02T13:54:52Z' -updated_at: '2026-07-02T16:45:19Z' +updated_at: '2026-07-02T17:01:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-02T16:45:19Z' diff --git a/.yoi/tickets/00001KWHHRTM9/thread.md b/.yoi/tickets/00001KWHHRTM9/thread.md index 9a653bf8..b34a63a3 100644 --- a/.yoi/tickets/00001KWHHRTM9/thread.md +++ b/.yoi/tickets/00001KWHHRTM9/thread.md @@ -78,4 +78,101 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Ticket は Runtime connection settings API / `.yoi/workspace-backend.local.toml` 永続化 / Settings UI / sanitized diagnostics / restart_required の v0 境界を具体化しており、observable な acceptance criteria と validation がある。 +- 先行 Settings shell Ticket `00001KWHJ0XH6` は closed を確認済み。 +- typed relation blocker は 0 件、OrchestrationPlan は human gate の waiting note のみで、ユーザーから「2つとも消化して」と明示 follow-up があったため human gate は解除された。 +- Manual Coding Worker Ticket `00001KWHEM8YJ` と UI/API surface が近く、並列別 worktree では衝突リスクが高いので、同一 implementation worktree/branch で Runtime connection 管理を先に実装し、続けて manual Worker 作成導線を実装する。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWHHRTM9)` は 0 件。 +- `TicketOrchestrationPlanQuery(00001KWHHRTM9)` は prior human-gate waiting note のみ。 +- `TicketShow(00001KWHJ0XH6)` は closed。 +- queued Ticket 一覧では `00001KWHEM8YJ` も queued、ready/inprogress は 0 件。 +- workspace/orchestration git state と worktree 一覧、visible Pods、TicketDoctor(0 errors / 既存 warning のみ)。 +- Bounded code map: `crates/workspace-server` の workspace backend / runtime registry / config file 周辺、`web/workspace/src/lib/workspace-settings/SettingsPage.svelte`、`web/workspace/src/routes/settings/+page.svelte`、既存 `/api/workers` / `/api/runtimes/{runtime_id}/workers` projection、`WorkersNavSection.svelte`。 + +IntentPacket: + +Intent: +- Workspace Browser の Settings / Runtime Connections で embedded Runtime 表示、remote Runtime connection の list/add/delete/test negotiation、`.yoi/workspace-backend.local.toml` 永続化、restart_required diagnostic を提供する。 +- `00001KWHEM8YJ` が使う Runtime candidate projection の基盤を作る。 + +Binding decisions / invariants: +- Runtime が Backend に接続するのではなく、Backend が configured Runtime source を `RuntimeRegistry` に登録する現行構造を前提にする。 +- embedded Runtime は built-in として表示し、config 管理対象や削除対象にしない。 +- remote Runtime connection は既存 `[[runtimes.remote]]` schema に保存する。 +- v0 は config persistence 優先で、config 更新後は `restart_required = true`。live register/unregister は対象外。 +- raw token 値、secret、socket/session/store path、Runtime event cursor、live handle、config file path は UI/API response に出さない。 +- negotiation/test result、observed capabilities、health result、checked_at は local config に保存しない。 +- protocol version が無ければ fake version を作らず、compatibility basis として表現する。 + +Requirements / acceptance criteria: +- Runtime Connections 管理画面が Settings shell 内にある。 +- embedded Runtime は built-in / delete不可として表示される。 +- remote connection を add/delete でき、config の `[[runtimes.remote]]` が read-modify-write される。 +- test negotiation は `GET /v1/runtime` parse と Browser 必要操作に対する compatibility/sanitized diagnostics を返す。 +- duplicate id / invalid endpoint / incompatible runtime / embedded delete attempt は typed diagnostic。 +- request/response/config に raw token 値や internal paths を含めない。 +- focused backend/UI tests を追加し、指定 validation を可能な範囲で実行する。 + +Implementation latitude: +- TOML comments/format preservation は v0 で typed serialize により失われてもよい。ただし implementation report/docs/test で明記する。 +- POST 保存前に test を必須にするか、保存は許して test diagnostic を別扱いにするかは、Ticket の v0 境界内で選んでよい。 +- API response shape / Svelte component分割 / test helper構成は既存 style に合わせて選んでよい。 + +Escalate if: +- secret store / raw token input / live RuntimeRegistry unregister / remote workspace provisioning / protocol version追加を必須にしないと満たせない場合。 +- existing config schema を壊す必要がある場合。 +- Browser-facing API に raw path/secret/runtime internal location を出す誘惑が出た場合。 + +Validation: +- `cd web/workspace && deno task test` +- `cd web/workspace && deno task check` +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` は時間/依存変更の重さを見て実行、未実行なら理由を report。 + +Current code map: +- Settings UI: `web/workspace/src/lib/workspace-settings/SettingsPage.svelte`, `web/workspace/src/routes/settings/+page.svelte`。 +- Worker/sidebar UI: `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`, console route under `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/`。 +- Backend/runtime/config areas: `crates/workspace-server`, existing `/api/workers`, `/api/runtimes/{runtime_id}/workers`, `WorkspaceBackendConfigFile`, `ServerConfig.remote_runtime_sources`, `RuntimeRegistry`, `EmbeddedWorkerRuntime`。 + +Critical risks / reviewer focus: +- persisted config、live registry state、test/negotiation observation を混同していないか。 +- Browser-facing API に secrets/internal paths/runtime store/socket/session/config file path が漏れていないか。 +- embedded delete が fail closed か。 +- restart_required semantics が UI/API で明確か。 +- `00001KWHEM8YJ` と同一 worktreeで実装し、runtime candidates の shared contract が破綻していないか。 + +--- + + + +## State changed + +Queued acceptance recorded after explicit user follow-up 「2つとも消化して」。 + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWHHRTM9)`: blocking relation 0 件。 +- `TicketOrchestrationPlanQuery(00001KWHHRTM9)`: prior human-gate waiting note を確認し、今回 accepted_plan / before ordering を記録済み。 +- `00001KWHJ0XH6` は closed。 +- workspace/worktree/visible Pod/TicketDoctor/code-map の bounded check。 + +Acceptance basis: +- concrete missing decision / information は残っていない。 +- `00001KWHEM8YJ` と surface が重なるため、同一 implementation worktree でこの Ticket を先に実装する。 +- side effect はこの `queued -> inprogress` acceptance 後に開始する。 + --- diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 28d20f20..5ba59434 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::{fs, io}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; @@ -16,7 +16,7 @@ const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_MAX_RECORDS: usize = 200; -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendConfigFile { #[serde(default)] @@ -29,7 +29,7 @@ pub struct WorkspaceBackendConfigFile { pub runtimes: WorkspaceBackendRuntimesConfig, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendServerConfig { #[serde(default)] @@ -40,7 +40,7 @@ pub struct WorkspaceBackendServerConfig { pub static_assets_dir: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendDataConfig { #[serde(default)] @@ -51,21 +51,21 @@ pub struct WorkspaceBackendDataConfig { pub embedded_runtime_store_root: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendLimitsConfig { #[serde(default)] pub max_records: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { #[serde(default)] pub remote: Vec, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RemoteRuntimeConfigFile { pub id: String, @@ -189,6 +189,20 @@ impl WorkspaceBackendConfigFile { } } + pub fn write_for_workspace(&self, workspace_root: impl AsRef) -> Result<()> { + let path = Self::path_for_workspace(workspace_root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self).map_err(|error| { + Error::Config(format!( + "failed to serialize workspace backend config: {error}" + )) + })?; + fs::write(path, raw)?; + Ok(()) + } + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { toml::from_str(raw).map_err(|error| { Error::Config(format!( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c18515fd..103a7bc0 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -266,6 +266,7 @@ pub struct WorkerSpawnRequest { pub enum WorkerSpawnIntent { WorkspaceCompanion, WorkspaceOrchestrator, + WorkspaceCoding, TicketRole { ticket_id: String, role: TicketWorkerRole, @@ -2260,6 +2261,7 @@ fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector { ProfileSelector::Builtin("builtin:companion".to_string()) } WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault, + WorkerSpawnIntent::WorkspaceCoding => ProfileSelector::Builtin("builtin:coder".to_string()), } } @@ -2680,6 +2682,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str { match intent { WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion", WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator", + WorkerSpawnIntent::WorkspaceCoding => "workspace_coding", WorkerSpawnIntent::TicketRole { role, .. } => match role { TicketWorkerRole::Intake => "ticket_intake", TicketWorkerRole::Orchestrator => "ticket_orchestrator", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cec76ffb..73257b8e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,8 +6,9 @@ use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::CONTENT_TYPE; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; +use chrono::{SecondsFormat, Utc}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; @@ -17,11 +18,13 @@ use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, }; +use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile}; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, - WorkerLifecycleResult, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, + WorkerLifecycleResult, WorkerOperationState, WorkerSpawnAcceptanceRequirement, + WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection, }; use crate::identity::WorkspaceIdentity; @@ -35,8 +38,17 @@ use crate::records::{ use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; -use worker_runtime::catalog::ConfigBundleRef; +use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector}; use worker_runtime::config_bundle::ConfigBundle; +use worker_runtime::http_server::{ + RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse, + RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, +}; +use worker_runtime::interaction::{ + WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind, +}; + +const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthConfig { @@ -222,7 +234,30 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route("/api/hosts", get(list_hosts)) .route("/api/runtimes", get(list_runtimes)) - .route("/api/workers", get(list_workers)) + .route( + "/api/workers", + get(list_workers).post(create_workspace_worker), + ) + .route( + "/api/workers/launch-options", + get(get_worker_launch_options), + ) + .route( + "/api/settings/runtime-connections", + get(get_runtime_connection_settings), + ) + .route( + "/api/settings/runtime-connections/remotes", + post(add_remote_runtime_connection), + ) + .route( + "/api/settings/runtime-connections/remotes/{runtime_id}", + delete(delete_remote_runtime_connection), + ) + .route( + "/api/settings/runtime-connections/remotes/{runtime_id}/test", + post(test_remote_runtime_connection), + ) .route("/api/companion/status", get(get_companion_status)) .route("/api/companion/transcript", get(get_companion_transcript)) .route("/api/companion/messages", post(post_companion_message)) @@ -321,6 +356,110 @@ pub struct RuntimeListResponse { pub diagnostics: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionSettingsResponse { + pub workspace_id: String, + pub embedded: RuntimeConnectionSummary, + pub remotes: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionSummary { + pub runtime_id: String, + pub display_name: String, + pub kind: String, + pub built_in: bool, + pub config_managed: bool, + pub active: bool, + pub can_spawn_worker: bool, + pub restart_required: bool, + pub status: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RemoteRuntimeConnectionSummary { + #[serde(flatten)] + pub summary: RuntimeConnectionSummary, + pub endpoint_configured: bool, + pub token_ref_configured: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionMutationResponse { + pub workspace_id: String, + pub restart_required: bool, + pub remotes: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AddRemoteRuntimeConnectionRequest { + pub runtime_id: String, + pub display_name: Option, + pub endpoint: String, + pub token_ref: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RemoteRuntimeTestResponse { + pub workspace_id: String, + pub runtime_id: String, + pub checked_at: String, + pub state: String, + pub protocol_version: Option, + pub compatibility_basis: String, + pub capabilities: Vec, + pub health_result: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkerLaunchOptionsResponse { + pub workspace_id: String, + pub runtimes: Vec, + pub profiles: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkerLaunchRuntimeOption { + pub runtime_id: String, + pub display_name: String, + pub built_in: bool, + pub can_spawn_worker: bool, + pub status: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerLaunchProfileCandidate { + pub id: String, + pub label: String, + pub description: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserCreateWorkerRequest { + pub runtime_id: String, + pub display_name: String, + pub profile: String, + pub initial_text: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BrowserCreateWorkerResponse { + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub console_href: String, + pub worker: WorkerSummary, + pub diagnostics: Vec, +} + #[derive(Debug, Serialize, Deserialize)] pub struct RepositoryListResponse { pub workspace_id: String, @@ -599,6 +738,188 @@ async fn list_workers( workers_response(api).map(Json) } +async fn get_runtime_connection_settings( + State(api): State, +) -> ApiResult> { + let local_config = load_workspace_backend_config_for_settings(&api)?; + Ok(Json(runtime_connection_settings_response( + &api, + &local_config, + ))) +} + +async fn add_remote_runtime_connection( + State(api): State, + Json(request): Json, +) -> ApiResult> { + validate_runtime_connection_request(&request)?; + let mut local_config = load_workspace_backend_config_for_settings(&api)?; + let id = request.runtime_id.trim().to_string(); + if id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_not_config_managed", + "the embedded Runtime is built in and cannot be managed from local remote Runtime config", + )); + } + if request + .token_ref + .as_ref() + .is_some_and(|value| !value.trim().is_empty()) + { + return Err(settings_bad_request( + "remote_runtime_token_ref_unsupported", + "remote Runtime token_ref persistence is not supported by this v0 browser settings surface", + )); + } + if local_config + .runtimes + .remote + .iter() + .any(|remote| remote.id == id) + { + return Err(settings_bad_request( + "remote_runtime_already_exists", + "a remote Runtime connection with that id is already configured", + )); + } + local_config.runtimes.remote.push(RemoteRuntimeConfigFile { + id, + endpoint: request.endpoint.trim().to_string(), + display_name: request + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + token_ref: None, + }); + write_workspace_backend_config_for_settings(&api, &local_config)?; + let mut response = runtime_connection_mutation_response(&api, &local_config); + response.diagnostics.push(settings_diagnostic( + "workspace_backend_config_rewritten", + DiagnosticSeverity::Info, + "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + )); + Ok(Json(response)) +} + +async fn delete_remote_runtime_connection( + State(api): State, + AxumPath(runtime_id): AxumPath, +) -> ApiResult> { + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_not_config_managed", + "the embedded Runtime is built in and cannot be deleted from remote Runtime config", + )); + } + let mut local_config = load_workspace_backend_config_for_settings(&api)?; + let before = local_config.runtimes.remote.len(); + local_config + .runtimes + .remote + .retain(|remote| remote.id != runtime_id); + if before == local_config.runtimes.remote.len() { + return Err(Error::UnknownRuntime(runtime_id).into()); + } + write_workspace_backend_config_for_settings(&api, &local_config)?; + let mut response = runtime_connection_mutation_response(&api, &local_config); + response.diagnostics.push(settings_diagnostic( + "workspace_backend_config_rewritten", + DiagnosticSeverity::Info, + "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + )); + Ok(Json(response)) +} + +async fn test_remote_runtime_connection( + State(api): State, + AxumPath(runtime_id): AxumPath, +) -> ApiResult> { + let local_config = load_workspace_backend_config_for_settings(&api)?; + let remote = local_config + .runtimes + .remote + .iter() + .find(|remote| remote.id == runtime_id) + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + Ok(Json(test_remote_runtime_config(&api, remote).await)) +} + +async fn get_worker_launch_options( + State(api): State, +) -> ApiResult> { + Ok(Json(worker_launch_options_response(&api))) +} + +async fn create_workspace_worker( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { + settings_bad_request( + "unsupported_worker_profile", + "profile must be selected from Backend-published worker profile candidates", + ) + })?; + let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| { + settings_bad_request( + "invalid_worker_display_name", + "display_name must contain at least one non-control character", + ) + })?; + let initial_text = request.initial_text.trim().to_string(); + let initial_input = if initial_text.is_empty() { + None + } else { + Some(EmbeddedWorkerInput { + kind: EmbeddedWorkerInputKind::User, + content: initial_text, + }) + }; + let result = api + .runtime + .spawn_worker( + &request.runtime_id, + WorkerSpawnRequest { + requested_worker_name: Some(display_name), + intent: WorkerSpawnIntent::WorkspaceCoding, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: if initial_input.is_some() { 1 } else { 0 }, + }, + profile: Some(profile_selector), + initial_input, + }, + ) + .map_err(|err| err.into_error())?; + if result.state != WorkerOperationState::Accepted { + return Err(worker_create_not_accepted_error( + request.runtime_id.clone(), + result.diagnostics, + )); + } + let worker = result.worker.ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: request.runtime_id.clone(), + code: "workspace_worker_create_missing_summary".to_string(), + message: "Runtime completed worker creation without returning a Worker summary".to_string(), + })?; + let runtime_id = worker.runtime_id.clone(); + let worker_id = worker.worker_id.clone(); + let console_href = format!( + "/runtimes/{}/workers/{}/console", + encode_path_segment(&runtime_id), + encode_path_segment(&worker_id) + ); + Ok(Json(BrowserCreateWorkerResponse { + workspace_id: api.config.workspace_id, + runtime_id, + worker_id, + console_href, + worker, + diagnostics: result.diagnostics, + })) +} + async fn get_companion_status( State(api): State, ) -> ApiResult> { @@ -756,7 +1077,7 @@ async fn worker_observation_ws( Ok(source) => ws.on_upgrade(move |socket| { worker_observation_ws_session(api.observation_proxy, source, query, socket) }), - Err(error) => ApiError(error.into_error()).into_response(), + Err(error) => ApiError::from(error.into_error()).into_response(), } } Err(error) => { @@ -914,6 +1235,731 @@ fn workers_response(api: WorkspaceApi) -> ApiResult ApiResult { + WorkspaceBackendConfigFile::load_for_workspace(&api.config.workspace_root).map_err(|error| { + Error::Config(format!( + "failed to read workspace backend local config for Runtime connections: {}", + sanitize_backend_error(&error.to_string()) + )) + .into() + }) +} + +fn write_workspace_backend_config_for_settings( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> ApiResult<()> { + local_config + .write_for_workspace(&api.config.workspace_root) + .map_err(|error| { + Error::Config(format!( + "failed to write workspace backend local config for Runtime connections: {}", + sanitize_backend_error(&error.to_string()) + )) + .into() + }) +} + +fn runtime_connection_settings_response( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> RuntimeConnectionSettingsResponse { + RuntimeConnectionSettingsResponse { + workspace_id: api.config.workspace_id.clone(), + embedded: embedded_runtime_connection_summary(api), + remotes: remote_runtime_connection_summaries(api, local_config, false), + diagnostics: Vec::new(), + } +} + +fn runtime_connection_mutation_response( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> RuntimeConnectionMutationResponse { + RuntimeConnectionMutationResponse { + workspace_id: api.config.workspace_id.clone(), + restart_required: true, + remotes: remote_runtime_connection_summaries(api, local_config, true), + diagnostics: vec![settings_diagnostic( + "runtime_registry_restart_required", + DiagnosticSeverity::Warning, + "Runtime connection config changed; restart the Workspace backend for the live Runtime registry to use the new config.", + )], + } +} + +fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionSummary { + let active = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items + .into_iter() + .find(|runtime| runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID); + match active { + Some(runtime) => RuntimeConnectionSummary { + runtime_id: runtime.runtime_id, + display_name: runtime.label, + kind: runtime.kind, + built_in: true, + config_managed: false, + active: runtime.status == "active", + can_spawn_worker: runtime.capabilities.can_spawn_worker, + restart_required: false, + status: runtime.status, + diagnostics: runtime.diagnostics, + }, + None => RuntimeConnectionSummary { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Embedded Runtime".to_string(), + kind: "embedded_worker_runtime".to_string(), + built_in: true, + config_managed: false, + active: false, + can_spawn_worker: false, + restart_required: false, + status: "unavailable".to_string(), + diagnostics: vec![settings_diagnostic( + "embedded_runtime_unavailable", + DiagnosticSeverity::Warning, + "The built-in embedded Runtime is not active in the current Runtime registry projection.", + )], + }, + } +} + +fn remote_runtime_connection_summaries( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, + restart_required: bool, +) -> Vec { + let live_runtimes = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items; + local_config + .runtimes + .remote + .iter() + .map(|remote| { + let live = live_runtimes + .iter() + .find(|runtime| runtime.runtime_id == remote.id); + let (display_name, kind, active, can_spawn_worker, status, diagnostics) = match live { + Some(runtime) => ( + runtime.label.clone(), + runtime.kind.clone(), + runtime.status == "active", + runtime.capabilities.can_spawn_worker, + runtime.status.clone(), + runtime.diagnostics.clone(), + ), + None => ( + remote + .display_name + .clone() + .unwrap_or_else(|| remote.id.clone()), + "remote_http".to_string(), + false, + false, + "configured_restart_required".to_string(), + if restart_required { + vec![settings_diagnostic( + "runtime_registry_restart_required", + DiagnosticSeverity::Warning, + "This remote Runtime config is persisted but not active until the Workspace backend restarts.", + )] + } else { + Vec::new() + }, + ), + }; + RemoteRuntimeConnectionSummary { + summary: RuntimeConnectionSummary { + runtime_id: remote.id.clone(), + display_name, + kind, + built_in: false, + config_managed: true, + active, + can_spawn_worker, + restart_required, + status, + diagnostics, + }, + endpoint_configured: !remote.endpoint.trim().is_empty(), + token_ref_configured: remote + .token_ref + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + } + }) + .collect() +} + +fn validate_runtime_connection_request( + request: &AddRemoteRuntimeConnectionRequest, +) -> ApiResult<()> { + validate_public_runtime_id(request.runtime_id.trim())?; + let endpoint = request.endpoint.trim(); + if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) + { + return Err(settings_bad_request( + "invalid_remote_runtime_endpoint", + "endpoint must be an absolute http or https URL", + )); + } + if request + .display_name + .as_deref() + .is_some_and(|value| value.chars().any(char::is_control)) + { + return Err(settings_bad_request( + "invalid_remote_runtime_display_name", + "display_name cannot contain control characters", + )); + } + Ok(()) +} + +fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { + if runtime_id.is_empty() { + return Err(settings_bad_request( + "invalid_runtime_id", + "runtime_id must not be empty", + )); + } + if runtime_id.len() > 96 + || !runtime_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return Err(settings_bad_request( + "invalid_runtime_id", + "runtime_id may contain only ASCII letters, digits, '-', '_' and '.' and must be at most 96 characters", + )); + } + Ok(()) +} + +async fn test_remote_runtime_config( + api: &WorkspaceApi, + remote: &RemoteRuntimeConfigFile, +) -> RemoteRuntimeTestResponse { + let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + if remote + .token_ref + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + return RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: "rejected".to_string(), + protocol_version: None, + compatibility_basis: "not_checked_token_ref_unsupported".to_string(), + capabilities: Vec::new(), + health_result: "not_checked".to_string(), + diagnostics: vec![settings_diagnostic( + "remote_runtime_token_ref_unsupported", + DiagnosticSeverity::Error, + "Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.", + )], + }; + } + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(client) => client, + Err(_) => { + return remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_test_client_unavailable", + "Remote Runtime test client could not be initialized.", + ); + } + }; + + let mut observation = RuntimeCompatibilityObservation::default(); + let summary_url = match remote_probe_url(remote, "/v1/runtime") { + Ok(url) => url, + Err(diagnostic) => { + return remote_runtime_test_failed( + api, + remote, + checked_at, + diagnostic.code, + diagnostic.message, + ); + } + }; + + let summary_payload = + match probe_remote_json(&client, summary_url, "runtime.summary", "Runtime summary").await { + Ok(payload) => payload, + Err(diagnostic) => { + return remote_runtime_test_failed( + api, + remote, + checked_at, + diagnostic.code, + diagnostic.message, + ); + } + }; + let protocol_version = summary_payload + .get("protocol_version") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + let summary = match serde_json::from_value::(summary_payload) { + Ok(summary) => summary, + Err(_) => { + return remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_malformed_summary", + "Remote Runtime summary responded, but the payload was not recognized.", + ); + } + }; + observation.available("runtime.summary", "Runtime summary was readable."); + + let workers_url = match remote_probe_url(remote, "/v1/workers") { + Ok(url) => url, + Err(diagnostic) => { + observation.incompatible("workers.list", diagnostic); + String::new() + } + }; + let workers = if workers_url.is_empty() { + None + } else { + match probe_remote_json(&client, workers_url, "workers.list", "Worker list").await { + Ok(payload) => match serde_json::from_value::(payload) { + Ok(workers) => { + observation.available("workers.list", "Worker list was readable."); + Some(workers) + } + Err(_) => { + observation.incompatible( + "workers.list", + settings_diagnostic( + "remote_runtime_workers_malformed", + DiagnosticSeverity::Error, + "Remote Runtime worker list responded, but the payload was not recognized.", + ), + ); + None + } + }, + Err(diagnostic) => { + observation.incompatible("workers.list", diagnostic); + None + } + } + }; + + if let Some(worker) = workers.as_ref().and_then(|workers| workers.workers.first()) { + let path = format!( + "/v1/workers/{}", + encode_path_segment(worker.worker_id.as_str()) + ); + match remote_probe_url(remote, &path) { + Ok(url) => match probe_remote_json(&client, url, "workers.detail", "Worker detail").await { + Ok(payload) => match serde_json::from_value::(payload) { + Ok(_) => observation.available("workers.detail", "Worker detail was readable."), + Err(_) => observation.incompatible( + "workers.detail", + settings_diagnostic( + "remote_runtime_worker_detail_malformed", + DiagnosticSeverity::Error, + "Remote Runtime worker detail responded, but the payload was not recognized.", + ), + ), + }, + Err(diagnostic) => observation.incompatible("workers.detail", diagnostic), + }, + Err(diagnostic) => observation.incompatible("workers.detail", diagnostic), + } + } else { + observation.unknown( + "workers.detail", + "Worker detail compatibility could not be proven because the Runtime reported no workers during the lightweight probe.", + ); + } + + observation.available( + "workers.events_ws.construct", + "Worker event websocket URL can be constructed from the configured HTTP(S) Runtime endpoint, but no websocket connection was opened during this lightweight test.", + ); + + let bundles_url = match remote_probe_url(remote, "/v1/config-bundles") { + Ok(url) => url, + Err(diagnostic) => { + observation.incompatible("config_bundles.list", diagnostic); + String::new() + } + }; + let bundles = if bundles_url.is_empty() { + None + } else { + match probe_remote_json( + &client, + bundles_url, + "config_bundles.list", + "Config-bundle list", + ) + .await + { + Ok(payload) => { + match serde_json::from_value::(payload) { + Ok(bundles) => { + observation + .available("config_bundles.list", "Config-bundle list was readable."); + Some(bundles) + } + Err(_) => { + observation.incompatible( + "config_bundles.list", + settings_diagnostic( + "remote_runtime_config_bundles_malformed", + DiagnosticSeverity::Error, + "Remote Runtime config-bundle list responded, but the payload was not recognized.", + ), + ); + None + } + } + } + Err(diagnostic) => { + observation.incompatible("config_bundles.list", diagnostic); + None + } + } + }; + + if let Some(bundle) = bundles.as_ref().and_then(|bundles| bundles.bundles.first()) { + let path = format!( + "/v1/config-bundles/{}/availability?digest={}", + encode_path_segment(&bundle.id), + encode_path_segment(&bundle.digest) + ); + match remote_probe_url(remote, &path) { + Ok(url) => match probe_remote_json( + &client, + url, + "config_bundles.availability", + "Config-bundle availability", + ) + .await + { + Ok(payload) => { + match serde_json::from_value::(payload) + { + Ok(_) => observation.available( + "config_bundles.availability", + "Config-bundle availability was readable for an advertised bundle.", + ), + Err(_) => observation.incompatible( + "config_bundles.availability", + settings_diagnostic( + "remote_runtime_config_bundle_availability_malformed", + DiagnosticSeverity::Error, + "Remote Runtime config-bundle availability responded, but the payload was not recognized.", + ), + ), + } + } + Err(diagnostic) => { + observation.incompatible("config_bundles.availability", diagnostic) + } + }, + Err(diagnostic) => observation.incompatible("config_bundles.availability", diagnostic), + } + } else { + observation.unknown( + "config_bundles.availability", + "Config-bundle availability compatibility could not be proven because the Runtime advertised no bundles during the lightweight probe.", + ); + } + + observation.unknown( + "workers.spawn", + "Worker spawn compatibility was not proven because the lightweight test does not create remote workers as a side effect.", + ); + observation.unknown( + "workers.input_dispatch", + "Worker input dispatch compatibility was not proven because the lightweight test does not send model-visible input as a side effect.", + ); + observation.unknown( + "config_bundles.sync", + "Config-bundle sync compatibility was not proven because the lightweight test does not upload bundles as a side effect.", + ); + + RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: observation.state().to_string(), + protocol_version, + compatibility_basis: "Observed worker-runtime HTTP endpoints for summary, worker listing/detail when available, event websocket URL construction, and config-bundle listing/availability when available; side-effecting spawn/input/sync operations are reported unknown unless the Runtime API proves them.".to_string(), + capabilities: observation.capabilities, + health_result: format!( + "runtime_status={:?}; incompatible={}; unknown={}", + summary.runtime.status, observation.incompatible_count, observation.unknown_count + ), + diagnostics: observation.diagnostics, + } +} + +fn remote_runtime_test_failed( + api: &WorkspaceApi, + remote: &RemoteRuntimeConfigFile, + checked_at: String, + code: impl Into, + message: impl Into, +) -> RemoteRuntimeTestResponse { + RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: "failed".to_string(), + protocol_version: None, + compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(), + capabilities: Vec::new(), + health_result: "failed".to_string(), + diagnostics: vec![settings_diagnostic( + code, + DiagnosticSeverity::Error, + message, + )], + } +} + +#[derive(Default)] +struct RuntimeCompatibilityObservation { + capabilities: Vec, + diagnostics: Vec, + incompatible_count: usize, + unknown_count: usize, +} + +impl RuntimeCompatibilityObservation { + fn available(&mut self, operation: &str, _message: &str) { + self.capabilities.push(format!("{operation}:available")); + } + + fn unknown(&mut self, operation: &str, message: impl Into) { + self.unknown_count += 1; + self.capabilities.push(format!("{operation}:unknown")); + self.diagnostics.push(settings_diagnostic( + format!("{operation}.unknown"), + DiagnosticSeverity::Warning, + message, + )); + } + + fn incompatible(&mut self, operation: &str, diagnostic: RuntimeDiagnostic) { + self.incompatible_count += 1; + self.capabilities.push(format!("{operation}:incompatible")); + self.diagnostics.push(diagnostic); + } + + fn state(&self) -> &'static str { + if self.incompatible_count > 0 { + "incompatible" + } else if self.unknown_count > 0 { + "unknown" + } else { + "compatible" + } + } +} + +fn remote_probe_url( + remote: &RemoteRuntimeConfigFile, + path: &str, +) -> std::result::Result { + let endpoint = remote.endpoint.trim(); + if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) { + return Err(settings_diagnostic( + "remote_runtime_endpoint_invalid", + DiagnosticSeverity::Error, + "Configured remote Runtime endpoint is not an absolute HTTP(S) URL.", + )); + } + Ok(format!("{}{}", endpoint.trim_end_matches('/'), path)) +} + +async fn probe_remote_json( + client: &reqwest::Client, + url: String, + operation: &'static str, + label: &'static str, +) -> std::result::Result { + let response = client.get(url).send().await.map_err(|error| { + let (code, message) = if error.is_timeout() { + ( + format!("{operation}.timeout"), + format!("Remote Runtime probe for {label} timed out."), + ) + } else if error.is_connect() { + ( + format!("{operation}.connect_failed"), + format!("Remote Runtime probe for {label} could not connect."), + ) + } else { + ( + format!("{operation}.request_failed"), + format!("Remote Runtime probe for {label} failed before a response was received."), + ) + }; + settings_diagnostic(code, DiagnosticSeverity::Error, message) + })?; + + if !response.status().is_success() { + return Err(settings_diagnostic( + format!("{operation}.http_status"), + DiagnosticSeverity::Error, + format!( + "Remote Runtime probe for {label} returned HTTP status {}.", + response.status().as_u16() + ), + )); + } + + response.json::().await.map_err(|_| { + settings_diagnostic( + format!("{operation}.malformed_json"), + DiagnosticSeverity::Error, + format!("Remote Runtime probe for {label} returned an unrecognized JSON payload."), + ) + }) +} + +fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse { + let runtimes = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items + .into_iter() + .map(|runtime| { + let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; + WorkerLaunchRuntimeOption { + runtime_id: runtime.runtime_id, + display_name: runtime.label, + built_in, + can_spawn_worker: runtime.capabilities.can_spawn_worker, + status: runtime.status, + diagnostics: runtime.diagnostics, + } + }) + .collect(); + WorkerLaunchOptionsResponse { + workspace_id: api.config.workspace_id.clone(), + runtimes, + profiles: worker_profile_candidates(), + diagnostics: Vec::new(), + } +} + +fn worker_profile_candidates() -> Vec { + vec![ + WorkerLaunchProfileCandidate { + id: "builtin:coder".to_string(), + label: "Coding Worker".to_string(), + description: "Built-in coding role profile for implementation work.".to_string(), + }, + WorkerLaunchProfileCandidate { + id: "runtime_default".to_string(), + label: "Runtime default".to_string(), + description: "Use the selected Runtime's default profile.".to_string(), + }, + ] +} + +fn profile_selector_for_candidate(profile: &str) -> Option { + match profile { + "builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())), + "runtime_default" => Some(ProfileSelector::RuntimeDefault), + _ => None, + } +} + +fn sanitize_worker_display_name(value: &str) -> Option { + let display_name = value.trim(); + if display_name.chars().any(char::is_control) { + None + } else if display_name.is_empty() { + Some("Coding Worker".to_string()) + } else { + Some(display_name.chars().take(80).collect()) + } +} + +fn encode_path_segment(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +fn worker_create_not_accepted_error( + runtime_id: String, + mut diagnostics: Vec, +) -> ApiError { + diagnostics.push(settings_diagnostic( + "workspace_worker_create_not_accepted", + DiagnosticSeverity::Error, + "Runtime did not accept worker creation; see diagnostics for sanitized Runtime compatibility details.", + )); + ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id, + code: "workspace_worker_create_failed".to_string(), + message: "Runtime did not accept worker creation".to_string(), + }, + diagnostics, + ) +} + +fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.to_string(), + message: message.to_string(), + } + .into() +} + +fn settings_diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.into(), + severity, + message: message.into(), + } +} + +fn sanitize_backend_error(_message: &str) -> String { + "operation failed; backend-private details were omitted".to_string() +} + fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result { let canonical_repository_id = api.local_repository_id(); if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) { @@ -1057,17 +2103,29 @@ fn content_type_for(path: &Path) -> &'static str { type ApiResult = std::result::Result; -struct ApiError(Error); +struct ApiError { + error: Error, + diagnostics: Vec, +} impl From for ApiError { fn from(error: Error) -> Self { - Self(error) + Self { + error, + diagnostics: Vec::new(), + } + } +} + +impl ApiError { + fn with_diagnostics(error: Error, diagnostics: Vec) -> Self { + Self { error, diagnostics } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = match &self.0 { + let status = match &self.error { Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST, Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) @@ -1086,6 +2144,16 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { StatusCode::NOT_IMPLEMENTED } + Error::RuntimeOperationFailed { code, .. } + if code.starts_with("workspace_settings_") + || code.starts_with("invalid_") + || code.starts_with("unsupported_worker_profile") + || code.ends_with("_already_exists") + || code.ends_with("_not_config_managed") + || code.ends_with("_unsupported") => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -1094,7 +2162,8 @@ impl IntoResponse for ApiError { [(CONTENT_TYPE, "application/json")], Json(serde_json::json!({ "error": status.canonical_reason().unwrap_or("error"), - "message": self.0.to_string(), + "message": self.error.to_string(), + "diagnostics": self.diagnostics, })) .to_string(), ) @@ -1125,6 +2194,46 @@ mod tests { const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; + #[test] + fn worker_profile_candidates_are_backend_published_and_mapped() { + let candidates = worker_profile_candidates(); + assert!( + candidates + .iter() + .any(|candidate| candidate.id == "builtin:coder") + ); + assert!(matches!( + profile_selector_for_candidate("builtin:coder"), + Some(ProfileSelector::Builtin(value)) if value == "builtin:coder" + )); + assert!(profile_selector_for_candidate("free-text-profile").is_none()); + } + + #[test] + fn runtime_connection_request_validation_bounds_browser_input() { + let ok = AddRemoteRuntimeConnectionRequest { + runtime_id: "team-runtime_1".to_string(), + display_name: Some("Team Runtime".to_string()), + endpoint: "https://runtime.example".to_string(), + token_ref: None, + }; + assert!(validate_runtime_connection_request(&ok).is_ok()); + + let bad_endpoint = AddRemoteRuntimeConnectionRequest { + endpoint: "/tmp/socket".to_string(), + ..ok + }; + assert!(validate_runtime_connection_request(&bad_endpoint).is_err()); + } + + #[test] + fn sanitized_errors_omit_backend_private_paths() { + let sanitized = sanitize_backend_error( + "failed to open /home/example/.yoi/workspace-backend.local.toml", + ); + assert!(!sanitized.contains("/home/example")); + } + #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< @@ -1198,6 +2307,18 @@ mod tests { .with_embedded_runtime_store_root(store_root) } + async fn test_app(workspace_root: impl Into) -> Router { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + let api = WorkspaceApi::new_with_execution_backend( + test_server_config(workspace_root), + Arc::new(store), + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + build_router(api) + } + fn runtime_test_bundle() -> worker_runtime::config_bundle::ConfigBundle { worker_runtime::config_bundle::ConfigBundle { metadata: worker_runtime::config_bundle::ConfigBundleMetadata { @@ -1243,6 +2364,193 @@ mod tests { (runtime, worker.worker_ref) } + #[tokio::test] + async fn runtime_connection_settings_add_delete_persist_restart_required() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + + let settings = get_json(app.clone(), "/api/settings/runtime-connections").await; + assert_eq!(settings["embedded"]["built_in"], true); + assert_eq!(settings["embedded"]["config_managed"], false); + + let added = post_json( + app.clone(), + "/api/settings/runtime-connections/remotes", + serde_json::json!({ + "runtime_id": "team-runtime", + "display_name": "Team Runtime", + "endpoint": "https://runtime.example.invalid" + }), + ) + .await; + assert_eq!(added["restart_required"], true); + assert_eq!(added["remotes"][0]["runtime_id"], "team-runtime"); + assert_eq!(added["remotes"][0]["endpoint_configured"], true); + let projected = serde_json::to_string(&added).unwrap(); + assert!(!projected.contains("runtime.example.invalid")); + + let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + assert_eq!(persisted.runtimes.remote.len(), 1); + assert_eq!(persisted.runtimes.remote[0].id, "team-runtime"); + assert_eq!( + persisted.runtimes.remote[0].endpoint, + "https://runtime.example.invalid" + ); + + let deleted = request_json( + app, + "DELETE", + "/api/settings/runtime-connections/remotes/team-runtime", + None, + StatusCode::OK, + ) + .await; + assert_eq!(deleted["restart_required"], true); + assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0); + let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + assert!(persisted.runtimes.remote.is_empty()); + } + + #[tokio::test] + async fn runtime_connection_test_reports_observed_and_unknown_capabilities_without_endpoint_leak() + { + let (runtime, _worker_ref) = runtime_with_worker(); + let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let runtime_addr = runtime_listener.local_addr().unwrap(); + tokio::spawn({ + let runtime = runtime.clone(); + async move { + worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None) + .await + .unwrap() + } + }); + + let dir = tempfile::tempdir().unwrap(); + let endpoint = format!("http://{runtime_addr}"); + WorkspaceBackendConfigFile { + runtimes: crate::config::WorkspaceBackendRuntimesConfig { + remote: vec![RemoteRuntimeConfigFile { + id: "probe-runtime".to_string(), + endpoint: endpoint.clone(), + display_name: Some("Probe Runtime".to_string()), + token_ref: None, + }], + }, + ..WorkspaceBackendConfigFile::default() + } + .write_for_workspace(dir.path()) + .unwrap(); + let app = test_app(dir.path()).await; + + let response = post_json( + app, + "/api/settings/runtime-connections/remotes/probe-runtime/test", + serde_json::json!({}), + ) + .await; + assert_eq!(response["state"], "unknown"); + let capabilities = response["capabilities"].as_array().unwrap(); + assert!( + capabilities + .iter() + .any(|value| value == "runtime.summary:available") + ); + assert!( + capabilities + .iter() + .any(|value| value == "workers.list:available") + ); + assert!( + capabilities + .iter() + .any(|value| value == "workers.spawn:unknown") + ); + assert!( + response["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { diagnostic["code"] == "workers.spawn.unknown" }) + ); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains(&endpoint)); + assert!(!projected.contains(&runtime_addr.to_string())); + assert_eq!(response["protocol_version"], serde_json::Value::Null); + } + + #[tokio::test] + async fn browser_worker_create_succeeds_and_preserves_unsupported_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let created = post_json( + app.clone(), + "/api/workers", + serde_json::json!({ + "runtime_id": "embedded-worker-runtime", + "display_name": "", + "profile": "runtime_default", + "initial_text": "" + }), + ) + .await; + assert_eq!(created["runtime_id"], "embedded-worker-runtime"); + assert!( + created["console_href"] + .as_str() + .unwrap() + .contains("/console") + ); + + let response = worker_create_not_accepted_error( + "unsupported-runtime".to_string(), + vec![settings_diagnostic( + "remote_runtime_unsupported", + DiagnosticSeverity::Warning, + "Remote Runtime provisioning is unsupported by this v0 worker launch path.", + )], + ) + .into_response(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let response: Value = serde_json::from_slice(&bytes).unwrap(); + let diagnostics = response["diagnostics"].as_array().unwrap(); + assert!(diagnostics.len() >= 2); + assert!( + diagnostics + .iter() + .any(|diagnostic| { diagnostic["code"] == "workspace_worker_create_not_accepted" }) + ); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains("http://")); + } + + #[tokio::test] + async fn browser_worker_create_rejects_extra_request_fields() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let response = request_json( + app, + "POST", + "/api/workers", + Some(serde_json::json!({ + "runtime_id": "embedded-worker-runtime", + "display_name": "Coding Worker", + "profile": "builtin:coder", + "initial_text": "", + "kind": "internal" + })), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("unknown field") + ); + } + #[tokio::test] async fn serves_bounded_read_apis_and_static_spa_separately() { let dir = tempfile::tempdir().unwrap(); @@ -2178,6 +3486,31 @@ mod tests { serde_json::from_slice(&bytes).unwrap() } + async fn request_json( + app: Router, + method: &str, + uri: &str, + body: Option, + expected_status: StatusCode, + ) -> Value { + let mut builder = Request::builder().method(method).uri(uri); + let request_body = if let Some(body) = body { + builder = builder.header(CONTENT_TYPE, "application/json"); + Body::from(serde_json::to_vec(&body).unwrap()) + } else { + Body::empty() + }; + let response = app + .oneshot(builder.body(request_body).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected_status, "{method} {uri}"); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap_or_else( + |_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }), + ) + } + async fn post_json(app: Router, uri: &str, body: Value) -> Value { let response = app .oneshot( diff --git a/web/workspace/deno.json b/web/workspace/deno.json index adda00c7..60c8b090 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts", + "test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index fda816ee..f448b453 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -1140,3 +1140,156 @@ display: grid; } } + +.section-heading-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section-action { + margin-left: auto; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-strong); + font-size: 0.72rem; + padding: 0.2rem 0.55rem; + cursor: pointer; +} + +.worker-new-form { + display: grid; + gap: 0.65rem; + margin: 0.6rem 0 0.75rem; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.03); +} + +.worker-new-form label, +.settings-runtime-form label { + display: grid; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.78rem; +} + +.worker-new-form input, +.worker-new-form select, +.worker-new-form textarea, +.settings-runtime-form input { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--bg-raised); + color: var(--text-strong); + padding: 0.45rem 0.55rem; + font: inherit; +} + +.worker-new-form textarea { + resize: vertical; +} + +.worker-new-form button, +.settings-runtime-form button, +.settings-action-row button { + border: 0; + border-radius: 0.6rem; + background: var(--accent); + color: var(--bg); + font-weight: 700; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.worker-new-form button:disabled, +.settings-runtime-form button:disabled, +.settings-action-row button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.settings-runtime-form, +.settings-runtime-list { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.settings-runtime-form { + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.03); +} + +.settings-runtime-card { + display: grid; + gap: 0.75rem; + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: var(--bg-raised); +} + +.settings-runtime-card header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.settings-runtime-card.inactive { + opacity: 0.86; +} + +.settings-identity-list.compact { + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); +} + +.settings-action-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.settings-action-row .danger { + background: var(--danger); + color: var(--bg); +} + +.settings-diagnostics-list { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; +} + +.settings-diagnostics-list li { + display: grid; + gap: 0.15rem; + border-radius: 0.6rem; + border: 1px solid var(--line); + padding: 0.55rem 0.65rem; + color: var(--text-muted); +} + +.settings-diagnostics-list li.error { + border-color: rgba(255, 99, 99, 0.55); +} + +.settings-diagnostics-list li.warning { + border-color: rgba(255, 205, 86, 0.55); +} + +.settings-test-result { + display: grid; + gap: 0.3rem; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.75rem; +} diff --git a/web/workspace/src/lib/workspace-settings/SettingsPage.svelte b/web/workspace/src/lib/workspace-settings/SettingsPage.svelte index fbe897b5..b2241cac 100644 --- a/web/workspace/src/lib/workspace-settings/SettingsPage.svelte +++ b/web/workspace/src/lib/workspace-settings/SettingsPage.svelte @@ -5,12 +5,39 @@ SETTINGS_PATTERNS, SETTINGS_PERMISSION_NOTICE, SETTINGS_SECTIONS, + diagnosticLabel, settingsSectionHref, + type Diagnostic, + type RemoteRuntimeConnectionSummary, + type RemoteRuntimeTestResponse, + type RuntimeConnectionMutationResponse, + type RuntimeConnectionSettingsResponse, + type RuntimeConnectionSummary, } from "./model"; + type RemoteAddForm = { + runtime_id: string; + display_name: string; + endpoint: string; + }; + let workspace = $state(null); + let runtimeSettings = $state(null); let loading = $state(true); + let runtimeLoading = $state(true); let error = $state(null); + let runtimeError = $state(null); + let mutationMessage = $state(null); + let mutationDiagnostics = $state([]); + let tests = $state>({}); + let deleting = $state(null); + let testing = $state(null); + let submitting = $state(false); + let remoteForm = $state({ + runtime_id: "", + display_name: "", + endpoint: "", + }); $effect(() => { let cancelled = false; @@ -39,12 +66,165 @@ } } + async function loadRuntimeSettings() { + runtimeLoading = true; + runtimeError = null; + try { + const response = await fetch("/api/settings/runtime-connections"); + if (!response.ok) { + throw new Error(`runtime settings request failed (${response.status})`); + } + const data = (await response.json()) as RuntimeConnectionSettingsResponse; + if (!cancelled) { + runtimeSettings = data; + } + } catch (err) { + if (!cancelled) { + runtimeError = err instanceof Error ? err.message : "runtime settings request failed"; + } + } finally { + if (!cancelled) { + runtimeLoading = false; + } + } + } + loadWorkspace(); + loadRuntimeSettings(); return () => { cancelled = true; }; }); + + async function submitRemoteRuntime() { + submitting = true; + mutationMessage = null; + mutationDiagnostics = []; + try { + const response = await fetch("/api/settings/runtime-connections/remotes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + runtime_id: remoteForm.runtime_id, + display_name: remoteForm.display_name || null, + endpoint: remoteForm.endpoint, + }), + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "add remote Runtime failed")); + } + const data = (await response.json()) as RuntimeConnectionMutationResponse; + applyRuntimeMutation(data); + remoteForm = { runtime_id: "", display_name: "", endpoint: "" }; + } catch (err) { + mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed"; + } finally { + submitting = false; + } + } + + async function deleteRemoteRuntime(runtimeId: string) { + deleting = runtimeId; + mutationMessage = null; + mutationDiagnostics = []; + try { + const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "delete remote Runtime failed")); + } + const data = (await response.json()) as RuntimeConnectionMutationResponse; + applyRuntimeMutation(data); + const nextTests = { ...tests }; + delete nextTests[runtimeId]; + tests = nextTests; + } catch (err) { + mutationMessage = err instanceof Error ? err.message : "delete remote Runtime failed"; + } finally { + deleting = null; + } + } + + async function testRemoteRuntime(runtimeId: string) { + testing = runtimeId; + try { + const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "test remote Runtime failed")); + } + const data = (await response.json()) as RemoteRuntimeTestResponse; + tests = { ...tests, [runtimeId]: data }; + } catch (err) { + tests = { + ...tests, + [runtimeId]: { + workspace_id: runtimeSettings?.workspace_id ?? "unknown", + runtime_id: runtimeId, + checked_at: new Date().toISOString(), + state: "failed", + protocol_version: null, + compatibility_basis: "browser request failed", + capabilities: [], + health_result: "failed", + diagnostics: [ + { + code: "browser_runtime_test_failed", + severity: "error", + message: err instanceof Error ? err.message : "test remote Runtime failed", + }, + ], + }, + }; + } finally { + testing = null; + } + } + + function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) { + runtimeSettings = runtimeSettings + ? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics } + : { + workspace_id: data.workspace_id, + embedded: { + runtime_id: "embedded-worker-runtime", + display_name: "Embedded Runtime", + kind: "embedded_worker_runtime", + built_in: true, + config_managed: false, + active: false, + can_spawn_worker: false, + restart_required: false, + status: "unknown", + diagnostics: [], + }, + remotes: data.remotes, + diagnostics: data.diagnostics, + }; + mutationDiagnostics = data.diagnostics; + mutationMessage = data.restart_required + ? "Runtime config saved. Restart the Workspace backend to apply live registry changes." + : "Runtime config saved."; + } + + async function responseErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string }; + if (typeof payload.error === "object" && payload.error?.message) { + return `${payload.error.code ?? "request_failed"}: ${payload.error.message}`; + } + if (payload.message) { + const code = typeof payload.error === "string" ? payload.error : "request_failed"; + return `${code}: ${payload.message}`; + } + } catch { + // fall through + } + return `${fallback} (${response.status})`; + } @@ -60,11 +240,10 @@

Workspace Browser

Settings / Admin

- Read-only shell for future local administration surfaces. This page creates - navigation and operator context without adding mutation authority. + Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.

- shell only + local only
@@ -74,8 +253,8 @@

{SETTINGS_PERMISSION_NOTICE}

- Diagnostic pattern - Future controls must use typed Backend diagnostics and restart-required states. + Restart-required + Runtime config changes are persisted, then applied after backend restart.
@@ -83,13 +262,95 @@ {#each SETTINGS_SECTIONS as section} {section.label} - {section.status === "read-only" ? "Read-only" : "Placeholder"} + {section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"} {/each} +
+
+
+

editable

+

Runtime Connections

+
+ typed API +
+

{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}

+ + {#if runtimeLoading} +

Loading Runtime connections…

+ {:else if runtimeError} +

Runtime connection settings unavailable: {runtimeError}

+ {:else if runtimeSettings} + {@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })} + +
{ event.preventDefault(); void submitRemoteRuntime(); }}> +

Add remote Runtime

+

Endpoint is submitted to the Backend but not echoed back in settings responses.

+ + + + +
+ + {#if mutationMessage} +

{mutationMessage}

+ {/if} + {@render DiagnosticsList({ diagnostics: mutationDiagnostics })} + +
+

Remote Runtimes

+ {#if runtimeSettings.remotes.length === 0} +

No remote Runtime connections configured.

+ {:else} + {#each runtimeSettings.remotes as remote (remote.runtime_id)} +
+ {@render RuntimeConnectionCard({ connection: remote })} +
+
+
Endpoint
+
{remote.endpoint_configured ? "configured (hidden)" : "not configured"}
+
+
+
Token ref
+
{remote.token_ref_configured ? "configured (hidden)" : "not configured"}
+
+
+
+ + +
+ {#if tests[remote.runtime_id]} + {@const test = tests[remote.runtime_id]} +
+ Test: {test.state} + {test.health_result} · {test.checked_at} +

{test.compatibility_basis}

+ {@render DiagnosticsList({ diagnostics: test.diagnostics })} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
+
- {#each SETTINGS_SECTIONS as section} + {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
@@ -132,7 +393,7 @@

Implementation patterns

-

How future settings should appear

+

How settings should appear

{#each SETTINGS_PATTERNS as pattern} @@ -151,3 +412,51 @@ {/if}
+ +{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })} +
+
+
+

{connection.display_name}

+

{connection.runtime_id}

+
+ {connection.status} +
+
+
+
Kind
+
{connection.kind}
+
+
+
Built in
+
{connection.built_in ? "yes" : "no"}
+
+
+
Config managed
+
{connection.config_managed ? "yes" : "no"}
+
+
+
Spawn
+
{connection.can_spawn_worker ? "available" : "unavailable"}
+
+
+
Restart required
+
{connection.restart_required ? "yes" : "no"}
+
+
+ {@render DiagnosticsList({ diagnostics: connection.diagnostics })} +
+{/snippet} + +{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })} + {#if diagnostics.length > 0} +
    + {#each diagnostics as diagnostic} +
  • + {diagnosticLabel(diagnostic)} + {diagnostic.message} +
  • + {/each} +
+ {/if} +{/snippet} diff --git a/web/workspace/src/lib/workspace-settings/model.test.ts b/web/workspace/src/lib/workspace-settings/model.test.ts index 6c876dc9..bd4c96ad 100644 --- a/web/workspace/src/lib/workspace-settings/model.test.ts +++ b/web/workspace/src/lib/workspace-settings/model.test.ts @@ -3,6 +3,7 @@ import { SETTINGS_PERMISSION_NOTICE, SETTINGS_ROUTE, SETTINGS_SECTIONS, + diagnosticLabel, settingsSectionHref, } from "./model.ts"; @@ -43,7 +44,12 @@ Deno.test("settings shell advertises no fake browser admin model", () => { ); }); -Deno.test("settings placeholders avoid mutation promises and raw authority leaks", () => { +Deno.test("runtime connections are editable without advertising raw authority leaks", () => { + const runtimeSection = SETTINGS_SECTIONS.find((section) => + section.id === "runtime-connections" + ); + assert(runtimeSection?.status === "editable", "Runtime Connections should be editable"); + const allText = [ SETTINGS_PERMISSION_NOTICE, ...SETTINGS_SECTIONS.flatMap((section) => [ @@ -55,14 +61,12 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks ].join("\n"); assert( - allText.includes( - "does not add, remove, test, or persist Runtime endpoints", - ), - "Runtime Connections should remain a placeholder", + allText.includes("restart_required=true") || allText.includes("Restart-required"), + "restart-required pattern should be visible", ); assert( - allText.includes("Restart-required"), - "restart-required pattern should be visible", + allText.includes("not echoed back") || allText.includes("not echoed"), + "endpoint submission should not imply endpoint echoing", ); for ( @@ -72,6 +76,7 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks "token:", "secret:", "store root:", + "config file path:", ] ) { assert( @@ -80,3 +85,15 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks ); } }); + +Deno.test("diagnostic labels preserve severity and code", () => { + const diagnostic = { + severity: "warning", + code: "runtime_registry_restart_required", + message: "Restart required.", + } as const; + assert( + diagnosticLabel(diagnostic) === "warning: runtime_registry_restart_required", + "diagnostic label should be bounded and stable", + ); +}); diff --git a/web/workspace/src/lib/workspace-settings/model.ts b/web/workspace/src/lib/workspace-settings/model.ts index f823c162..c692244e 100644 --- a/web/workspace/src/lib/workspace-settings/model.ts +++ b/web/workspace/src/lib/workspace-settings/model.ts @@ -1,3 +1,9 @@ +export type Diagnostic = { + severity: "info" | "warning" | "error"; + code: string; + message: string; +}; + export type SettingsSectionId = | "runtime-connections" | "backend-config" @@ -6,7 +12,7 @@ export type SettingsSectionId = export type SettingsSection = { readonly id: SettingsSectionId; readonly label: string; - readonly status: "placeholder" | "read-only"; + readonly status: "editable" | "placeholder" | "read-only"; readonly summary: string; readonly bullets: readonly string[]; }; @@ -16,22 +22,66 @@ export type SettingsPattern = { readonly body: string; }; +export type RuntimeConnectionSummary = { + runtime_id: string; + display_name: string; + kind: string; + built_in: boolean; + config_managed: boolean; + active: boolean; + can_spawn_worker: boolean; + restart_required: boolean; + status: string; + diagnostics: Diagnostic[]; +}; + +export type RemoteRuntimeConnectionSummary = RuntimeConnectionSummary & { + endpoint_configured: boolean; + token_ref_configured: boolean; +}; + +export type RuntimeConnectionSettingsResponse = { + workspace_id: string; + embedded: RuntimeConnectionSummary; + remotes: RemoteRuntimeConnectionSummary[]; + diagnostics: Diagnostic[]; +}; + +export type RuntimeConnectionMutationResponse = { + workspace_id: string; + restart_required: boolean; + remotes: RemoteRuntimeConnectionSummary[]; + diagnostics: Diagnostic[]; +}; + +export type RemoteRuntimeTestResponse = { + workspace_id: string; + runtime_id: string; + checked_at: string; + state: string; + protocol_version?: string | null; + compatibility_basis: string; + capabilities: string[]; + health_result: string; + diagnostics: Diagnostic[]; +}; + export const SETTINGS_ROUTE = "/settings"; export const SETTINGS_PERMISSION_NOTICE = - "Yoi currently has no browser user, role, permission, or multi-user authorization model. This shell is intentionally local and descriptive; it does not create an admin role or grant mutation authority."; + "Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority."; export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { id: "runtime-connections", label: "Runtime Connections", - status: "placeholder", + status: "editable", summary: - "Future Runtime connection management will live here. The current view does not add, remove, test, or persist Runtime endpoints.", + "Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.", bullets: [ - "Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.", - "Connection changes require a later typed Backend API and are not performed by this shell.", - "Restart-required states should be shown as bounded diagnostics rather than live mutation controls.", + "Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.", + "The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.", + "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", ], }, { @@ -39,11 +89,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ label: "Backend Config", status: "placeholder", summary: - "Configuration inspection is planned, but editing Backend config or secrets is out of scope for this shell.", + "General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.", bullets: [ "Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.", "Missing-provider or invalid-config states should be displayed as typed diagnostics.", - "No fake permission model is created to make config editing appear available.", + "No fake permission model is created to make unrelated config editing appear available.", ], }, { @@ -64,20 +114,24 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [ { title: "Sanitized diagnostics", body: - "Settings cards should show bounded codes and operator-facing messages, not raw socket paths, credentials, secret refs, token values, or Runtime store paths.", + "Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, or Runtime store paths.", }, { title: "Restart-required changes", body: - "When a future setting cannot apply live, the browser should say restart required and leave the mutation to a typed Backend workflow.", + "Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.", }, { - title: "Read-only until typed APIs exist", + title: "Typed Runtime surface only", body: - "Placeholder sections describe planned surfaces without pretending that user, role, permission, or Runtime mutation APIs already exist.", + "Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.", }, ]; export function settingsSectionHref(id: SettingsSectionId): string { return `${SETTINGS_ROUTE}#${id}`; } + +export function diagnosticLabel(diagnostic: Diagnostic): string { + return `${diagnostic.severity}: ${diagnostic.code}`; +} diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index 10a414d0..613676c6 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,6 +1,12 @@