Compare commits

...

44 Commits

Author SHA1 Message Date
f5b200d36b
ticket: start worker cleanup stop 2026-07-13 22:58:54 +09:00
9c3677ff67
ticket: stop worker before cleanup delete 2026-07-13 22:58:54 +09:00
498337317c
workspace: stabilize cleanup plan digest 2026-07-13 22:38:35 +09:00
655cdf9533
ticket: start cleanup plan stabilization 2026-07-13 22:30:39 +09:00
1b9d8c91c3
ticket: stabilize cleanup plan ordering 2026-07-13 22:30:39 +09:00
59524679c1
workspace: type workdir cleanup status 2026-07-13 21:57:20 +09:00
daa06f98fb
ticket: start typed workdir cleanup 2026-07-13 21:46:05 +09:00
2061c810db
ticket: type workdir cleanup status 2026-07-13 21:46:05 +09:00
80a84b854c
ticket: close completed runtime work 2026-07-13 21:45:11 +09:00
f88ee692c9
workspace: allow not-found workdir cleanup 2026-07-13 20:54:33 +09:00
b583af4dd3
ticket: start not-found workdir cleanup 2026-07-13 20:44:18 +09:00
81ceb1ceff
ticket: allow not-found workdir cleanup 2026-07-13 20:44:18 +09:00
da0fe7c81f
runtime: preserve typed workdir errors 2026-07-13 20:17:09 +09:00
72c7beb9c7
ticket: start workdir error preservation 2026-07-13 19:59:02 +09:00
490d5ed674
ticket: preserve typed workdir errors 2026-07-13 19:59:02 +09:00
60c772ec38
workspace: show backend error details 2026-07-13 19:51:28 +09:00
fb0d5aeaa9
ticket: start backend error details 2026-07-13 19:46:30 +09:00
ec4b404580
ticket: show backend error details 2026-07-13 19:46:30 +09:00
6b91d83fe2
runtime: normalize filesystem options 2026-07-13 19:06:26 +09:00
b82407816e
runtime: remove workspace cwd options 2026-07-13 18:31:54 +09:00
f54ba6950c
ticket: start runtime option cleanup 2026-07-13 18:22:30 +09:00
a5e38f5964
ticket: remove runtime workspace cwd options 2026-07-13 18:22:30 +09:00
49f085916e
runtime: remove runtime-owned id 2026-07-13 17:38:09 +09:00
6fedf81785
ticket: start runtime id removal 2026-07-13 16:43:36 +09:00
6af7f61247
ticket: remove runtime-owned id 2026-07-13 16:43:36 +09:00
4a90267ebc
workspace: remove input readiness capability 2026-07-12 23:43:44 +09:00
c948b669c1
runtime: restore persisted worker execution 2026-07-12 23:38:56 +09:00
c56b8c04ea
ticket: start runtime worker restore 2026-07-12 23:16:14 +09:00
8b235b6cf5
ticket: add runtime worker restore plan 2026-07-12 23:16:05 +09:00
59b1b3df79
workspace: gate workdir assignment 2026-07-12 20:07:32 +09:00
21802d68f8
workspace: finalize workdir deletion flow 2026-07-12 16:33:14 +09:00
52cccc7f2f
workspace: track workdir observation state 2026-07-12 12:32:24 +09:00
3009bb4ad9
workspace: delete workers through runtime 2026-07-12 09:06:59 +09:00
d8c0853d55
workspace: refine runtime and cleanup lists 2026-07-12 06:57:10 +09:00
c5aae4b234
workspace: use opaque numeric worker ids 2026-07-12 05:07:39 +09:00
664f8693a0
ticket: close worker display name preservation 2026-07-11 17:44:32 +09:00
88d819591e
workspace: preserve worker display names 2026-07-11 17:44:23 +09:00
593d3309d9
ticket: preserve worker display names 2026-07-11 17:31:54 +09:00
32d1ae597e
ticket: close archived worker console links 2026-07-11 16:01:41 +09:00
30d15daddf
workspace: disable archived worker console links 2026-07-11 16:01:29 +09:00
d8412d022b
ticket: disable archived worker console links 2026-07-11 15:52:17 +09:00
d53bfd8087
ticket: close worker status removal 2026-07-11 15:22:34 +09:00
2d2c29a775
workspace: remove worker status field 2026-07-11 15:22:23 +09:00
69560addf8
ticket: remove worker status field 2026-07-11 15:06:13 +09:00
89 changed files with 4918 additions and 1506 deletions

View File

@ -0,0 +1,27 @@
---
title: 'Remove duplicate Worker status field'
state: 'closed'
created_at: '2026-07-11T06:05:38Z'
updated_at: '2026-07-11T06:22:34Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-11T06:06:13Z'
---
## 背景
Workspace Backend/Frontend の Worker projection は `state``status` を両方持っているが、現状ほぼ同じ値を入れており区別がない。live/archived Worker 表示で不整合を避けるため、browser-facing Worker summary は `state` に一本化する。
## 要件
- Workspace browser-facing `WorkerSummary` から duplicate な `status` field を削除する。
- Backend projection は `state` のみを返す。
- Frontend types / UI / tests は `state` のみを参照する。
- Runtime 内部や別ドメインの typed status は必要ならそのまま残すが、Workspace browser Worker summary に duplicate status を露出しない。
## 受け入れ条件
- `/api/workers` の Worker item に `status` field が含まれない。
- `/api/runtimes/{runtime}/workers/{worker}` の Worker detail に `status` field が含まれない。
- Workspace frontend に `.status` 参照が残らない。
- `cargo test`、`cd web/workspace && deno task check`、`cd web/workspace && deno task test`、`nix build .#yoi` が通る。

View File

@ -0,0 +1 @@
Removed duplicate browser-facing Worker `status` field. Workspace Worker summaries now use `state` only; Frontend Worker list/sidebar/Console no longer reference `worker.status`. Runtime, Host, Workdir, and protocol statuses remain where they are distinct concepts.

View File

@ -0,0 +1,91 @@
<!-- event: create author: "yoi ticket" at: 2026-07-11T06:05:38Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-11T06:06:13Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-11T06:22:05Z -->
## Implementation report
Removed duplicate Workspace Worker `status` from browser-facing Worker summaries.
- `WorkerSummary` now exposes `state` only for Worker state.
- Backend Worker projections populate only `state`; registry-only Workers remain `state: archived`.
- Frontend Worker type, sidebar, Workers page, and Console header no longer reference `worker.status`.
- Runtime/Host/Workdir/protocol statuses are unchanged where they are distinct domain concepts.
Validation:
- cargo test -q -p yoi-workspace-server
- cd web/workspace && deno task check
- cd web/workspace && deno task test
- cargo test -q
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:22:05Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-11T06:22:34Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T06:22:34Z status: closed -->
## 完了
Removed duplicate browser-facing Worker `status` field. Workspace Worker summaries now use `state` only; Frontend Worker list/sidebar/Console no longer reference `worker.status`. Runtime, Host, Workdir, and protocol statuses remain where they are distinct concepts.
---

View File

@ -0,0 +1,26 @@
---
title: 'Disable Console links for archived Workers'
state: 'closed'
created_at: '2026-07-11T06:51:23Z'
updated_at: '2026-07-11T07:01:41Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-11T06:52:17Z'
---
## 背景
Workers list は live Worker と registry-only archived Worker を含む。Archived Worker は live Runtime Worker ではなく Console/detail API が 404 になるため、Sidebar/Workers page で Console に遷移可能な UI として表示してはいけない。
## 要件
- `implementation.kind == "backend_worker_registry"` または `state == "archived"` の Worker は Console link を表示しない。
- Sidebar では archived Worker を disabled/readonly 行として表示し、クリックできる Console target にしない。
- Workers page でも archived Worker の `Open Console` link を表示しない。
- live Worker の Console link は維持する。
## 受け入れ条件
- Archived Worker が Sidebar から Console にアクセス可能に見えない。
- Archived Worker が Workers page から Console にアクセス可能に見えない。
- `cd web/workspace && deno task check``cd web/workspace && deno task test` が通る。

View File

@ -0,0 +1 @@
Archived registry-only Workers no longer appear as Console-accessible. Sidebar renders them as disabled rows, and the Workers page shows `Archived` instead of `Open Console`; live Workers keep Console links.

View File

@ -0,0 +1,89 @@
<!-- event: create author: "yoi ticket" at: 2026-07-11T06:51:23Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-11T06:52:17Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-11T07:01:17Z -->
## Implementation report
Archived registry-only Workers no longer render as Console links.
- Added shared `canOpenWorkerConsole` helper that rejects `state: archived` and `implementation.kind: backend_worker_registry`.
- Sidebar renders archived Workers as disabled readonly rows instead of anchors.
- Workers page shows `Archived` instead of `Open Console` for archived Workers.
- Added unit coverage for live vs archived Console availability.
Validation:
- cd web/workspace && deno task check
- cd web/workspace && deno task test
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T07:01:17Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-11T07:01:41Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T07:01:41Z status: closed -->
## 完了
Archived registry-only Workers no longer appear as Console-accessible. Sidebar renders them as disabled rows, and the Workers page shows `Archived` instead of `Open Console`; live Workers keep Console links.
---

View File

@ -0,0 +1,29 @@
---
title: 'Preserve requested Worker display names'
state: 'closed'
created_at: '2026-07-11T08:31:24Z'
updated_at: '2026-07-11T08:44:32Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-11T08:31:54Z'
---
## 背景
New Worker 画面は既定で `Coding Worker` などの display name を送るが、Workspace Backend が Worker 作成後の registry 同期で Runtime projection の `worker_id` 由来 label を display name として保存している。その結果 Workers list の label と id がどちらも `worker-00000001` のように見える。
Display name は UI/request の値を preserving metadata として扱い、Runtime worker id とは分ける必要がある。
## 要件
- Worker 作成時に `requested_worker_name` / UI display name を Backend worker registry の `display_name` として保存する。
- 以後の Runtime observation/list sync で既存 registry の `display_name` を Runtime id label で上書きしない。
- registry record が無い live Worker だけ Runtime projection label を fallback display name として使う。
- Workers list の1カラム目は display name、補助表示として worker id を別に出す。
## 受け入れ条件
- New Worker 画面から `Coding Worker` で作成した Worker が Workers list で `Coding Worker` と表示される。
- 同じ行で `worker-00000001` 等の Worker id は metadata として別に確認できる。
- Runtime list sync 後も display name が worker id に戻らない。
- `cargo test -q -p yoi-workspace-server`、`cd web/workspace && deno task check`、`cd web/workspace && deno task test`、`nix build .#yoi` が通る。

View File

@ -0,0 +1 @@
Worker display names are now preserved separately from Runtime worker ids. New Worker creation records the requested/sanitized display name, and later live Runtime sync does not overwrite it with the worker id label.

View File

@ -0,0 +1,91 @@
<!-- event: create author: "yoi ticket" at: 2026-07-11T08:31:24Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-11T08:31:54Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-11T08:44:11Z -->
## Implementation report
Fixed Worker display name preservation.
- Browser Worker create now records the sanitized UI display name in Backend worker registry instead of immediately syncing the Runtime worker id label.
- Runtime Worker create API records `requested_worker_name` as display name when provided.
- Later Runtime observation/list sync preserves existing registry display_name and only falls back to Runtime label for never-recorded live Workers.
- Added coverage that a Worker created with empty browser display_name uses the default `Coding Worker` label in Workers list/detail while keeping worker id separate.
Validation:
- cargo test -q -p yoi-workspace-server
- cd web/workspace && deno task check
- cd web/workspace && deno task test
- cargo test -q
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:44:11Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-11T08:44:32Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T08:44:32Z status: closed -->
## 完了
Worker display names are now preserved separately from Runtime worker ids. New Worker creation records the requested/sanitized display name, and later live Runtime sync does not overwrite it with the worker id label.
---

View File

@ -0,0 +1,17 @@
---
title: 'Opaque Worker and Workdir ids'
state: 'closed'
created_at: '2026-07-11T10:56:08Z'
updated_at: '2026-07-11T12:05:24Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-11T10:56:21Z'
---
## 背景
LocalTicketBackend によって作成されました。
## 受け入れ条件
- 未定

View File

@ -0,0 +1 @@
/tmp/yoi-id-implementation-report.md

View File

@ -0,0 +1,116 @@
<!-- event: create author: "yoi ticket" at: 2026-07-11T10:56:08Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-11T10:56:21Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: plan author: hare at: 2026-07-11T10:56:21Z -->
## Plan
Replace meaningful concatenated Worker/Workdir ids with opaque runtime/local ids and composite backend registry keys. Preserve display labels separately, migrate legacy registry/link rows, and validate server/runtime/frontend builds.
---
<!-- event: implementation_report author: hare at: 2026-07-11T11:25:17Z -->
## Implementation report
Implemented opaque Worker/Workdir ids and removed Backend synthetic Worker PKs.
- Runtime generated Worker ids are now opaque fixed-width hex counters without the `worker-` prefix.
- Runtime and Backend generated Workdir ids are now opaque timestamp+sequence hex ids without `backend-`, `alloc-`, repository id, or Runtime/Worker-derived components.
- Workspace Backend worker registry now uses `(workspace_id, runtime_id, runtime_worker_id)` as the primary key.
- Worker/Workdir links now reference the same composite worker key instead of `{runtime_id}/{worker_id}` synthetic strings.
- Added schema v5 migration from the legacy synthetic `worker_id` registry/link tables.
- Updated cleanup, list, sync, retention, and tests to use composite keys and keep public `WorkerSummary.worker_id` Runtime-local.
Validation:
- cargo test -q -p yoi-workspace-server
- cargo test -q
- cd web/workspace && deno task check && deno task test
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T11:25:17Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-11T11:25:17Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T11:25:17Z status: closed -->
## 完了
/tmp/yoi-id-implementation-report.md
---
<!-- event: implementation_report author: hare at: 2026-07-11T12:05:24Z -->
## Implementation report
Follow-up: Worker ids are now numeric, not String-backed.
- `worker_runtime::WorkerId` is now a transparent `u64` newtype instead of `String`.
- Runtime HTTP JSON uses numeric Worker ids; URL path handling parses the path segment as `u64`.
- Workspace Backend stores `runtime_worker_id` as INTEGER in `worker_registry` and `worker_workdir_links` composite keys.
- Browser-facing Workspace `WorkerSummary.worker_id` remains a string route segment derived from the numeric id, so existing frontend route composition stays simple.
- Schema v5 migration converts legacy `worker-*` / numeric string worker ids into integer `runtime_worker_id` values before rebuilding registry/link tables.
---

View File

@ -0,0 +1,47 @@
---
title: 'Runtime restart-crossing Worker restore path'
state: 'closed'
created_at: '2026-07-12T13:21:38Z'
updated_at: '2026-07-13T12:05:05Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-12T14:16:14Z'
---
## 背景
Filesystem-backed Runtime は persisted Runtime / Worker record を reload できるが、Runtime process の再起動後は persisted connected execution mapping が stale 扱いになり、live Worker controller / handle は復元されない。そのため、復元後の Worker に input を送ると `worker has no execution backend` で失敗する。
TUI の restore 経路では、live row は socket override で attach し、stopped row は Worker name だけを使って Worker runtime command を spawn する。restore 本体は name-keyed metadata と session log replay を authority とし、`Worker::restore_from_worker_metadata_with_context` が active session/segment、manifest snapshot、session log、duplicate writer 防止 allocation、delegated child scope reconciliation を扱う。
Runtime の restart-crossing restore もこの境界に揃え、persisted execution binding を liveness と見なさず、実 controller / handle を明示的に materialize する経路を設計・実装する。
## 要件
- Runtime process restart 後、fs-backed Runtime が persisted active Worker を execution backend 経由で復元できる。
- Persisted execution binding は restore hint に留め、live handle の証明として扱わない。
- Restore authority は TUI と同じく deterministic Worker name metadata と session log replay に置く。
- Fresh Worker creation と persisted Worker restore は execution backend / factory API 上で分ける。
- Restore 後は fresh spawn と同じ protocol/event observation bridge を再接続する。
- Workdir は新規 materialize せず、persisted assignment / Workdir id を rebind する。
- 同じ Workdir が別の active Worker に primary assigned されている場合は restore を拒否または stale diagnostic にする。
- 個別 Worker の restore failure で Runtime startup 全体を失敗させない。対象 Worker は stale のまま diagnostic を残す。
- Pending / no-history Worker を明示的に扱い、initial input の二重投入を避ける。
## 設計メモ
- `WorkerExecutionOperation::Restore` を追加する。
- `WorkerExecutionRestoreRequest``WorkerExecutionBackend::restore_worker` を追加する。
- `RuntimeWorkerFactory::restore_controller` を追加し、`ProfileRuntimeWorkerFactory` では resolved active segment を持つ Worker に対して `Worker::restore_from_worker_metadata_with_context` を呼ぶ。
- Runtime は `RuntimeState::from_persisted` の stale marking を維持し、execution backend install 後に active stale candidates の restore pass を走らせる。
- Restore 成功時は `execution_handle`、connected binding/status、run state、Workdir status、snapshot、restore event を commit する専用経路を持つ。
- Restore 失敗時は `execution_handle = None` のまま、stale status と `worker_execution_restore_failed` diagnostic を残す。
- Pending metadata で resolved `segment_id` がない場合は、少なくとも no-history / no-initial-input Worker と initial-input 境界が曖昧な Worker を区別する。
## 受け入れ条件
- fs-backed Runtime の再起動後、valid metadata / session log を持つ active Worker が live execution handle 付きで restored connected になり、`send_input` が成功する。
- Worker metadata missing / corrupt / pending conflict などの restore failure は Runtime startup を止めず、該当 Worker を stale のまま clear diagnostic として表示する。
- Pending no-history Worker の restore behavior がテストされ、initial input を二重投入しない。
- Workdir restore は persisted Workdir binding を再利用し、新しい Workdir を作らない。
- Spawn と restore が同じ observation bridge behavior を共有し、frontend / WebSocket observation が restored Worker の状態変化を受け取れる。

View File

