merge: runtime worker controls

This commit is contained in:
Keisuke Hirata 2026-07-03 03:20:35 +09:00
commit 4edaa73dde
No known key found for this signature in database
19 changed files with 2423 additions and 52 deletions

View File

@ -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、TicketRelationQuery0件、TicketOrchestrationPlanQuery0件、workspace git/worktree 状態、visible Pods、TicketDoctor0 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"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Workspace Browserから手動Coding Workerを作成する導線を追加する' title: 'Workspace Browserから手動Coding Workerを作成する導線を追加する'
state: 'queued' state: 'inprogress'
created_at: '2026-07-02T12:59:57Z' created_at: '2026-07-02T12:59:57Z'
updated_at: '2026-07-02T16:13:24Z' updated_at: '2026-07-02T17:01:11Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-02T16:13:24Z' queued_at: '2026-07-02T16:13:24Z'

View File

@ -62,4 +62,97 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-02T17:00:32Z -->
## 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、TicketDoctor0 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 placementinline/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.
---
<!-- event: state_changed author: orchestrator at: 2026-07-02T17:01:11Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
## 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 後に開始する。
--- ---

View File

@ -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、TicketRelationQuery0件、TicketOrchestrationPlanQuery0件、workspace git/worktree 状態、visible Pods、TicketDoctor0 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"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Workspace Backend Runtime接続の管理画面と永続configを追加する' title: 'Workspace Backend Runtime接続の管理画面と永続configを追加する'
state: 'queued' state: 'inprogress'
created_at: '2026-07-02T13:54:52Z' created_at: '2026-07-02T13:54:52Z'
updated_at: '2026-07-02T16:45:19Z' updated_at: '2026-07-02T17:01:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-02T16:45:19Z' queued_at: '2026-07-02T16:45:19Z'

View File

@ -78,4 +78,101 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-02T16:59:57Z -->
## 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、TicketDoctor0 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 が破綻していないか。
---
<!-- event: state_changed author: orchestrator at: 2026-07-02T17:01:01Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
## 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 後に開始する。
--- ---

View File

