Compare commits
No commits in common. "f5b200d36b8b07fa6fc4431aa4edcad16a8c62c8" and "b6bb22f956ee3418bf00643dd986c9c38c60b695" have entirely different histories.
f5b200d36b
...
b6bb22f956
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
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` が通る。
|
||||
|
|
@ -1 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<!-- 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.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
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` が通る。
|
||||
|
|
@ -1 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<!-- 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.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
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` が通る。
|
||||
|
|
@ -1 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<!-- 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.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
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 によって作成されました。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- 未定
|
||||
|
|
@ -1 +0,0 @@
|
|||
/tmp/yoi-id-implementation-report.md
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<!-- 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.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
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 の状態変化を受け取れる。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXB7V4WY` (`Runtime restart-crossing Worker restore path`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
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 として機能する。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXD6VFJ8` (`Remove Runtime-owned runtime_id`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
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` が通る。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXDCGNJE` (`Remove workspace and cwd from worker runtime server`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXDEVT13` (`Normalize worker runtime filesystem options`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXDHAWEF` (`Show backend operation error details`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
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 の関連テストが通る。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXDJ1V3D` (`Preserve typed Workdir not-found errors`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Ticket `00001KXDMM975` (`Allow not-found Workdir cleanup action`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||
|
||||
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<!-- 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 は開始されていません。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<!-- 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`.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<!-- 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`.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
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 が通る。
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<!-- 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`.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::execution::WorkerExecutionStatus;
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||
use crate::interaction::WorkerInput;
|
||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -103,11 +103,20 @@ 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")]
|
||||
|
|
@ -125,10 +134,8 @@ pub struct WorkingDirectoryClaim {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryStatusKind {
|
||||
Active,
|
||||
Removed,
|
||||
CleanupPending,
|
||||
Corrupted,
|
||||
NotFound,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -145,17 +152,16 @@ 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")]
|
||||
|
|
@ -209,6 +215,7 @@ 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,
|
||||
|
|
@ -223,6 +230,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
use crate::execution::WorkerExecutionResult;
|
||||
use crate::identity::WorkerId;
|
||||
use crate::working_directory::WorkingDirectoryDiagnostic;
|
||||
use crate::identity::{RuntimeId, WorkerId};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Errors returned by the embedded Runtime API.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("runtime is stopped")]
|
||||
RuntimeStopped,
|
||||
#[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("initial worker input must be user input, got {kind}")]
|
||||
InvalidInitialInputKind { kind: String },
|
||||
|
||||
#[error("worker {worker_id} was not found")]
|
||||
WorkerNotFound { worker_id: WorkerId },
|
||||
#[error("worker {worker_id} was not found in runtime {runtime_id}")]
|
||||
WorkerNotFound {
|
||||
runtime_id: RuntimeId,
|
||||
worker_id: WorkerId,
|
||||
},
|
||||
|
||||
#[error("worker {worker_id} has no execution backend: {message}")]
|
||||
WorkerExecutionUnavailable {
|
||||
|
|
@ -39,9 +56,6 @@ 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 },
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ pub enum WorkerExecutionRunState {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerExecutionOperation {
|
||||
Spawn,
|
||||
Restore,
|
||||
Input,
|
||||
Stop,
|
||||
Cancel,
|
||||
|
|
@ -292,20 +291,6 @@ 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 {
|
||||
|
|
@ -328,16 +313,6 @@ 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,
|
||||
|
|
@ -419,19 +394,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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::{WorkerId, WorkerRef};
|
||||
use crate::identity::{RuntimeId, 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,8 +29,9 @@ 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 this Runtime's store data.
|
||||
/// Root directory containing all Runtime-scoped store data.
|
||||
pub root: PathBuf,
|
||||
pub runtime_id: Option<RuntimeId>,
|
||||
pub display_name: Option<String>,
|
||||
pub limits: RuntimeLimits,
|
||||
}
|
||||
|
|
@ -39,19 +40,23 @@ 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 one Worker Runtime state.
|
||||
/// Filesystem persistence boundary for Worker Runtime state.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FsRuntimeStore {
|
||||
root: PathBuf,
|
||||
runtime_id: RuntimeId,
|
||||
runtime_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FsRuntimeStore {
|
||||
|
|
@ -59,8 +64,12 @@ impl FsRuntimeStore {
|
|||
&self.root
|
||||
}
|
||||
|
||||
pub fn runtime_id(&self) -> &RuntimeId {
|
||||
&self.runtime_id
|
||||
}
|
||||
|
||||
pub fn runtime_dir(&self) -> &Path {
|
||||
&self.root
|
||||
&self.runtime_dir
|
||||
}
|
||||
|
||||
/// Read persisted Runtime events directly from the event log with the same
|
||||
|
|
@ -71,6 +80,12 @@ 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,
|
||||
|
|
@ -94,33 +109,49 @@ impl FsRuntimeStore {
|
|||
let has_more = events.iter().any(|event| event.id >= next_event_id);
|
||||
|
||||
Ok(RuntimeEventBatch {
|
||||
cursor: EventCursor { next_event_id },
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
cursor: EventCursor {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
next_event_id,
|
||||
},
|
||||
events: selected,
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> {
|
||||
let existed = root.exists();
|
||||
if existed && !root.is_dir() {
|
||||
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() {
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "open runtime store",
|
||||
path: root,
|
||||
path: runtime_dir,
|
||||
message: "runtime path exists but is not a directory".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if existed {
|
||||
migrate_legacy_single_runtime_layout(&root)?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
|
||||
fs::create_dir_all(runtime_dir.join(WORKERS_DIR)).map_err(|source| {
|
||||
RuntimeError::StoreIo {
|
||||
operation: "create runtime store",
|
||||
path: root.join(WORKERS_DIR),
|
||||
path: runtime_dir.join(WORKERS_DIR),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
|
||||
let store = Self { root };
|
||||
let store = Self {
|
||||
root,
|
||||
runtime_id,
|
||||
runtime_dir,
|
||||
};
|
||||
let state = if existed {
|
||||
Some(store.load_runtime_state()?)
|
||||
} else {
|
||||
|
|
@ -161,18 +192,6 @@ 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)?;
|
||||
|
|
@ -196,10 +215,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(&runtime_path)?;
|
||||
snapshot.validate(&self.runtime_id, &runtime_path)?;
|
||||
|
||||
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
||||
let workers_dir = self.root.join(WORKERS_DIR);
|
||||
let workers_dir = self.runtime_dir.join(WORKERS_DIR);
|
||||
if !workers_dir.exists() {
|
||||
return Err(RuntimeError::StoreMissing {
|
||||
operation: "read workers",
|
||||
|
|
@ -255,7 +274,10 @@ impl FsRuntimeStore {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
if worker_snapshot.validate(&worker_snapshot_path).is_err() {
|
||||
if worker_snapshot
|
||||
.validate(&self.runtime_id, &worker_snapshot_path)
|
||||
.is_err()
|
||||
{
|
||||
record_worker_load_diagnostic(
|
||||
&mut snapshot,
|
||||
Some(worker_snapshot.worker_ref.clone()),
|
||||
|
|
@ -321,20 +343,30 @@ impl FsRuntimeStore {
|
|||
))
|
||||
}
|
||||
|
||||
fn ensure_worker_ref(&self, _worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
||||
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
||||
if worker_ref.runtime_id == self.runtime_id {
|
||||
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.root.join(RUNTIME_FILE)
|
||||
self.runtime_dir.join(RUNTIME_FILE)
|
||||
}
|
||||
|
||||
fn events_path(&self) -> PathBuf {
|
||||
self.root.join(EVENTS_FILE)
|
||||
self.runtime_dir.join(EVENTS_FILE)
|
||||
}
|
||||
|
||||
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
|
||||
self.root.join(WORKERS_DIR).join(worker_id.to_string())
|
||||
self.runtime_dir
|
||||
.join(WORKERS_DIR)
|
||||
.join(encoded_component(worker_id.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
|
|
@ -351,6 +383,7 @@ 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,
|
||||
|
|
@ -378,6 +411,7 @@ 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,
|
||||
|
|
@ -410,6 +444,7 @@ 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,
|
||||
|
|
@ -422,7 +457,7 @@ impl RuntimeSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
|
||||
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
|
||||
if self.schema_version != SCHEMA_VERSION {
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read runtime snapshot",
|
||||
|
|
@ -433,6 +468,16 @@ 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",
|
||||
|
|
@ -450,6 +495,7 @@ 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,
|
||||
|
|
@ -491,7 +537,7 @@ impl WorkerSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
|
||||
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
|
||||
if self.schema_version != SCHEMA_VERSION {
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read worker snapshot",
|
||||
|
|
@ -502,6 +548,16 @@ 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",
|
||||
|
|
@ -527,81 +583,27 @@ impl WorkerSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
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(());
|
||||
fn runtime_dir(root: &Path, runtime_id: &RuntimeId) -> PathBuf {
|
||||
root.join(RUNTIMES_DIR)
|
||||
.join(encoded_component(runtime_id.as_str()))
|
||||
}
|
||||
|
||||
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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
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(),
|
||||
});
|
||||
encoded
|
||||
}
|
||||
|
||||
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 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"),
|
||||
}
|
||||
|
||||
fn rename_if_exists(src: &Path, dst: &Path, operation: &'static str) -> Result<(), RuntimeError> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ use crate::catalog::{
|
|||
};
|
||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||
use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
||||
use crate::management::{RuntimeLimits, RuntimeSummary, WorkerDeleteResult};
|
||||
use crate::management::{RuntimeLimits, RuntimeSummary};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::observation::WorkerObservationCursor;
|
||||
use axum::body::{Body, Bytes};
|
||||
|
|
@ -50,6 +50,9 @@ 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.
|
||||
|
|
@ -65,6 +68,7 @@ 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,
|
||||
|
|
@ -77,6 +81,7 @@ 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)
|
||||
|
|
@ -113,9 +118,10 @@ pub async fn serve_runtime_http(
|
|||
|
||||
/// Build the REST router for an existing Runtime.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
|
||||
let state = RuntimeHttpState {
|
||||
runtime,
|
||||
|
|
@ -141,10 +147,7 @@ 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).delete(delete_worker),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}", get(get_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));
|
||||
|
|
@ -216,12 +219,6 @@ 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 {
|
||||
|
|
@ -464,18 +461,6 @@ 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>,
|
||||
|
|
@ -706,18 +691,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::parse(&worker_id).ok_or_else(|| {
|
||||
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
|
||||
let worker_id = WorkerId::new(worker_id).ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_worker_id",
|
||||
"worker_id must be an unsigned integer",
|
||||
"worker_id must not be empty",
|
||||
)
|
||||
})?;
|
||||
Ok(WorkerRef::new(worker_id))
|
||||
let runtime_id = runtime
|
||||
.runtime_id()
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(WorkerRef::new(runtime_id, worker_id))
|
||||
}
|
||||
|
||||
fn parse_optional_lifecycle_request(
|
||||
|
|
@ -761,15 +746,15 @@ async fn require_local_token(
|
|||
#[derive(Debug)]
|
||||
struct RuntimeHttpRestError {
|
||||
status: StatusCode,
|
||||
code: String,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl RuntimeHttpRestError {
|
||||
fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
code: code.into(),
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
|
@ -801,7 +786,7 @@ impl IntoResponse for RuntimeHttpRestError {
|
|||
fn into_response(self) -> Response {
|
||||
let body = RuntimeHttpErrorResponse {
|
||||
error: RuntimeHttpErrorDetail {
|
||||
code: self.code,
|
||||
code: self.code.to_string(),
|
||||
message: self.message,
|
||||
},
|
||||
};
|
||||
|
|
@ -814,12 +799,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
RuntimeError::WorkingDirectory(diagnostic)
|
||||
if diagnostic.code == "working_directory_not_found" =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
RuntimeError::RuntimeStopped
|
||||
RuntimeError::RuntimeStopped { .. }
|
||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||
| RuntimeError::ExecutionBackendUnavailable { .. }
|
||||
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
|
||||
|
|
@ -829,7 +809,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
||||
| RuntimeError::InvalidProfileSelector { .. }
|
||||
| RuntimeError::UnsupportedConfigDeclaration { .. }
|
||||
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
||||
| RuntimeError::WrongRuntime { .. }
|
||||
| RuntimeError::WrongRuntimeCursor { .. } => StatusCode::BAD_REQUEST,
|
||||
RuntimeError::StoreIo { .. }
|
||||
| RuntimeError::StoreMissing { .. }
|
||||
| RuntimeError::StoreCorrupt { .. }
|
||||
|
|
@ -837,33 +818,26 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn code_for_runtime_error(error: &RuntimeError) -> String {
|
||||
fn code_for_runtime_error(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
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(),
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1043,8 +1017,8 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let created: RuntimeHttpWorkerResponse = read_json(response).await;
|
||||
assert_eq!(
|
||||
created.worker.worker_ref.worker_id,
|
||||
created.worker.worker_id
|
||||
created.worker.worker_ref.runtime_id,
|
||||
runtime.runtime_id().unwrap()
|
||||
);
|
||||
|
||||
let input = WorkerInput::user("hello from backend");
|
||||
|
|
@ -1133,27 +1107,12 @@ 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/999").await;
|
||||
let response = empty_request(app, Method::GET, "/v1/workers/worker-missing").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("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"
|
||||
);
|
||||
assert!(error.error.message.contains("worker-missing"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,61 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Runtime-local Worker identity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
/// 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 WorkerId(u64);
|
||||
pub struct RuntimeId(String);
|
||||
|
||||
impl WorkerId {
|
||||
pub fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
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 fn parse(value: &str) -> Option<Self> {
|
||||
value.parse::<u64>().ok().map(Self)
|
||||
}
|
||||
|
||||
pub(crate) fn generated(sequence: u64) -> Self {
|
||||
Self(sequence)
|
||||
Self(format!("runtime-mem-{sequence:016x}"))
|
||||
}
|
||||
|
||||
pub fn as_u64(&self) -> u64 {
|
||||
self.0
|
||||
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)]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkerId(String);
|
||||
|
||||
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(crate) fn generated(sequence: u64) -> Self {
|
||||
Self(format!("worker-{sequence:08x}"))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,14 +65,18 @@ impl fmt::Display for WorkerId {
|
|||
}
|
||||
}
|
||||
|
||||
/// Runtime-local authority reference for Worker operations.
|
||||
/// Complete public 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(worker_id: WorkerId) -> Self {
|
||||
Self { worker_id }
|
||||
pub fn new(runtime_id: RuntimeId, worker_id: WorkerId) -> Self {
|
||||
Self {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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};
|
||||
|
|
@ -66,11 +67,17 @@ fn run() -> Result<(), ProcessError> {
|
|||
}
|
||||
|
||||
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
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"));
|
||||
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);
|
||||
}
|
||||
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||
factory = factory.with_resource_client(Arc::new(
|
||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||
|
|
@ -82,11 +89,12 @@ 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(
|
||||
fs_paths.workdir_target.clone(),
|
||||
working_directory_root,
|
||||
)),
|
||||
);
|
||||
|
||||
|
|
@ -97,6 +105,7 @@ 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)
|
||||
|
|
@ -108,15 +117,28 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||
RuntimeOptions {
|
||||
display_name: config.display_name.clone(),
|
||||
limits: config.limits.clone(),
|
||||
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_fs_root() -> PathBuf {
|
||||
manifest::paths::data_dir().unwrap_or_else(|| env::temp_dir().join("yoi"))
|
||||
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 parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
|
||||
|
|
@ -125,6 +147,7 @@ where
|
|||
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() {
|
||||
|
|
@ -137,19 +160,31 @@ 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)?);
|
||||
}
|
||||
"--fs-worker-dir" => {
|
||||
config.fs_worker_dir =
|
||||
"--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 =
|
||||
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||
}
|
||||
"--fs-runtime-dir" => {
|
||||
config.fs_runtime_dir =
|
||||
"--worker-metadata-dir" => {
|
||||
config.worker_metadata_dir =
|
||||
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||
}
|
||||
"--workdir-target" => {
|
||||
config.workdir_target =
|
||||
"--worker-runtime-base-dir" => {
|
||||
config.worker_runtime_base_dir =
|
||||
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||
}
|
||||
"--backend-resource-endpoint" => {
|
||||
|
|
@ -164,19 +199,21 @@ where
|
|||
}
|
||||
"--store" => {
|
||||
let value = take_value(&flag, inline_value, &mut args)?;
|
||||
if !matches!(value.as_str(), "fs" | "fs-store") {
|
||||
store = match value.as_str() {
|
||||
"memory" => StoreArg::Memory,
|
||||
"fs" | "fs-store" => StoreArg::Fs { root: None },
|
||||
_ => {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"unsupported --store `{value}`; only `fs` is accepted (use --no-store for ephemeral mode)"
|
||||
"unsupported --store `{value}`; expected `memory` or `fs`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"--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)?;
|
||||
config.fs_root = Some(PathBuf::from(value));
|
||||
store = StoreArg::Fs {
|
||||
root: Some(PathBuf::from(value)),
|
||||
};
|
||||
}
|
||||
"--local-token" => {
|
||||
let value = take_value(&flag, inline_value, &mut args)?;
|
||||
|
|
@ -211,10 +248,34 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
apply_store_selection(&mut config);
|
||||
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);
|
||||
}
|
||||
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!(
|
||||
|
|
@ -240,84 +301,65 @@ 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 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 };
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ProcessConfig {
|
||||
http: RuntimeHttpServerConfig,
|
||||
fs_root: Option<PathBuf>,
|
||||
fs_worker_dir: Option<PathBuf>,
|
||||
fs_runtime_dir: Option<PathBuf>,
|
||||
workdir_target: Option<PathBuf>,
|
||||
no_store: bool,
|
||||
workspace_root: PathBuf,
|
||||
cwd: PathBuf,
|
||||
worker_store_dir: Option<PathBuf>,
|
||||
worker_metadata_dir: Option<PathBuf>,
|
||||
worker_runtime_base_dir: Option<PathBuf>,
|
||||
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(),
|
||||
fs_root: None,
|
||||
fs_worker_dir: None,
|
||||
fs_runtime_dir: None,
|
||||
workdir_target: None,
|
||||
no_store: false,
|
||||
cwd: workspace_root.clone(),
|
||||
workspace_root,
|
||||
worker_store_dir: None,
|
||||
worker_metadata_dir: None,
|
||||
worker_runtime_base_dir: None,
|
||||
backend_resource_endpoint: None,
|
||||
backend_resource_token: None,
|
||||
profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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(Clone, Debug, PartialEq, Eq)]
|
||||
enum StoreArg {
|
||||
Memory,
|
||||
Fs { root: Option<PathBuf> },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -371,27 +413,27 @@ impl From<std::io::Error> for ProcessError {
|
|||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
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"#
|
||||
"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"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -414,26 +456,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
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());
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_store_memory_in_favor_of_no_store() {
|
||||
assert!(parse_args(["--store", "memory"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_runtime_process_config() {
|
||||
fn parses_memory_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",
|
||||
|
|
@ -447,8 +491,13 @@ 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",
|
||||
|
|
@ -461,42 +510,25 @@ 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/yoi",
|
||||
"--fs-worker-dir",
|
||||
"/tmp/yoi-worker",
|
||||
"--fs-runtime-dir",
|
||||
"/tmp/yoi-runtime",
|
||||
"--workdir-target",
|
||||
"/tmp/yoi-workdirs",
|
||||
])
|
||||
let config = parse_args(["--store", "fs", "--fs-root", "/tmp/runtime-store"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
config.http.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
|
||||
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/runtime-store")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::identity::WorkerId;
|
||||
use crate::identity::RuntimeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Runtime backend kind.
|
||||
|
|
@ -35,6 +35,7 @@ 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,
|
||||
}
|
||||
|
|
@ -42,25 +43,17 @@ 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,
|
||||
|
|
@ -70,10 +63,6 @@ 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use crate::identity::WorkerRef;
|
||||
use crate::identity::{RuntimeId, 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,
|
||||
}
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ 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,
|
||||
}
|
||||
|
|
@ -37,15 +39,14 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::identity::WorkerId;
|
||||
use crate::identity::{RuntimeId, 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: &str,
|
||||
runtime_id: &RuntimeId,
|
||||
worker_id: Option<&WorkerId>,
|
||||
) -> BackendResourceFetchRequest {
|
||||
let audit_correlation_id = handle.audit_correlation_id.clone();
|
||||
BackendResourceFetchRequest {
|
||||
handle,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.map(|id| id.to_string()),
|
||||
runtime_id: runtime_id.as_str().to_string(),
|
||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
||||
audit_correlation_id,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,13 +15,12 @@ use std::sync::{Arc, Mutex, mpsc};
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::catalog::{
|
||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkingDirectoryRequest,
|
||||
WorkingDirectoryStatus,
|
||||
};
|
||||
use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionRunState,
|
||||
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
||||
|
|
@ -34,12 +33,11 @@ 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, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||
WorkerWorkspaceContext, WorkspaceId,
|
||||
Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext,
|
||||
WorkspaceId,
|
||||
};
|
||||
|
||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||
|
|
@ -53,11 +51,6 @@ 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
|
||||
|
|
@ -65,6 +58,7 @@ 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>,
|
||||
|
|
@ -77,6 +71,7 @@ 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,
|
||||
|
|
@ -87,6 +82,11 @@ 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 {
|
||||
Self::runtime_worker_name_for_ref(&request.worker_ref)
|
||||
format!(
|
||||
"runtime-{}-{}",
|
||||
sanitize_worker_name_component(request.worker_ref.runtime_id.as_str()),
|
||||
request.worker_ref.worker_id
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_profile_value(
|
||||
|
|
@ -165,21 +165,14 @@ impl ProfileRuntimeWorkerFactory {
|
|||
}
|
||||
}
|
||||
|
||||
fn runtime_profile_for_request<'a>(
|
||||
&'a self,
|
||||
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.profile)
|
||||
}
|
||||
|
||||
fn runtime_profile<'a>(
|
||||
&'a self,
|
||||
request: &'a WorkerExecutionSpawnRequest,
|
||||
) -> Option<std::borrow::Cow<'a, str>> {
|
||||
self.runtime_profile_for_request(&request.request)
|
||||
if let Some(profile) = self.profile.as_deref() {
|
||||
return Some(std::borrow::Cow::Borrowed(profile));
|
||||
}
|
||||
Self::runtime_profile_value(&request.request.profile)
|
||||
}
|
||||
async fn resolve_profile_source_archive(
|
||||
&self,
|
||||
|
|
@ -227,6 +220,19 @@ 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,
|
||||
|
|
@ -391,110 +397,6 @@ 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 {
|
||||
|
|
@ -630,69 +532,6 @@ 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> {
|
||||
|
|
@ -855,108 +694,53 @@ where
|
|||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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(),
|
||||
),
|
||||
);
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
WorkerExecutionOperation::Spawn,
|
||||
message,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
self.connect_handle(
|
||||
WorkerExecutionOperation::Restore,
|
||||
worker_ref,
|
||||
bridge_context,
|
||||
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,
|
||||
working_directory,
|
||||
)
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_input(
|
||||
|
|
@ -1082,10 +866,11 @@ mod tests {
|
|||
|
||||
use crate::Runtime as EmbeddedRuntime;
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
|
||||
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, 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;
|
||||
|
|
@ -1203,19 +988,6 @@ 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> {
|
||||
|
|
@ -1342,16 +1114,21 @@ 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_runtime_local() {
|
||||
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||
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(),
|
||||
);
|
||||
let request = WorkerExecutionSpawnRequest {
|
||||
worker_ref: worker_ref.clone(),
|
||||
request: create_request("1"),
|
||||
request: create_request("worker-00000001"),
|
||||
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
|
||||
working_directory: None,
|
||||
config_bundle: None,
|
||||
|
|
@ -1359,11 +1136,11 @@ mod tests {
|
|||
|
||||
assert_eq!(
|
||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||
"worker-runtime-1"
|
||||
"runtime-arc-remote-worker-00000001"
|
||||
);
|
||||
assert_ne!(
|
||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||
"00000001"
|
||||
"worker-00000001"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1414,8 +1191,13 @@ mod tests {
|
|||
observed_cwds: observed_cwds.clone(),
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
||||
RuntimeOptions {
|
||||
runtime_id: RuntimeId::new("embedded"),
|
||||
..RuntimeOptions::default()
|
||||
},
|
||||
Arc::new(backend),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let detail = runtime.create_worker(create_request("chat")).unwrap();
|
||||
|
|
@ -1483,8 +1265,13 @@ mod tests {
|
|||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
||||
RuntimeOptions {
|
||||
runtime_id: RuntimeId::new("embedded-materialized"),
|
||||
..RuntimeOptions::default()
|
||||
},
|
||||
Arc::new(backend),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = create_request("chat");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::catalog::{
|
||||
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
|
||||
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
|
||||
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||
};
|
||||
use crate::identity::WorkerRef;
|
||||
|
|
@ -23,6 +23,7 @@ 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)]
|
||||
|
|
@ -30,8 +31,10 @@ 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,
|
||||
}
|
||||
|
||||
|
|
@ -42,12 +45,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,
|
||||
}
|
||||
}
|
||||
|
|
@ -80,19 +83,9 @@ impl WorkingDirectoryBinding {
|
|||
}
|
||||
|
||||
pub fn status(&self) -> WorkingDirectoryStatus {
|
||||
let mut working_directory = self.working_directory.clone();
|
||||
if working_directory.status == WorkingDirectoryStatusKind::Active
|
||||
&& !binding_paths_are_available(self)
|
||||
{
|
||||
working_directory.status = WorkingDirectoryStatusKind::Corrupted;
|
||||
WorkingDirectoryStatus {
|
||||
summary: self.working_directory.status_summary(),
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,27 +151,6 @@ 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,
|
||||
|
|
@ -195,8 +167,13 @@ impl LocalGitWorktreeMaterializer {
|
|||
&self.runtime_root
|
||||
}
|
||||
|
||||
fn working_directory_id(_worker_ref: &WorkerRef, repository_id: &str) -> String {
|
||||
next_working_directory_id(repository_id)
|
||||
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_root(&self, working_directory_id: &str) -> PathBuf {
|
||||
|
|
@ -205,28 +182,6 @@ 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,
|
||||
|
|
@ -282,6 +237,7 @@ 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 {
|
||||
|
|
@ -299,6 +255,12 @@ 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",
|
||||
|
|
@ -324,7 +286,7 @@ impl LocalGitWorktreeMaterializer {
|
|||
if !status.trim().is_empty() {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_dirty_source_rejected",
|
||||
"working directory materialization rejects dirty source repository state",
|
||||
"clean_point_only working directory materialization rejects dirty source repository state",
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -383,6 +345,7 @@ 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
|
||||
|
|
@ -393,12 +356,14 @@ 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 {
|
||||
|
|
@ -420,7 +385,11 @@ 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)
|
||||
self.materialize_with_working_directory_id(
|
||||
working_directory_id,
|
||||
request,
|
||||
"remove_on_worker_stop",
|
||||
)
|
||||
}
|
||||
|
||||
fn create(
|
||||
|
|
@ -431,7 +400,11 @@ 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)
|
||||
self.materialize_with_working_directory_id(
|
||||
working_directory_id,
|
||||
request,
|
||||
"manual_or_worker_stop",
|
||||
)
|
||||
}
|
||||
|
||||
fn bind_working_directory(
|
||||
|
|
@ -478,9 +451,8 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
|||
if validate_working_directory_id(&working_directory_id).is_err() {
|
||||
continue;
|
||||
}
|
||||
match self.read_binding(&working_directory_id) {
|
||||
Ok(binding) => statuses.push(binding.status()),
|
||||
Err(_) => statuses.push(self.corrupted_status(&working_directory_id)),
|
||||
if let Ok(status) = self.working_directory_status(&working_directory_id) {
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
statuses.sort_by(|left, right| {
|
||||
|
|
@ -496,17 +468,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
|||
working_directory_id: &str,
|
||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||
validate_working_directory_id(working_directory_id)?;
|
||||
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)),
|
||||
}
|
||||
Ok(self.read_binding(working_directory_id)?.status())
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
|
|
@ -514,35 +476,9 @@ 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)?;
|
||||
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 })
|
||||
self.working_directory_status(working_directory_id)
|
||||
}
|
||||
|
||||
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
|
|
@ -582,8 +518,11 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
|||
Ok(())
|
||||
}
|
||||
});
|
||||
if remove_result.is_err() {
|
||||
working_directory.status = WorkingDirectoryStatusKind::CleanupPending;
|
||||
working_directory.status = if remove_result.is_ok() {
|
||||
WorkingDirectoryStatusKind::Removed
|
||||
} else {
|
||||
WorkingDirectoryStatusKind::CleanupPending
|
||||
};
|
||||
let updated = WorkingDirectoryBinding {
|
||||
working_directory,
|
||||
root: binding.root.clone(),
|
||||
|
|
@ -592,7 +531,6 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
|||
source_repository_path: binding.source_repository_path.clone(),
|
||||
};
|
||||
let _ = self.write_record(&updated);
|
||||
}
|
||||
remove_result
|
||||
}
|
||||
}
|
||||
|
|
@ -671,13 +609,16 @@ 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() as u64)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0x00ff_ffff;
|
||||
format!("{now:013x}{sequence:06x}")
|
||||
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
format!(
|
||||
"alloc-{now}-{sequence}-{}",
|
||||
sanitize_path_component(repository_id)
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_working_directory_id(
|
||||
|
|
@ -745,7 +686,7 @@ fn validate_relative_cwd(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
use crate::identity::{RuntimeId, WorkerId};
|
||||
|
||||
fn git(path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
|
|
@ -781,12 +722,16 @@ 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(WorkerId::generated(sequence))
|
||||
WorkerRef::new(
|
||||
RuntimeId::new("runtime-test").unwrap(),
|
||||
WorkerId::generated(sequence),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -809,6 +754,10 @@ mod tests {
|
|||
binding.working_directory.materializer_kind,
|
||||
MaterializerKind::LocalGitWorktree
|
||||
);
|
||||
assert_eq!(
|
||||
binding.working_directory.dirty_state_policy,
|
||||
DirtyStatePolicy::CleanPointOnly
|
||||
);
|
||||
assert!(
|
||||
binding
|
||||
.working_directory_root()
|
||||
|
|
@ -837,7 +786,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_source_is_rejected_by_materialization() {
|
||||
fn dirty_source_is_rejected_by_clean_point_only_policy() {
|
||||
let repo = create_clean_repo();
|
||||
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
|
|
@ -848,7 +797,7 @@ mod tests {
|
|||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "working_directory_dirty_source_rejected");
|
||||
assert!(error.message.contains("dirty source"));
|
||||
assert!(error.message.contains("clean_point_only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -962,7 +911,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_working_directory_removes_worktree_and_record() {
|
||||
fn cleanup_removes_worktree_and_updates_record() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
|
|
@ -970,14 +919,16 @@ 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();
|
||||
|
||||
let status = materializer
|
||||
.cleanup_working_directory(&binding.working_directory.id)
|
||||
.unwrap();
|
||||
materializer.cleanup(&binding).unwrap();
|
||||
|
||||
assert_eq!(status.summary.status, WorkingDirectoryStatusKind::NotFound);
|
||||
assert!(!root.exists());
|
||||
assert!(!record_root.exists());
|
||||
let raw = fs::read_to_string(
|
||||
binding
|
||||
.working_directory_root()
|
||||
.join(MATERIALIZATION_RECORD),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(raw.contains("removed"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,14 @@ use worker_runtime::execution::{
|
|||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerDeleteResponse,
|
||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
|
||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerInputResponse,
|
||||
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
||||
RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse,
|
||||
RuntimeHttpWorkingDirectoryResponse,
|
||||
};
|
||||
use worker_runtime::identity::{
|
||||
RuntimeId as EmbeddedRuntimeId, WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef,
|
||||
};
|
||||
use worker_runtime::identity::{WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef};
|
||||
use worker_runtime::interaction::{
|
||||
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
|
||||
};
|
||||
|
|
@ -168,6 +170,7 @@ 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,
|
||||
|
|
@ -220,6 +223,7 @@ 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,
|
||||
}
|
||||
|
|
@ -234,6 +238,7 @@ 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,
|
||||
|
|
@ -438,15 +443,6 @@ 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")]
|
||||
|
|
@ -675,20 +671,6 @@ 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,
|
||||
|
|
@ -1060,25 +1042,6 @@ 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,
|
||||
|
|
@ -1126,7 +1089,10 @@ 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()
|
||||
}
|
||||
|
|
@ -1154,9 +1120,12 @@ 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,
|
||||
},
|
||||
|
|
@ -1173,8 +1142,13 @@ 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: EMBEDDED_RUNTIME_ID.to_string(),
|
||||
runtime_id,
|
||||
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
|
||||
runtime,
|
||||
execution_enabled: false,
|
||||
|
|
@ -1183,7 +1157,18 @@ impl EmbeddedWorkerRuntime {
|
|||
}
|
||||
|
||||
fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> {
|
||||
Some(EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(worker_id)?))
|
||||
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)
|
||||
}
|
||||
|
||||
fn can_stop_embedded_worker(
|
||||
|
|
@ -1197,16 +1182,17 @@ 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.to_string(),
|
||||
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
|
||||
host_id: self.host_id.clone(),
|
||||
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
|
||||
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
|
||||
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_execution_status_label(summary.status, &summary.execution)
|
||||
state: embedded_worker_status_label(summary.status).to_string(),
|
||||
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
||||
.to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
|
|
@ -1216,6 +1202,8 @@ 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,
|
||||
},
|
||||
|
|
@ -1231,16 +1219,17 @@ impl EmbeddedWorkerRuntime {
|
|||
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
||||
WorkerSummary {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: detail.worker_id.to_string(),
|
||||
worker_id: detail.worker_id.as_str().to_string(),
|
||||
host_id: self.host_id.clone(),
|
||||
label: safe_display_hint(&detail.worker_id.to_string()),
|
||||
label: safe_display_hint(detail.worker_id.as_str()),
|
||||
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_execution_status_label(detail.status, &detail.execution)
|
||||
state: embedded_worker_status_label(detail.status).to_string(),
|
||||
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
||||
.to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
|
|
@ -1250,6 +1239,7 @@ 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,
|
||||
},
|
||||
|
|
@ -1671,38 +1661,6 @@ 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,
|
||||
|
|
@ -1810,9 +1768,7 @@ impl RemoteRuntimeConfig {
|
|||
display_name: display_name.into(),
|
||||
base_url: base_url.into(),
|
||||
bearer_token,
|
||||
cached_capabilities: remote_runtime_capabilities(
|
||||
200, false, false, "unknown", "unknown",
|
||||
),
|
||||
cached_capabilities: remote_runtime_capabilities(200, false, false),
|
||||
cached_status: "configured".to_string(),
|
||||
timeout: Duration::from_secs(10),
|
||||
}
|
||||
|
|
@ -1966,16 +1922,17 @@ 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.to_string(),
|
||||
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
|
||||
host_id: self.host_id.clone(),
|
||||
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
|
||||
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
|
||||
role: None,
|
||||
profile: embedded_profile_label(&summary.profile),
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "remote_runtime".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
},
|
||||
state: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
||||
state: embedded_worker_status_label(summary.status).to_string(),
|
||||
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
||||
.to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
|
|
@ -1985,6 +1942,7 @@ 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,
|
||||
},
|
||||
|
|
@ -2000,16 +1958,17 @@ impl RemoteWorkerRuntime {
|
|||
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
||||
WorkerSummary {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: detail.worker_id.to_string(),
|
||||
worker_id: detail.worker_id.as_str().to_string(),
|
||||
host_id: self.host_id.clone(),
|
||||
label: safe_display_hint(&detail.worker_id.to_string()),
|
||||
label: safe_display_hint(detail.worker_id.as_str()),
|
||||
role: None,
|
||||
profile: embedded_profile_label(&detail.profile),
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "remote_runtime".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
},
|
||||
state: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
||||
state: embedded_worker_status_label(detail.status).to_string(),
|
||||
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
||||
.to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
|
|
@ -2019,6 +1978,7 @@ 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,
|
||||
},
|
||||
|
|
@ -2078,8 +2038,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||
limit,
|
||||
true,
|
||||
response.runtime.worker_creation_available,
|
||||
response.runtime.os,
|
||||
response.runtime.arch,
|
||||
),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
|
|
@ -2113,7 +2071,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, "unknown", "unknown"),
|
||||
capabilities: remote_runtime_capabilities(limit, true, false),
|
||||
diagnostics: Vec::new(),
|
||||
}],
|
||||
Vec::new(),
|
||||
|
|
@ -2144,7 +2102,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||
worker: Some(self.map_worker_detail(response.worker)),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(diagnostic) if diagnostic.code == "worker_not_found" => WorkerLookupResult {
|
||||
Err(diagnostic) if diagnostic.code == "remote_worker_not_found" => WorkerLookupResult {
|
||||
worker: None,
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
|
|
@ -2240,10 +2198,11 @@ 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,
|
||||
Some(self.runtime_id.as_str()),
|
||||
runtime_id.as_ref(),
|
||||
&self.resource_broker,
|
||||
&self.backend_base_url,
|
||||
) {
|
||||
|
|
@ -2357,27 +2316,6 @@ 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,
|
||||
|
|
@ -2427,6 +2365,7 @@ 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,
|
||||
|
|
@ -2475,6 +2414,18 @@ 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,
|
||||
|
|
@ -2581,7 +2532,7 @@ fn default_profile_source_archive_source(
|
|||
fn default_profile_source_archive_http_source(
|
||||
profile: &ProfileSelector,
|
||||
workspace_id: &str,
|
||||
runtime_id: Option<&str>,
|
||||
runtime_id: Option<&EmbeddedRuntimeId>,
|
||||
resource_broker: &BackendResourceBroker,
|
||||
backend_base_url: &str,
|
||||
) -> Result<ProfileSourceArchiveSource, String> {
|
||||
|
|
@ -2619,7 +2570,7 @@ enum ProfileSourceArchiveTransport {
|
|||
fn default_embedded_config_bundle(
|
||||
profile: &ProfileSelector,
|
||||
workspace_id: &str,
|
||||
runtime_id: Option<&str>,
|
||||
runtime_id: Option<&EmbeddedRuntimeId>,
|
||||
resource_broker: &BackendResourceBroker,
|
||||
archive_transport: ProfileSourceArchiveTransport,
|
||||
) -> Result<ConfigBundle, String> {
|
||||
|
|
@ -2827,11 +2778,17 @@ 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,
|
||||
|
|
@ -2860,11 +2817,6 @@ 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 { .. }
|
||||
|
|
@ -2925,8 +2877,6 @@ 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,
|
||||
|
|
@ -2934,6 +2884,7 @@ 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,
|
||||
|
|
@ -2941,8 +2892,8 @@ fn remote_runtime_capabilities(
|
|||
supports_backend_internal_tools: false,
|
||||
workspace_scope: "remote_runtime_backend_private".to_string(),
|
||||
max_workers: limit,
|
||||
os: os.into(),
|
||||
arch: arch.into(),
|
||||
os: "remote".to_string(),
|
||||
arch: "remote".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2994,8 +2945,7 @@ fn remote_http_status_diagnostic(
|
|||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||
("remote_runtime_auth_failed", DiagnosticSeverity::Error)
|
||||
}
|
||||
_ if error.is_some() => (remote_code, DiagnosticSeverity::Warning),
|
||||
StatusCode::NOT_FOUND => ("remote_runtime_not_found", DiagnosticSeverity::Warning),
|
||||
StatusCode::NOT_FOUND => ("remote_worker_not_found", DiagnosticSeverity::Warning),
|
||||
StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => {
|
||||
("remote_runtime_unsupported", DiagnosticSeverity::Warning)
|
||||
}
|
||||
|
|
@ -3154,6 +3104,7 @@ 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(),
|
||||
|
|
@ -3162,6 +3113,7 @@ 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,
|
||||
},
|
||||
|
|
@ -3201,7 +3153,7 @@ mod tests {
|
|||
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
|
||||
for selector in [
|
||||
ProfileSelector::RuntimeDefault,
|
||||
ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||
|
|
@ -3213,7 +3165,7 @@ mod tests {
|
|||
let bundle = default_embedded_config_bundle(
|
||||
&selector,
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
&broker,
|
||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||
)
|
||||
|
|
@ -3224,7 +3176,7 @@ mod tests {
|
|||
.fetch_profile_source_archive(
|
||||
worker_runtime::resource::BackendResourceFetchRequest {
|
||||
handle: handle.clone(),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
runtime_id: runtime_id.as_str().to_string(),
|
||||
worker_id: None,
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
},
|
||||
|
|
@ -3252,11 +3204,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 = "remote:test";
|
||||
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
|
||||
let bundle = default_embedded_config_bundle(
|
||||
&ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
&broker,
|
||||
ProfileSourceArchiveTransport::Inline,
|
||||
)
|
||||
|
|
@ -3277,11 +3229,11 @@ mod tests {
|
|||
#[test]
|
||||
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "remote:test";
|
||||
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
|
||||
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/",
|
||||
)
|
||||
|
|
@ -3302,12 +3254,12 @@ mod tests {
|
|||
#[test]
|
||||
fn embedded_archive_rejects_unknown_selectors() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
|
||||
assert!(
|
||||
default_embedded_config_bundle(
|
||||
&ProfileSelector::Builtin("builtin:missing".to_string()),
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
&broker,
|
||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||
)
|
||||
|
|
@ -3317,7 +3269,7 @@ mod tests {
|
|||
default_embedded_config_bundle(
|
||||
&ProfileSelector::Named("custom".to_string()),
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
&broker,
|
||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||
)
|
||||
|
|
@ -3479,7 +3431,8 @@ mod tests {
|
|||
visibility: "opaque".to_string(),
|
||||
identity: host_id.to_string(),
|
||||
},
|
||||
state: "available".to_string(),
|
||||
state: "running".to_string(),
|
||||
status: "available".to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
retention_state: "transient".to_string(),
|
||||
|
|
@ -3488,6 +3441,7 @@ mod tests {
|
|||
display_hint: "test fixture".to_string(),
|
||||
},
|
||||
capabilities: WorkerCapabilitySummary {
|
||||
can_accept_input: false,
|
||||
can_stop: false,
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
|
|
@ -3517,6 +3471,7 @@ 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,
|
||||
|
|
@ -3614,18 +3569,18 @@ mod tests {
|
|||
Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing"
|
||||
));
|
||||
|
||||
let unknown_worker = registry.worker("runtime-a", "999").unwrap_err();
|
||||
let unknown_worker = registry.worker("runtime-a", "worker-missing").unwrap_err();
|
||||
assert_eq!(
|
||||
unknown_worker,
|
||||
RuntimeRegistryError::UnknownWorker {
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
worker_id: "999".to_string(),
|
||||
worker_id: "worker-missing".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
unknown_worker.into_error(),
|
||||
Error::UnknownWorker { runtime_id, worker_id }
|
||||
if runtime_id == "runtime-a" && worker_id == "999"
|
||||
if runtime_id == "runtime-a" && worker_id == "worker-missing"
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -3700,6 +3655,7 @@ 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(
|
||||
|
|
@ -3717,7 +3673,8 @@ mod tests {
|
|||
.worker(&worker.worker_id)
|
||||
.worker
|
||||
.expect("worker detail");
|
||||
if detail.state == "idle" {
|
||||
if detail.status == "idle" {
|
||||
assert!(detail.capabilities.can_accept_input);
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
|
|
@ -3750,6 +3707,7 @@ 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(
|
||||
|
|
@ -3785,6 +3743,8 @@ 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,
|
||||
|
|
@ -3928,7 +3888,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
|
||||
let worker_json = worker_json("remote:primary", "1");
|
||||
let worker_json = worker_json("remote:primary", "worker-remote-1");
|
||||
let (base_url, server) = serve_mock_http(vec![
|
||||
mock_response(
|
||||
"GET",
|
||||
|
|
@ -3939,19 +3899,19 @@ mod tests {
|
|||
),
|
||||
mock_response(
|
||||
"GET",
|
||||
"/v1/workers/1",
|
||||
"/v1/workers/worker-remote-1",
|
||||
true,
|
||||
200,
|
||||
json!({ "worker": worker_json.clone() }).to_string(),
|
||||
),
|
||||
mock_response(
|
||||
"POST",
|
||||
"/v1/workers/1/input",
|
||||
"/v1/workers/worker-remote-1/input",
|
||||
true,
|
||||
200,
|
||||
json!({
|
||||
"ack": {
|
||||
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
|
||||
"worker_ref": { "runtime_id": "remote:primary", "worker_id": "worker-remote-1" },
|
||||
"status": "running",
|
||||
"event_id": 8
|
||||
}
|
||||
|
|
@ -3976,20 +3936,24 @@ mod tests {
|
|||
);
|
||||
|
||||
let observation = registry
|
||||
.observation_source("remote:primary", "1")
|
||||
.observation_source("remote:primary", "worker-remote-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/1/events/ws"));
|
||||
assert!(
|
||||
observation
|
||||
.endpoint
|
||||
.ends_with("/v1/workers/worker-remote-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, "1");
|
||||
assert_eq!(workers.items[0].worker_id, "worker-remote-1");
|
||||
assert_eq!(
|
||||
workers.items[0].implementation.kind,
|
||||
"remote_worker_runtime"
|
||||
|
|
@ -3998,12 +3962,13 @@ 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",
|
||||
"1",
|
||||
"worker-remote-1",
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::User,
|
||||
content: "hello remote".to_string(),
|
||||
|
|
@ -4039,28 +4004,28 @@ mod tests {
|
|||
"workers": [
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"1",
|
||||
"worker-stale",
|
||||
"stale",
|
||||
"unconnected",
|
||||
None,
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"2",
|
||||
"worker-unconnected",
|
||||
"unconnected",
|
||||
"unconnected",
|
||||
None,
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"3",
|
||||
"worker-rejected",
|
||||
"connected",
|
||||
"rejected",
|
||||
Some("rejected"),
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"4",
|
||||
"worker-errored",
|
||||
"connected",
|
||||
"errored",
|
||||
Some("errored"),
|
||||
|
|
@ -4071,13 +4036,13 @@ mod tests {
|
|||
),
|
||||
mock_response(
|
||||
"GET",
|
||||
"/v1/workers/1",
|
||||
"/v1/workers/worker-stale",
|
||||
true,
|
||||
200,
|
||||
json!({
|
||||
"worker": worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"1",
|
||||
"worker-stale",
|
||||
"stale",
|
||||
"unconnected",
|
||||
None,
|
||||
|
|
@ -4102,20 +4067,26 @@ 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].state, "stale");
|
||||
assert_eq!(workers.items[1].state, "unconnected");
|
||||
assert_eq!(workers.items[2].state, "rejected");
|
||||
assert_eq!(workers.items[3].state, "errored");
|
||||
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");
|
||||
|
||||
let stale_detail = registry.worker("remote:primary", "1").unwrap();
|
||||
let stale_detail = registry.worker("remote:primary", "worker-stale").unwrap();
|
||||
assert!(!stale_detail.capabilities.can_accept_input);
|
||||
assert!(!stale_detail.capabilities.can_stop);
|
||||
assert_eq!(stale_detail.state, "stale");
|
||||
assert_eq!(stale_detail.status, "stale");
|
||||
|
||||
server.join().expect("mock remote server finished");
|
||||
}
|
||||
|
|
@ -4198,7 +4169,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/999",
|
||||
"/v1/workers/worker-missing",
|
||||
true,
|
||||
401,
|
||||
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
|
||||
|
|
@ -4219,7 +4190,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let error = registry
|
||||
.worker("remote:primary", "999")
|
||||
.worker("remote:primary", "worker-missing")
|
||||
.expect_err("auth failure is a backend operation error");
|
||||
assert!(matches!(
|
||||
error,
|
||||
|
|
@ -4314,7 +4285,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use chrono::{Duration, Utc};
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
use worker_runtime::identity::WorkerId;
|
||||
use worker_runtime::identity::{RuntimeId, 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<&str>,
|
||||
runtime_id: Option<&RuntimeId>,
|
||||
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.to_string()),
|
||||
worker_id: worker_id.map(|id| id.to_string()),
|
||||
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
|
||||
worker_id: worker_id.map(|id| id.as_str().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.to_string()),
|
||||
worker_id: worker_id.map(|id| id.to_string()),
|
||||
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
|
||||
worker_id: worker_id.map(|id| id.as_str().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::WorkerId;
|
||||
use worker_runtime::identity::{RuntimeId, WorkerId};
|
||||
use worker_runtime::profile_archive::{
|
||||
ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex,
|
||||
};
|
||||
|
|
@ -214,31 +214,31 @@ mod tests {
|
|||
|
||||
fn request(
|
||||
handle: BackendResourceHandle,
|
||||
runtime_id: &str,
|
||||
runtime_id: &RuntimeId,
|
||||
worker_id: Option<&WorkerId>,
|
||||
) -> BackendResourceFetchRequest {
|
||||
BackendResourceFetchRequest {
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
handle,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.map(|id| id.to_string()),
|
||||
runtime_id: runtime_id.as_str().to_string(),
|
||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_issues_and_verifies_profile_source_archive_handles() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
||||
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.to_string(),
|
||||
runtime_id: runtime_id.as_str().to_string(),
|
||||
worker_id: None,
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})
|
||||
|
|
@ -250,15 +250,19 @@ mod tests {
|
|||
#[test]
|
||||
fn broker_rejects_runtime_mismatch() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_a = "runtime-a";
|
||||
let runtime_a = RuntimeId::new("runtime-a").unwrap();
|
||||
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, "runtime-b", None))
|
||||
.fetch_profile_source_archive(request(
|
||||
handle,
|
||||
&RuntimeId::new("runtime-b").unwrap(),
|
||||
None,
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
|
||||
}
|
||||
|
|
@ -266,12 +270,12 @@ mod tests {
|
|||
#[test]
|
||||
fn broker_rejects_worker_mismatch() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let worker_a = WorkerId::new(1);
|
||||
let worker_b = WorkerId::new(2);
|
||||
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 handle = broker.issue_profile_source_archive_handle(
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
Some(&worker_a),
|
||||
archive(),
|
||||
);
|
||||
|
|
@ -284,10 +288,10 @@ mod tests {
|
|||
#[test]
|
||||
fn broker_rejects_expiry_extension_from_request_handle() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
||||
let handle = broker.issue_profile_source_archive_handle(
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
None,
|
||||
archive(),
|
||||
);
|
||||
|
|
@ -310,10 +314,10 @@ mod tests {
|
|||
#[test]
|
||||
fn broker_rejects_policy_tampered_request_handle() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
||||
let mut handle = broker.issue_profile_source_archive_handle(
|
||||
"workspace-test",
|
||||
Some(runtime_id),
|
||||
Some(&runtime_id),
|
||||
None,
|
||||
archive(),
|
||||
);
|
||||
|
|
@ -327,11 +331,11 @@ mod tests {
|
|||
#[test]
|
||||
fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() {
|
||||
let broker = BackendResourceBroker::default();
|
||||
let runtime_id = "runtime-test";
|
||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
||||
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
|
|
@ -37,16 +37,6 @@ 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 {
|
||||
|
|
@ -67,8 +57,10 @@ 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: u64,
|
||||
pub runtime_worker_id: String,
|
||||
pub display_name: String,
|
||||
pub profile: Option<String>,
|
||||
/// Retention state is explicit so `pinned` can be represented before prune exists.
|
||||
|
|
@ -100,8 +92,7 @@ pub struct WorkdirRegistryRecord {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerWorkdirLinkRecord {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub runtime_worker_id: u64,
|
||||
pub worker_id: String,
|
||||
pub workdir_id: String,
|
||||
pub role: String,
|
||||
pub linked_at: String,
|
||||
|
|
@ -116,10 +107,15 @@ 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: u64,
|
||||
runtime_worker_id: &str,
|
||||
) -> Result<Option<WorkerRegistryRecord>>;
|
||||
fn list_worker_registry(
|
||||
&self,
|
||||
|
|
@ -129,17 +125,11 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||
fn update_worker_retention(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
worker_id: &str,
|
||||
retention_state: &str,
|
||||
updated_at: &str,
|
||||
) -> Result<bool>;
|
||||
fn delete_worker_registry(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
) -> Result<bool>;
|
||||
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool>;
|
||||
|
||||
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
|
||||
fn get_workdir_registry(
|
||||
|
|
@ -163,8 +153,7 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||
fn list_worker_workdir_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
worker_id: &str,
|
||||
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||
fn list_workdir_worker_links(
|
||||
&self,
|
||||
|
|
@ -258,11 +247,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, runtime_worker_id, display_name, profile,
|
||||
workspace_id, worker_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)
|
||||
ON CONFLICT(workspace_id, runtime_id, runtime_worker_id) DO UPDATE SET
|
||||
) 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,
|
||||
display_name = excluded.display_name,
|
||||
profile = excluded.profile,
|
||||
retention_state = CASE
|
||||
|
|
@ -277,6 +268,7 @@ 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,
|
||||
|
|
@ -295,10 +287,26 @@ 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: u64,
|
||||
runtime_worker_id: &str,
|
||||
) -> Result<Option<WorkerRegistryRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
|
|
@ -336,38 +344,26 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
fn update_worker_retention(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
worker_id: &str,
|
||||
retention_state: &str,
|
||||
updated_at: &str,
|
||||
) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
let changed = conn.execute(
|
||||
r#"UPDATE worker_registry
|
||||
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
|
||||
],
|
||||
SET retention_state = ?3, updated_at = ?4
|
||||
WHERE workspace_id = ?1 AND worker_id = ?2"#,
|
||||
params![workspace_id, worker_id, retention_state, updated_at],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_worker_registry(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
) -> Result<bool> {
|
||||
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
let changed = conn.execute(
|
||||
"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],
|
||||
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2",
|
||||
params![workspace_id, worker_id],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
})
|
||||
|
|
@ -472,15 +468,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO worker_workdir_links (
|
||||
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
|
||||
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
|
||||
linked_at = excluded.linked_at,
|
||||
unlinked_at = excluded.unlinked_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.runtime_id,
|
||||
record.runtime_worker_id,
|
||||
record.worker_id,
|
||||
record.workdir_id,
|
||||
record.role,
|
||||
record.linked_at,
|
||||
|
|
@ -494,18 +489,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
fn list_worker_workdir_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
runtime_worker_id: u64,
|
||||
worker_id: &str,
|
||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
|
||||
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||
FROM worker_workdir_links
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3 AND unlinked_at IS NULL
|
||||
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL
|
||||
ORDER BY linked_at DESC"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![workspace_id, runtime_id, runtime_worker_id],
|
||||
params![workspace_id, worker_id],
|
||||
read_worker_workdir_link_record,
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
|
|
@ -520,7 +514,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
|
||||
r#"SELECT workspace_id, 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"#,
|
||||
|
|
@ -540,18 +534,17 @@ fn read_worker_workdir_link_record(
|
|||
) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
|
||||
Ok(WorkerWorkdirLinkRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
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)?,
|
||||
worker_id: row.get(1)?,
|
||||
workdir_id: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
linked_at: row.get(4)?,
|
||||
unlinked_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||
format!(
|
||||
"SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
|
||||
"SELECT workspace_id, worker_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}"
|
||||
)
|
||||
|
|
@ -560,17 +553,18 @@ 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)?,
|
||||
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)?,
|
||||
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)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -605,8 +599,9 @@ 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 INTEGER NOT NULL,
|
||||
runtime_worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
||||
|
|
@ -616,7 +611,8 @@ CREATE TABLE IF NOT EXISTS worker_registry (
|
|||
diagnostics_ref TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
|
||||
PRIMARY KEY (workspace_id, worker_id),
|
||||
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
|
@ -627,7 +623,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', 'not_found', 'corrupted', 'unknown', 'failed')),
|
||||
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'missing', 'removed', '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,
|
||||
|
|
@ -638,14 +634,13 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
|
|||
|
||||
CREATE TABLE IF NOT EXISTS worker_workdir_links (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
runtime_worker_id INTEGER NOT NULL,
|
||||
worker_id TEXT 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(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
|
||||
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,
|
||||
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
|
@ -654,7 +649,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, runtime_id, runtime_worker_id, linked_at DESC);
|
||||
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -807,7 +802,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 INTEGER NOT NULL,
|
||||
runtime_worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
||||
|
|
@ -840,142 +835,6 @@ 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#"
|
||||
|
|
@ -1199,7 +1058,7 @@ mod tests {
|
|||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 4);
|
||||
|
||||
let record = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
|
|
@ -1211,7 +1070,7 @@ mod tests {
|
|||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 6);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 4);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
|
|
@ -1331,6 +1190,7 @@ mod tests {
|
|||
"worker_registry",
|
||||
[
|
||||
"workspace_id",
|
||||
"worker_id",
|
||||
"runtime_id",
|
||||
"runtime_worker_id",
|
||||
"display_name",
|
||||
|
|
@ -1420,7 +1280,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 4);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
|
|
@ -1521,12 +1381,13 @@ 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: 1,
|
||||
runtime_worker_id: "browser-1".to_string(),
|
||||
display_name: "Browser 1".to_string(),
|
||||
profile: Some("builtin:companion".to_string()),
|
||||
retention_state: "pinned".to_string(),
|
||||
transcript_ref: Some("runtime://embedded/workers/00000001/transcript".to_string()),
|
||||
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()),
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
|
|
@ -1543,12 +1404,12 @@ mod tests {
|
|||
|
||||
let workdir = WorkdirRegistryRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
workdir_id: "0000019a00000000001".to_string(),
|
||||
workdir_id: "backend-2-repo".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: "not_found".to_string(),
|
||||
materialization_status: "removed".to_string(),
|
||||
cleanliness: "clean".to_string(),
|
||||
management_kind: "backend_managed".to_string(),
|
||||
created_at: "2".to_string(),
|
||||
|
|
@ -1572,8 +1433,7 @@ mod tests {
|
|||
|
||||
let link = WorkerWorkdirLinkRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
runtime_worker_id: worker.runtime_worker_id.clone(),
|
||||
worker_id: worker.worker_id.clone(),
|
||||
workdir_id: workdir.workdir_id.clone(),
|
||||
role: "primary_cwd".to_string(),
|
||||
linked_at: "4".to_string(),
|
||||
|
|
@ -1583,13 +1443,13 @@ mod tests {
|
|||
|
||||
assert_eq!(
|
||||
store
|
||||
.get_worker_registry("local-dev", "embedded", 1)
|
||||
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1")
|
||||
.unwrap(),
|
||||
Some(expected_worker.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workdir_registry("local-dev", "0000019a00000000001")
|
||||
.get_workdir_registry("local-dev", "backend-2-repo")
|
||||
.unwrap(),
|
||||
Some(workdir.clone())
|
||||
);
|
||||
|
|
@ -1605,7 +1465,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_worker_workdir_links("local-dev", "embedded", 1)
|
||||
.list_worker_workdir_links("local-dev", "embedded/browser-1")
|
||||
.unwrap(),
|
||||
vec![link]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/workers.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/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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -436,15 +436,6 @@
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -14,10 +14,7 @@ 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),
|
||||
|
|
@ -27,8 +24,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(
|
||||
|
|
@ -38,16 +35,15 @@ 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('class="icon-action"') &&
|
||||
workersPage.includes('Delete ${worker.label}'),
|
||||
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
|
||||
workersPage.includes("<table class=\"workers-table\">") &&
|
||||
workersPage.includes("Open Console"),
|
||||
"dedicated Workers page should expose a table and attach action per Worker",
|
||||
);
|
||||
assert(
|
||||
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",
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
|
|
@ -58,17 +54,12 @@ 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"),
|
||||
|
|
@ -78,10 +69,7 @@ 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(
|
||||
|
|
@ -101,27 +89,18 @@ 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",
|
||||
);
|
||||
|
|
@ -144,21 +123,18 @@ 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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<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;
|
||||
|
|
@ -49,9 +48,7 @@
|
|||
throw new Error(`workers request failed (${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as ListResponse<Worker>;
|
||||
workers = Array.isArray(payload.items)
|
||||
? payload.items.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS)
|
||||
: [];
|
||||
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
|
||||
if (workers.length === 0) {
|
||||
placeholder = 'No workers reported by the current API.';
|
||||
}
|
||||
|
|
@ -109,7 +106,7 @@
|
|||
<span class="worker-task-title">-</span>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
|
||||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||
</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ 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;
|
||||
|
|
@ -69,6 +70,7 @@ export type Host = {
|
|||
};
|
||||
|
||||
export type WorkerCapabilities = {
|
||||
can_accept_input: boolean;
|
||||
can_stop: boolean;
|
||||
can_spawn_followup: boolean;
|
||||
};
|
||||
|
|
@ -82,6 +84,7 @@ 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;
|
||||
|
|
@ -120,12 +123,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;
|
||||
|
|
@ -217,6 +220,10 @@ 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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
cleanliness: "clean",
|
||||
primary_worker_id: null,
|
||||
cleanup_policy: "manual_or_worker_stop",
|
||||
cleanup_target: {
|
||||
kind: "git_worktree",
|
||||
working_directory_id: "wd-1-repo",
|
||||
|
|
|
|||
|
|
@ -35,16 +35,11 @@ export function defaultWorkerLaunchForm(
|
|||
const preferredProfile =
|
||||
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
|
||||
options?.profiles[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 preferredWorkingDirectory =
|
||||
options?.working_directories.find((directory) =>
|
||||
directory.status === "active"
|
||||
) ??
|
||||
options?.working_directories[0];
|
||||
const preferredRepository =
|
||||
options?.repositories.find((repository) =>
|
||||
repository.id === current.working_directory_repository_id
|
||||
|
|
@ -59,7 +54,7 @@ export function defaultWorkerLaunchForm(
|
|||
? current.profile
|
||||
: preferredProfile?.id || "",
|
||||
initial_text: current.initial_text,
|
||||
working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some(
|
||||
working_directory_id: options?.working_directories.some(
|
||||
(directory) =>
|
||||
directory.working_directory_id === current.working_directory_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<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';
|
||||
|
|
@ -9,8 +8,6 @@
|
|||
let sidebarCollapsed = $state(false);
|
||||
</script>
|
||||
|
||||
<WorkspaceAlerts />
|
||||
|
||||
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
|
||||
<WorkspaceSidebar
|
||||
workspace={data.workspace}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -36,6 +40,7 @@
|
|||
<th>Runtime</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Scope</th>
|
||||
<th>Platform</th>
|
||||
<th>Capacity</th>
|
||||
<th>Workdirs</th>
|
||||
|
|
@ -50,6 +55,7 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import type {
|
||||
CleanupWorkdirCandidate,
|
||||
RuntimeCleanupExecutionResponse,
|
||||
RuntimeCleanupPlanResponse,
|
||||
WorkingDirectorySummary,
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
import type { CleanupWorkdirCandidate, WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let cleanupBusyTarget = $state<string | null>(null);
|
||||
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
|
||||
let workdirs = $state<WorkingDirectorySummary[]>([]);
|
||||
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 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) : '—';
|
||||
|
|
@ -31,35 +23,39 @@
|
|||
return workdir.requested_selector ?? 'HEAD';
|
||||
}
|
||||
|
||||
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
|
||||
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
|
||||
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 isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
|
||||
return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== null;
|
||||
function toggleSelected(targetId: string): void {
|
||||
const next = new Set(selectedCleanupTargets);
|
||||
if (next.has(targetId)) next.delete(targetId);
|
||||
else next.add(targetId);
|
||||
selectedCleanupTargets = 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 toggleWorkerSelected(targetId: string): void {
|
||||
const next = new Set(selectedWorkerCleanupTargets);
|
||||
if (next.has(targetId)) next.delete(targetId);
|
||||
else next.add(targetId);
|
||||
selectedWorkerCleanupTargets = next;
|
||||
}
|
||||
|
||||
async function deleteWorkdir(workdir: WorkingDirectorySummary, candidate: CleanupWorkdirCandidate): Promise<void> {
|
||||
if (!cleanupPlan || isDeleteDisabled(candidate)) return;
|
||||
cleanupBusyTarget = candidate.target_id;
|
||||
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;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
||||
|
|
@ -67,32 +63,24 @@
|
|||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected_plan_revision: cleanupPlan.revision,
|
||||
expected_plan_digest: cleanupPlan.digest,
|
||||
worker_target_ids: [],
|
||||
workdir_target_ids: [candidate.target_id],
|
||||
confirm_dirty_discard_target_ids: [],
|
||||
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),
|
||||
}),
|
||||
},
|
||||
);
|
||||
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);
|
||||
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();
|
||||
} catch (error) {
|
||||
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Workdir deletion failed', {
|
||||
title: 'Workdir deletion failed',
|
||||
});
|
||||
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed';
|
||||
} finally {
|
||||
cleanupBusyTarget = null;
|
||||
cleanupBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -108,7 +96,6 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -116,9 +103,71 @@
|
|||
<p class="section-state error">{data.workdirsError}</p>
|
||||
{:else if !data.workdirs}
|
||||
<p class="section-state">Loading workdirs…</p>
|
||||
{:else if workdirs.length === 0}
|
||||
{:else if data.workdirs.items.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>
|
||||
|
|
@ -128,39 +177,20 @@
|
|||
<th>Selector</th>
|
||||
<th>Commit</th>
|
||||
<th>Status</th>
|
||||
<th>Cleanliness</th>
|
||||
<th>Action</th>
|
||||
<th>Policy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workdirs as workdir}
|
||||
{@const cleanup = cleanupCandidate(workdir)}
|
||||
{#each data.workdirs.items as 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>
|
||||
{#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}
|
||||
<span>{workdir.dirty_state_policy}</span>
|
||||
<small>{workdir.cleanup_policy}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
|
@ -169,55 +199,3 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
ClientWorkerEventWsFrame,
|
||||
Diagnostic,
|
||||
Worker,
|
||||
WorkerInputResult,
|
||||
PodProtocolEvent
|
||||
WorkerInputResult
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -33,7 +32,6 @@
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
@ -61,9 +59,7 @@
|
|||
);
|
||||
const lines = $derived(projection.lines);
|
||||
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
||||
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
|
||||
const inputReady = $derived(workerState === 'idle');
|
||||
const canSend = $derived(inputReady && draft.trim().length > 0 && !sending);
|
||||
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
|
|
@ -101,15 +97,12 @@
|
|||
async function loadWorker(target: ConsoleTarget) {
|
||||
workerError = null;
|
||||
try {
|
||||
const payload = await getJson<Worker>(
|
||||
worker = 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +132,7 @@
|
|||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const content = draft.trim();
|
||||
if (!content || sending || !inputReady) {
|
||||
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +145,6 @@
|
|||
);
|
||||
if (result.state === 'accepted') {
|
||||
draft = '';
|
||||
liveWorkerState = 'running';
|
||||
} else {
|
||||
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||
}
|
||||
|
|
@ -163,18 +155,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
|
@ -202,10 +182,6 @@
|
|||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||
return;
|
||||
}
|
||||
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
|
||||
if (observedState) {
|
||||
liveWorkerState = observedState;
|
||||
}
|
||||
observedEvents = [
|
||||
...observedEvents,
|
||||
{
|
||||
|
|
@ -328,7 +304,6 @@
|
|||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
resetObservedEvents();
|
||||
liveWorkerState = null;
|
||||
streamDiagnostics = [];
|
||||
advanceReloadToken();
|
||||
void loadConsoleData(target);
|
||||
|
|
@ -349,7 +324,7 @@
|
|||
</div>
|
||||
<div class="console-header-actions">
|
||||
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
||||
{workerState} · stream {streamState}
|
||||
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
||||
</div>
|
||||
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
||||
Details
|
||||
|
|
@ -455,6 +430,7 @@
|
|||
<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>
|
||||
|
|
@ -485,7 +461,7 @@
|
|||
id="worker-console-message"
|
||||
aria-label="Console input"
|
||||
bind:value={draft}
|
||||
disabled={!inputReady || sending}
|
||||
disabled={!worker?.capabilities.can_accept_input || sending}
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||
|
|
|
|||
|
|
@ -1,66 +1,14 @@
|
|||
<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 { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
|
||||
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace-sidebar/types';
|
||||
import type { Worker } from '$lib/workspace-sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type WorkerActionKind = 'pin' | 'delete';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
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 };
|
||||
}
|
||||
let retentionStatus = $state<string | null>(null);
|
||||
|
||||
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
|
||||
if (busyAction) return;
|
||||
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
|
||||
try {
|
||||
retentionStatus = null;
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.workspaceId,
|
||||
|
|
@ -70,72 +18,16 @@
|
|||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
|
||||
retentionStatus = payload?.message ?? payload?.error ?? response.statusText;
|
||||
return;
|
||||
}
|
||||
worker.pinned = Boolean(payload?.pinned);
|
||||
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
|
||||
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;
|
||||
}
|
||||
retentionStatus = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`;
|
||||
}
|
||||
|
||||
function workerStatus(worker: Worker): string {
|
||||
return worker.state;
|
||||
return `${worker.state} · ${worker.status}`;
|
||||
}
|
||||
|
||||
function workerProfile(worker: Worker): string {
|
||||
|
|
@ -160,7 +52,8 @@
|
|||
<header class="workers-page-header">
|
||||
<div>
|
||||
<h1 id="workers-heading">Workers</h1>
|
||||
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
|
||||
<p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p>
|
||||
{#if retentionStatus}<p>{retentionStatus}</p>{/if}
|
||||
</div>
|
||||
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
|
||||
</header>
|
||||
|
|
@ -169,7 +62,7 @@
|
|||
<p class="section-state error">{data.workersError}</p>
|
||||
{:else if !data.workers}
|
||||
<p class="section-state">Loading Workers…</p>
|
||||
{:else if workers.length === 0}
|
||||
{:else if data.workers.items.length === 0}
|
||||
<p class="section-state">No Workers are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap workers-table-wrap">
|
||||
|
|
@ -186,17 +79,10 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workers as worker}
|
||||
{@const cleanup = cleanupCandidate(worker)}
|
||||
{@const canDelete = cleanup && !cleanup.blocking_reason}
|
||||
{@const anyActionDisabled = actionsDisabled()}
|
||||
{#each data.workers.items as worker}
|
||||
<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>
|
||||
|
|
@ -205,40 +91,10 @@
|
|||
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
||||
<td>{workerDirectory(worker)}</td>
|
||||
<td>
|
||||
<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}
|
||||
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
||||
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
||||
{worker.pinned ? 'Unpin' : 'Pin'}
|
||||
</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}
|
||||
|
|
@ -247,78 +103,3 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||
import type { ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
|
|
@ -7,28 +7,10 @@ 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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,16 +43,7 @@
|
|||
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 && !selectedRuntimeAllowsNoWorkdir,
|
||||
));
|
||||
let availableWorkingDirectories = $derived(
|
||||
selectedRuntimeAllowsNoWorkdir
|
||||
? []
|
||||
: (options?.working_directories ?? []).filter((directory) =>
|
||||
directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null
|
||||
),
|
||||
);
|
||||
let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected));
|
||||
let canStartWorker = $derived(Boolean(
|
||||
runtimeId &&
|
||||
profile &&
|
||||
|
|
@ -74,13 +65,6 @@
|
|||
return () => controller.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) {
|
||||
workingDirectoryId = '';
|
||||
relativeCwd = '';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
optionsError = null;
|
||||
|
|
@ -125,10 +109,6 @@
|
|||
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;
|
||||
|
|
@ -144,6 +124,7 @@
|
|||
runtime_id: runtimeId,
|
||||
repository_id: workingDirectoryRepositoryId,
|
||||
selector: workingDirectorySelector || null,
|
||||
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
@ -264,16 +245,16 @@
|
|||
<span>Run at</span>
|
||||
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
||||
{#if selectedRuntimeAllowsNoWorkdir}
|
||||
<option value="">No workdir</option>
|
||||
<option value="">No workdir · embedded conversation only</option>
|
||||
{:else}
|
||||
<option value="" disabled>Select workdir</option>
|
||||
{#each availableWorkingDirectories as directory}
|
||||
<option value={directory.working_directory_id}>
|
||||
{/if}
|
||||
{#each options?.working_directories ?? [] as directory}
|
||||
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
|
||||
{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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user