@ -0,0 +1,3 @@
Ticket `00001KXB7V4WY` (`Runtime restart-crossing Worker restore path`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,97 @@
<!-- event: create author: "yoi ticket" at: 2026-07-12T13:21:38Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-12T14:16:14Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-12T14:38:35Z -->
## Implementation report
Implemented Runtime restart-crossing Worker restore path.
- Added explicit `Restore` execution operation and `WorkerExecutionRestoreRequest` / backend restore API.
- Added Runtime restore pass for fs-backed Runtime startup after execution backend install.
- Persisted connected mappings remain stale on load until a real backend handle is restored.
- Restore success commits a live execution handle, connected status, Workdir status, and `WorkerExecutionRestored` event.
- Restore failure keeps the Worker stale and records `worker_execution_restore_failed` diagnostics without failing Runtime startup.
- Worker runtime backend can restore controllers through `RuntimeWorkerFactory::restore_controller`, using `Worker::restore_from_worker_metadata_with_context` and a conservative pending/no-history fallback.
- Spawn and restore share event bridge/handle registration behavior.
Validation:
- `cargo fmt`
- `cargo check -q -p worker-runtime`
- `cargo check -q -p worker-runtime --features fs-store`
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cargo check -q`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:38:56Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:05Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:05Z status: closed -->
## 完了
Ticket `00001KXB7V4WY` (`Runtime restart-crossing Worker restore path`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,32 @@
---
title: 'Remove Runtime-owned runtime_id'
state: 'closed'
created_at: '2026-07-13T07:42:49Z'
updated_at: '2026-07-13T12:05:05Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T07:43:36Z'
---
## 背景
Remote Runtime は backend から URL で到達される実体であり、backend 側の runtime id / alias は workspace-server が endpoint を識別するための名前である。Runtime process / Worker は、その backend alias を感知する必要がない。
現在は worker-runtime crate の `RuntimeId` / `WorkerRef { runtime_id, worker_id }` が Runtime 内部、fs-store、REST server、Worker 名生成に入り込んでおり、`--runtime-id` を指定しないと restart crossing restore が成立しない。これは backend の識別子を Runtime 実体に渡している設計で、remote alias と Runtime 内部永続化 namespace が混ざっている。
## 要件
- `worker-runtime` の Runtime 実装から Runtime-owned `runtime_id` を削除する。
- `worker-runtime` の Worker identity は Runtime-local `WorkerId` / `WorkerRef` にする。
- Worker / Worker controller / restore 用 deterministic Worker name は backend alias や Runtime id に依存しない。
- REST runtime server の `--runtime-id` 指定を削除する。
- fs-store は runtime id ごとのディレクトリ分割をやめ、指定された root 自体を単一 Runtime store として扱う。
- Workspace backend の runtime id / alias は workspace-server 側の endpoint identifier として残し、Runtime 内部へ渡さない。
- 既存の frontend / workspace-server API で必要な runtime id は workspace-server projection 層で付与する。
## 受け入れ条件
- `cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --store fs --fs-root <dir>``--runtime-id` なしで durable Runtime として起動する。
- 同じ fs-root で再起動した時、persisted Worker record が Runtime-local id で読める。
- `Runtime` 内部 API は Worker lookup に backend alias を要求しない。
- workspace-server remote runtime id は引き続き URL endpoint alias として機能する。

View File

@ -0,0 +1,3 @@
Ticket `00001KXD6VFJ8` (`Remove Runtime-owned runtime_id`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,100 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T07:42:49Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T07:43:36Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T08:37:44Z -->
## Implementation report
Implemented removal of Runtime-owned runtime_id from worker-runtime.
- Removed `RuntimeId` and made worker-runtime Worker authority Runtime-local (`WorkerRef { worker_id }`).
- Removed `runtime_id` from Runtime options, summaries, event cursors/subscriptions, Worker summaries/details, Runtime errors, persisted Runtime state, and Worker snapshots.
- Removed REST server `--runtime-id`; the process exposes a single Runtime identified externally by URL/backend alias only.
- Changed REST server default store selection to durable fs-store with a default user data root, keeping `--store memory` as the explicit throwaway mode.
- Changed fs-store layout so the supplied root is the Runtime store, not `root/runtimes/<runtime-id>`.
- Added legacy single-runtime layout migration from the old `runtimes/<encoded-runtime-id>/` layout into the root store when exactly one legacy Runtime directory exists.
- Changed deterministic Worker runtime names from `runtime-<runtime_id>-<worker_id>` to Runtime-local `worker-runtime-<worker_id>`.
- Kept workspace-server runtime ids as backend/endpoint aliases and adapted embedded/remote projections to attach those aliases at the workspace boundary instead of passing them into worker-runtime.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p yoi-workspace-server`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --runtime-id arc` rejects the removed option
- started `worker-runtime-rest-server` with `--fs-root /tmp/yoi-runtime-idless-test` and verified `/v1/runtime` reports `backend: fs_store` without runtime_id
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T08:37:44Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:05Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:05Z status: closed -->
## 完了
Ticket `00001KXD6VFJ8` (`Remove Runtime-owned runtime_id`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,30 @@
---
title: 'Remove workspace and cwd from worker runtime server'
state: 'closed'
created_at: '2026-07-13T09:21:46Z'
updated_at: '2026-07-13T12:05:04Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T09:22:30Z'
---
## 背景
`worker-runtime-rest-server` は remote Runtime process として backend から URL で操作される。Workspace authority は workspace-server / backend 側が提供するため、Runtime process が `--workspace` / `--cwd` を受け取り、自身で workspace root や cwd を推測する必要はない。
現在の `--workspace` / `--cwd` は、古い local workspace 前提の名残であり、profile source archive / backend resource / working-directory binding による authority 境界と合っていない。
## 要件
- `worker-runtime-rest-server``--workspace``--cwd` option を削除する。
- Runtime-local Worker root は workspace ではなく Runtime-local durable/scratch root から導出する。
- Workdir が materialize された Worker だけが materialized Workdir root/cwd を受け取る。
- Workdirless Worker は workspace filesystem authority を持たないまま起動する。
- Backend-provided profile source / resource endpoint / working-directory binding の経路は維持する。
## 受け入れ条件
- `--workspace` / `--cwd` は help から消え、指定すると unknown argument として reject される。
- `worker-runtime-rest-server``--workspace` なしで起動できる。
- `cargo test -q -p worker-runtime --features fs-store,ws-server` が通る。
- `cargo test -q -p yoi-workspace-server` が通る。

View File

@ -0,0 +1,3 @@
Ticket `00001KXDCGNJE` (`Remove workspace and cwd from worker runtime server`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,98 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T09:21:46Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T09:22:30Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T09:31:37Z -->
## Implementation report
Removed `worker-runtime-rest-server` `--workspace` and `--cwd` options.
- Removed process config fields and CLI parsing for workspace/cwd.
- Runtime Worker factory now uses a Runtime-local `worker-root` under the Runtime root instead of a workspace root for workdirless Workers.
- Workdir bindings still provide their materialized root/cwd when a Worker has a Workdir.
- Help no longer lists `--workspace` / `--cwd`; specifying either now fails as an unknown argument.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p yoi-workspace-server`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --workspace /tmp` rejects the removed option
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --cwd /tmp` rejects the removed option
- Started `worker-runtime-rest-server` with only `--bind` and `--fs-root` and verified `/v1/runtime` responds with `backend: fs_store`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:31:37Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:04Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:04Z status: closed -->
## 完了
Ticket `00001KXDCGNJE` (`Remove workspace and cwd from worker runtime server`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,33 @@
---
title: 'Normalize worker runtime filesystem options'
state: 'closed'
created_at: '2026-07-13T10:02:48Z'
updated_at: '2026-07-13T12:05:03Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T10:04:08Z'
---
## 背景
`worker-runtime-rest-server` の filesystem option は、`fs-store` feature によって使える永続化境界として見えるべきである。現在の `--worker-store-dir` / `--worker-metadata-dir` / `--worker-runtime-base-dir` は内部実装の分割を直接露出しており、`--store memory` も「store backend」ではなく「永続 store を使わない ephemeral mode」を表している。
`fs-store` feature 付き build では filesystem-backed store が default で動き、明示的に永続化を切る場合だけ `--no-store` とする。
## 要件
- `--fs-root` は filesystem-backed storage 全体の top-level root とする。
- `--fs-worker-dir` を追加し、Worker session / metadata / controller runtime / workdirless Worker root をまとめて導出する。
- `--fs-runtime-dir` を追加し、Runtime catalog / Worker list / execution mapping / events の保存先にする。
- `--workdir-target` を追加し、Workdir materialization target を指定できるようにする。
- `--store <fs>` は future backend switching 用に残し、`memory` は受け付けない。
- 旧 `--store memory``--no-store` に置き換える。
- 旧 `--worker-store-dir` / `--worker-metadata-dir` / `--worker-runtime-base-dir` は削除する。
## 受け入れ条件
- `--help` に新しい option 名が表示され、旧 worker path option は表示されない。
- `--store memory` は reject され、`--no-store` は Runtime catalog persistence を無効化する。
- `--fs-root <dir>` だけで `<dir>/runtime`、`<dir>/worker`、`<dir>/workdirs` が default 派生される。
- Worker Runtime server は新 option で起動できる。
- worker-runtime / workspace-server の関連テストと Nix build が通る。

View File

@ -0,0 +1,3 @@
Ticket `00001KXDEVT13` (`Normalize worker runtime filesystem options`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,102 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:02:48Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T10:04:08Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T10:04:08Z -->
## Implementation report
Implemented filesystem option cleanup for `worker-runtime-rest-server`.
- Kept `--store <fs>` as the only accepted store selector value.
- Added `--no-store` to represent the old ephemeral memory Runtime catalog mode.
- Changed `--fs-root` to mean the top-level filesystem-backed storage root.
- Added `--fs-worker-dir` for Worker storage, defaulting to `<fs-root>/worker`.
- Added `--fs-runtime-dir` for Runtime catalog storage, defaulting to `<fs-root>/runtime`.
- Added `--workdir-target` for Workdir materialization, defaulting to `<fs-root>/workdirs`.
- Removed the old `--worker-store-dir`, `--worker-metadata-dir`, and `--worker-runtime-base-dir` CLI options.
- Worker session store, Worker metadata store, Worker controller runtime base, and workdirless Worker root are now derived from `--fs-worker-dir`.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p yoi-workspace-server`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --store memory` rejects the old memory store selector
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --worker-store-dir /tmp/x` rejects the removed worker path option
- Started `worker-runtime-rest-server` with `--fs-root /tmp/yoi-runtime-fs-options-test` and verified `/v1/runtime` responds with `backend: fs_store` while using the derived runtime directory.
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:09Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:03Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:03Z status: closed -->
## 完了
Ticket `00001KXDEVT13` (`Normalize worker runtime filesystem options`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,25 @@
---
title: 'Show backend operation error details'
state: 'closed'
created_at: '2026-07-13T10:45:59Z'
updated_at: '2026-07-13T12:05:02Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T10:46:30Z'
---
## 背景
Workspace cleanup などの backend operation failure が `operation failed; backend-private details were omitted` に置き換えられ、実際の失敗理由が UI に出ない。現在は local workspace backend / trusted local UI の開発中であり、原因調査のためには backend error message をそのまま表示できる必要がある。
## 要件
- Workspace API error response の backend operation message を固定の omitted 文言に置換しない。
- `workspace_cleanup_plan_stale` などの RuntimeOperationFailed message が UI/API response にそのまま乗る。
- 既存の diagnostic surface は維持する。
## 受け入れ条件
- `sanitize_backend_error` が入力 message を保持する。
- 既存の omitted-path 前提 test を新しい方針に合わせる。
- workspace-server tests が通る。

View File

@ -0,0 +1,3 @@
Ticket `00001KXDHAWEF` (`Show backend operation error details`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,89 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:45:59Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T10:46:30Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T10:51:20Z -->
## Implementation report
Changed workspace-server backend operation error reporting to preserve backend messages instead of replacing them with the fixed `backend-private details were omitted` text.
- `sanitize_backend_error` now returns the original message.
- Updated the existing test to assert backend operation details are preserved.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -q -p yoi-workspace-server`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:51:20Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:02Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:02Z status: closed -->
## 完了
Ticket `00001KXDHAWEF` (`Show backend operation error details`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,25 @@
---
title: 'Preserve typed Workdir not-found errors'
state: 'closed'
created_at: '2026-07-13T10:58:32Z'
updated_at: '2026-07-13T12:05:01Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T10:59:02Z'
---
## 背景
Workdir detail / sync で Runtime 側に存在しない Workdir を問い合わせた時、worker-runtime は `WorkingDirectoryDiagnostic { code: working_directory_not_found }` を一度作っているにもかかわらず、RuntimeError::InvalidRequest に変換して REST error code が `invalid_request` になっている。workspace-server 側は diagnostic code の substring match で not_found を推測しており、typed error が失われて `unknown` になる。
## 要件
- Worker Runtime REST error response に Workdir diagnostic code を preserve する。
- workspace-server は diagnostic message / substring ではなく typed diagnostic code の exact match で Workdir not-found を扱う。
- Runtime に存在しない Workdir は `unknown` ではなく `not_found` / NotFound として扱える。
## 受け入れ条件
- `working_directory_not_found` が REST response の error.code として返る。
- workspace-server の `workdir_status_from_runtime_miss` が exact code match を使う。
- worker-runtime / workspace-server の関連テストが通る。

View File

@ -0,0 +1,3 @@
Ticket `00001KXDJ1V3D` (`Preserve typed Workdir not-found errors`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,95 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:58:32Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T10:59:02Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T11:16:48Z -->
## Implementation report
Removed text/substring-based Workdir not-found handling.
- Added `RuntimeError::WorkingDirectory(WorkingDirectoryDiagnostic)` so worker-runtime preserves typed Workdir diagnostic codes instead of flattening them into `invalid_request`.
- Worker Runtime REST errors now return Workdir diagnostic codes such as `working_directory_not_found` as `error.code` and map that code to HTTP 404.
- Remote Runtime HTTP error mapping now preserves typed error codes from Runtime error bodies instead of remapping all HTTP 404 responses to a generic worker-not-found code.
- Embedded Runtime diagnostics also preserve typed Workdir diagnostic codes.
- `workdir_status_from_runtime_miss` now uses exact `diagnostic.code == "working_directory_not_found"` rather than substring matching.
- Added tests for preserving Workdir REST error codes and for exact Workdir miss classification.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cargo test -q -p yoi-workspace-server`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:16:48Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:05:01Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:05:01Z status: closed -->
## 完了
Ticket `00001KXDJ1V3D` (`Preserve typed Workdir not-found errors`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,27 @@
---
title: 'Allow not-found Workdir cleanup action'
state: 'closed'
created_at: '2026-07-13T11:43:33Z'
updated_at: '2026-07-13T12:04:57Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T11:44:18Z'
---
## 背景
Workdir list では Runtime から `not_found` が確認できているが、cleanup plan では `file_status``notfound` になり、`workdir_record_delete` ではなく `workdir_dirty_discard` と判定される。そのため frontend の delete action が disabled になっている。
原因は `WorkingDirectoryStatusKind::NotFound``format!("{:?}").to_lowercase()` で string 化しているためで、serde の `snake_case` 表現 (`not_found`) とずれている。
## 要件
- Workdir cleanup plan の observed Runtime status string は Debug 表現ではなく canonical snake_case label を使う。
- `WorkingDirectoryStatusKind::NotFound``not_found` として扱われる。
- `not_found` Workdir candidate は `workdir_record_delete` action になり、blocking reason がなければ削除可能になる。
## 受け入れ条件
- cleanup plan API で not-found Workdir の `file_status``not_found` になる。
- cleanup plan API で not-found Workdir の `action``workdir_record_delete` になる。
- workspace-server tests が通る。

View File

@ -0,0 +1,3 @@
Ticket `00001KXDMM975` (`Allow not-found Workdir cleanup action`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,96 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T11:43:33Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T11:44:18Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T11:51:11Z -->
## Implementation report
Fixed not-found Workdir cleanup plan classification.
Root cause:
- Cleanup plan converted `WorkingDirectoryStatusKind::NotFound` with `format!("{:?}").to_lowercase()`, producing `notfound` instead of the canonical API/status label `not_found`.
- The action classifier only recognized `missing` / `not_found`, so the candidate fell through to `workdir_dirty_discard`, which the frontend disables by design.
Changes:
- Added explicit `workdir_status_kind_label` mapping for `WorkingDirectoryStatusKind`.
- `NotFound` now maps to `not_found`, so cleanup plan candidates become `workdir_record_delete`.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p yoi-workspace-server`
- targeted cleanup plan test
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:51:11Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-13T12:04:57Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-13T12:04:57Z status: closed -->
## 完了
Ticket `00001KXDMM975` (`Allow not-found Workdir cleanup action`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,31 @@
---
title: 'Type Workdir cleanup plan status'
state: 'done'
created_at: '2026-07-13T12:45:21Z'
updated_at: '2026-07-13T12:56:55Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T12:46:05Z'
---
## 背景
Runtime と workspace-server はどちらも Rust であり、Runtime catalog の Workdir status / cleanliness は `worker-runtime` の typed enum として共有できる。それにもかかわらず cleanup plan は `file_status: String` / `cleanliness: String` に落として判定しており、`WorkingDirectoryStatusKind::NotFound` が `notfound` になるバグを起こした。
Rust 内部の cleanup policy 判定では文字列化せず、typed enum / 明示的 parse 境界に閉じる。
## 要件
- `CleanupWorkdirCandidate.file_status` を文字列ではなく typed enum にする。
- `CleanupWorkdirCandidate.cleanliness` を文字列ではなく typed enum にする。
- Runtime observation 由来の Workdir status / cleanliness は `worker-runtime` の型から typed に変換する。
- DB registry 由来の status / cleanliness は明示的な parse 境界で typed に変換し、未知値は `unknown` として扱う。
- cleanup action 判定は string match ではなく enum match にする。
- JSON API では従来通り snake_case string として serialize される。
## 受け入れ条件
- `not_found` Workdir が型付き判定で `workdir_record_delete` になる。
- `clean` Workdir が型付き cleanliness 判定で `workdir_clean_cleanup` になる。
- Rust 内部 cleanup policy に `format!("{:?}").to_lowercase()` や status string match が残らない。
- workspace-server tests と Nix build が通る。

View File

@ -0,0 +1,76 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T12:45:21Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T12:46:05Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T12:56:55Z -->
## Implementation report
Typed Workdir cleanup plan status and cleanliness.
- Added `CleanupWorkdirFileStatus` and `CleanupWorkdirCleanliness` typed enums with snake_case JSON serialization.
- `CleanupWorkdirCandidate.file_status` and `.cleanliness` now use those enums instead of `String`.
- Runtime-observed `WorkingDirectoryStatusKind` is converted directly into cleanup status without string formatting.
- Registry string fields are parsed only at the DB/API boundary; unknown values become `Unknown`.
- Cleanup action policy now uses enum matches/predicates rather than string matching.
- `NotFound` remains represented as `not_found` over JSON and maps to `workdir_record_delete` internally.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p yoi-workspace-server`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cd web/workspace && deno task check && deno task test`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:56:55Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---

View File

@ -0,0 +1,25 @@
---
title: 'Stabilize cleanup plan ordering'
state: 'done'
created_at: '2026-07-13T13:29:51Z'
updated_at: '2026-07-13T13:38:25Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T13:30:39Z'
---
## 背景
Workdir cleanup endpoint を実際に叩くと、直前に取得した cleanup plan の revision / digest を送っても `workspace_cleanup_plan_stale` になる。cleanup plan の候補順序が HashMap / DB / merge の順序に依存しており、GET と POST 内部再生成で同じ集合でも digest が変わっている可能性が高い。
## 要件
- Cleanup plan の worker / workdir candidates を digest 計算前に deterministic に並べる。
- API response と execute 時再生成の plan digest が同一集合で安定する。
- not_found Workdir の `workdir_record_delete` が削除 endpoint で実行できる。
## 受け入れ条件
- cleanup plan を連続生成しても digest が安定する。
- 直前 plan の revision/digest を使った cleanup execution が stale 扱いにならない。
- workspace-server tests が通る。

View File

@ -0,0 +1,82 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T13:29:51Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T13:30:39Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T13:38:25Z -->
## Implementation report
Stabilized cleanup plan candidate ordering and verified the not-found Workdir deletion endpoint.
Root cause:
- Cleanup plan digest includes worker/workdir candidate arrays.
- Candidate order was not normalized before digest calculation, so GET `/cleanup-plan` and POST `/cleanup-executions` could compute different digests for the same target set.
- The POST then failed with `workspace_cleanup_plan_stale`, preventing `workdir_record_delete` execution.
Changes:
- Sort worker cleanup candidates by `target_id` before digest calculation.
- Sort Workdir cleanup candidates by `target_id` before digest calculation.
Endpoint verification:
- Fetched `GET /api/w/0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6/runtimes/arc/cleanup-plan`.
- Posted the returned revision/digest and all `file_status: not_found` Workdir target ids to `POST /cleanup-executions`.
- Received HTTP 200 and four `workdir_record_delete` results with `status: deleted`.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p yoi-workspace-server`
- targeted cleanup plan test
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:38:25Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---

View File

@ -0,0 +1,27 @@
---
title: 'Stop Worker before cleanup deletion'
state: 'inprogress'
created_at: '2026-07-13T13:58:15Z'
updated_at: '2026-07-13T13:58:54Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T13:58:54Z'
---
## 背景
Worker cleanup execution で selected Worker を削除すると、Runtime 側では Worker status がまだ active/running のため `delete_worker` が rejected し、workspace-server は `workspace_cleanup_worker_runtime_delete_rejected: Runtime did not delete selected Worker` を返す。
Cleanup の Worker delete action は、対象が cleanup plan で許可された場合、Runtime record を削除する前に Runtime 側 Worker を stopped に遷移させてから delete する必要がある。
## 要件
- Worker cleanup execution は Runtime delete の前に `stop_worker` を実行する。
- stop が rejected/error の場合は typed diagnostic を返して registry record を削除しない。
- stop 後の delete が成功した場合だけ backend registry row を削除する。
- 既に stopped / missing など delete 可能なケースを壊さない。
## 受け入れ条件
- Runtime が active Worker の delete を直接 reject するケースでも cleanup execution が stop -> delete の順に実行できる。
- workspace-server tests が通る。

View File

@ -0,0 +1,42 @@
<!-- event: create author: "yoi ticket" at: 2026-07-13T13:58:15Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-13T13:58:54Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---

View File

@ -1,5 +1,5 @@
use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::identity::{WorkerId, WorkerRef};
use crate::interaction::WorkerInput;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
use serde::{Deserialize, Serialize};
@ -103,20 +103,11 @@ pub enum MaterializerKind {
LocalGitWorktree,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DirtyStatePolicy {
#[default]
CleanPointOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
#[serde(default)]
pub materializer: MaterializerKind,
#[serde(default)]
pub dirty_state_policy: DirtyStatePolicy,
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
/// Backend can create canonical registry rows before materialization.
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -134,8 +125,10 @@ pub struct WorkingDirectoryClaim {
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind {
Active,
Removed,
CleanupPending,
Corrupted,
NotFound,
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -152,16 +145,17 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_policy: Option<String>,
pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<WorkerId>,
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -215,7 +209,6 @@ impl WorkerStatus {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSummary {
pub worker_ref: WorkerRef,
pub runtime_id: RuntimeId,
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,
@ -230,7 +223,6 @@ pub struct WorkerSummary {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerDetail {
pub worker_ref: WorkerRef,
pub runtime_id: RuntimeId,
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,

View File

@ -1,36 +1,19 @@
use crate::execution::WorkerExecutionResult;
use crate::identity::{RuntimeId, WorkerId};
use crate::identity::WorkerId;
use crate::working_directory::WorkingDirectoryDiagnostic;
use std::path::PathBuf;
/// Errors returned by the embedded Runtime API.
#[derive(Debug, thiserror::Error)]
pub enum RuntimeError {
#[error("runtime {runtime_id} is stopped")]
RuntimeStopped { runtime_id: RuntimeId },
#[error(
"worker {worker_id} belongs to runtime {actual_runtime_id}, not runtime {expected_runtime_id}"
)]
WrongRuntime {
expected_runtime_id: RuntimeId,
actual_runtime_id: RuntimeId,
worker_id: WorkerId,
},
#[error("cursor belongs to runtime {actual_runtime_id}, not runtime {expected_runtime_id}")]
WrongRuntimeCursor {
expected_runtime_id: RuntimeId,
actual_runtime_id: RuntimeId,
},
#[error("runtime is stopped")]
RuntimeStopped,
#[error("initial worker input must be user input, got {kind}")]
InvalidInitialInputKind { kind: String },
#[error("worker {worker_id} was not found in runtime {runtime_id}")]
WorkerNotFound {
runtime_id: RuntimeId,
worker_id: WorkerId,
},
#[error("worker {worker_id} was not found")]
WorkerNotFound { worker_id: WorkerId },
#[error("worker {worker_id} has no execution backend: {message}")]
WorkerExecutionUnavailable {
@ -56,6 +39,9 @@ pub enum RuntimeError {
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error(transparent)]
WorkingDirectory(#[from] WorkingDirectoryDiagnostic),
#[error("config bundle `{bundle_id}` was not found")]
ConfigBundleMissing { bundle_id: String },

View File

@ -61,6 +61,7 @@ pub enum WorkerExecutionRunState {
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionOperation {
Spawn,
Restore,
Input,
Stop,
Cancel,
@ -291,6 +292,20 @@ pub struct WorkerExecutionSpawnRequest {
pub config_bundle: Option<ConfigBundle>,
}
/// Restore request passed to an execution backend for a persisted Runtime Worker.
///
/// The persisted execution status is a restore hint, not a live handle. Backends
/// must create a fresh controller/handle before returning `Connected`.
#[derive(Clone, Debug)]
pub struct WorkerExecutionRestoreRequest {
pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext,
pub previous_execution: WorkerExecutionStatus,
pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
}
/// Result of backend Worker spawn/initialization.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorkerExecutionSpawnResult {
@ -313,6 +328,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn restore_worker(
&self,
_request: WorkerExecutionRestoreRequest,
) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Restore,
"execution backend does not support restoring workers",
))
}
fn create_working_directory(
&self,
_request: &WorkingDirectoryRequest,
@ -394,6 +419,19 @@ impl WorkerExecutionBackendRef {
self.backend.spawn_worker(request)
}
#[cfg(feature = "fs-store")]
pub(crate) fn backend_id(&self) -> &str {
&self.id
}
#[cfg(feature = "fs-store")]
pub(crate) fn restore_worker(
&self,
request: WorkerExecutionRestoreRequest,
) -> WorkerExecutionSpawnResult {
self.backend.restore_worker(request)
}
pub(crate) fn create_working_directory(
&self,
request: &WorkingDirectoryRequest,

View File

@ -3,7 +3,7 @@ use crate::config_bundle::ConfigBundle;
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::error::RuntimeError;
use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::identity::{WorkerId, WorkerRef};
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationEvent;
@ -16,10 +16,10 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 1;
const RUNTIMES_DIR: &str = "runtimes";
const RUNTIME_FILE: &str = "runtime.json";
const EVENTS_FILE: &str = "events.jsonl";
const WORKERS_DIR: &str = "workers";
const LEGACY_RUNTIMES_DIR: &str = "runtimes";
const WORKER_FILE: &str = "worker.json";
#[cfg(feature = "ws-server")]
const OBSERVATIONS_FILE: &str = "observations.jsonl";
@ -29,9 +29,8 @@ static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
/// Options for constructing a filesystem-backed Runtime store.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FsRuntimeStoreOptions {
/// Root directory containing all Runtime-scoped store data.
/// Root directory containing this Runtime's store data.
pub root: PathBuf,
pub runtime_id: Option<RuntimeId>,
pub display_name: Option<String>,
pub limits: RuntimeLimits,
}
@ -40,23 +39,19 @@ impl FsRuntimeStoreOptions {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
runtime_id: None,
display_name: None,
limits: RuntimeLimits::default(),
}
}
}
/// Filesystem persistence boundary for Worker Runtime state.
/// Filesystem persistence boundary for one Worker Runtime state.
///
/// Authority is the typed `runtime_id + worker_id` pair. Those ids are encoded
/// into path components only after validation; legacy pod paths, socket paths,
/// and session paths are deliberately not part of the layout or lookup API.
/// Authority is Runtime-local typed Worker identity. Legacy pod paths, socket
/// paths, and session paths are deliberately not part of the layout or lookup API.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FsRuntimeStore {
root: PathBuf,
runtime_id: RuntimeId,
runtime_dir: PathBuf,
}
impl FsRuntimeStore {
@ -64,12 +59,8 @@ impl FsRuntimeStore {
&self.root
}
pub fn runtime_id(&self) -> &RuntimeId {
&self.runtime_id
}
pub fn runtime_dir(&self) -> &Path {
&self.runtime_dir
&self.root
}
/// Read persisted Runtime events directly from the event log with the same
@ -80,12 +71,6 @@ impl FsRuntimeStore {
limit: usize,
max_limit: usize,
) -> Result<RuntimeEventBatch, RuntimeError> {
if cursor.runtime_id != self.runtime_id {
return Err(RuntimeError::WrongRuntimeCursor {
expected_runtime_id: self.runtime_id.clone(),
actual_runtime_id: cursor.runtime_id.clone(),
});
}
if limit > max_limit {
return Err(RuntimeError::LimitTooLarge {
requested: limit,
@ -109,49 +94,33 @@ impl FsRuntimeStore {
let has_more = events.iter().any(|event| event.id >= next_event_id);
Ok(RuntimeEventBatch {
runtime_id: self.runtime_id.clone(),
cursor: EventCursor {
runtime_id: self.runtime_id.clone(),
next_event_id,
},
cursor: EventCursor { next_event_id },
events: selected,
has_more,
})
}
pub(crate) fn open_or_create(
root: PathBuf,
runtime_id: RuntimeId,
) -> Result<OpenedFsRuntimeStore, RuntimeError> {
fs::create_dir_all(root.join(RUNTIMES_DIR)).map_err(|source| RuntimeError::StoreIo {
operation: "create store root",
path: root.join(RUNTIMES_DIR),
source,
})?;
let runtime_dir = runtime_dir(&root, &runtime_id);
let existed = runtime_dir.exists();
if existed && !runtime_dir.is_dir() {
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> {
let existed = root.exists();
if existed && !root.is_dir() {
return Err(RuntimeError::StoreCorrupt {
operation: "open runtime store",
path: runtime_dir,
path: root,
message: "runtime path exists but is not a directory".to_string(),
});
}
fs::create_dir_all(runtime_dir.join(WORKERS_DIR)).map_err(|source| {
RuntimeError::StoreIo {
operation: "create runtime store",
path: runtime_dir.join(WORKERS_DIR),
source,
if existed {
migrate_legacy_single_runtime_layout(&root)?;
}
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
operation: "create runtime store",
path: root.join(WORKERS_DIR),
source,
})?;
let store = Self {
root,
runtime_id,
runtime_dir,
};
let store = Self { root };
let state = if existed {
Some(store.load_runtime_state()?)
} else {
@ -192,6 +161,18 @@ impl FsRuntimeStore {
Ok(())
}
pub(crate) fn delete_worker_snapshot(&self, worker_id: &WorkerId) -> Result<(), RuntimeError> {
let worker_dir = self.worker_dir(worker_id);
if !worker_dir.exists() {
return Ok(());
}
fs::remove_dir_all(&worker_dir).map_err(|source| RuntimeError::StoreIo {
operation: "delete worker store",
path: worker_dir,
source,
})
}
pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> {
if let Some(worker_ref) = &event.worker_ref {
self.ensure_worker_ref(worker_ref)?;
@ -215,10 +196,10 @@ impl FsRuntimeStore {
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
let runtime_path = self.runtime_path();
let mut snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
snapshot.validate(&self.runtime_id, &runtime_path)?;
snapshot.validate(&runtime_path)?;
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
let workers_dir = self.runtime_dir.join(WORKERS_DIR);
let workers_dir = self.root.join(WORKERS_DIR);
if !workers_dir.exists() {
return Err(RuntimeError::StoreMissing {
operation: "read workers",
@ -274,10 +255,7 @@ impl FsRuntimeStore {
continue;
}
};
if worker_snapshot
.validate(&self.runtime_id, &worker_snapshot_path)
.is_err()
{
if worker_snapshot.validate(&worker_snapshot_path).is_err() {
record_worker_load_diagnostic(
&mut snapshot,
Some(worker_snapshot.worker_ref.clone()),
@ -343,30 +321,20 @@ impl FsRuntimeStore {
))
}
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
if worker_ref.runtime_id == self.runtime_id {
fn ensure_worker_ref(&self, _worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
Ok(())
} else {
Err(RuntimeError::WrongRuntime {
expected_runtime_id: self.runtime_id.clone(),
actual_runtime_id: worker_ref.runtime_id.clone(),
worker_id: worker_ref.worker_id.clone(),
})
}
}
fn runtime_path(&self) -> PathBuf {
self.runtime_dir.join(RUNTIME_FILE)
self.root.join(RUNTIME_FILE)
}
fn events_path(&self) -> PathBuf {
self.runtime_dir.join(EVENTS_FILE)
self.root.join(EVENTS_FILE)
}
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
self.runtime_dir
.join(WORKERS_DIR)
.join(encoded_component(worker_id.as_str()))
self.root.join(WORKERS_DIR).join(worker_id.to_string())
}
#[cfg(feature = "ws-server")]
@ -383,7 +351,6 @@ pub(crate) struct OpenedFsRuntimeStore {
#[derive(Clone, Debug)]
pub(crate) struct PersistedRuntimeState {
pub(crate) runtime_id: RuntimeId,
pub(crate) display_name: Option<String>,
pub(crate) status: RuntimeStatus,
pub(crate) limits: RuntimeLimits,
@ -411,7 +378,6 @@ pub(crate) struct PersistedWorkerRecord {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct RuntimeSnapshot {
schema_version: u32,
runtime_id: RuntimeId,
display_name: Option<String>,
backend: RuntimeBackendKind,
status: RuntimeStatus,
@ -444,7 +410,6 @@ impl RuntimeSnapshot {
fn from_persisted(state: &PersistedRuntimeState) -> Self {
Self {
schema_version: SCHEMA_VERSION,
runtime_id: state.runtime_id.clone(),
display_name: state.display_name.clone(),
backend: RuntimeBackendKind::FsStore,
status: state.status,
@ -457,7 +422,7 @@ impl RuntimeSnapshot {
}
}
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
if self.schema_version != SCHEMA_VERSION {
return Err(RuntimeError::StoreCorrupt {
operation: "read runtime snapshot",
@ -468,16 +433,6 @@ impl RuntimeSnapshot {
),
});
}
if &self.runtime_id != expected_runtime_id {
return Err(RuntimeError::StoreCorrupt {
operation: "read runtime snapshot",
path: path.to_path_buf(),
message: format!(
"runtime snapshot id {} does not match requested runtime {}",
self.runtime_id, expected_runtime_id
),
});
}
if self.backend != RuntimeBackendKind::FsStore {
return Err(RuntimeError::StoreCorrupt {
operation: "read runtime snapshot",
@ -495,7 +450,6 @@ impl RuntimeSnapshot {
#[cfg(feature = "ws-server")] observation_events: Vec<WorkerObservationEvent>,
) -> PersistedRuntimeState {
PersistedRuntimeState {
runtime_id: self.runtime_id,
display_name: self.display_name,
status: self.status,
limits: self.limits,
@ -537,7 +491,7 @@ impl WorkerSnapshot {
}
}
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
if self.schema_version != SCHEMA_VERSION {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
@ -548,16 +502,6 @@ impl WorkerSnapshot {
),
});
}
if self.worker_ref.runtime_id != *expected_runtime_id {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: format!(
"worker belongs to runtime {}, expected {}",
self.worker_ref.runtime_id, expected_runtime_id
),
});
}
if self.worker_ref.worker_id != self.worker_id {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
@ -583,27 +527,81 @@ impl WorkerSnapshot {
}
}
fn runtime_dir(root: &Path, runtime_id: &RuntimeId) -> PathBuf {
root.join(RUNTIMES_DIR)
.join(encoded_component(runtime_id.as_str()))
fn migrate_legacy_single_runtime_layout(root: &Path) -> Result<(), RuntimeError> {
if root.join(RUNTIME_FILE).exists() {
return Ok(());
}
let legacy_root = root.join(LEGACY_RUNTIMES_DIR);
if !legacy_root.is_dir() {
return Ok(());
}
let mut candidates = Vec::new();
for entry in fs::read_dir(&legacy_root).map_err(|source| RuntimeError::StoreIo {
operation: "read legacy runtime store root",
path: legacy_root.clone(),
source,
})? {
let entry = entry.map_err(|source| RuntimeError::StoreIo {
operation: "read legacy runtime store entry",
path: legacy_root.clone(),
source,
})?;
let path = entry.path();
if path.join(RUNTIME_FILE).is_file() {
candidates.push(path);
}
}
if candidates.is_empty() {
return Ok(());
}
if candidates.len() > 1 {
return Err(RuntimeError::StoreCorrupt {
operation: "migrate legacy runtime store",
path: legacy_root,
message: "multiple legacy runtime directories exist; choose a concrete fs root"
.to_string(),
});
}
let legacy_dir = candidates.remove(0);
rename_if_exists(
&legacy_dir.join(RUNTIME_FILE),
&root.join(RUNTIME_FILE),
"migrate legacy runtime snapshot",
)?;
rename_if_exists(
&legacy_dir.join(EVENTS_FILE),
&root.join(EVENTS_FILE),
"migrate legacy runtime events",
)?;
rename_if_exists(
&legacy_dir.join(WORKERS_DIR),
&root.join(WORKERS_DIR),
"migrate legacy runtime workers",
)?;
Ok(())
}
fn encoded_component(value: &str) -> String {
let mut encoded = String::with_capacity(3 + value.len() * 2);
encoded.push_str("id-");
for byte in value.as_bytes() {
encoded.push(hex_digit(byte >> 4));
encoded.push(hex_digit(byte & 0x0f));
fn rename_if_exists(src: &Path, dst: &Path, operation: &'static str) -> Result<(), RuntimeError> {
if !src.exists() {
return Ok(());
}
encoded
}
fn hex_digit(value: u8) -> char {
match value {
0..=9 => (b'0' + value) as char,
10..=15 => (b'a' + (value - 10)) as char,
_ => unreachable!("hex digit nybble is always <= 15"),
if dst.exists() {
return Err(RuntimeError::StoreCorrupt {
operation,
path: dst.to_path_buf(),
message: format!(
"cannot migrate {} because destination already exists",
src.display()
),
});
}
fs::rename(src, dst).map_err(|source| RuntimeError::StoreIo {
operation,
path: dst.to_path_buf(),
source,
})
}
fn read_json<T>(path: &Path, operation: &'static str) -> Result<T, RuntimeError>

View File

@ -13,9 +13,9 @@ use crate::catalog::{
};
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::identity::{WorkerId, WorkerRef};
use crate::interaction::{WorkerInput, WorkerInteractionAck};
use crate::management::{RuntimeLimits, RuntimeSummary};
use crate::management::{RuntimeLimits, RuntimeSummary, WorkerDeleteResult};
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationCursor;
use axum::body::{Body, Bytes};
@ -50,9 +50,6 @@ pub struct RuntimeHttpServerConfig {
/// Address for the Runtime process to bind. Use a loopback address unless a
/// trusted backend proxy explicitly owns network exposure.
pub bind_addr: SocketAddr,
/// Optional explicit Runtime authority id. If omitted, the Runtime library
/// generates one.
pub runtime_id: Option<RuntimeId>,
/// Optional display label surfaced by `GET /v1/runtime`.
pub display_name: Option<String>,
/// Bounded Runtime API limits.
@ -68,7 +65,6 @@ impl Default for RuntimeHttpServerConfig {
fn default() -> Self {
Self {
bind_addr: default_runtime_http_bind_addr(),
runtime_id: None,
display_name: None,
limits: RuntimeLimits::default(),
store: RuntimeHttpStoreSelection::Memory,
@ -81,7 +77,6 @@ impl fmt::Debug for RuntimeHttpServerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimeHttpServerConfig")
.field("bind_addr", &self.bind_addr)
.field("runtime_id", &self.runtime_id)
.field("display_name", &self.display_name)
.field("limits", &self.limits)
.field("store", &self.store)
@ -118,10 +113,9 @@ pub async fn serve_runtime_http(
/// Build the REST router for an existing Runtime.
///
/// Handlers delegate to [`Runtime`] methods and keep Worker authority as
/// `(runtime_id, worker_id)`. The path contains only a Runtime-local
/// `worker_id`; the server supplies its own Runtime id instead of accepting a
/// legacy pod/socket/session path as authority.
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
/// accepted or forwarded as Runtime authority.
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
let state = RuntimeHttpState {
runtime,
@ -147,7 +141,10 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
get(get_working_directory).delete(cleanup_working_directory),
)
.route("/v1/workers", get(list_workers).post(create_worker))
.route("/v1/workers/{worker_id}", get(get_worker))
.route(
"/v1/workers/{worker_id}",
get(get_worker).delete(delete_worker),
)
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
@ -219,6 +216,12 @@ pub struct RuntimeHttpWorkerResponse {
pub worker: WorkerDetail,
}
/// Worker delete response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkerDeleteResponse {
pub worker: WorkerDeleteResult,
}
/// Worker input acknowledgement response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkerInputResponse {
@ -461,6 +464,18 @@ async fn get_worker(
Ok(Json(RuntimeHttpWorkerResponse { worker }))
}
async fn delete_worker(
State(state): State<RuntimeHttpState>,
Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerDeleteResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = state
.runtime
.delete_worker(&worker_ref)
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerDeleteResponse { worker }))
}
async fn create_worker(
State(state): State<RuntimeHttpState>,
body: Result<Json<CreateWorkerRequest>, JsonRejection>,
@ -691,18 +706,18 @@ async fn cancel_worker(
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
}
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
let worker_id = WorkerId::new(worker_id).ok_or_else(|| {
fn worker_ref_for(
_runtime: &Runtime,
worker_id: String,
) -> Result<WorkerRef, RuntimeHttpRestError> {
let worker_id = WorkerId::parse(&worker_id).ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"invalid_worker_id",
"worker_id must not be empty",
"worker_id must be an unsigned integer",
)
})?;
let runtime_id = runtime
.runtime_id()
.map_err(RuntimeHttpRestError::runtime)?;
Ok(WorkerRef::new(runtime_id, worker_id))
Ok(WorkerRef::new(worker_id))
}
fn parse_optional_lifecycle_request(
@ -746,15 +761,15 @@ async fn require_local_token(
#[derive(Debug)]
struct RuntimeHttpRestError {
status: StatusCode,
code: &'static str,
code: String,
message: String,
}
impl RuntimeHttpRestError {
fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status,
code,
code: code.into(),
message: message.into(),
}
}
@ -786,7 +801,7 @@ impl IntoResponse for RuntimeHttpRestError {
fn into_response(self) -> Response {
let body = RuntimeHttpErrorResponse {
error: RuntimeHttpErrorDetail {
code: self.code.to_string(),
code: self.code,
message: self.message,
},
};
@ -799,7 +814,12 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
StatusCode::NOT_FOUND
}
RuntimeError::RuntimeStopped { .. }
RuntimeError::WorkingDirectory(diagnostic)
if diagnostic.code == "working_directory_not_found" =>
{
StatusCode::NOT_FOUND
}
RuntimeError::RuntimeStopped
| RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. }
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
@ -809,8 +829,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
| RuntimeError::ConfigBundleDigestMismatch { .. }
| RuntimeError::InvalidProfileSelector { .. }
| RuntimeError::UnsupportedConfigDeclaration { .. }
| RuntimeError::WrongRuntime { .. }
| RuntimeError::WrongRuntimeCursor { .. } => StatusCode::BAD_REQUEST,
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
RuntimeError::StoreIo { .. }
| RuntimeError::StoreMissing { .. }
| RuntimeError::StoreCorrupt { .. }
@ -818,26 +837,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
}
}
fn code_for_runtime_error(error: &RuntimeError) -> &'static str {
fn code_for_runtime_error(error: &RuntimeError) -> String {
match error {
RuntimeError::RuntimeStopped { .. } => "runtime_stopped",
RuntimeError::WrongRuntime { .. } => "wrong_runtime",
RuntimeError::WrongRuntimeCursor { .. } => "wrong_runtime_cursor",
RuntimeError::WorkerNotFound { .. } => "worker_not_found",
RuntimeError::WorkerExecutionUnavailable { .. } => "worker_execution_unavailable",
RuntimeError::ExecutionBackendUnavailable { .. } => "execution_backend_unavailable",
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected",
RuntimeError::LimitTooLarge { .. } => "limit_too_large",
RuntimeError::InvalidRequest(_) => "invalid_request",
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind",
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing",
RuntimeError::ConfigBundleDigestMismatch { .. } => "config_bundle_digest_mismatch",
RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector",
RuntimeError::UnsupportedConfigDeclaration { .. } => "unsupported_config_declaration",
RuntimeError::StoreIo { .. } => "store_io",
RuntimeError::StoreMissing { .. } => "store_missing",
RuntimeError::StoreCorrupt { .. } => "store_corrupt",
RuntimeError::StatePoisoned => "state_poisoned",
RuntimeError::RuntimeStopped => "runtime_stopped".to_string(),
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
RuntimeError::WorkerExecutionUnavailable { .. } => {
"worker_execution_unavailable".to_string()
}
RuntimeError::ExecutionBackendUnavailable { .. } => {
"execution_backend_unavailable".to_string()
}
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(),
RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(),
RuntimeError::InvalidRequest(_) => "invalid_request".to_string(),
RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(),
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(),
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing".to_string(),
RuntimeError::ConfigBundleDigestMismatch { .. } => {
"config_bundle_digest_mismatch".to_string()
}
RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector".to_string(),
RuntimeError::UnsupportedConfigDeclaration { .. } => {
"unsupported_config_declaration".to_string()
}
RuntimeError::StoreIo { .. } => "store_io".to_string(),
RuntimeError::StoreMissing { .. } => "store_missing".to_string(),
RuntimeError::StoreCorrupt { .. } => "store_corrupt".to_string(),
RuntimeError::StatePoisoned => "state_poisoned".to_string(),
}
}
@ -1017,8 +1043,8 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
let created: RuntimeHttpWorkerResponse = read_json(response).await;
assert_eq!(
created.worker.worker_ref.runtime_id,
runtime.runtime_id().unwrap()
created.worker.worker_ref.worker_id,
created.worker.worker_id
);
let input = WorkerInput::user("hello from backend");
@ -1107,12 +1133,27 @@ mod tests {
#[tokio::test]
async fn runtime_errors_use_typed_rest_error_shape() {
let app = runtime_http_router(Runtime::new_memory(), None);
let response = empty_request(app, Method::GET, "/v1/workers/worker-missing").await;
let response = empty_request(app, Method::GET, "/v1/workers/999").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "worker_not_found");
assert!(error.error.message.contains("worker-missing"));
assert!(error.error.message.contains("999"));
}
#[test]
fn workdir_runtime_errors_preserve_diagnostic_code() {
let error =
RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic {
code: "working_directory_not_found".to_string(),
message: "working directory missing-workdir was not found".to_string(),
});
assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND);
assert_eq!(
code_for_runtime_error(&error),
"working_directory_not_found"
);
}
}

View File

@ -1,61 +1,26 @@
use serde::{Deserialize, Serialize};
use std::fmt;
/// Public Runtime identity.
///
/// This is the first half of Worker authority. Runtime APIs that operate on a
/// Worker require this id alongside a [`WorkerId`]; socket paths, session paths,
/// and display names are deliberately not authority.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RuntimeId(String);
impl RuntimeId {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
if value.trim().is_empty() {
None
} else {
Some(Self(value))
}
}
pub(crate) fn generated(sequence: u64) -> Self {
Self(format!("runtime-mem-{sequence:016x}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RuntimeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
/// Runtime-local Worker identity.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WorkerId(String);
pub struct WorkerId(u64);
impl WorkerId {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
if value.trim().is_empty() {
None
} else {
Some(Self(value))
pub fn new(value: u64) -> Self {
Self(value)
}
pub fn parse(value: &str) -> Option<Self> {
value.parse::<u64>().ok().map(Self)
}
pub(crate) fn generated(sequence: u64) -> Self {
Self(format!("worker-{sequence:08x}"))
Self(sequence)
}
pub fn as_str(&self) -> &str {
&self.0
pub fn as_u64(&self) -> u64 {
self.0
}
}
@ -65,18 +30,14 @@ impl fmt::Display for WorkerId {
}
}
/// Complete public authority reference for Worker operations.
/// Runtime-local authority reference for Worker operations.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WorkerRef {
pub runtime_id: RuntimeId,
pub worker_id: WorkerId,
}
impl WorkerRef {
pub fn new(runtime_id: RuntimeId, worker_id: WorkerId) -> Self {
Self {
runtime_id,
worker_id,
}
pub fn new(worker_id: WorkerId) -> Self {
Self { worker_id }
}
}

View File

@ -18,7 +18,6 @@ use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::identity::RuntimeId;
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
use worker_runtime::{Runtime, RuntimeOptions};
@ -67,17 +66,11 @@ fn run() -> Result<(), ProcessError> {
}
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
let mut factory = ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
.with_cwd(config.cwd.clone());
if let Some(store_dir) = config.worker_store_dir.clone() {
factory = factory.with_store_dir(store_dir);
}
if let Some(metadata_dir) = config.worker_metadata_dir.clone() {
factory = factory.with_worker_metadata_dir(metadata_dir);
}
if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() {
factory = factory.with_runtime_base_dir(runtime_base_dir);
}
let fs_paths = config.resolved_fs_paths();
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
.with_store_dir(fs_paths.worker_dir.join("sessions"))
.with_worker_metadata_dir(fs_paths.worker_dir.join("metadata"))
.with_runtime_base_dir(fs_paths.worker_dir.join("runtime"));
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
factory = factory.with_resource_client(Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new(
@ -89,12 +82,11 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let working_directory_root = working_directory_runtime_root(config);
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)?
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
working_directory_root,
fs_paths.workdir_target.clone(),
)),
);
@ -105,7 +97,6 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone());
options.runtime_id = config.http.runtime_id.clone();
options.display_name = config.http.display_name.clone();
options.limits = config.http.limits.clone();
Runtime::with_fs_store_and_execution_backend(options, backend)
@ -117,37 +108,23 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
}
fn working_directory_runtime_root(config: &ProcessConfig) -> PathBuf {
match &config.http.store {
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
_ => config
.worker_runtime_base_dir
.clone()
.unwrap_or_else(default_working_directory_runtime_root),
}
}
fn default_working_directory_runtime_root() -> PathBuf {
manifest::paths::data_dir()
.map(|data_dir| data_dir.join("worker-runtime"))
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime"))
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
RuntimeOptions {
runtime_id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
limits: config.limits.clone(),
}
}
fn default_fs_root() -> PathBuf {
manifest::paths::data_dir().unwrap_or_else(|| env::temp_dir().join("yoi"))
}
fn parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut config = ProcessConfig::default()?;
let mut store = StoreArg::Memory;
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
while let Some(arg) = args.pop_front() {
@ -160,31 +137,19 @@ where
ProcessError::usage(format!("invalid --bind socket address `{value}`: {error}"))
})?;
}
"--runtime-id" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.http.runtime_id = Some(RuntimeId::new(value).ok_or_else(|| {
ProcessError::usage("--runtime-id must not be empty".to_string())
})?);
}
"--display-name" => {
config.http.display_name = Some(take_value(&flag, inline_value, &mut args)?);
}
"--workspace" => {
config.workspace_root = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--cwd" => {
config.cwd = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--worker-store-dir" => {
config.worker_store_dir =
"--fs-worker-dir" => {
config.fs_worker_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-metadata-dir" => {
config.worker_metadata_dir =
"--fs-runtime-dir" => {
config.fs_runtime_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-runtime-base-dir" => {
config.worker_runtime_base_dir =
"--workdir-target" => {
config.workdir_target =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--backend-resource-endpoint" => {
@ -199,21 +164,19 @@ where
}
"--store" => {
let value = take_value(&flag, inline_value, &mut args)?;
store = match value.as_str() {
"memory" => StoreArg::Memory,
"fs" | "fs-store" => StoreArg::Fs { root: None },
_ => {
if !matches!(value.as_str(), "fs" | "fs-store") {
return Err(ProcessError::usage(format!(
"unsupported --store `{value}`; expected `memory` or `fs`"
"unsupported --store `{value}`; only `fs` is accepted (use --no-store for ephemeral mode)"
)));
}
};
}
"--no-store" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
config.no_store = true;
}
"--fs-root" => {
let value = take_value(&flag, inline_value, &mut args)?;
store = StoreArg::Fs {
root: Some(PathBuf::from(value)),
};
config.fs_root = Some(PathBuf::from(value));
}
"--local-token" => {
let value = take_value(&flag, inline_value, &mut args)?;
@ -248,34 +211,10 @@ where
}
}
apply_store_selection(&mut config.http, store)?;
config.workspace_root = normalize_workspace_path(config.workspace_root)?;
if config.cwd.as_os_str().is_empty() {
config.cwd = config.workspace_root.clone();
} else {
config.cwd = normalize_child_path(&config.workspace_root, config.cwd);
}
apply_store_selection(&mut config);
Ok(Some(config))
}
fn normalize_workspace_path(path: PathBuf) -> Result<PathBuf, ProcessError> {
let absolute = if path.is_absolute() {
path
} else {
env::current_dir()?.join(path)
};
Ok(absolute.canonicalize().unwrap_or(absolute))
}
fn normalize_child_path(base: &std::path::Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path.canonicalize().unwrap_or(path)
} else {
let absolute = base.join(path);
absolute.canonicalize().unwrap_or(absolute)
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), ProcessError> {
if !arg.starts_with('-') {
return Err(ProcessError::usage(format!(
@ -301,65 +240,84 @@ fn take_value(
.ok_or_else(|| ProcessError::usage(format!("{flag} requires a value")))
}
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), ProcessError> {
if inline_value.is_some() {
return Err(ProcessError::usage(format!(
"{flag} does not accept a value"
)));
}
Ok(())
}
fn parse_usize_flag(flag: &str, value: String) -> Result<usize, ProcessError> {
value
.parse::<usize>()
.map_err(|error| ProcessError::usage(format!("invalid {flag} value `{value}`: {error}")))
}
fn apply_store_selection(
config: &mut RuntimeHttpServerConfig,
store: StoreArg,
) -> Result<(), ProcessError> {
match store {
StoreArg::Memory => {
config.store = RuntimeHttpStoreSelection::Memory;
Ok(())
}
StoreArg::Fs { root } => {
let root = root.ok_or_else(|| {
ProcessError::usage("--store fs requires --fs-root <PATH>".to_string())
})?;
config.store = RuntimeHttpStoreSelection::Fs { root };
Ok(())
}
fn apply_store_selection(config: &mut ProcessConfig) {
if config.no_store {
config.http.store = RuntimeHttpStoreSelection::Memory;
} else {
let runtime_dir = config.resolved_fs_paths().runtime_dir;
config.http.store = RuntimeHttpStoreSelection::Fs { root: runtime_dir };
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProcessConfig {
http: RuntimeHttpServerConfig,
workspace_root: PathBuf,
cwd: PathBuf,
worker_store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
worker_runtime_base_dir: Option<PathBuf>,
fs_root: Option<PathBuf>,
fs_worker_dir: Option<PathBuf>,
fs_runtime_dir: Option<PathBuf>,
workdir_target: Option<PathBuf>,
no_store: bool,
backend_resource_endpoint: Option<String>,
backend_resource_token: Option<String>,
profile: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct FsPathConfig {
root: PathBuf,
worker_dir: PathBuf,
runtime_dir: PathBuf,
workdir_target: PathBuf,
}
impl ProcessConfig {
fn default() -> Result<Self, ProcessError> {
let workspace_root = env::current_dir()?;
Ok(Self {
http: RuntimeHttpServerConfig::default(),
cwd: workspace_root.clone(),
workspace_root,
worker_store_dir: None,
worker_metadata_dir: None,
worker_runtime_base_dir: None,
fs_root: None,
fs_worker_dir: None,
fs_runtime_dir: None,
workdir_target: None,
no_store: false,
backend_resource_endpoint: None,
backend_resource_token: None,
profile: None,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum StoreArg {
Memory,
Fs { root: Option<PathBuf> },
fn resolved_fs_paths(&self) -> FsPathConfig {
let root = self.fs_root.clone().unwrap_or_else(default_fs_root);
FsPathConfig {
worker_dir: self
.fs_worker_dir
.clone()
.unwrap_or_else(|| root.join("worker")),
runtime_dir: self
.fs_runtime_dir
.clone()
.unwrap_or_else(|| root.join("runtime")),
workdir_target: self
.workdir_target
.clone()
.unwrap_or_else(|| root.join("workdirs")),
root,
}
}
}
#[derive(Debug)]
@ -413,27 +371,27 @@ impl From<std::io::Error> for ProcessError {
}
fn usage() -> &'static str {
"Usage: worker-runtime-rest-server [OPTIONS]\n\n\
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.\n\
Browsers must not connect to this Runtime process directly.\n\n\
Options:\n\
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)\n\
--runtime-id <ID> Runtime authority id (default: generated)\n\
--display-name <NAME> Runtime display name\n\
--workspace <PATH> Workspace root used for spawned Workers (default: cwd)\n\
--cwd <PATH> Process cwd used for spawned Workers (default: workspace)\n\
--profile <SELECTOR> Force spawned Workers to use a Profile selector\n\
--worker-store-dir <PATH> Worker session store directory\n\
--worker-metadata-dir <PATH> Worker metadata directory\n\
--worker-runtime-base-dir <PATH> Worker controller runtime directory\n\
--backend-resource-endpoint <URL> Internal Backend resource fetch endpoint for resource handles\n\
--backend-resource-token <TOKEN> Optional bearer token for the Backend resource fetch endpoint\n\
--store <memory|fs> Runtime catalog store selection (default: memory)\n\
--fs-root <PATH> Runtime catalog filesystem store root\n\
--local-token <TOKEN> Minimal local bearer token placeholder\n\
--local-token-env <ENV> Read local bearer token placeholder from env\n\
--max-event-batch-items <N> Override event batch limit\n\
-h, --help Show this help"
r#"Usage: worker-runtime-rest-server [OPTIONS]
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
Browsers must not connect to this Runtime process directly.
Options:
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)
--display-name <NAME> Runtime display name
--profile <SELECTOR> Force spawned Workers to use a Profile selector
--fs-root <PATH> Filesystem-backed storage root (default: user data dir)
--fs-worker-dir <PATH> Worker storage directory (default: <fs-root>/worker)
--fs-runtime-dir <PATH> Runtime catalog directory (default: <fs-root>/runtime)
--workdir-target <PATH> Workdir materialization target (default: <fs-root>/workdirs)
--backend-resource-endpoint <URL> Internal Backend resource fetch endpoint for resource handles
--backend-resource-token <TOKEN> Optional bearer token for the Backend resource fetch endpoint
--store <fs> Runtime catalog store kind (default: fs)
--no-store Disable Runtime catalog persistence for ephemeral runs
--local-token <TOKEN> Minimal local bearer token placeholder
--local-token-env <ENV> Read local bearer token placeholder from env
--max-event-batch-items <N> Override event batch limit
-h, --help Show this help"#
}
#[cfg(test)]
@ -456,28 +414,26 @@ mod tests {
}
#[test]
fn normalizes_relative_workspace_for_worker_spawn() {
let current_dir = env::current_dir().unwrap().canonicalize().unwrap();
let config = parse_args(["--workspace", ".", "--cwd", "."])
.unwrap()
.unwrap();
assert_eq!(config.workspace_root, current_dir);
assert_eq!(config.cwd, current_dir);
fn rejects_removed_workspace_cwd_and_worker_path_options() {
assert!(parse_args(["--workspace", "/tmp/workspace"]).is_err());
assert!(parse_args(["--cwd", "/tmp/workspace"]).is_err());
assert!(parse_args(["--worker-store-dir", "/tmp/sessions"]).is_err());
assert!(parse_args(["--worker-metadata-dir", "/tmp/metadata"]).is_err());
assert!(parse_args(["--worker-runtime-base-dir", "/tmp/runtime"]).is_err());
}
#[test]
fn parses_memory_runtime_process_config() {
fn rejects_store_memory_in_favor_of_no_store() {
assert!(parse_args(["--store", "memory"]).is_err());
}
#[test]
fn parses_runtime_process_config() {
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
@ -491,13 +447,8 @@ mod tests {
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
@ -510,25 +461,42 @@ mod tests {
}
assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap());
assert_eq!(
config.http.runtime_id.as_ref().map(ToString::to_string),
Some("runtime-alpha".to_string())
);
assert_eq!(config.http.display_name.as_deref(), Some("Runtime Alpha"));
assert_eq!(config.workspace_root, PathBuf::from("/tmp/workspace"));
assert_eq!(config.cwd, PathBuf::from("/tmp/workspace/subdir"));
assert_eq!(config.profile.as_deref(), Some("builtin:coder"));
assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
}
#[test]
fn parses_fs_store_runtime_process_config() {
let config = parse_args(["--store", "fs", "--fs-root", "/tmp/runtime-store"])
let config = parse_args([
"--store",
"fs",
"--fs-root",
"/tmp/yoi",
"--fs-worker-dir",
"/tmp/yoi-worker",
"--fs-runtime-dir",
"/tmp/yoi-runtime",
"--workdir-target",
"/tmp/yoi-workdirs",
])
.unwrap()
.unwrap();
assert!(matches!(
config.http.store,
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/runtime-store")
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/yoi-runtime")
));
let paths = config.resolved_fs_paths();
assert_eq!(paths.worker_dir, PathBuf::from("/tmp/yoi-worker"));
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
}
#[test]
fn no_store_disables_runtime_catalog_persistence() {
let config = parse_args(["--no-store"]).unwrap().unwrap();
assert!(matches!(
config.http.store,
RuntimeHttpStoreSelection::Memory
));
}
}

View File

@ -1,4 +1,4 @@
use crate::identity::RuntimeId;
use crate::identity::WorkerId;
use serde::{Deserialize, Serialize};
/// Runtime backend kind.
@ -35,7 +35,6 @@ impl Default for RuntimeLimits {
/// Options used to construct an embedded memory Runtime.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeOptions {
pub runtime_id: Option<RuntimeId>,
pub display_name: Option<String>,
pub limits: RuntimeLimits,
}
@ -43,17 +42,25 @@ pub struct RuntimeOptions {
impl Default for RuntimeOptions {
fn default() -> Self {
Self {
runtime_id: None,
display_name: None,
limits: RuntimeLimits::default(),
}
}
}
fn unknown_platform_component() -> String {
"unknown".to_string()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerDeleteResult {
pub worker_id: WorkerId,
pub deleted: bool,
}
/// Management-plane summary for a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeSummary {
pub runtime_id: RuntimeId,
pub display_name: Option<String>,
pub backend: RuntimeBackendKind,
pub status: RuntimeStatus,
@ -63,6 +70,10 @@ pub struct RuntimeSummary {
pub cancelled_worker_count: usize,
pub diagnostic_count: usize,
pub limits: RuntimeLimits,
#[serde(default = "unknown_platform_component")]
pub os: String,
#[serde(default = "unknown_platform_component")]
pub arch: String,
#[serde(default)]
pub worker_creation_available: bool,
}

View File

@ -1,11 +1,10 @@
use crate::identity::{RuntimeId, WorkerRef};
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
/// Event cursor. `next_event_id` is the first event id that should be returned
/// by the next poll.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventCursor {
pub runtime_id: RuntimeId,
pub next_event_id: u64,
}
@ -13,7 +12,6 @@ pub struct EventCursor {
/// poll-only so HTTP/WS/SSE dependencies are not pulled into this crate.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventSubscription {
pub runtime_id: RuntimeId,
pub cursor: EventCursor,
pub mode: EventSubscriptionMode,
}
@ -39,14 +37,15 @@ pub enum RuntimeEventKind {
RuntimeStarted,
RuntimeStopped,
WorkerCreated,
WorkerExecutionRestored,
WorkerInputAccepted,
WorkerStopped,
WorkerCancelled,
WorkerDeleted,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEventBatch {
pub runtime_id: RuntimeId,
pub cursor: EventCursor,
pub events: Vec<RuntimeEvent>,
pub has_more: bool,

View File

@ -1,4 +1,4 @@
use crate::identity::{RuntimeId, WorkerId};
use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@ -179,14 +179,14 @@ impl ProfileSourceArchiveCache {
pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle,
runtime_id: &RuntimeId,
runtime_id: &str,
worker_id: Option<&WorkerId>,
) -> BackendResourceFetchRequest {
let audit_correlation_id = handle.audit_correlation_id.clone();
BackendResourceFetchRequest {
handle,
runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()),
runtime_id: runtime_id.to_string(),
worker_id: worker_id.map(|id| id.to_string()),
audit_correlation_id,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -15,12 +15,13 @@ use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use crate::catalog::{
ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkingDirectoryRequest,
WorkingDirectoryStatus,
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionRunState,
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::interaction::{WorkerInput, WorkerInputKind};
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
@ -33,11 +34,12 @@ use protocol::{Method, Segment, WorkerStatus};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use tokio::runtime::Runtime;
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
use worker::{
Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext,
WorkspaceId,
Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
WorkerWorkspaceContext, WorkspaceId,
};
const DEFAULT_BACKEND_ID: &str = "worker-crate";
@ -51,6 +53,11 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String>;
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String>;
}
/// Production factory that resolves a normal Worker profile and spawns it under
@ -58,7 +65,6 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
#[derive(Clone)]
pub struct ProfileRuntimeWorkerFactory {
profile_base_dir: PathBuf,
cwd: PathBuf,
store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
profile: Option<String>,
@ -71,7 +77,6 @@ impl ProfileRuntimeWorkerFactory {
pub fn new(profile_base_dir: impl Into<PathBuf>) -> Self {
let profile_base_dir = profile_base_dir.into();
Self {
cwd: profile_base_dir.clone(),
profile_base_dir,
store_dir: None,
worker_metadata_dir: None,
@ -82,11 +87,6 @@ impl ProfileRuntimeWorkerFactory {
}
}
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = cwd.into();
self
}
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
self.store_dir = Some(store_dir.into());
self
@ -139,12 +139,12 @@ impl ProfileRuntimeWorkerFactory {
.ok_or_else(|| "could not resolve worker runtime directory".to_string())
}
fn runtime_worker_name_for_ref(worker_ref: &crate::identity::WorkerRef) -> String {
format!("worker-runtime-{}", worker_ref.worker_id)
}
fn runtime_worker_name(request: &WorkerExecutionSpawnRequest) -> String {
format!(
"runtime-{}-{}",
sanitize_worker_name_component(request.worker_ref.runtime_id.as_str()),
request.worker_ref.worker_id
)
Self::runtime_worker_name_for_ref(&request.worker_ref)
}
fn runtime_profile_value(
@ -165,14 +165,21 @@ impl ProfileRuntimeWorkerFactory {
}
}
fn runtime_profile<'a>(
fn runtime_profile_for_request<'a>(
&'a self,
request: &'a WorkerExecutionSpawnRequest,
request: &'a CreateWorkerRequest,
) -> Option<std::borrow::Cow<'a, str>> {
if let Some(profile) = self.profile.as_deref() {
return Some(std::borrow::Cow::Borrowed(profile));
}
Self::runtime_profile_value(&request.request.profile)
Self::runtime_profile_value(&request.profile)
}
fn runtime_profile<'a>(
&'a self,
request: &'a WorkerExecutionSpawnRequest,
) -> Option<std::borrow::Cow<'a, str>> {
self.runtime_profile_for_request(&request.request)
}
async fn resolve_profile_source_archive(
&self,
@ -220,19 +227,6 @@ impl ProfileRuntimeWorkerFactory {
}
}
fn sanitize_worker_name_component(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect()
}
#[derive(Debug, Clone)]
enum RuntimeWorkspaceBackendRef {
None,
@ -397,6 +391,110 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
Ok(handle)
}
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref);
let profile = self.runtime_profile_for_request(&request.request);
let worker_root = request
.working_directory
.as_ref()
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.profile_base_dir.clone());
let filesystem_authority = request
.working_directory
.as_ref()
.map(|binding| {
WorkerFilesystemAuthority::local(
binding.root().to_path_buf(),
binding.cwd().to_path_buf(),
)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref());
let workspace_context = workspace_backend_ref.worker_context();
let selector = profile.as_deref().unwrap_or("builtin:default");
let archive = self
.resolve_profile_source_archive(&request.request.profile_source)
.await?;
let (mut manifest, loader) = {
let manifest = archive
.resolve_profile(selector, &worker_root, &worker_name)
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
manifest,
&worker_root,
&worker_name,
)?
};
manifest.worker.name = worker_name.clone();
let store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
worker_metadata_dir.display()
)
})?;
let store = CombinedStore::new(session_store, worker_metadata_store);
let worker = match Worker::restore_from_worker_metadata_with_context(
&worker_name,
manifest.clone(),
store,
loader.clone(),
workspace_context.clone(),
filesystem_authority.clone(),
)
.await
{
Ok(worker) => worker,
Err(WorkerError::WorkerMetadataPending { .. })
if request.request.initial_input.is_none() =>
{
let session_store = FsStore::new(&store_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let worker_metadata_store =
FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
worker_metadata_dir.display()
)
})?;
let store = CombinedStore::new(session_store, worker_metadata_store);
Worker::from_manifest_with_context(
manifest,
store,
loader,
workspace_context,
filesystem_authority,
)
.await
.map_err(|err| format!("failed to recreate pending Worker from profile: {err}"))?
}
Err(err) => return Err(format!("failed to restore Worker from metadata: {err}")),
};
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
.await
.map_err(|err| format!("failed to spawn restored Worker controller: {err}"))?;
Ok(handle)
}
}
struct RuntimeWorkerExecution {
@ -532,6 +630,69 @@ where
.map(|_| WorkerExecutionResult::accepted(operation, accepted_run_state))
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
}
fn connect_handle(
&self,
operation: WorkerExecutionOperation,
worker_ref: crate::identity::WorkerRef,
bridge_context: crate::execution::WorkerExecutionContext,
handle: WorkerHandle,
working_directory: Option<WorkingDirectoryBinding>,
) -> WorkerExecutionSpawnResult {
let busy = Arc::new(AtomicBool::new(false));
#[cfg(feature = "ws-server")]
{
let mut events = handle.subscribe();
let bridge_handle = handle.clone();
let bridge_busy = busy.clone();
if let Err(message) = self.spawn_on_adapter_runtime(async move {
loop {
match events.recv().await {
Ok(event) => {
let _ = bridge_context.publish_protocol_event(event);
if bridge_handle.shared_state.get_status() == WorkerStatus::Idle {
bridge_busy.store(false, Ordering::SeqCst);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
}) {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
operation, message,
));
}
}
#[cfg(not(feature = "ws-server"))]
{
let _ = bridge_context;
}
let mut workers = match self.workers.lock() {
Ok(workers) => workers,
Err(_) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
operation,
"worker adapter registry lock is poisoned",
));
}
};
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
busy,
working_directory: working_directory.clone(),
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: working_directory.map(|binding| binding.status()),
}
}
}
impl<F> Drop for WorkerRuntimeExecutionBackend<F> {
@ -694,53 +855,108 @@ where
}
};
let mut events = handle.subscribe();
let bridge_handle = handle.clone();
let busy = Arc::new(AtomicBool::new(false));
let bridge_busy = busy.clone();
if let Err(message) = self.spawn_on_adapter_runtime(async move {
loop {
match events.recv().await {
Ok(event) => {
let _ = bridge_context.publish_protocol_event(event);
if bridge_handle.shared_state.get_status() == WorkerStatus::Idle {
bridge_busy.store(false, Ordering::SeqCst);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
}) {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
self.connect_handle(
WorkerExecutionOperation::Spawn,
worker_ref,
bridge_context,
handle,
working_directory,
)
}
fn restore_worker(
&self,
mut request: WorkerExecutionRestoreRequest,
) -> WorkerExecutionSpawnResult {
let working_directory = match request.previous_execution.working_directory.clone() {
Some(status) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker has a working directory binding, but no materializer is configured for this runtime backend",
));
};
let relative_cwd = request
.request
.working_directory
.as_ref()
.and_then(|working_directory| working_directory.relative_cwd.as_deref());
match materializer
.bind_working_directory(&status.summary.working_directory_id, relative_cwd)
{
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
error.to_string(),
),
);
}
}
}
None if request.request.working_directory_request.is_some() => {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker requested a working directory, but no persisted working directory binding is available to restore",
));
}
None if request.request.working_directory.is_some() => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker has a working directory claim, but no materializer is configured for this runtime backend",
));
};
let working_directory =
request.request.working_directory.as_ref().expect("checked");
match materializer.bind_working_directory(
&working_directory.working_directory_id,
working_directory.relative_cwd.as_deref(),
) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
error.to_string(),
),
);
}
}
}
None => None,
};
let factory = self.factory.clone();
let bridge_context = request.context.clone();
let worker_ref = request.worker_ref.clone();
let restore_result =
self.run_on_adapter_runtime(async move { factory.restore_controller(request).await });
let handle = match restore_result {
Ok(handle) => handle,
Err(message) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Restore,
message,
));
}
let mut workers = match self.workers.lock() {
Ok(workers) => workers,
Err(_) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
"worker adapter registry lock is poisoned",
));
}
};
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
busy,
working_directory: working_directory.clone(),
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: working_directory.map(|binding| binding.status()),
}
self.connect_handle(
WorkerExecutionOperation::Restore,
worker_ref,
bridge_context,
handle,
working_directory,
)
}
fn dispatch_input(
@ -866,11 +1082,10 @@ mod tests {
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector,
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
};
use crate::execution::WorkerExecutionContext;
use crate::identity::RuntimeId;
use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor;
use crate::working_directory::LocalGitWorktreeMaterializer;
@ -988,6 +1203,19 @@ mod tests {
.map_err(|err| err.to_string())?;
Ok(handle)
}
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String> {
let request = WorkerExecutionSpawnRequest {
worker_ref: request.worker_ref,
request: request.request,
context: request.context,
working_directory: request.working_directory,
config_bundle: request.config_bundle,
};
self.spawn_controller(request).await
}
}
fn core_filesystem_tool_names() -> BTreeSet<&'static str> {
@ -1114,21 +1342,16 @@ mod tests {
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
backend_workdir_id: None,
}
}
#[test]
fn runtime_worker_name_is_namespaced_by_runtime_id() {
let runtime_id = RuntimeId::new("arc:remote".to_string()).unwrap();
let worker_ref = crate::identity::WorkerRef::new(
runtime_id,
crate::identity::WorkerId::new("worker-00000001".to_string()).unwrap(),
);
fn runtime_worker_name_is_runtime_local() {
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
let request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
request: create_request("worker-00000001"),
request: create_request("1"),
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
working_directory: None,
config_bundle: None,
@ -1136,11 +1359,11 @@ mod tests {
assert_eq!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
"runtime-arc-remote-worker-00000001"
"worker-runtime-1"
);
assert_ne!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
"worker-00000001"
"00000001"
);
}
@ -1191,13 +1414,8 @@ mod tests {
observed_cwds: observed_cwds.clone(),
};
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend(
RuntimeOptions {
runtime_id: RuntimeId::new("embedded"),
..RuntimeOptions::default()
},
Arc::new(backend),
)
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(create_request("chat")).unwrap();
@ -1265,13 +1483,8 @@ mod tests {
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
runtime_base.path(),
));
let runtime = EmbeddedRuntime::with_execution_backend(
RuntimeOptions {
runtime_id: RuntimeId::new("embedded-materialized"),
..RuntimeOptions::default()
},
Arc::new(backend),
)
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");

View File

@ -1,5 +1,5 @@
use crate::catalog::{
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
use crate::identity::WorkerRef;
@ -23,7 +23,6 @@ pub struct WorkingDirectoryEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -31,10 +30,8 @@ pub struct WorkingDirectory {
pub id: String,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
pub evidence: WorkingDirectoryEvidence,
pub cleanup_target: WorkingDirectoryCleanupTarget,
pub cleanup_policy: String,
pub status: WorkingDirectoryStatusKind,
}
@ -45,12 +42,12 @@ impl WorkingDirectory {
repository_id: self.repository_id.clone(),
requested_selector: self.evidence.requested_selector.clone(),
materializer_kind: self.materializer_kind.clone(),
dirty_state_policy: self.dirty_state_policy.clone(),
resolved_commit: Some(self.evidence.resolved_commit.clone()),
resolved_tree: self.evidence.resolved_tree.clone(),
cleanup_target: Some(self.cleanup_target.clone()),
cleanup_policy: Some(self.cleanup_policy.clone()),
status: self.status.clone(),
cleanliness: None,
primary_worker_id: None,
management_kind: None,
}
}
@ -83,9 +80,19 @@ impl WorkingDirectoryBinding {
}
pub fn status(&self) -> WorkingDirectoryStatus {
WorkingDirectoryStatus {
summary: self.working_directory.status_summary(),
let mut working_directory = self.working_directory.clone();
if working_directory.status == WorkingDirectoryStatusKind::Active
&& !binding_paths_are_available(self)
{
working_directory.status = WorkingDirectoryStatusKind::Corrupted;
}
let mut summary = working_directory.status_summary();
summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active {
Some(binding_cleanliness(self))
} else {
Some("unknown".to_string())
};
WorkingDirectoryStatus { summary }
}
}
@ -151,6 +158,27 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
}
fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
let Ok(root) = binding.root.canonicalize() else {
return false;
};
if !root.is_dir() {
return false;
}
let Ok(source_repository_path) = binding.source_repository_path.canonicalize() else {
return false;
};
source_repository_path.is_dir()
}
fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
match git_stdout(binding.root(), ["status", "--porcelain"]) {
Ok(output) if output.is_empty() => "clean".to_string(),
Ok(_) => "dirty".to_string(),
Err(_) => "unknown".to_string(),
}
}
#[derive(Clone, Debug)]
pub struct LocalGitWorktreeMaterializer {
runtime_root: PathBuf,
@ -167,13 +195,8 @@ impl LocalGitWorktreeMaterializer {
&self.runtime_root
}
fn working_directory_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
format!(
"{}-{}-{}",
sanitize_path_component(worker_ref.runtime_id.as_str()),
sanitize_path_component(worker_ref.worker_id.as_str()),
sanitize_path_component(repository_id)
)
fn working_directory_id(_worker_ref: &WorkerRef, repository_id: &str) -> String {
next_working_directory_id(repository_id)
}
fn working_directory_root(&self, working_directory_id: &str) -> PathBuf {
@ -182,6 +205,28 @@ impl LocalGitWorktreeMaterializer {
.join(working_directory_id)
}
fn corrupted_status(&self, working_directory_id: &str) -> WorkingDirectoryStatus {
WorkingDirectoryStatus {
summary: WorkingDirectorySummary {
working_directory_id: working_directory_id.to_string(),
repository_id: "unknown".to_string(),
requested_selector: None,
materializer_kind: MaterializerKind::LocalGitWorktree,
resolved_commit: None,
resolved_tree: None,
cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "local_git_worktree".to_string(),
working_directory_id: working_directory_id.to_string(),
repository_id: "unknown".to_string(),
}),
status: WorkingDirectoryStatusKind::Corrupted,
cleanliness: Some("unknown".to_string()),
primary_worker_id: None,
management_kind: None,
},
}
}
fn write_record(
&self,
binding: &WorkingDirectoryBinding,
@ -237,7 +282,6 @@ impl LocalGitWorktreeMaterializer {
&self,
working_directory_id: String,
request: &WorkingDirectoryRequest,
cleanup_policy: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_working_directory_id(&working_directory_id)?;
if request.materializer != MaterializerKind::LocalGitWorktree {
@ -255,12 +299,6 @@ impl LocalGitWorktreeMaterializer {
),
));
}
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_policy_unsupported",
"only clean_point_only dirty-state policy is supported in v0",
));
}
if is_remote_uri(&request.repository.uri) {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_unsupported",
@ -286,7 +324,7 @@ impl LocalGitWorktreeMaterializer {
if !status.trim().is_empty() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_source_rejected",
"clean_point_only working directory materialization rejects dirty source repository state",
"working directory materialization rejects dirty source repository state",
));
}
@ -345,7 +383,6 @@ impl LocalGitWorktreeMaterializer {
id: working_directory_id.clone(),
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
evidence: WorkingDirectoryEvidence {
repository_id: request.repository.id.clone(),
requested_selector: request
@ -356,14 +393,12 @@ impl LocalGitWorktreeMaterializer {
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
},
cleanup_target: WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
working_directory_id,
repository_id: request.repository.id.clone(),
},
cleanup_policy: cleanup_policy.to_string(),
status: WorkingDirectoryStatusKind::Active,
};
let binding = WorkingDirectoryBinding {
@ -385,11 +420,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
self.materialize_with_working_directory_id(
working_directory_id,
request,
"remove_on_worker_stop",
)
self.materialize_with_working_directory_id(working_directory_id, request)
}
fn create(
@ -400,11 +431,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
.backend_workdir_id
.clone()
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
self.materialize_with_working_directory_id(
working_directory_id,
request,
"manual_or_worker_stop",
)
self.materialize_with_working_directory_id(working_directory_id, request)
}
fn bind_working_directory(
@ -451,8 +478,9 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
if validate_working_directory_id(&working_directory_id).is_err() {
continue;
}
if let Ok(status) = self.working_directory_status(&working_directory_id) {
statuses.push(status);
match self.read_binding(&working_directory_id) {
Ok(binding) => statuses.push(binding.status()),
Err(_) => statuses.push(self.corrupted_status(&working_directory_id)),
}
}
statuses.sort_by(|left, right| {
@ -468,7 +496,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_working_directory_id(working_directory_id)?;
Ok(self.read_binding(working_directory_id)?.status())
let working_directory_root = self.working_directory_root(working_directory_id);
if !working_directory_root.exists() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_not_found",
"working directory working_directory was not found",
));
}
match self.read_binding(working_directory_id) {
Ok(binding) => Ok(binding.status()),
Err(_) => Ok(self.corrupted_status(working_directory_id)),
}
}
fn cleanup_working_directory(
@ -476,9 +514,35 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_working_directory_id(working_directory_id)?;
let status = self.working_directory_status(working_directory_id)?;
if status.summary.status == WorkingDirectoryStatusKind::Corrupted {
let working_directory_root = self.working_directory_root(working_directory_id);
if working_directory_root.exists() {
fs::remove_dir_all(&working_directory_root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_corrupted_cleanup_failed",
"failed to remove corrupted working directory; backend-private path details were omitted",
)
})?;
}
let mut summary = status.summary;
summary.status = WorkingDirectoryStatusKind::NotFound;
return Ok(WorkingDirectoryStatus { summary });
}
let binding = self.read_binding(working_directory_id)?;
self.cleanup(&binding)?;
self.working_directory_status(working_directory_id)
let mut summary = binding.working_directory.status_summary();
summary.status = WorkingDirectoryStatusKind::NotFound;
summary.cleanliness = Some("unknown".to_string());
if binding.working_directory_root.exists() {
fs::remove_dir_all(&binding.working_directory_root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_cleanup_failed",
"failed to remove working directory record; backend-private path details were omitted",
)
})?;
}
Ok(WorkingDirectoryStatus { summary })
}
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
@ -518,11 +582,8 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
Ok(())
}
});
working_directory.status = if remove_result.is_ok() {
WorkingDirectoryStatusKind::Removed
} else {
WorkingDirectoryStatusKind::CleanupPending
};
if remove_result.is_err() {
working_directory.status = WorkingDirectoryStatusKind::CleanupPending;
let updated = WorkingDirectoryBinding {
working_directory,
root: binding.root.clone(),
@ -531,6 +592,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
source_repository_path: binding.source_repository_path.clone(),
};
let _ = self.write_record(&updated);
}
remove_result
}
}
@ -609,16 +671,13 @@ fn sanitize_path_component(value: &str) -> String {
}
}
fn next_working_directory_id(repository_id: &str) -> String {
fn next_working_directory_id(_repository_id: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default();
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"alloc-{now}-{sequence}-{}",
sanitize_path_component(repository_id)
)
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0x00ff_ffff;
format!("{now:013x}{sequence:06x}")
}
fn validate_working_directory_id(
@ -686,7 +745,7 @@ fn validate_relative_cwd(
mod tests {
use super::*;
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
use crate::identity::{RuntimeId, WorkerId};
use crate::identity::{WorkerId, WorkerRef};
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
@ -722,16 +781,12 @@ mod tests {
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
backend_workdir_id: None,
}
}
fn worker_ref(sequence: u64) -> WorkerRef {
WorkerRef::new(
RuntimeId::new("runtime-test").unwrap(),
WorkerId::generated(sequence),
)
WorkerRef::new(WorkerId::generated(sequence))
}
#[test]
@ -754,10 +809,6 @@ mod tests {
binding.working_directory.materializer_kind,
MaterializerKind::LocalGitWorktree
);
assert_eq!(
binding.working_directory.dirty_state_policy,
DirtyStatePolicy::CleanPointOnly
);
assert!(
binding
.working_directory_root()
@ -786,7 +837,7 @@ mod tests {
}
#[test]
fn dirty_source_is_rejected_by_clean_point_only_policy() {
fn dirty_source_is_rejected_by_materialization() {
let repo = create_clean_repo();
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
let runtime_root = tempfile::tempdir().unwrap();
@ -797,7 +848,7 @@ mod tests {
.unwrap_err();
assert_eq!(error.code, "working_directory_dirty_source_rejected");
assert!(error.message.contains("clean_point_only"));
assert!(error.message.contains("dirty source"));
}
#[test]
@ -911,7 +962,7 @@ mod tests {
}
#[test]
fn cleanup_removes_worktree_and_updates_record() {
fn cleanup_working_directory_removes_worktree_and_record() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
@ -919,16 +970,14 @@ mod tests {
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let root = binding.root.clone();
let record_root = binding.working_directory_root().to_path_buf();
materializer.cleanup(&binding).unwrap();
assert!(!root.exists());
let raw = fs::read_to_string(
binding
.working_directory_root()
.join(MATERIALIZATION_RECORD),
)
let status = materializer
.cleanup_working_directory(&binding.working_directory.id)
.unwrap();
assert!(raw.contains("removed"));
assert_eq!(status.summary.status, WorkingDirectoryStatusKind::NotFound);
assert!(!root.exists());
assert!(!record_root.exists());
}
}

View File

@ -31,14 +31,12 @@ use worker_runtime::execution::{
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse,
RuntimeHttpWorkingDirectoryResponse,
};
use worker_runtime::identity::{
RuntimeId as EmbeddedRuntimeId, WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerDeleteResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
};
use worker_runtime::identity::{WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef};
use worker_runtime::interaction::{
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
};
@ -170,7 +168,6 @@ pub struct RuntimeCapabilitySummary {
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub can_accept_input: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
@ -223,7 +220,6 @@ pub struct WorkerImplementationSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerCapabilitySummary {
pub can_accept_input: bool,
pub can_stop: bool,
pub can_spawn_followup: bool,
}
@ -238,7 +234,6 @@ pub struct WorkerSummary {
pub profile: Option<String>,
pub workspace: WorkerWorkspaceSummary,
pub state: String,
pub status: String,
pub last_seen_at: Option<String>,
#[serde(default)]
pub pinned: bool,
@ -443,6 +438,15 @@ pub enum WorkerInputKind {
System,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerDeleteResult {
pub state: WorkerOperationState,
pub runtime_id: String,
pub worker_id: String,
pub deleted: bool,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerInputRequest {
#[serde(default = "default_worker_input_kind")]
@ -671,6 +675,20 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
WorkerDeleteResult {
state: WorkerOperationState::Unsupported,
runtime_id: self.runtime_id().to_string(),
worker_id: worker_id.to_string(),
deleted: false,
diagnostics: vec![diagnostic(
"worker_delete_unsupported",
DiagnosticSeverity::Info,
format!("runtime does not implement worker deletion for '{worker_id}'"),
)],
}
}
fn observation_source(
&self,
_worker_id: &str,
@ -1042,6 +1060,25 @@ impl RuntimeRegistry {
Ok(runtime.cancel_worker(worker_id, request))
}
pub fn delete_worker(
&self,
runtime_id: &str,
worker_id: &str,
) -> Result<WorkerDeleteResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
validate_backend_identifier("worker_id", worker_id)?;
let runtime = self.runtime(runtime_id)?;
let lookup = runtime.worker(worker_id);
if lookup.worker.is_none() {
return Err(operation_failed_or_unknown_worker(
runtime_id,
worker_id,
lookup.diagnostics,
));
}
Ok(runtime.delete_worker(worker_id))
}
pub fn observation_source(
&self,
runtime_id: &str,
@ -1089,10 +1126,7 @@ pub struct EmbeddedWorkerRuntime {
}
fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
.expect("embedded runtime id is a non-empty literal");
EmbeddedRuntimeOptions {
runtime_id: Some(runtime_id),
display_name: Some("embedded".to_string()),
..EmbeddedRuntimeOptions::default()
}
@ -1120,12 +1154,9 @@ impl EmbeddedWorkerRuntime {
store_root: impl Into<PathBuf>,
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self, worker_runtime::error::RuntimeError> {
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
.expect("embedded runtime id is a non-empty literal");
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
FsRuntimeStoreOptions {
root: store_root.into(),
runtime_id: Some(runtime_id),
display_name: Some("embedded".to_string()),
limits: EmbeddedRuntimeOptions::default().limits,
},
@ -1142,13 +1173,8 @@ impl EmbeddedWorkerRuntime {
}
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
let runtime_id = runtime
.runtime_id()
.ok()
.map(|id| id.as_str().to_string())
.unwrap_or_else(|| EMBEDDED_RUNTIME_ID.to_string());
Self {
runtime_id,
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
runtime,
execution_enabled: false,
@ -1157,18 +1183,7 @@ impl EmbeddedWorkerRuntime {
}
fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> {
Some(EmbeddedWorkerRef::new(
EmbeddedRuntimeId::new(self.runtime_id.clone())?,
EmbeddedWorkerId::new(worker_id.to_string())?,
))
}
fn can_accept_embedded_input(
&self,
status: EmbeddedWorkerStatus,
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
runtime_worker_can_accept_input(self.execution_enabled, status, execution)
Some(EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(worker_id)?))
}
fn can_stop_embedded_worker(
@ -1182,17 +1197,16 @@ impl EmbeddedWorkerRuntime {
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
WorkerSummary {
runtime_id: self.runtime_id.clone(),
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
worker_id: summary.worker_ref.worker_id.to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
role: embedded_profile_label(&summary.profile),
profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
state: embedded_worker_execution_status_label(summary.status, &summary.execution)
.to_string(),
last_seen_at: None,
pinned: false,
@ -1202,8 +1216,6 @@ impl EmbeddedWorkerRuntime {
display_hint: "backend-internal worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: self
.can_accept_embedded_input(summary.status, &summary.execution),
can_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
can_spawn_followup: false,
},
@ -1219,17 +1231,16 @@ impl EmbeddedWorkerRuntime {
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
WorkerSummary {
runtime_id: self.runtime_id.clone(),
worker_id: detail.worker_id.as_str().to_string(),
worker_id: detail.worker_id.to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()),
label: safe_display_hint(&detail.worker_id.to_string()),
role: embedded_profile_label(&detail.profile),
profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
state: embedded_worker_execution_status_label(detail.status, &detail.execution)
.to_string(),
last_seen_at: None,
pinned: false,
@ -1239,7 +1250,6 @@ impl EmbeddedWorkerRuntime {
display_hint: "backend-internal worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: self.can_accept_embedded_input(detail.status, &detail.execution),
can_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
can_spawn_followup: false,
},
@ -1661,6 +1671,38 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
let Some(worker_ref) = self.worker_ref(worker_id) else {
return WorkerDeleteResult {
state: WorkerOperationState::Rejected,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
deleted: false,
diagnostics: vec![diagnostic(
"embedded_worker_id_invalid",
DiagnosticSeverity::Warning,
"Worker id was empty and cannot be resolved".to_string(),
)],
};
};
match self.runtime.delete_worker(&worker_ref) {
Ok(result) => WorkerDeleteResult {
state: WorkerOperationState::Accepted,
runtime_id: self.runtime_id.clone(),
worker_id: result.worker_id.to_string(),
deleted: result.deleted,
diagnostics: Vec::new(),
},
Err(error) => WorkerDeleteResult {
state: WorkerOperationState::Rejected,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
deleted: false,
diagnostics: vec![embedded_runtime_diagnostic(&error)],
},
}
}
fn observation_source(
&self,
worker_id: &str,
@ -1768,7 +1810,9 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
cached_capabilities: remote_runtime_capabilities(200, false, false),
cached_capabilities: remote_runtime_capabilities(
200, false, false, "unknown", "unknown",
),
cached_status: "configured".to_string(),
timeout: Duration::from_secs(10),
}
@ -1922,17 +1966,16 @@ impl RemoteWorkerRuntime {
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
WorkerSummary {
runtime_id: self.runtime_id.clone(),
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
worker_id: summary.worker_ref.worker_id.to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
role: None,
profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
state: embedded_worker_execution_status_label(summary.status, &summary.execution)
.to_string(),
last_seen_at: None,
pinned: false,
@ -1942,7 +1985,6 @@ impl RemoteWorkerRuntime {
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: runtime_worker_can_accept_input(true, summary.status, &summary.execution),
can_stop: runtime_worker_can_stop(true, summary.status, &summary.execution),
can_spawn_followup: false,
},
@ -1958,17 +2000,16 @@ impl RemoteWorkerRuntime {
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
WorkerSummary {
runtime_id: self.runtime_id.clone(),
worker_id: detail.worker_id.as_str().to_string(),
worker_id: detail.worker_id.to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()),
label: safe_display_hint(&detail.worker_id.to_string()),
role: None,
profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
state: embedded_worker_execution_status_label(detail.status, &detail.execution)
.to_string(),
last_seen_at: None,
pinned: false,
@ -1978,7 +2019,6 @@ impl RemoteWorkerRuntime {
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: runtime_worker_can_accept_input(true, detail.status, &detail.execution),
can_stop: runtime_worker_can_stop(true, detail.status, &detail.execution),
can_spawn_followup: false,
},
@ -2038,6 +2078,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
limit,
true,
response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
),
diagnostics: Vec::new(),
},
@ -2071,7 +2113,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false),
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
diagnostics: Vec::new(),
}],
Vec::new(),
@ -2102,7 +2144,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
worker: Some(self.map_worker_detail(response.worker)),
diagnostics: Vec::new(),
},
Err(diagnostic) if diagnostic.code == "remote_worker_not_found" => WorkerLookupResult {
Err(diagnostic) if diagnostic.code == "worker_not_found" => WorkerLookupResult {
worker: None,
diagnostics: Vec::new(),
},
@ -2198,11 +2240,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
let profile_source = match default_profile_source_archive_http_source(
&profile,
&self.workspace_id,
runtime_id.as_ref(),
Some(self.runtime_id.as_str()),
&self.resource_broker,
&self.backend_base_url,
) {
@ -2316,6 +2357,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
match self
.delete_json::<RuntimeHttpWorkerDeleteResponse>(&format!("/v1/workers/{worker_id}"))
{
Ok(response) => WorkerDeleteResult {
state: WorkerOperationState::Accepted,
runtime_id: self.runtime_id.clone(),
worker_id: response.worker.worker_id.to_string(),
deleted: response.worker.deleted,
diagnostics: Vec::new(),
},
Err(diagnostic) => WorkerDeleteResult {
state: WorkerOperationState::Rejected,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
deleted: false,
diagnostics: vec![diagnostic],
},
}
}
fn observation_source(
&self,
worker_id: &str,
@ -2365,7 +2427,6 @@ fn embedded_runtime_capabilities(
can_get_worker: available,
can_spawn_worker: available,
can_stop_worker: available && execution_enabled,
can_accept_input: available && execution_enabled,
has_workspace_fs: false,
has_shell: false,
has_git: false,
@ -2414,18 +2475,6 @@ fn embedded_spawn_execution_failure_diagnostic(
))
}
fn runtime_worker_can_accept_input(
execution_enabled: bool,
status: EmbeddedWorkerStatus,
execution: &WorkerExecutionStatus,
) -> bool {
execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& execution.run_state == WorkerExecutionRunState::Idle
&& !execution_last_result_blocks_control(execution)
}
fn runtime_worker_can_stop(
execution_enabled: bool,
status: EmbeddedWorkerStatus,
@ -2532,7 +2581,7 @@ fn default_profile_source_archive_source(
fn default_profile_source_archive_http_source(
profile: &ProfileSelector,
workspace_id: &str,
runtime_id: Option<&EmbeddedRuntimeId>,
runtime_id: Option<&str>,
resource_broker: &BackendResourceBroker,
backend_base_url: &str,
) -> Result<ProfileSourceArchiveSource, String> {
@ -2570,7 +2619,7 @@ enum ProfileSourceArchiveTransport {
fn default_embedded_config_bundle(
profile: &ProfileSelector,
workspace_id: &str,
runtime_id: Option<&EmbeddedRuntimeId>,
runtime_id: Option<&str>,
resource_broker: &BackendResourceBroker,
archive_transport: ProfileSourceArchiveTransport,
) -> Result<ConfigBundle, String> {
@ -2778,17 +2827,11 @@ fn remote_lifecycle_rejected(
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
match error {
EmbeddedRuntimeError::RuntimeStopped { .. } => diagnostic(
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
"embedded_runtime_stopped",
DiagnosticSeverity::Warning,
"Embedded Runtime is stopped".to_string(),
),
EmbeddedRuntimeError::WrongRuntime { .. }
| EmbeddedRuntimeError::WrongRuntimeCursor { .. } => diagnostic(
"embedded_runtime_wrong_identity",
DiagnosticSeverity::Warning,
"Embedded Runtime rejected a worker/runtime identity mismatch".to_string(),
),
EmbeddedRuntimeError::WorkerNotFound { .. } => diagnostic(
"embedded_worker_not_found",
DiagnosticSeverity::Warning,
@ -2817,6 +2860,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning,
error.to_string(),
),
EmbeddedRuntimeError::WorkingDirectory(workdir_diagnostic) => diagnostic(
workdir_diagnostic.code.clone(),
DiagnosticSeverity::Warning,
workdir_diagnostic.message.clone(),
),
EmbeddedRuntimeError::InvalidRequest(_)
| EmbeddedRuntimeError::ConfigBundleMissing { .. }
| EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. }
@ -2877,6 +2925,8 @@ fn remote_runtime_capabilities(
limit: usize,
available: bool,
worker_creation_available: bool,
os: impl Into<String>,
arch: impl Into<String>,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
@ -2884,7 +2934,6 @@ fn remote_runtime_capabilities(
can_get_worker: available,
can_spawn_worker: available && worker_creation_available,
can_stop_worker: available,
can_accept_input: available && worker_creation_available,
has_workspace_fs: false,
has_shell: false,
has_git: false,
@ -2892,8 +2941,8 @@ fn remote_runtime_capabilities(
supports_backend_internal_tools: false,
workspace_scope: "remote_runtime_backend_private".to_string(),
max_workers: limit,
os: "remote".to_string(),
arch: "remote".to_string(),
os: os.into(),
arch: arch.into(),
}
}
@ -2945,7 +2994,8 @@ fn remote_http_status_diagnostic(
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
("remote_runtime_auth_failed", DiagnosticSeverity::Error)
}
StatusCode::NOT_FOUND => ("remote_worker_not_found", DiagnosticSeverity::Warning),
_ if error.is_some() => (remote_code, DiagnosticSeverity::Warning),
StatusCode::NOT_FOUND => ("remote_runtime_not_found", DiagnosticSeverity::Warning),
StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => {
("remote_runtime_unsupported", DiagnosticSeverity::Warning)
}
@ -3104,7 +3154,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
identity: "unsupported".to_string(),
},
state: "unsupported".to_string(),
status: "Worker runtime control is not wired yet".to_string(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@ -3113,7 +3162,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
display_hint: "unsupported".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: false,
can_stop: false,
can_spawn_followup: false,
},
@ -3153,7 +3201,7 @@ mod tests {
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
let runtime_id = "runtime-test";
for selector in [
ProfileSelector::RuntimeDefault,
ProfileSelector::Builtin("builtin:companion".to_string()),
@ -3165,7 +3213,7 @@ mod tests {
let bundle = default_embedded_config_bundle(
&selector,
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
&broker,
ProfileSourceArchiveTransport::BackendResourceHandle,
)
@ -3176,7 +3224,7 @@ mod tests {
.fetch_profile_source_archive(
worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.as_str().to_string(),
runtime_id: runtime_id.to_string(),
worker_id: None,
audit_correlation_id: handle.audit_correlation_id.clone(),
},
@ -3204,11 +3252,11 @@ mod tests {
fn remote_default_bundle_inlines_profile_archive_for_standalone_runtime() {
let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
let runtime_id = "remote:test";
let bundle = default_embedded_config_bundle(
&ProfileSelector::Builtin("builtin:coder".to_string()),
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
&broker,
ProfileSourceArchiveTransport::Inline,
)
@ -3229,11 +3277,11 @@ mod tests {
#[test]
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
let runtime_id = "remote:test";
let source = default_profile_source_archive_http_source(
&ProfileSelector::Builtin("builtin:coder".to_string()),
"workspace-actual",
Some(&runtime_id),
Some(runtime_id),
&broker,
"http://127.0.0.1:8787/",
)
@ -3254,12 +3302,12 @@ mod tests {
#[test]
fn embedded_archive_rejects_unknown_selectors() {
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
let runtime_id = "runtime-test";
assert!(
default_embedded_config_bundle(
&ProfileSelector::Builtin("builtin:missing".to_string()),
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
&broker,
ProfileSourceArchiveTransport::BackendResourceHandle,
)
@ -3269,7 +3317,7 @@ mod tests {
default_embedded_config_bundle(
&ProfileSelector::Named("custom".to_string()),
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
&broker,
ProfileSourceArchiveTransport::BackendResourceHandle,
)
@ -3431,8 +3479,7 @@ mod tests {
visibility: "opaque".to_string(),
identity: host_id.to_string(),
},
state: "running".to_string(),
status: "available".to_string(),
state: "available".to_string(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@ -3441,7 +3488,6 @@ mod tests {
display_hint: "test fixture".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: false,
can_stop: false,
can_spawn_followup: false,
},
@ -3471,7 +3517,6 @@ mod tests {
can_get_worker: true,
can_spawn_worker: false,
can_stop_worker: false,
can_accept_input: false,
has_workspace_fs: false,
has_shell: false,
has_git: false,
@ -3569,18 +3614,18 @@ mod tests {
Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing"
));
let unknown_worker = registry.worker("runtime-a", "worker-missing").unwrap_err();
let unknown_worker = registry.worker("runtime-a", "999").unwrap_err();
assert_eq!(
unknown_worker,
RuntimeRegistryError::UnknownWorker {
runtime_id: "runtime-a".to_string(),
worker_id: "worker-missing".to_string(),
worker_id: "999".to_string(),
}
);
assert!(matches!(
unknown_worker.into_error(),
Error::UnknownWorker { runtime_id, worker_id }
if runtime_id == "runtime-a" && worker_id == "worker-missing"
if runtime_id == "runtime-a" && worker_id == "999"
));
}
@ -3655,7 +3700,6 @@ mod tests {
let spawned = runtime.spawn_worker(embedded_spawn_request());
assert_eq!(spawned.state, WorkerOperationState::Accepted);
let worker = spawned.worker.expect("created embedded worker");
assert!(worker.capabilities.can_accept_input);
assert!(worker.capabilities.can_stop);
let input = runtime.send_input(
@ -3673,8 +3717,7 @@ mod tests {
.worker(&worker.worker_id)
.worker
.expect("worker detail");
if detail.status == "idle" {
assert!(detail.capabilities.can_accept_input);
if detail.state == "idle" {
break;
}
assert!(
@ -3707,7 +3750,6 @@ mod tests {
);
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker);
assert!(embedded_summary.capabilities.can_accept_input);
let spawned = registry
.spawn_worker(
@ -3743,8 +3785,6 @@ mod tests {
assert_eq!(worker.workspace.identity, "runtime_registry_worker");
assert_eq!(worker.implementation.kind, "embedded_worker_runtime");
assert_eq!(worker.profile.as_deref(), Some("builtin:coder"));
assert!(worker.capabilities.can_accept_input);
let input = registry
.send_input(
EMBEDDED_RUNTIME_ID,
@ -3888,7 +3928,7 @@ mod tests {
#[test]
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
let worker_json = worker_json("remote:primary", "worker-remote-1");
let worker_json = worker_json("remote:primary", "1");
let (base_url, server) = serve_mock_http(vec![
mock_response(
"GET",
@ -3899,19 +3939,19 @@ mod tests {
),
mock_response(
"GET",
"/v1/workers/worker-remote-1",
"/v1/workers/1",
true,
200,
json!({ "worker": worker_json.clone() }).to_string(),
),
mock_response(
"POST",
"/v1/workers/worker-remote-1/input",
"/v1/workers/1/input",
true,
200,
json!({
"ack": {
"worker_ref": { "runtime_id": "remote:primary", "worker_id": "worker-remote-1" },
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
"status": "running",
"event_id": 8
}
@ -3936,24 +3976,20 @@ mod tests {
);
let observation = registry
.observation_source("remote:primary", "worker-remote-1")
.observation_source("remote:primary", "1")
.expect("remote runtime exposes backend-owned WS observation source");
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
else {
panic!("remote runtime should expose a remote WS observation source");
};
assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
assert!(
observation
.endpoint
.ends_with("/v1/workers/worker-remote-1/events/ws")
);
assert!(observation.endpoint.ends_with("/v1/workers/1/events/ws"));
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 1);
assert_eq!(workers.items[0].runtime_id, "remote:primary");
assert_eq!(workers.items[0].worker_id, "worker-remote-1");
assert_eq!(workers.items[0].worker_id, "1");
assert_eq!(
workers.items[0].implementation.kind,
"remote_worker_runtime"
@ -3962,13 +3998,12 @@ mod tests {
workers.items[0].workspace.identity,
"runtime_registry_worker"
);
assert!(workers.items[0].capabilities.can_accept_input);
assert!(workers.items[0].capabilities.can_stop);
let input = registry
.send_input(
"remote:primary",
"worker-remote-1",
"1",
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello remote".to_string(),
@ -4004,28 +4039,28 @@ mod tests {
"workers": [
worker_json_with_execution(
"remote:primary",
"worker-stale",
"1",
"stale",
"unconnected",
None,
),
worker_json_with_execution(
"remote:primary",
"worker-unconnected",
"2",
"unconnected",
"unconnected",
None,
),
worker_json_with_execution(
"remote:primary",
"worker-rejected",
"3",
"connected",
"rejected",
Some("rejected"),
),
worker_json_with_execution(
"remote:primary",
"worker-errored",
"4",
"connected",
"errored",
Some("errored"),
@ -4036,13 +4071,13 @@ mod tests {
),
mock_response(
"GET",
"/v1/workers/worker-stale",
"/v1/workers/1",
true,
200,
json!({
"worker": worker_json_with_execution(
"remote:primary",
"worker-stale",
"1",
"stale",
"unconnected",
None,
@ -4067,26 +4102,20 @@ mod tests {
let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 4);
for worker in &workers.items {
assert!(
!worker.capabilities.can_accept_input,
"{} should not be input-capable",
worker.worker_id
);
assert!(
!worker.capabilities.can_stop,
"{} should not be stoppable",
worker.worker_id
);
}
assert_eq!(workers.items[0].status, "stale");
assert_eq!(workers.items[1].status, "unconnected");
assert_eq!(workers.items[2].status, "rejected");
assert_eq!(workers.items[3].status, "errored");
assert_eq!(workers.items[0].state, "stale");
assert_eq!(workers.items[1].state, "unconnected");
assert_eq!(workers.items[2].state, "rejected");
assert_eq!(workers.items[3].state, "errored");
let stale_detail = registry.worker("remote:primary", "worker-stale").unwrap();
assert!(!stale_detail.capabilities.can_accept_input);
let stale_detail = registry.worker("remote:primary", "1").unwrap();
assert!(!stale_detail.capabilities.can_stop);
assert_eq!(stale_detail.status, "stale");
assert_eq!(stale_detail.state, "stale");
server.join().expect("mock remote server finished");
}
@ -4169,7 +4198,7 @@ mod tests {
fn remote_runtime_auth_errors_map_to_typed_backend_error() {
let (base_url, server) = serve_mock_http(vec![mock_response(
"GET",
"/v1/workers/worker-missing",
"/v1/workers/999",
true,
401,
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
@ -4190,7 +4219,7 @@ mod tests {
);
let error = registry
.worker("remote:primary", "worker-missing")
.worker("remote:primary", "999")
.expect_err("auth failure is a backend operation error");
assert!(matches!(
error,
@ -4285,6 +4314,7 @@ mod tests {
"message": format!("{outcome} result")
})
});
let worker_id = worker_id.parse::<u64>().unwrap();
json!({
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
"runtime_id": runtime_id,

View File

@ -3,7 +3,7 @@ use chrono::{Duration, Utc};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use worker_runtime::identity::{RuntimeId, WorkerId};
use worker_runtime::identity::WorkerId;
use worker_runtime::profile_archive::ProfileSourceArchive;
use worker_runtime::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
@ -29,7 +29,7 @@ impl BackendResourceBroker {
pub fn issue_profile_source_archive_handle(
&self,
workspace_id: impl Into<String>,
runtime_id: Option<&RuntimeId>,
runtime_id: Option<&str>,
worker_id: Option<&WorkerId>,
archive: ProfileSourceArchive,
) -> BackendResourceHandle {
@ -41,8 +41,8 @@ impl BackendResourceBroker {
kind: BackendResourceKind::ProfileSourceArchive,
workspace_id: workspace_id.clone(),
scope_id: Some("workspace-profile-source".to_string()),
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()),
runtime_id: runtime_id.map(|id| id.to_string()),
worker_id: worker_id.map(|id| id.to_string()),
resource_id: archive.reference.id.clone(),
digest: archive.reference.digest.clone(),
operation: BackendResourceOperation::FetchArchive,
@ -57,8 +57,8 @@ impl BackendResourceBroker {
profile_source_graph: Some(archive.reference.source_graph.clone()),
};
let stored = StoredResource {
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()),
runtime_id: runtime_id.map(|id| id.to_string()),
worker_id: worker_id.map(|id| id.to_string()),
handle: handle.clone(),
archive,
};
@ -167,7 +167,7 @@ fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendReso
mod tests {
use super::*;
use std::collections::BTreeMap;
use worker_runtime::identity::{RuntimeId, WorkerId};
use worker_runtime::identity::WorkerId;
use worker_runtime::profile_archive::{
ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex,
};
@ -214,31 +214,31 @@ mod tests {
fn request(
handle: BackendResourceHandle,
runtime_id: &RuntimeId,
runtime_id: &str,
worker_id: Option<&WorkerId>,
) -> BackendResourceFetchRequest {
BackendResourceFetchRequest {
audit_correlation_id: handle.audit_correlation_id.clone(),
handle,
runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()),
runtime_id: runtime_id.to_string(),
worker_id: worker_id.map(|id| id.to_string()),
}
}
#[test]
fn broker_issues_and_verifies_profile_source_archive_handles() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let runtime_id = "runtime-test";
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
None,
archive(),
);
let response = broker
.fetch_profile_source_archive(BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.as_str().to_string(),
runtime_id: runtime_id.to_string(),
worker_id: None,
audit_correlation_id: handle.audit_correlation_id.clone(),
})
@ -250,19 +250,15 @@ mod tests {
#[test]
fn broker_rejects_runtime_mismatch() {
let broker = BackendResourceBroker::default();
let runtime_a = RuntimeId::new("runtime-a").unwrap();
let runtime_a = "runtime-a";
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_a),
Some(runtime_a),
None,
archive(),
);
let err = broker
.fetch_profile_source_archive(request(
handle,
&RuntimeId::new("runtime-b").unwrap(),
None,
))
.fetch_profile_source_archive(request(handle, "runtime-b", None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
@ -270,12 +266,12 @@ mod tests {
#[test]
fn broker_rejects_worker_mismatch() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let worker_a = WorkerId::new("worker-a").unwrap();
let worker_b = WorkerId::new("worker-b").unwrap();
let runtime_id = "runtime-test";
let worker_a = WorkerId::new(1);
let worker_b = WorkerId::new(2);
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
Some(&worker_a),
archive(),
);
@ -288,10 +284,10 @@ mod tests {
#[test]
fn broker_rejects_expiry_extension_from_request_handle() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let runtime_id = "runtime-test";
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
None,
archive(),
);
@ -314,10 +310,10 @@ mod tests {
#[test]
fn broker_rejects_policy_tampered_request_handle() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let runtime_id = "runtime-test";
let mut handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
None,
archive(),
);
@ -331,11 +327,11 @@ mod tests {
#[test]
fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let runtime_id = "runtime-test";
let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize);
let mut handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(runtime_id),
None,
archive,
);

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,16 @@ const MIGRATIONS: &[Migration] = &[
name: "remove durable worker lifecycle state",
apply: remove_worker_registry_legacy_live_state_column,
},
Migration {
version: 5,
name: "use composite worker registry keys",
apply: use_composite_worker_registry_keys,
},
Migration {
version: 6,
name: "add workdir runtime observation states",
apply: add_workdir_runtime_observation_states,
},
];
struct Migration {
@ -57,10 +67,8 @@ pub struct WorkspaceRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerRegistryRecord {
pub workspace_id: String,
/// Backend-owned archival Worker id. In v0 it is derived from runtime_id + runtime_worker_id.
pub worker_id: String,
pub runtime_id: String,
pub runtime_worker_id: String,
pub runtime_worker_id: u64,
pub display_name: String,
pub profile: Option<String>,
/// Retention state is explicit so `pinned` can be represented before prune exists.
@ -92,7 +100,8 @@ pub struct WorkdirRegistryRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerWorkdirLinkRecord {
pub workspace_id: String,
pub worker_id: String,
pub runtime_id: String,
pub runtime_worker_id: u64,
pub workdir_id: String,
pub role: String,
pub linked_at: String,
@ -107,15 +116,10 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
fn get_worker_registry(
&self,
workspace_id: &str,
worker_id: &str,
) -> Result<Option<WorkerRegistryRecord>>;
fn get_worker_registry_by_runtime(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: &str,
runtime_worker_id: u64,
) -> Result<Option<WorkerRegistryRecord>>;
fn list_worker_registry(
&self,
@ -125,11 +129,17 @@ pub trait ControlPlaneStore: Send + Sync {
fn update_worker_retention(
&self,
workspace_id: &str,
worker_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
retention_state: &str,
updated_at: &str,
) -> Result<bool>;
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool>;
fn delete_worker_registry(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<bool>;
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
fn get_workdir_registry(
@ -153,7 +163,8 @@ pub trait ControlPlaneStore: Send + Sync {
fn list_worker_workdir_links(
&self,
workspace_id: &str,
worker_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_workdir_worker_links(
&self,
@ -247,13 +258,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO worker_registry (
workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile,
workspace_id, runtime_id, runtime_worker_id, display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref,
diagnostics_ref, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(workspace_id, worker_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
runtime_worker_id = excluded.runtime_worker_id,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(workspace_id, runtime_id, runtime_worker_id) DO UPDATE SET
display_name = excluded.display_name,
profile = excluded.profile,
retention_state = CASE
@ -268,7 +277,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.worker_id,
record.runtime_id,
record.runtime_worker_id,
record.display_name,
@ -287,26 +295,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn get_worker_registry(
&self,
workspace_id: &str,
worker_id: &str,
) -> Result<Option<WorkerRegistryRecord>> {
self.with_conn(|conn| {
conn.query_row(
worker_registry_select_sql("WHERE workspace_id = ?1 AND worker_id = ?2").as_str(),
params![workspace_id, worker_id],
read_worker_registry_record,
)
.optional()
.map_err(Error::from)
})
}
fn get_worker_registry_by_runtime(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: &str,
runtime_worker_id: u64,
) -> Result<Option<WorkerRegistryRecord>> {
self.with_conn(|conn| {
conn.query_row(
@ -344,26 +336,38 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn update_worker_retention(
&self,
workspace_id: &str,
worker_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
retention_state: &str,
updated_at: &str,
) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
r#"UPDATE worker_registry
SET retention_state = ?3, updated_at = ?4
WHERE workspace_id = ?1 AND worker_id = ?2"#,
params![workspace_id, worker_id, retention_state, updated_at],
SET retention_state = ?4, updated_at = ?5
WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3"#,
params![
workspace_id,
runtime_id,
runtime_worker_id,
retention_state,
updated_at
],
)?;
Ok(changed > 0)
})
}
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool> {
fn delete_worker_registry(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2",
params![workspace_id, worker_id],
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3",
params![workspace_id, runtime_id, runtime_worker_id],
)?;
Ok(changed > 0)
})
@ -468,14 +472,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(workspace_id, runtime_id, runtime_worker_id, workdir_id, role) DO UPDATE SET
linked_at = excluded.linked_at,
unlinked_at = excluded.unlinked_at"#,
params![
record.workspace_id,
record.worker_id,
record.runtime_id,
record.runtime_worker_id,
record.workdir_id,
record.role,
record.linked_at,
@ -489,17 +494,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn list_worker_workdir_links(
&self,
workspace_id: &str,
worker_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL
WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
)?;
let rows = stmt.query_map(
params![workspace_id, worker_id],
params![workspace_id, runtime_id, runtime_worker_id],
read_worker_workdir_link_record,
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
@ -514,7 +520,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@ -534,17 +540,18 @@ fn read_worker_workdir_link_record(
) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
Ok(WorkerWorkdirLinkRecord {
workspace_id: row.get(0)?,
worker_id: row.get(1)?,
workdir_id: row.get(2)?,
role: row.get(3)?,
linked_at: row.get(4)?,
unlinked_at: row.get(5)?,
runtime_id: row.get(1)?,
runtime_worker_id: row.get(2)?,
workdir_id: row.get(3)?,
role: row.get(4)?,
linked_at: row.get(5)?,
unlinked_at: row.get(6)?,
})
}
fn worker_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \
"SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
created_at, updated_at FROM worker_registry {where_clause}"
)
@ -553,18 +560,17 @@ fn worker_registry_select_sql(where_clause: &str) -> String {
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
Ok(WorkerRegistryRecord {
workspace_id: row.get(0)?,
worker_id: row.get(1)?,
runtime_id: row.get(2)?,
runtime_worker_id: row.get(3)?,
display_name: row.get(4)?,
profile: row.get(5)?,
retention_state: row.get(6)?,
transcript_ref: row.get(7)?,
session_ref: row.get(8)?,
summary_ref: row.get(9)?,
diagnostics_ref: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
runtime_id: row.get(1)?,
runtime_worker_id: row.get(2)?,
display_name: row.get(3)?,
profile: row.get(4)?,
retention_state: row.get(5)?,
transcript_ref: row.get(6)?,
session_ref: row.get(7)?,
summary_ref: row.get(8)?,
diagnostics_ref: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
}
@ -599,9 +605,8 @@ fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> {
r#"
CREATE TABLE IF NOT EXISTS worker_registry (
workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL,
profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
@ -611,8 +616,7 @@ CREATE TABLE IF NOT EXISTS worker_registry (
diagnostics_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, worker_id),
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
@ -623,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
repository_id TEXT NOT NULL,
selector TEXT,
resolved_commit TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'missing', 'removed', 'failed')),
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL,
@ -634,13 +638,14 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
CREATE TABLE IF NOT EXISTS worker_workdir_links (
workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
workdir_id TEXT NOT NULL,
role TEXT NOT NULL,
linked_at TEXT NOT NULL,
unlinked_at TEXT,
PRIMARY KEY (workspace_id, worker_id, workdir_id, role),
FOREIGN KEY (workspace_id, worker_id) REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE,
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role),
FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
);
@ -649,7 +654,7 @@ CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
ON workdir_registry(workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC);
ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC);
"#,
)?;
Ok(())
@ -802,7 +807,7 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL,
profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
@ -835,6 +840,142 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
Ok(())
}
fn use_composite_worker_registry_keys(conn: &Connection) -> Result<()> {
if !table_exists(conn, "worker_registry")?
|| !table_columns(conn, "worker_registry")?
.iter()
.any(|column| column == "worker_id")
{
return Ok(());
}
conn.execute_batch(
r#"
CREATE TABLE worker_registry_v5 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL,
profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
transcript_ref TEXT,
session_ref TEXT,
summary_ref TEXT,
diagnostics_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO worker_registry_v5 (
workspace_id, runtime_id, runtime_worker_id, display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref,
created_at, updated_at
)
SELECT
workspace_id,
runtime_id,
CASE
WHEN typeof(runtime_worker_id) = 'integer' THEN runtime_worker_id
WHEN runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(runtime_worker_id, 8) AS INTEGER)
WHEN runtime_worker_id GLOB '[0-9]*' THEN CAST(runtime_worker_id AS INTEGER)
ELSE rowid
END,
display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref,
created_at, updated_at
FROM worker_registry;
CREATE TABLE worker_workdir_links_v5 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
workdir_id TEXT NOT NULL,
role TEXT NOT NULL,
linked_at TEXT NOT NULL,
unlinked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role),
FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry_v5(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO worker_workdir_links_v5 (
workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
)
SELECT
links.workspace_id,
registry.runtime_id,
CASE
WHEN typeof(registry.runtime_worker_id) = 'integer' THEN registry.runtime_worker_id
WHEN registry.runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(registry.runtime_worker_id, 8) AS INTEGER)
WHEN registry.runtime_worker_id GLOB '[0-9]*' THEN CAST(registry.runtime_worker_id AS INTEGER)
ELSE registry.rowid
END,
links.workdir_id,
links.role,
links.linked_at,
links.unlinked_at
FROM worker_workdir_links AS links
JOIN worker_registry AS registry
ON registry.workspace_id = links.workspace_id
AND registry.worker_id = links.worker_id;
DROP TABLE worker_workdir_links;
DROP TABLE worker_registry;
ALTER TABLE worker_registry_v5 RENAME TO worker_registry;
ALTER TABLE worker_workdir_links_v5 RENAME TO worker_workdir_links;
CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
ON worker_registry(workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC);
"#,
)?;
Ok(())
}
fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
if !table_exists(conn, "workdir_registry")? {
return Ok(());
}
conn.execute_batch(
r#"
CREATE TABLE workdir_registry_v6 (
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
selector TEXT,
resolved_commit TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, workdir_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO workdir_registry_v6 (
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
materialization_status, cleanliness, management_kind, created_at, updated_at
)
SELECT
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
CASE materialization_status
WHEN 'missing' THEN 'not_found'
WHEN 'removed' THEN 'not_found'
ELSE materialization_status
END,
cleanliness, management_kind, created_at, updated_at
FROM workdir_registry;
DROP TABLE workdir_registry;
ALTER TABLE workdir_registry_v6 RENAME TO workdir_registry;
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
ON workdir_registry(workspace_id, updated_at DESC);
"#,
)?;
Ok(())
}
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
@ -1058,7 +1199,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 4);
assert_eq!(store.schema_version().await.unwrap(), 6);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
@ -1070,7 +1211,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 4);
assert_eq!(reopened.schema_version().await.unwrap(), 6);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@ -1190,7 +1331,6 @@ mod tests {
"worker_registry",
[
"workspace_id",
"worker_id",
"runtime_id",
"runtime_worker_id",
"display_name",
@ -1280,7 +1420,7 @@ mod tests {
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 4);
assert_eq!(store.schema_version().await.unwrap(), 6);
store
.with_conn(|conn| {
@ -1381,13 +1521,12 @@ mod tests {
let worker = WorkerRegistryRecord {
workspace_id: "local-dev".to_string(),
worker_id: "embedded/browser-1".to_string(),
runtime_id: "embedded".to_string(),
runtime_worker_id: "browser-1".to_string(),
runtime_worker_id: 1,
display_name: "Browser 1".to_string(),
profile: Some("builtin:companion".to_string()),
retention_state: "pinned".to_string(),
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()),
transcript_ref: Some("runtime://embedded/workers/00000001/transcript".to_string()),
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
@ -1404,12 +1543,12 @@ mod tests {
let workdir = WorkdirRegistryRecord {
workspace_id: "local-dev".to_string(),
workdir_id: "backend-2-repo".to_string(),
workdir_id: "0000019a00000000001".to_string(),
runtime_id: "embedded".to_string(),
repository_id: "repo".to_string(),
selector: Some("develop".to_string()),
resolved_commit: Some("abcdef".to_string()),
materialization_status: "removed".to_string(),
materialization_status: "not_found".to_string(),
cleanliness: "clean".to_string(),
management_kind: "backend_managed".to_string(),
created_at: "2".to_string(),
@ -1433,7 +1572,8 @@ mod tests {
let link = WorkerWorkdirLinkRecord {
workspace_id: "local-dev".to_string(),
worker_id: worker.worker_id.clone(),
runtime_id: worker.runtime_id.clone(),
runtime_worker_id: worker.runtime_worker_id.clone(),
workdir_id: workdir.workdir_id.clone(),
role: "primary_cwd".to_string(),
linked_at: "4".to_string(),
@ -1443,13 +1583,13 @@ mod tests {
assert_eq!(
store
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1")
.get_worker_registry("local-dev", "embedded", 1)
.unwrap(),
Some(expected_worker.clone())
);
assert_eq!(
store
.get_workdir_registry("local-dev", "backend-2-repo")
.get_workdir_registry("local-dev", "0000019a00000000001")
.unwrap(),
Some(workdir.clone())
);
@ -1465,7 +1605,7 @@ mod tests {
);
assert_eq!(
store
.list_worker_workdir_links("local-dev", "embedded/browser-1")
.list_worker_workdir_links("local-dev", "embedded", 1)
.unwrap(),
vec![link]
);

View File

@ -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 --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts 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 src/lib/workspace-sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts 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/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -436,6 +436,15 @@
gap: 2px;
}
.worker-nav-item.disabled {
cursor: default;
opacity: 0.62;
}
.worker-nav-item.disabled .item-title {
color: var(--text-muted);
}
.worker-title-row {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);

View File

@ -0,0 +1,130 @@
<script lang="ts">
import { dismissWorkspaceAlert, workspaceAlerts, type WorkspaceAlertLevel } from './store';
function label(level: WorkspaceAlertLevel): string {
switch (level) {
case 'success': return 'Success';
case 'info': return 'Info';
case 'warning': return 'Warning';
case 'error': return 'Error';
case 'system': return 'System';
case 'debug': return 'Debug';
}
}
</script>
<div class="workspace-alerts" aria-live="polite" aria-atomic="false">
{#each $workspaceAlerts as alert (alert.id)}
<article class={`workspace-alert ${alert.level}`} role={alert.level === 'error' ? 'alert' : 'status'}>
<div class="workspace-alert-icon" aria-hidden="true">
{#if alert.level === 'success'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></svg>
{:else if alert.level === 'info'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>
{:else if alert.level === 'warning' || alert.level === 'error'}
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></svg>
{:else if alert.level === 'system'}
<svg viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></svg>
{:else}
<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88" /><path d="M14.12 3.88 16 2" /><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" /><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" /><path d="M12 20v-9" /><path d="M6.53 9C4.6 8.8 3 7.1 3 5" /><path d="M6 13H2" /><path d="M3 21c0-2.1 1.7-3.9 3.8-4" /><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" /><path d="M22 13h-4" /><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" /></svg>
{/if}
</div>
<div class="workspace-alert-body">
<strong>{alert.title ?? label(alert.level)}</strong>
<p>{alert.message}</p>
</div>
<button type="button" class="workspace-alert-dismiss" aria-label="Dismiss alert" onclick={() => dismissWorkspaceAlert(alert.id)}>×</button>
</article>
{/each}
</div>
<style>
.workspace-alerts {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: grid;
gap: 0.65rem;
width: min(28rem, calc(100vw - 2rem));
pointer-events: none;
}
.workspace-alert {
--alert-color: var(--text);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: start;
padding: 0.8rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--alert-color) 45%, var(--line));
border-radius: 0.85rem;
background: color-mix(in srgb, var(--alert-color) 12%, var(--bg-raised) 88%);
color: var(--text);
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
pointer-events: auto;
}
.workspace-alert.success { --alert-color: oklch(70% 0.18 145); }
.workspace-alert.info { --alert-color: oklch(68% 0.16 245); }
.workspace-alert.warning { --alert-color: oklch(78% 0.18 85); }
.workspace-alert.error { --alert-color: oklch(62% 0.2 30); }
.workspace-alert.system { --alert-color: oklch(96% 0.01 260); }
.workspace-alert.debug { --alert-color: oklch(68% 0.18 305); }
.workspace-alert-icon {
color: var(--alert-color);
margin-top: 0.1rem;
}
.workspace-alert-icon svg {
display: block;
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.workspace-alert-body {
min-width: 0;
}
.workspace-alert-body strong {
display: block;
margin-bottom: 0.15rem;
color: var(--alert-color);
font-size: 0.82rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.workspace-alert-body p {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.9rem;
line-height: 1.35;
}
.workspace-alert-dismiss {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
}
.workspace-alert-dismiss:hover,
.workspace-alert-dismiss:focus-visible {
background: color-mix(in srgb, var(--alert-color) 18%, transparent);
color: var(--text);
}
</style>

View File

@ -0,0 +1,61 @@
export type WorkspaceAlertLevel = "success" | "info" | "warning" | "error" | "system" | "debug";
export type WorkspaceAlert = {
id: string;
level: WorkspaceAlertLevel;
title?: string;
message: string;
createdAt: number;
};
type AlertSubscriber = (alerts: WorkspaceAlert[]) => void;
let sequence = 0;
let alerts: WorkspaceAlert[] = [];
const subscribers = new Set<AlertSubscriber>();
function emit(): void {
const snapshot = alerts.slice();
for (const subscriber of subscribers) subscriber(snapshot);
}
export const workspaceAlerts = {
subscribe(subscriber: AlertSubscriber): () => void {
subscribers.add(subscriber);
subscriber(alerts.slice());
return () => subscribers.delete(subscriber);
},
};
export function pushWorkspaceAlert(
level: WorkspaceAlertLevel,
message: string,
options: { title?: string; id?: string } = {},
): string {
const id = options.id ?? `${Date.now().toString(36)}-${(sequence++).toString(36)}`;
alerts = [
...alerts.filter((alert) => alert.id !== id),
{
id,
level,
title: options.title,
message,
createdAt: Date.now(),
},
].slice(-8);
emit();
return id;
}
export function dismissWorkspaceAlert(id: string): void {
const next = alerts.filter((alert) => alert.id !== id);
if (next.length === alerts.length) return;
alerts = next;
emit();
}
export function clearWorkspaceAlerts(): void {
if (alerts.length === 0) return;
alerts = [];
emit();
}

View File

@ -14,7 +14,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
);
const workersPage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/workers/+page.svelte", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/workers/+page.svelte",
import.meta.url,
),
);
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
@ -24,8 +27,8 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workspacePage.includes('href={`/w/${workspaceId}/runtimes`}') &&
workspacePage.includes('href={`/w/${workspaceId}/workers`}'),
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
"top workspace page should link to Runtimes and Workers pages",
);
assert(
@ -35,15 +38,16 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes("<table class=\"workers-table\">") &&
workersPage.includes("Open Console"),
"dedicated Workers page should expose a table and attach action per Worker",
workersPage.includes('<table class="workers-table">') &&
workersPage.includes('class="icon-action"') &&
workersPage.includes('Delete ${worker.label}'),
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
);
assert(
workersNav.includes('href={`/w/${workspaceId}/workers`}') &&
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
workersNav.includes("aria-current"),
"Workers sidebar should link to the Worker list page and target Console routes",
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
workersNav.includes("filter(canShowWorkerInSidebar)") &&
!workersNav.includes('aria-disabled="true"'),
"Workers sidebar should link to the Worker list page and omit registry-only Workers",
);
assert(
!sidebar.includes("CompanionNavSection") &&
@ -54,12 +58,17 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
const consolePage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
assert(
consolePage.includes("seenObservationEventIds") &&
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
consolePage.includes(
"rememberObservationEvent(frame.envelope.event_id)",
) &&
consolePage.includes("projectConsole(observedEvents.map") &&
!consolePage.includes("/transcript") &&
!consolePage.includes("WorkerTranscriptProjection"),
@ -69,7 +78,10 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
const editor = await Deno.readTextFile(
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
new URL(
"../workspace-settings/DecodalSourceEditor.svelte",
import.meta.url,
),
);
assert(
@ -89,18 +101,27 @@ Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
);
const runtimesPage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/runtimes/+page.svelte", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/runtimes/+page.svelte",
import.meta.url,
),
);
const workdirsPage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
import.meta.url,
),
);
const workdirsLoad = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
import.meta.url,
),
);
assert(
sidebar.includes("RuntimesNavSection") &&
runtimesNav.includes('href={runtimesHref}') &&
runtimesNav.includes("href={runtimesHref}") &&
runtimesNav.includes("/runtimes"),
"sidebar should expose Runtime management navigation",
);
@ -123,18 +144,21 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
);
const newWorkerPage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/workers/new/+page.svelte", import.meta.url),
new URL(
"./../../routes/w/[workspaceId]/workers/new/+page.svelte",
import.meta.url,
),
);
assert(
workersNav.includes('href={`/w/${workspaceId}/workers/new`}') &&
!workersNav.includes('worker-launch-form') &&
!workersNav.includes('createWorker()'),
workersNav.includes("href={`/w/${workspaceId}/workers/new`}") &&
!workersNav.includes("worker-launch-form") &&
!workersNav.includes("createWorker()"),
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
);
assert(
newWorkerPage.includes('worker-launch-form') &&
newWorkerPage.includes('buildBrowserCreateWorkerRequest') &&
newWorkerPage.includes("worker-launch-form") &&
newWorkerPage.includes("buildBrowserCreateWorkerRequest") &&
newWorkerPage.includes("/workers/launch-options"),
"New Worker page should own launch options and creation form behavior",
);

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import { canShowWorkerInSidebar } from './workers';
import type { ListResponse, Worker } from './types';
const MAX_VISIBLE_WORKERS = 6;
@ -48,7 +49,9 @@
throw new Error(`workers request failed (${response.status})`);
}
const payload = (await response.json()) as ListResponse<Worker>;
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
workers = Array.isArray(payload.items)
? payload.items.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS)
: [];
if (workers.length === 0) {
placeholder = 'No workers reported by the current API.';
}
@ -106,7 +109,7 @@
<span class="worker-task-title">-</span>
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
</span>
</a>

View File

@ -35,7 +35,6 @@ export type RuntimeCapabilities = {
can_get_worker: boolean;
can_spawn_worker: boolean;
can_stop_worker: boolean;
can_accept_input: boolean;
has_workspace_fs: boolean;
has_shell: boolean;
has_git: boolean;
@ -70,7 +69,6 @@ export type Host = {
};
export type WorkerCapabilities = {
can_accept_input: boolean;
can_stop: boolean;
can_spawn_followup: boolean;
};
@ -84,7 +82,6 @@ export type Worker = {
profile?: string | null;
workspace: { visibility: string; identity: string };
state: string;
status: string;
pinned?: boolean;
retention_state?: string;
last_seen_at?: string | null;
@ -123,12 +120,12 @@ export type WorkingDirectorySummary = {
repository_id: string;
requested_selector?: string | null;
materializer_kind: string;
dirty_state_policy: string;
resolved_commit: string;
resolved_tree?: string | null;
status: string;
cleanliness?: string | null;
primary_worker_id?: number | null;
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
cleanup_policy: string;
cleanup_target: {
kind: string;
working_directory_id: string;
@ -220,10 +217,6 @@ export type BrowserWorkingDirectoryCreateRequest = {
runtime_id: string;
repository_id: string;
selector?: string | null;
policy?: {
dirty_state?: "clean_point_only";
cleanup?: "manual_or_worker_stop";
};
};
export type WorkerLaunchOptionsResponse = {

View File

@ -51,10 +51,10 @@ const options: WorkerLaunchOptionsResponse = {
repository_id: "repo",
requested_selector: "HEAD",
materializer_kind: "local_git_worktree",
dirty_state_policy: "clean_point_only",
resolved_commit: "0123456789abcdef",
status: "active",
cleanup_policy: "manual_or_worker_stop",
cleanliness: "clean",
primary_worker_id: null,
cleanup_target: {
kind: "git_worktree",
working_directory_id: "wd-1-repo",

View File

@ -35,11 +35,16 @@ export function defaultWorkerLaunchForm(
const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0];
const preferredWorkingDirectory =
options?.working_directories.find((directory) =>
directory.status === "active"
) ??
options?.working_directories[0];
const availableWorkingDirectories = options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.cleanliness === "clean" &&
directory.primary_worker_id == null
) ?? [];
const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)
: preferredRuntime;
const workdirlessRuntime = selectedRuntime?.working_directory_required === false;
const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories[0];
const preferredRepository =
options?.repositories.find((repository) =>
repository.id === current.working_directory_repository_id
@ -54,7 +59,7 @@ export function defaultWorkerLaunchForm(
? current.profile
: preferredProfile?.id || "",
initial_text: current.initial_text,
working_directory_id: options?.working_directories.some(
working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some(
(directory) =>
directory.working_directory_id === current.working_directory_id,
)

View File

@ -0,0 +1,61 @@
import { canOpenWorkerConsole, canShowWorkerInSidebar } from "./workers.ts";
import type { Worker } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals<T>(actual: T, expected: T): void {
if (actual !== expected) {
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
}
function worker(overrides: Partial<Worker>): Worker {
return {
runtime_id: "arc",
worker_id: "1",
host_id: "host",
label: "worker-1",
role: null,
profile: null,
workspace: { visibility: "workspace", identity: "workspace" },
state: "running",
pinned: false,
retention_state: "normal",
last_seen_at: null,
implementation: {
kind: "runtime_worker",
display_hint: "Runtime Worker",
},
capabilities: {
can_stop: true,
can_spawn_followup: false,
},
working_directory: null,
diagnostics: [],
...overrides,
};
}
Deno.test("registry-only workers are not sidebar targets or console targets", () => {
const registryOnly = worker({
state: "missing",
implementation: {
kind: "backend_worker_registry",
display_hint: "Missing Worker",
},
capabilities: {
can_stop: false,
can_spawn_followup: false,
},
});
assertEquals(canShowWorkerInSidebar(registryOnly), false);
assertEquals(canOpenWorkerConsole(registryOnly), false);
});
Deno.test("live runtime workers are sidebar targets and console targets", () => {
const liveWorker = worker({ state: "running" });
assertEquals(canShowWorkerInSidebar(liveWorker), true);
assertEquals(canOpenWorkerConsole(liveWorker), true);
});

View File

@ -0,0 +1,9 @@
import type { Worker } from "./types";
export function canShowWorkerInSidebar(worker: Worker): boolean {
return worker.implementation.kind !== "backend_worker_registry";
}
export function canOpenWorkerConsole(worker: Worker): boolean {
return canShowWorkerInSidebar(worker);
}

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspaceAlerts from '$lib/workspace-alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import '../app.css';
import type { LayoutProps } from './$types';
@ -8,6 +9,8 @@
let sidebarCollapsed = $state(false);
</script>
<WorkspaceAlerts />
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
<WorkspaceSidebar
workspace={data.workspace}

View File

@ -77,10 +77,6 @@
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Scope</dt>
<dd>{host.capabilities.workspace_scope}</dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>

View File

@ -4,10 +4,6 @@
let { data }: PageProps = $props();
function runtimeScope(runtime: Runtime): string {
return runtime.capabilities.workspace_scope;
}
function runtimePlatform(runtime: Runtime): string {
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
}
@ -40,7 +36,6 @@
<th>Runtime</th>
<th>Kind</th>
<th>Status</th>
<th>Scope</th>
<th>Platform</th>
<th>Capacity</th>
<th>Workdirs</th>
@ -55,7 +50,6 @@
</td>
<td>{runtime.kind}</td>
<td>{runtime.status}</td>
<td>{runtimeScope(runtime)}</td>
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td>
<td>

View File

@ -1,19 +1,27 @@
<script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http';
import type { CleanupWorkdirCandidate, WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
import type {
CleanupWorkdirCandidate,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
WorkingDirectorySummary,
} from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let selectedCleanupTargets = $state(new Set<string>());
let selectedWorkerCleanupTargets = $state(new Set<string>());
let confirmedDirtyTargets = $state(new Set<string>());
let cleanupStatus = $state<string | null>(null);
let cleanupBusy = $state(false);
let cleanupCandidates = $derived(data.cleanupPlan?.workdirs ?? []);
let workerCleanupCandidates = $derived(data.cleanupPlan?.workers ?? []);
let cleanupBusyTarget = $state<string | null>(null);
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workdirs = $state<WorkingDirectorySummary[]>([]);
let runtimeLabel = $derived(
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
);
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
$effect(() => {
cleanupPlan = data.cleanupPlan ?? null;
workdirs = data.workdirs?.items ?? [];
});
function commitLabel(workdir: WorkingDirectorySummary): string {
return workdir.resolved_commit ? workdir.resolved_commit.slice(0, 12) : '—';
@ -23,39 +31,35 @@
return workdir.requested_selector ?? 'HEAD';
}
function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
if (candidate.action === 'workdir_dirty_discard') {
return candidate.cleanliness === 'dirty' ? 'Discard dirty workdir' : 'Discard unknown-state workdir';
}
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record';
return 'Clean up verified-clean workdir';
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
}
function toggleSelected(targetId: string): void {
const next = new Set(selectedCleanupTargets);
if (next.has(targetId)) next.delete(targetId);
else next.add(targetId);
selectedCleanupTargets = next;
function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null;
}
function toggleWorkerSelected(targetId: string): void {
const next = new Set(selectedWorkerCleanupTargets);
if (next.has(targetId)) next.delete(targetId);
else next.add(targetId);
selectedWorkerCleanupTargets = next;
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
if ('message' in payload && typeof payload.message === 'string') return payload.message;
if ('error' in payload) {
const error = payload.error;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message;
}
if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) {
const diagnostic = payload.diagnostics.find(
(entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string',
);
if (diagnostic) return diagnostic.message;
}
}
return fallback;
}
function toggleDirtyConfirmation(targetId: string): void {
const next = new Set(confirmedDirtyTargets);
if (next.has(targetId)) next.delete(targetId);
else next.add(targetId);
confirmedDirtyTargets = next;
}
async function executeCleanup(): Promise<void> {
if (!data.cleanupPlan || (selectedCleanupTargets.size === 0 && selectedWorkerCleanupTargets.size === 0)) return;
cleanupBusy = true;
cleanupStatus = null;
async function deleteWorkdir(workdir: WorkingDirectorySummary, candidate: CleanupWorkdirCandidate): Promise<void> {
if (!cleanupPlan || isDeleteDisabled(candidate)) return;
cleanupBusyTarget = candidate.target_id;
try {
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
@ -63,24 +67,32 @@
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expected_plan_revision: data.cleanupPlan.revision,
expected_plan_digest: data.cleanupPlan.digest,
worker_target_ids: Array.from(selectedWorkerCleanupTargets),
workdir_target_ids: Array.from(selectedCleanupTargets),
confirm_dirty_discard_target_ids: Array.from(confirmedDirtyTargets),
expected_plan_revision: cleanupPlan.revision,
expected_plan_digest: cleanupPlan.digest,
worker_target_ids: [],
workdir_target_ids: [candidate.target_id],
confirm_dirty_discard_target_ids: [],
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.message ?? payload?.error ?? response.statusText);
cleanupStatus = `Executed ${payload?.results?.length ?? 0} cleanup action(s). Refresh to see the latest plan.`;
selectedCleanupTargets = new Set();
selectedWorkerCleanupTargets = new Set();
confirmedDirtyTargets = new Set();
const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
if (payload && typeof payload === 'object' && 'plan_after' in payload) {
cleanupPlan = (payload as RuntimeCleanupExecutionResponse).plan_after;
}
const result = payload && typeof payload === 'object' && 'results' in payload
? (payload as RuntimeCleanupExecutionResponse).results.find((entry) => entry.target_id === candidate.target_id)
: undefined;
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Workdir');
}
workdirs = workdirs.filter((item) => item.working_directory_id !== workdir.working_directory_id);
} catch (error) {
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed';
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Workdir deletion failed', {
title: 'Workdir deletion failed',
});
} finally {
cleanupBusy = false;
cleanupBusyTarget = null;
}
}
</script>
@ -96,6 +108,7 @@
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
</div>
</header>
@ -103,71 +116,9 @@
<p class="section-state error">{data.workdirsError}</p>
{:else if !data.workdirs}
<p class="section-state">Loading workdirs…</p>
{:else if data.workdirs.items.length === 0}
{:else if workdirs.length === 0}
<p class="section-state">No workdirs are visible for this Runtime.</p>
{:else}
<section class="cleanup-panel">
<div>
<h2>Manual cleanup preview</h2>
<p class="muted">Select explicit Workdir targets. Raw Runtime paths are intentionally not shown.</p>
</div>
{#if data.cleanupPlanError}
<p class="section-state error">{data.cleanupPlanError}</p>
{:else if cleanupCandidates.length === 0 && workerCleanupCandidates.length === 0}
<p>No cleanup candidates.</p>
{:else}
<div class="cleanup-list">
{#each workerCleanupCandidates as candidate (candidate.target_id)}
<label class:blocked={!!candidate.blocking_reason}>
<input
type="checkbox"
checked={selectedWorkerCleanupTargets.has(candidate.target_id)}
disabled={!!candidate.blocking_reason}
onchange={() => toggleWorkerSelected(candidate.target_id)}
/>
<span>
<strong>Delete Worker registry row:</strong> <code>{candidate.runtime_worker_id}</code>
<small>{candidate.retention_state}; linked Workdirs {candidate.linked_workdir_ids.length}</small>
{#if candidate.blocking_reason}<small class="error">Blocked: {candidate.blocking_reason}</small>{/if}
</span>
</label>
{/each}
{#each cleanupCandidates as candidate (candidate.target_id)}
<label class:blocked={!!candidate.blocking_reason} class:dirty={candidate.action === 'workdir_dirty_discard'}>
<input
type="checkbox"
checked={selectedCleanupTargets.has(candidate.target_id)}
disabled={!!candidate.blocking_reason}
onchange={() => toggleSelected(candidate.target_id)}
/>
<span>
<strong>{cleanupLabel(candidate)}:</strong> <code>{candidate.workdir_id}</code>
<small>
file {candidate.file_status}; {candidate.cleanliness}; linked Workers {candidate.linked_worker_ids.length}
</small>
{#if candidate.blocking_reason}
<small class="error">Blocked: {candidate.blocking_reason}</small>
{:else if candidate.action === 'workdir_dirty_discard'}
<label class="confirm-dirty">
<input
type="checkbox"
checked={confirmedDirtyTargets.has(candidate.target_id)}
onchange={() => toggleDirtyConfirmation(candidate.target_id)}
/>
Confirm discard
</label>
{/if}
</span>
</label>
{/each}
</div>
<button type="button" onclick={executeCleanup} disabled={cleanupBusy || (selectedCleanupTargets.size === 0 && selectedWorkerCleanupTargets.size === 0)}>
{cleanupBusy ? 'Executing…' : 'Execute selected cleanup'}
</button>
{#if cleanupStatus}<p>{cleanupStatus}</p>{/if}
{/if}
</section>
<div class="table-wrap">
<table class="workdirs-table">
<thead>
@ -177,20 +128,39 @@
<th>Selector</th>
<th>Commit</th>
<th>Status</th>
<th>Policy</th>
<th>Cleanliness</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each data.workdirs.items as workdir}
{#each workdirs as workdir}
{@const cleanup = cleanupCandidate(workdir)}
<tr>
<td><code>{workdir.working_directory_id}</code></td>
<td>{workdir.repository_id}</td>
<td>{selectorLabel(workdir)}</td>
<td><code>{commitLabel(workdir)}</code></td>
<td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
<span>{workdir.dirty_state_policy}</span>
<small>{workdir.cleanup_policy}</small>
{#if cleanup}
<button
class="icon-action danger"
type="button"
disabled={isDeleteDisabled(cleanup)}
aria-label={`Delete ${workdir.working_directory_id}`}
title={cleanup.action === 'workdir_dirty_discard' ? 'Dirty Workdirs must be cleaned before deletion' : (cleanup.blocking_reason ?? cleanup.reason)}
onclick={() => deleteWorkdir(workdir, cleanup)}
>
{#if cleanupBusyTarget === cleanup.target_id}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{:else}
<span class="muted"></span>
{/if}
</td>
</tr>
{/each}
@ -199,3 +169,55 @@
</div>
{/if}
</section>
<style>
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: workdir-action-spin 0.8s linear infinite;
}
@keyframes workdir-action-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -10,7 +10,8 @@
ClientWorkerEventWsFrame,
Diagnostic,
Worker,
WorkerInputResult
WorkerInputResult,
PodProtocolEvent
} from '$lib/workspace-sidebar/types';
type Props = {
@ -32,6 +33,7 @@
}
let worker = $state<Worker | null>(null);
let liveWorkerState = $state<string | null>(null);
let workerError = $state<string | null>(null);
let draft = $state('');
let sending = $state(false);
@ -59,7 +61,9 @@
);
const lines = $derived(projection.lines);
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
const inputReady = $derived(workerState === 'idle');
const canSend = $derived(inputReady && draft.trim().length > 0 && !sending);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
@ -97,12 +101,15 @@
async function loadWorker(target: ConsoleTarget) {
workerError = null;
try {
worker = await getJson<Worker>(
const payload = await getJson<Worker>(
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
);
worker = payload;
liveWorkerState = payload.state;
} catch (error) {
workerError = error instanceof Error ? error.message : String(error);
worker = null;
liveWorkerState = null;
}
}
@ -132,7 +139,7 @@
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
if (!content || sending || !worker?.capabilities.can_accept_input) {
if (!content || sending || !inputReady) {
return;
}
@ -145,6 +152,7 @@
);
if (result.state === 'accepted') {
draft = '';
liveWorkerState = 'running';
} else {
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
}
@ -155,6 +163,18 @@
}
}
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
switch (event.event) {
case 'snapshot':
case 'status':
return event.data.status;
case 'shutdown':
return 'shutdown';
default:
return null;
}
}
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
if (!targetWorker) {
streamState = 'closed';
@ -182,6 +202,10 @@
if (!rememberObservationEvent(frame.envelope.event_id)) {
return;
}
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
if (observedState) {
liveWorkerState = observedState;
}
observedEvents = [
...observedEvents,
{
@ -304,6 +328,7 @@
$effect(() => {
const target = consoleTarget;
resetObservedEvents();
liveWorkerState = null;
streamDiagnostics = [];
advanceReloadToken();
void loadConsoleData(target);
@ -324,7 +349,7 @@
</div>
<div class="console-header-actions">
<div class="console-status-pill" class:warn={streamState !== 'open'}>
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
{workerState} · stream {streamState}
</div>
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
Details
@ -430,7 +455,6 @@
<details class="metadata-details">
<summary>Capabilities</summary>
<ul>
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
</ul>
@ -461,7 +485,7 @@
id="worker-console-message"
aria-label="Console input"
bind:value={draft}
disabled={!worker?.capabilities.can_accept_input || sending}
disabled={!inputReady || sending}
></textarea>
<div class="composer-actions">
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>

View File

@ -1,14 +1,66 @@
<script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { Worker } from '$lib/workspace-sidebar/types';
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types';
type WorkerActionKind = 'pin' | 'delete';
let { data }: PageProps = $props();
let retentionStatus = $state<string | null>(null);
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
$effect(() => {
cleanupPlans = data.cleanupPlans;
workers = data.workers?.items ?? [];
});
function workerKey(worker: Worker): string {
return `${worker.runtime_id}/${worker.worker_id}`;
}
function isActionBusy(worker: Worker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function actionsDisabled(): boolean {
return busyAction !== null;
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
if ('message' in payload && typeof payload.message === 'string') return payload.message;
if ('error' in payload) {
const error = payload.error;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message;
}
if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) {
const diagnostic = payload.diagnostics.find(
(entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string',
);
if (diagnostic) return diagnostic.message;
}
}
return fallback;
}
async function refreshCleanupPlan(runtimeId: string): Promise<void> {
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
if (!response.ok) return;
const plan = (await response.json()) as RuntimeCleanupPlanResponse;
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
}
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
retentionStatus = null;
if (busyAction) return;
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
try {
const response = await fetch(
workspaceApiPath(
data.workspaceId,
@ -18,16 +70,72 @@
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
retentionStatus = payload?.message ?? payload?.error ?? response.statusText;
pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
return;
}
worker.pinned = Boolean(payload?.pinned);
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
retentionStatus = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`;
await refreshCleanupPlan(worker.runtime_id);
} catch (error) {
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally {
busyAction = null;
}
}
function cleanupCandidate(worker: Worker): CleanupWorkerCandidate | undefined {
return cleanupPlans?.[worker.runtime_id]?.workers.find(
(candidate) => candidate.runtime_id === worker.runtime_id && candidate.runtime_worker_id === worker.worker_id,
);
}
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
if (!cleanupPlans?.[worker.runtime_id] || busyAction) return;
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
const plan = cleanupPlans[worker.runtime_id];
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expected_plan_revision: plan.revision,
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
if (payload && typeof payload === 'object' && 'plan_after' in payload) {
const execution = payload as RuntimeCleanupExecutionResponse;
cleanupPlans = { ...cleanupPlans, [worker.runtime_id]: execution.plan_after };
}
const result = payload && typeof payload === 'object' && 'results' in payload
? (payload as RuntimeCleanupExecutionResponse).results.find((entry) => entry.target_id === candidate.target_id)
: undefined;
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Worker');
}
workers = workers.filter(
(item) => !(item.runtime_id === worker.runtime_id && item.worker_id === worker.worker_id),
);
} catch (error) {
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
}
}
function workerStatus(worker: Worker): string {
return `${worker.state} · ${worker.status}`;
return worker.state;
}
function workerProfile(worker: Worker): string {
@ -52,8 +160,7 @@
<header class="workers-page-header">
<div>
<h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p>
{#if retentionStatus}<p>{retentionStatus}</p>{/if}
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
</div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
@ -62,7 +169,7 @@
<p class="section-state error">{data.workersError}</p>
{:else if !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if data.workers.items.length === 0}
{:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p>
{:else}
<div class="table-wrap workers-table-wrap">
@ -79,10 +186,17 @@
</tr>
</thead>
<tbody>
{#each data.workers.items as worker}
{#each workers as worker}
{@const cleanup = cleanupCandidate(worker)}
{@const canDelete = cleanup && !cleanup.blocking_reason}
{@const anyActionDisabled = actionsDisabled()}
<tr>
<td>
{#if canOpenWorkerConsole(worker)}
<a class="worker-title-link" href={workerConsoleHref(worker, data.workspaceId)}><strong>{worker.label}</strong></a>
{:else}
<strong>{worker.label}</strong>
{/if}
<small><code>{worker.worker_id}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
@ -91,10 +205,40 @@
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
<td>{workerDirectory(worker)}</td>
<td>
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
{worker.pinned ? 'Unpin' : 'Pin'}
<div class="worker-actions" aria-label={`Actions for ${worker.label}`}>
<button
class="icon-action"
type="button"
disabled={anyActionDisabled}
aria-label={worker.pinned ? `Unpin ${worker.label}` : `Pin ${worker.label}`}
title={worker.pinned ? 'Unpin' : 'Pin'}
onclick={() => setPinned(worker, !worker.pinned)}
>
{#if isActionBusy(worker, 'pin')}
<span class="spinner" aria-hidden="true"></span>
{:else if worker.pinned}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" /></svg>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></svg>
{/if}
</button>
{#if cleanup}
<button
class="icon-action danger"
type="button"
disabled={!canDelete || anyActionDisabled}
aria-label={`Delete ${worker.label}`}
title={cleanup.blocking_reason ?? cleanup.reason}
onclick={() => deleteWorker(worker, cleanup)}
>
{#if isActionBusy(worker, 'delete')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{/if}
</div>
</td>
</tr>
{/each}
@ -103,3 +247,78 @@
</div>
{/if}
</section>
<style>
.worker-title-link {
color: inherit;
text-decoration: none;
}
.worker-title-link:hover,
.worker-title-link:focus-visible {
color: var(--accent);
text-decoration: underline;
}
.worker-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.icon-action:hover:not(:disabled),
.icon-action:focus-visible:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: worker-action-spin 0.8s linear infinite;
}
@keyframes worker-action-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ListResponse, Worker } from "$lib/workspace-sidebar/types";
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace-sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
@ -7,10 +7,28 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
workspaceApiPath(params.workspaceId, "/workers"),
);
const runtimeIds = Array.from(new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? []));
const cleanupPlanEntries = await Promise.all(
runtimeIds.map(async (runtimeId) => {
const cleanupPlan = await loadJson<RuntimeCleanupPlanResponse>(
fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
return [runtimeId, cleanupPlan] as const;
}),
);
const cleanupPlans: Record<string, RuntimeCleanupPlanResponse> = {};
const cleanupPlanErrors: Record<string, string> = {};
for (const [runtimeId, cleanupPlan] of cleanupPlanEntries) {
if (cleanupPlan.data) cleanupPlans[runtimeId] = cleanupPlan.data;
if (cleanupPlan.error) cleanupPlanErrors[runtimeId] = cleanupPlan.error;
}
return {
workspaceId: params.workspaceId,
workers: workers.data,
workersError: workers.error,
cleanupPlans,
cleanupPlanErrors,
};
};

View File

@ -43,7 +43,16 @@
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected));
let hasSelectedExistingWorkdir = $derived(Boolean(
workingDirectoryId && !isNewWorkingDirectorySelected && !selectedRuntimeAllowsNoWorkdir,
));
let availableWorkingDirectories = $derived(
selectedRuntimeAllowsNoWorkdir
? []
: (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null
),
);
let canStartWorker = $derived(Boolean(
runtimeId &&
profile &&
@ -65,6 +74,13 @@
return () => controller.abort();
});
$effect(() => {
if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) {
workingDirectoryId = '';
relativeCwd = '';
}
});
async function loadLaunchOptions(signal?: AbortSignal) {
loading = true;
optionsError = null;
@ -109,6 +125,10 @@
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
return;
}
if (selectedRuntimeAllowsNoWorkdir) {
submitError = { message: 'embedded Runtime does not create workdirs', diagnostics: [] };
return;
}
if (!workingDirectoryRepositoryId) {
submitError = { message: 'select a repository before creating a workdir', diagnostics: [] };
return;
@ -124,7 +144,6 @@
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
}),
},
);
@ -245,16 +264,16 @@
<span>Run at</span>
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
{#if selectedRuntimeAllowsNoWorkdir}
<option value="">No workdir · embedded conversation only</option>
<option value="">No workdir</option>
{:else}
<option value="" disabled>Select workdir</option>
{/if}
{#each options?.working_directories ?? [] as directory}
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
{#each availableWorkingDirectories as directory}
<option value={directory.working_directory_id}>
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
</option>
{/each}
<option value={NEW_WORKING_DIRECTORY_VALUE}>New workdir…</option>
{/if}
</select>
<span>in</span>
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">