@ -2,7 +2,7 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{fs, io}; use std::{fs, io};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity; 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_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_MAX_RECORDS: usize = 200; 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)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile { pub struct WorkspaceBackendConfigFile {
#[serde(default)] #[serde(default)]
@ -29,7 +29,7 @@ pub struct WorkspaceBackendConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig { pub struct WorkspaceBackendServerConfig {
#[serde(default)] #[serde(default)]
@ -40,7 +40,7 @@ pub struct WorkspaceBackendServerConfig {
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig { pub struct WorkspaceBackendDataConfig {
#[serde(default)] #[serde(default)]
@ -51,21 +51,21 @@ pub struct WorkspaceBackendDataConfig {
pub embedded_runtime_store_root: Option<PathBuf>, pub embedded_runtime_store_root: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig { pub struct WorkspaceBackendLimitsConfig {
#[serde(default)] #[serde(default)]
pub max_records: Option<usize>, pub max_records: Option<usize>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig { pub struct WorkspaceBackendRuntimesConfig {
#[serde(default)] #[serde(default)]
pub remote: Vec<RemoteRuntimeConfigFile>, pub remote: Vec<RemoteRuntimeConfigFile>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RemoteRuntimeConfigFile { pub struct RemoteRuntimeConfigFile {
pub id: String, pub id: String,
@ -189,6 +189,20 @@ impl WorkspaceBackendConfigFile {
} }
} }
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> 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<Path>) -> Result<Self> { pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| { toml::from_str(raw).map_err(|error| {
Error::Config(format!( Error::Config(format!(

View File

@ -266,6 +266,7 @@ pub struct WorkerSpawnRequest {
pub enum WorkerSpawnIntent { pub enum WorkerSpawnIntent {
WorkspaceCompanion, WorkspaceCompanion,
WorkspaceOrchestrator, WorkspaceOrchestrator,
WorkspaceCoding,
TicketRole { TicketRole {
ticket_id: String, ticket_id: String,
role: TicketWorkerRole, role: TicketWorkerRole,
@ -2260,6 +2261,7 @@ fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
ProfileSelector::Builtin("builtin:companion".to_string()) ProfileSelector::Builtin("builtin:companion".to_string())
} }
WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault, 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 { match intent {
WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion", WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion",
WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator", WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator",
WorkerSpawnIntent::WorkspaceCoding => "workspace_coding",
WorkerSpawnIntent::TicketRole { role, .. } => match role { WorkerSpawnIntent::TicketRole { role, .. } => match role {
TicketWorkerRole::Intake => "ticket_intake", TicketWorkerRole::Intake => "ticket_intake",
TicketWorkerRole::Orchestrator => "ticket_orchestrator", TicketWorkerRole::Orchestrator => "ticket_orchestrator",

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },

View File

@ -1140,3 +1140,156 @@
display: grid; 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;
}

View File

@ -5,12 +5,39 @@
SETTINGS_PATTERNS, SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref, settingsSectionHref,
type Diagnostic,
type RemoteRuntimeConnectionSummary,
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
type RuntimeConnectionSummary,
} from "./model"; } from "./model";
type RemoteAddForm = {
runtime_id: string;
display_name: string;
endpoint: string;
};
let workspace = $state<WorkspaceResponse | null>(null); let workspace = $state<WorkspaceResponse | null>(null);
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
let loading = $state(true); let loading = $state(true);
let runtimeLoading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let runtimeError = $state<string | null>(null);
let mutationMessage = $state<string | null>(null);
let mutationDiagnostics = $state<Diagnostic[]>([]);
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
let deleting = $state<string | null>(null);
let testing = $state<string | null>(null);
let submitting = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: "",
display_name: "",
endpoint: "",
});
$effect(() => { $effect(() => {
let cancelled = false; 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(); loadWorkspace();
loadRuntimeSettings();
return () => { return () => {
cancelled = true; 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<string> {
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})`;
}
</script> </script>
<svelte:head> <svelte:head>
@ -60,11 +240,10 @@
<p class="eyebrow">Workspace Browser</p> <p class="eyebrow">Workspace Browser</p>
<h1 id="settings-title">Settings / Admin</h1> <h1 id="settings-title">Settings / Admin</h1>
<p class="hero-copy"> <p class="hero-copy">
Read-only shell for future local administration surfaces. This page creates Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
navigation and operator context without adding mutation authority.
</p> </p>
</div> </div>
<span class="badge warning">shell only</span> <span class="badge warning">local only</span>
</section> </section>
<section class="card settings-notice" aria-labelledby="settings-boundary-title"> <section class="card settings-notice" aria-labelledby="settings-boundary-title">
@ -74,8 +253,8 @@
<p>{SETTINGS_PERMISSION_NOTICE}</p> <p>{SETTINGS_PERMISSION_NOTICE}</p>
</div> </div>
<div class="settings-diagnostic" role="note"> <div class="settings-diagnostic" role="note">
<strong>Diagnostic pattern</strong> <strong>Restart-required</strong>
<span>Future controls must use typed Backend diagnostics and restart-required states.</span> <span>Runtime config changes are persisted, then applied after backend restart.</span>
</div> </div>
</section> </section>
@ -83,13 +262,95 @@
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={settingsSectionHref(section.id)}> <a class="settings-nav-link" href={settingsSectionHref(section.id)}>
<span>{section.label}</span> <span>{section.label}</span>
<small>{section.status === "read-only" ? "Read-only" : "Placeholder"}</small> <small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
</a> </a>
{/each} {/each}
</section> </section>
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="runtime-connections-title">Runtime Connections</h2>
</div>
<span class="badge success">typed API</span>
</header>
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</p>
{#if runtimeLoading}
<p class="status-message">Loading Runtime connections…</p>
{:else if runtimeError}
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
{:else if runtimeSettings}
{@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
<h3>Add remote Runtime</h3>
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
<label>
<span>Runtime id</span>
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
</label>
<label>
<span>Display name</span>
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
</label>
<label>
<span>Endpoint</span>
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{#if mutationMessage}
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
{/if}
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
<div class="settings-runtime-list" aria-label="Remote Runtime connections">
<h3>Remote Runtimes</h3>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{:else}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<article class="settings-runtime-card">
{@render RuntimeConnectionCard({ connection: remote })}
<dl class="settings-identity-list compact">
<div>
<dt>Endpoint</dt>
<dd>{remote.endpoint_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
<div>
<dt>Token ref</dt>
<dd>{remote.token_ref_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
{testing === remote.runtime_id ? "Testing…" : "Test"}
</button>
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
{/if}
</article>
{/each}
{/if}
</div>
{/if}
</section>
<div class="grid settings-grid"> <div class="grid settings-grid">
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}> <section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
<header class="settings-section-header"> <header class="settings-section-header">
<div> <div>
@ -132,7 +393,7 @@
<section class="card settings-patterns" aria-labelledby="settings-patterns-title"> <section class="card settings-patterns" aria-labelledby="settings-patterns-title">
<div> <div>
<p class="eyebrow">Implementation patterns</p> <p class="eyebrow">Implementation patterns</p>
<h2 id="settings-patterns-title">How future settings should appear</h2> <h2 id="settings-patterns-title">How settings should appear</h2>
</div> </div>
<div class="grid settings-pattern-grid"> <div class="grid settings-pattern-grid">
{#each SETTINGS_PATTERNS as pattern} {#each SETTINGS_PATTERNS as pattern}
@ -151,3 +412,51 @@
{/if} {/if}
</main> </main>
</div> </div>
{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })}
<article class="settings-runtime-card embedded" class:inactive={!connection.active}>
<header>
<div>
<h3>{connection.display_name}</h3>
<p><code>{connection.runtime_id}</code></p>
</div>
<span class="badge" class:success={connection.active} class:warning={!connection.active}>{connection.status}</span>
</header>
<dl class="settings-identity-list compact">
<div>
<dt>Kind</dt>
<dd>{connection.kind}</dd>
</div>
<div>
<dt>Built in</dt>
<dd>{connection.built_in ? "yes" : "no"}</dd>
</div>
<div>
<dt>Config managed</dt>
<dd>{connection.config_managed ? "yes" : "no"}</dd>
</div>
<div>
<dt>Spawn</dt>
<dd>{connection.can_spawn_worker ? "available" : "unavailable"}</dd>
</div>
<div>
<dt>Restart required</dt>
<dd>{connection.restart_required ? "yes" : "no"}</dd>
</div>
</dl>
{@render DiagnosticsList({ diagnostics: connection.diagnostics })}
</article>
{/snippet}
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnosticLabel(diagnostic)}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{/snippet}

View File

@ -3,6 +3,7 @@ import {
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
SETTINGS_ROUTE, SETTINGS_ROUTE,
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref, settingsSectionHref,
} from "./model.ts"; } 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 = [ const allText = [
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
...SETTINGS_SECTIONS.flatMap((section) => [ ...SETTINGS_SECTIONS.flatMap((section) => [
@ -55,14 +61,12 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
].join("\n"); ].join("\n");
assert( assert(
allText.includes( allText.includes("restart_required=true") || allText.includes("Restart-required"),
"does not add, remove, test, or persist Runtime endpoints", "restart-required pattern should be visible",
),
"Runtime Connections should remain a placeholder",
); );
assert( assert(
allText.includes("Restart-required"), allText.includes("not echoed back") || allText.includes("not echoed"),
"restart-required pattern should be visible", "endpoint submission should not imply endpoint echoing",
); );
for ( for (
@ -72,6 +76,7 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
"token:", "token:",
"secret:", "secret:",
"store root:", "store root:",
"config file path:",
] ]
) { ) {
assert( 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",
);
});

View File

@ -1,3 +1,9 @@
export type Diagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
};
export type SettingsSectionId = export type SettingsSectionId =
| "runtime-connections" | "runtime-connections"
| "backend-config" | "backend-config"
@ -6,7 +12,7 @@ export type SettingsSectionId =
export type SettingsSection = { export type SettingsSection = {
readonly id: SettingsSectionId; readonly id: SettingsSectionId;
readonly label: string; readonly label: string;
readonly status: "placeholder" | "read-only"; readonly status: "editable" | "placeholder" | "read-only";
readonly summary: string; readonly summary: string;
readonly bullets: readonly string[]; readonly bullets: readonly string[];
}; };
@ -16,22 +22,66 @@ export type SettingsPattern = {
readonly body: string; 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_ROUTE = "/settings";
export const SETTINGS_PERMISSION_NOTICE = 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[] = [ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ {
id: "runtime-connections", id: "runtime-connections",
label: "Runtime Connections", label: "Runtime Connections",
status: "placeholder", status: "editable",
summary: 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: [ bullets: [
"Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.", "Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
"Connection changes require a later typed Backend API and are not performed by this shell.", "The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
"Restart-required states should be shown as bounded diagnostics rather than live mutation controls.", "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", label: "Backend Config",
status: "placeholder", status: "placeholder",
summary: 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: [ bullets: [
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.", "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.", "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", title: "Sanitized diagnostics",
body: 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", title: "Restart-required changes",
body: 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: 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 { export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}#${id}`; return `${SETTINGS_ROUTE}#${id}`;
} }
export function diagnosticLabel(diagnostic: Diagnostic): string {
return `${diagnostic.severity}: ${diagnostic.code}`;
}

View File

@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model'; import { workerConsoleHref } from '$lib/workspace-console/model';
import type { ListResponse, Worker } from './types'; import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
import type {
BrowserCreateWorkerResponse,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
} from './types';
const MAX_VISIBLE_WORKERS = 6; const MAX_VISIBLE_WORKERS = 6;
@ -14,14 +20,24 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<Worker[]>([]); let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null); let placeholder = $state<string | null>(null);
let options = $state<WorkerLaunchOptionsResponse | null>(null);
let optionsError = $state<string | null>(null);
let showNewWorker = $state(false);
let submitting = $state(false);
let submitError = $state<string | null>(null);
let displayName = $state('Coding Worker');
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
$effect(() => { $effect(() => {
const controller = new AbortController(); const controller = new AbortController();
void loadWorkers(controller.signal); void loadWorkers(controller.signal);
void loadLaunchOptions(controller.signal);
return () => controller.abort(); return () => controller.abort();
}); });
async function loadWorkers(signal: AbortSignal) { async function loadWorkers(signal?: AbortSignal) {
loading = true; loading = true;
error = null; error = null;
placeholder = null; placeholder = null;
@ -47,21 +63,141 @@
error = err instanceof Error ? err.message : 'workers request failed'; error = err instanceof Error ? err.message : 'workers request failed';
workers = []; workers = [];
} finally { } finally {
if (!signal.aborted) { if (!signal?.aborted) {
loading = false; loading = false;
} }
} }
} }
async function loadLaunchOptions(signal?: AbortSignal) {
optionsError = null;
try {
const response = await fetch('/api/workers/launch-options', { signal });
if (!response.ok) {
throw new Error(`worker launch options failed (${response.status})`);
}
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
options = payload;
const form = defaultWorkerLaunchForm(payload, {
runtime_id: runtimeId,
display_name: displayName,
profile,
initial_text: initialText,
});
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
}
}
async function createWorker() {
submitError = null;
submitting = true;
try {
const response = await fetch('/api/workers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildBrowserCreateWorkerRequest({
runtime_id: runtimeId,
display_name: displayName,
profile,
initial_text: initialText,
})),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'worker create failed'));
}
const payload = (await response.json()) as BrowserCreateWorkerResponse;
await loadWorkers();
window.location.href = payload.console_href;
} catch (err) {
submitError = err instanceof Error ? err.message : 'worker create failed';
} finally {
submitting = false;
}
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
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})`;
}
</script> </script>
<section class="nav-section" aria-labelledby="workers-heading"> <section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="workers-heading">workers</h2> <h2 id="workers-heading">workers</h2>
<button type="button" class="section-action" onclick={() => (showNewWorker = !showNewWorker)}>
{showNewWorker ? 'Close' : 'New'}
</button>
{#if !loading && !error && workers.length > 0} {#if !loading && !error && workers.length > 0}
<span class="section-count">{workers.length}</span> <span class="section-count">{workers.length}</span>
{/if} {/if}
</div> </div>
{#if showNewWorker}
<form class="worker-new-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
<label>
<span>Display name</span>
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
</label>
<label>
<span>Runtime</span>
<select bind:value={runtimeId} required>
{#if options?.runtimes.length}
{#each options.runtimes as runtime}
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
{runtime.display_name} · {runtime.status}{runtime.built_in ? ' · embedded' : ''}
</option>
{/each}
{:else}
<option value="" disabled>No Runtime options</option>
{/if}
</select>
</label>
<label>
<span>Profile</span>
<select bind:value={profile} required>
{#if options?.profiles.length}
{#each options.profiles as candidate}
<option value={candidate.id}>{candidate.label}</option>
{/each}
{:else}
<option value="" disabled>No profile candidates</option>
{/if}
</select>
</label>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
</label>
{#if optionsError}
<p class="section-state error">{optionsError}</p>
{/if}
{#if submitError}
<p class="section-state error">{submitError}</p>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
{/if}
{#if loading} {#if loading}
<p class="section-state">Checking workers…</p> <p class="section-state">Checking workers…</p>
{:else if error} {:else if error}

View File

@ -93,6 +93,37 @@ export type Worker = {
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected'; export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
can_spawn_worker: boolean;
status: string;
diagnostics: Diagnostic[];
};
export type WorkerLaunchProfileCandidate = {
id: string;
label: string;
description: string;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
diagnostics: Diagnostic[];
};
export type BrowserCreateWorkerResponse = {
workspace_id: string;
runtime_id: string;
worker_id: string;
console_href: string;
worker: Worker;
diagnostics: Diagnostic[];
};
export type WorkerInputResult = { export type WorkerInputResult = {
state: WorkerOperationState; state: WorkerOperationState;
runtime_id: string; runtime_id: string;

View File

@ -0,0 +1,83 @@
import {
buildBrowserCreateWorkerRequest,
defaultWorkerLaunchForm,
type WorkerLaunchFormState,
} from './worker-launch.ts';
import type { WorkerLaunchOptionsResponse } from './types.ts';
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
const options: WorkerLaunchOptionsResponse = {
workspace_id: 'workspace',
runtimes: [
{
runtime_id: 'remote-runtime',
display_name: 'Remote Runtime',
built_in: false,
can_spawn_worker: false,
status: 'active',
diagnostics: [],
},
{
runtime_id: 'embedded-worker-runtime',
display_name: 'Embedded Runtime',
built_in: true,
can_spawn_worker: true,
status: 'active',
diagnostics: [],
},
],
profiles: [
{
id: 'runtime_default',
label: 'Runtime default',
description: 'Runtime default profile.',
},
{
id: 'builtin:coder',
label: 'Coding Worker',
description: 'Coding role.',
},
],
diagnostics: [],
};
Deno.test('new worker form defaults to backend-published runtime and profile candidates', () => {
const current: WorkerLaunchFormState = {
runtime_id: '',
display_name: '',
profile: 'free-text-profile',
initial_text: 'start here',
};
const form = defaultWorkerLaunchForm(options, current);
assert(form.runtime_id === 'embedded-worker-runtime', 'should choose spawn-capable runtime');
assert(form.profile === 'builtin:coder', 'should choose backend-published coder profile');
assert(form.display_name === 'Coding Worker', 'should derive default display name');
assert(form.initial_text === 'start here', 'should preserve initial text');
});
Deno.test('new worker submit payload exposes only browser contract fields', () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: 'embedded-worker-runtime',
display_name: 'Coding Worker',
profile: 'builtin:coder',
initial_text: 'implement ticket',
});
assert(
JSON.stringify(Object.keys(request).sort()) ===
JSON.stringify(['display_name', 'initial_text', 'profile', 'runtime_id'].sort()),
'submit payload should contain only Browser-facing worker create fields',
);
assert(!('kind' in request), 'kind must not be exposed as a Browser request field');
});

View File

@ -0,0 +1,41 @@
import type { WorkerLaunchOptionsResponse } from './types';
export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
};
export type BrowserCreateWorkerRequest = WorkerLaunchFormState;
export function defaultWorkerLaunchForm(
options: WorkerLaunchOptionsResponse | null,
current: WorkerLaunchFormState,
): WorkerLaunchFormState {
const preferredRuntime = options?.runtimes.find((runtime) => runtime.can_spawn_worker && runtime.status === 'active')
?? options?.runtimes.find((runtime) => runtime.can_spawn_worker)
?? options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) => candidate.id === 'builtin:coder')
?? options?.profiles[0];
return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || '',
display_name: current.display_name || 'Coding Worker',
profile: options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || '',
initial_text: current.initial_text,
};
}
export function buildBrowserCreateWorkerRequest(
form: WorkerLaunchFormState,
): BrowserCreateWorkerRequest {
return {
runtime_id: form.runtime_id,
display_name: form.display_name,
profile: form.profile,
initial_text: form.initial_text,
};
}

View File

@ -10,6 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"module": "ESNext",
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
} }