Compare commits
44 Commits
b6bb22f956
...
f5b200d36b
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b200d36b | |||
| 9c3677ff67 | |||
| 498337317c | |||
| 655cdf9533 | |||
| 1b9d8c91c3 | |||
| 59524679c1 | |||
| daa06f98fb | |||
| 2061c810db | |||
| 80a84b854c | |||
| f88ee692c9 | |||
| b583af4dd3 | |||
| 81ceb1ceff | |||
| da0fe7c81f | |||
| 72c7beb9c7 | |||
| 490d5ed674 | |||
| 60c772ec38 | |||
| fb0d5aeaa9 | |||
| ec4b404580 | |||
| 6b91d83fe2 | |||
| b82407816e | |||
| f54ba6950c | |||
| a5e38f5964 | |||
| 49f085916e | |||
| 6fedf81785 | |||
| 6af7f61247 | |||
| 4a90267ebc | |||
| c948b669c1 | |||
| c56b8c04ea | |||
| 8b235b6cf5 | |||
| 59b1b3df79 | |||
| 21802d68f8 | |||
| 52cccc7f2f | |||
| 3009bb4ad9 | |||
| d8c0853d55 | |||
| c5aae4b234 | |||
| 664f8693a0 | |||
| 88d819591e | |||
| 593d3309d9 | |||
| 32d1ae597e | |||
| 30d15daddf | |||
| d8412d022b | |||
| d53bfd8087 | |||
| 2d2c29a775 | |||
| 69560addf8 |
0
.yoi/tickets/00001KX7WG3QF/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7WG3QF/artifacts/.gitkeep
Normal file
27
.yoi/tickets/00001KX7WG3QF/item.md
Normal file
27
.yoi/tickets/00001KX7WG3QF/item.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
title: 'Remove duplicate Worker status field'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T06:05:38Z'
|
||||||
|
updated_at: '2026-07-11T06:22:34Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T06:06:13Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace Backend/Frontend の Worker projection は `state` と `status` を両方持っているが、現状ほぼ同じ値を入れており区別がない。live/archived Worker 表示で不整合を避けるため、browser-facing Worker summary は `state` に一本化する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Workspace browser-facing `WorkerSummary` から duplicate な `status` field を削除する。
|
||||||
|
- Backend projection は `state` のみを返す。
|
||||||
|
- Frontend types / UI / tests は `state` のみを参照する。
|
||||||
|
- Runtime 内部や別ドメインの typed status は必要ならそのまま残すが、Workspace browser Worker summary に duplicate status を露出しない。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `/api/workers` の Worker item に `status` field が含まれない。
|
||||||
|
- `/api/runtimes/{runtime}/workers/{worker}` の Worker detail に `status` field が含まれない。
|
||||||
|
- Workspace frontend に `.status` 参照が残らない。
|
||||||
|
- `cargo test`、`cd web/workspace && deno task check`、`cd web/workspace && deno task test`、`nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX7WG3QF/resolution.md
Normal file
1
.yoi/tickets/00001KX7WG3QF/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Removed duplicate browser-facing Worker `status` field. Workspace Worker summaries now use `state` only; Frontend Worker list/sidebar/Console no longer reference `worker.status`. Runtime, Host, Workdir, and protocol statuses remain where they are distinct concepts.
|
||||||
91
.yoi/tickets/00001KX7WG3QF/thread.md
Normal file
91
.yoi/tickets/00001KX7WG3QF/thread.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T06:05:38Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T06:06:13Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:06:13Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T06:22:05Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed duplicate Workspace Worker `status` from browser-facing Worker summaries.
|
||||||
|
|
||||||
|
- `WorkerSummary` now exposes `state` only for Worker state.
|
||||||
|
- Backend Worker projections populate only `state`; registry-only Workers remain `state: archived`.
|
||||||
|
- Frontend Worker type, sidebar, Workers page, and Console header no longer reference `worker.status`.
|
||||||
|
- Runtime/Host/Workdir/protocol statuses are unchanged where they are distinct domain concepts.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- cargo test -q
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:22:05Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T06:22:34Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T06:22:34Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Removed duplicate browser-facing Worker `status` field. Workspace Worker summaries now use `state` only; Frontend Worker list/sidebar/Console no longer reference `worker.status`. Runtime, Host, Workdir, and protocol statuses remain where they are distinct concepts.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX7Z3WE6/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7Z3WE6/artifacts/.gitkeep
Normal file
26
.yoi/tickets/00001KX7Z3WE6/item.md
Normal file
26
.yoi/tickets/00001KX7Z3WE6/item.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
title: 'Disable Console links for archived Workers'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T06:51:23Z'
|
||||||
|
updated_at: '2026-07-11T07:01:41Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T06:52:17Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workers list は live Worker と registry-only archived Worker を含む。Archived Worker は live Runtime Worker ではなく Console/detail API が 404 になるため、Sidebar/Workers page で Console に遷移可能な UI として表示してはいけない。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `implementation.kind == "backend_worker_registry"` または `state == "archived"` の Worker は Console link を表示しない。
|
||||||
|
- Sidebar では archived Worker を disabled/readonly 行として表示し、クリックできる Console target にしない。
|
||||||
|
- Workers page でも archived Worker の `Open Console` link を表示しない。
|
||||||
|
- live Worker の Console link は維持する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Archived Worker が Sidebar から Console にアクセス可能に見えない。
|
||||||
|
- Archived Worker が Workers page から Console にアクセス可能に見えない。
|
||||||
|
- `cd web/workspace && deno task check` と `cd web/workspace && deno task test` が通る。
|
||||||
1
.yoi/tickets/00001KX7Z3WE6/resolution.md
Normal file
1
.yoi/tickets/00001KX7Z3WE6/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Archived registry-only Workers no longer appear as Console-accessible. Sidebar renders them as disabled rows, and the Workers page shows `Archived` instead of `Open Console`; live Workers keep Console links.
|
||||||
89
.yoi/tickets/00001KX7Z3WE6/thread.md
Normal file
89
.yoi/tickets/00001KX7Z3WE6/thread.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T06:51:23Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T06:52:17Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T06:52:17Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T07:01:17Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Archived registry-only Workers no longer render as Console links.
|
||||||
|
|
||||||
|
- Added shared `canOpenWorkerConsole` helper that rejects `state: archived` and `implementation.kind: backend_worker_registry`.
|
||||||
|
- Sidebar renders archived Workers as disabled readonly rows instead of anchors.
|
||||||
|
- Workers page shows `Archived` instead of `Open Console` for archived Workers.
|
||||||
|
- Added unit coverage for live vs archived Console availability.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T07:01:17Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T07:01:41Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T07:01:41Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Archived registry-only Workers no longer appear as Console-accessible. Sidebar renders them as disabled rows, and the Workers page shows `Archived` instead of `Open Console`; live Workers keep Console links.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX84V0C4/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX84V0C4/artifacts/.gitkeep
Normal file
29
.yoi/tickets/00001KX84V0C4/item.md
Normal file
29
.yoi/tickets/00001KX84V0C4/item.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
title: 'Preserve requested Worker display names'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T08:31:24Z'
|
||||||
|
updated_at: '2026-07-11T08:44:32Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T08:31:54Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
New Worker 画面は既定で `Coding Worker` などの display name を送るが、Workspace Backend が Worker 作成後の registry 同期で Runtime projection の `worker_id` 由来 label を display name として保存している。その結果 Workers list の label と id がどちらも `worker-00000001` のように見える。
|
||||||
|
|
||||||
|
Display name は UI/request の値を preserving metadata として扱い、Runtime worker id とは分ける必要がある。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Worker 作成時に `requested_worker_name` / UI display name を Backend worker registry の `display_name` として保存する。
|
||||||
|
- 以後の Runtime observation/list sync で既存 registry の `display_name` を Runtime id label で上書きしない。
|
||||||
|
- registry record が無い live Worker だけ Runtime projection label を fallback display name として使う。
|
||||||
|
- Workers list の1カラム目は display name、補助表示として worker id を別に出す。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- New Worker 画面から `Coding Worker` で作成した Worker が Workers list で `Coding Worker` と表示される。
|
||||||
|
- 同じ行で `worker-00000001` 等の Worker id は metadata として別に確認できる。
|
||||||
|
- Runtime list sync 後も display name が worker id に戻らない。
|
||||||
|
- `cargo test -q -p yoi-workspace-server`、`cd web/workspace && deno task check`、`cd web/workspace && deno task test`、`nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX84V0C4/resolution.md
Normal file
1
.yoi/tickets/00001KX84V0C4/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Worker display names are now preserved separately from Runtime worker ids. New Worker creation records the requested/sanitized display name, and later live Runtime sync does not overwrite it with the worker id label.
|
||||||
91
.yoi/tickets/00001KX84V0C4/thread.md
Normal file
91
.yoi/tickets/00001KX84V0C4/thread.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T08:31:24Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T08:31:54Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:31:54Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T08:44:11Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Fixed Worker display name preservation.
|
||||||
|
|
||||||
|
- Browser Worker create now records the sanitized UI display name in Backend worker registry instead of immediately syncing the Runtime worker id label.
|
||||||
|
- Runtime Worker create API records `requested_worker_name` as display name when provided.
|
||||||
|
- Later Runtime observation/list sync preserves existing registry display_name and only falls back to Runtime label for never-recorded live Workers.
|
||||||
|
- Added coverage that a Worker created with empty browser display_name uses the default `Coding Worker` label in Workers list/detail while keeping worker id separate.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- cargo test -q
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T08:44:11Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T08:44:32Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T08:44:32Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Worker display names are now preserved separately from Runtime worker ids. New Worker creation records the requested/sanitized display name, and later live Runtime sync does not overwrite it with the worker id label.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX8D417N/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX8D417N/artifacts/.gitkeep
Normal file
17
.yoi/tickets/00001KX8D417N/item.md
Normal file
17
.yoi/tickets/00001KX8D417N/item.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
title: 'Opaque Worker and Workdir ids'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T10:56:08Z'
|
||||||
|
updated_at: '2026-07-11T12:05:24Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T10:56:21Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- 未定
|
||||||
1
.yoi/tickets/00001KX8D417N/resolution.md
Normal file
1
.yoi/tickets/00001KX8D417N/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/tmp/yoi-id-implementation-report.md
|
||||||
116
.yoi/tickets/00001KX8D417N/thread.md
Normal file
116
.yoi/tickets/00001KX8D417N/thread.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T10:56:08Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T10:56:21Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: hare at: 2026-07-11T10:56:21Z -->
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Replace meaningful concatenated Worker/Workdir ids with opaque runtime/local ids and composite backend registry keys. Preserve display labels separately, migrate legacy registry/link rows, and validate server/runtime/frontend builds.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T11:25:17Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented opaque Worker/Workdir ids and removed Backend synthetic Worker PKs.
|
||||||
|
|
||||||
|
- Runtime generated Worker ids are now opaque fixed-width hex counters without the `worker-` prefix.
|
||||||
|
- Runtime and Backend generated Workdir ids are now opaque timestamp+sequence hex ids without `backend-`, `alloc-`, repository id, or Runtime/Worker-derived components.
|
||||||
|
- Workspace Backend worker registry now uses `(workspace_id, runtime_id, runtime_worker_id)` as the primary key.
|
||||||
|
- Worker/Workdir links now reference the same composite worker key instead of `{runtime_id}/{worker_id}` synthetic strings.
|
||||||
|
- Added schema v5 migration from the legacy synthetic `worker_id` registry/link tables.
|
||||||
|
- Updated cleanup, list, sync, retention, and tests to use composite keys and keep public `WorkerSummary.worker_id` Runtime-local.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cargo test -q
|
||||||
|
- cd web/workspace && deno task check && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T11:25:17Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T11:25:17Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T11:25:17Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
/tmp/yoi-id-implementation-report.md
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T12:05:24Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up: Worker ids are now numeric, not String-backed.
|
||||||
|
|
||||||
|
- `worker_runtime::WorkerId` is now a transparent `u64` newtype instead of `String`.
|
||||||
|
- Runtime HTTP JSON uses numeric Worker ids; URL path handling parses the path segment as `u64`.
|
||||||
|
- Workspace Backend stores `runtime_worker_id` as INTEGER in `worker_registry` and `worker_workdir_links` composite keys.
|
||||||
|
- Browser-facing Workspace `WorkerSummary.worker_id` remains a string route segment derived from the numeric id, so existing frontend route composition stays simple.
|
||||||
|
- Schema v5 migration converts legacy `worker-*` / numeric string worker ids into integer `runtime_worker_id` values before rebuilding registry/link tables.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXB7V4WY/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXB7V4WY/artifacts/.gitkeep
Normal file
47
.yoi/tickets/00001KXB7V4WY/item.md
Normal file
47
.yoi/tickets/00001KXB7V4WY/item.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
title: 'Runtime restart-crossing Worker restore path'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-12T13:21:38Z'
|
||||||
|
updated_at: '2026-07-13T12:05:05Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-12T14:16:14Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Filesystem-backed Runtime は persisted Runtime / Worker record を reload できるが、Runtime process の再起動後は persisted connected execution mapping が stale 扱いになり、live Worker controller / handle は復元されない。そのため、復元後の Worker に input を送ると `worker has no execution backend` で失敗する。
|
||||||
|
|
||||||
|
TUI の restore 経路では、live row は socket override で attach し、stopped row は Worker name だけを使って Worker runtime command を spawn する。restore 本体は name-keyed metadata と session log replay を authority とし、`Worker::restore_from_worker_metadata_with_context` が active session/segment、manifest snapshot、session log、duplicate writer 防止 allocation、delegated child scope reconciliation を扱う。
|
||||||
|
|
||||||
|
Runtime の restart-crossing restore もこの境界に揃え、persisted execution binding を liveness と見なさず、実 controller / handle を明示的に materialize する経路を設計・実装する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Runtime process restart 後、fs-backed Runtime が persisted active Worker を execution backend 経由で復元できる。
|
||||||
|
- Persisted execution binding は restore hint に留め、live handle の証明として扱わない。
|
||||||
|
- Restore authority は TUI と同じく deterministic Worker name metadata と session log replay に置く。
|
||||||
|
- Fresh Worker creation と persisted Worker restore は execution backend / factory API 上で分ける。
|
||||||
|
- Restore 後は fresh spawn と同じ protocol/event observation bridge を再接続する。
|
||||||
|
- Workdir は新規 materialize せず、persisted assignment / Workdir id を rebind する。
|
||||||
|
- 同じ Workdir が別の active Worker に primary assigned されている場合は restore を拒否または stale diagnostic にする。
|
||||||
|
- 個別 Worker の restore failure で Runtime startup 全体を失敗させない。対象 Worker は stale のまま diagnostic を残す。
|
||||||
|
- Pending / no-history Worker を明示的に扱い、initial input の二重投入を避ける。
|
||||||
|
|
||||||
|
## 設計メモ
|
||||||
|
|
||||||
|
- `WorkerExecutionOperation::Restore` を追加する。
|
||||||
|
- `WorkerExecutionRestoreRequest` と `WorkerExecutionBackend::restore_worker` を追加する。
|
||||||
|
- `RuntimeWorkerFactory::restore_controller` を追加し、`ProfileRuntimeWorkerFactory` では resolved active segment を持つ Worker に対して `Worker::restore_from_worker_metadata_with_context` を呼ぶ。
|
||||||
|
- Runtime は `RuntimeState::from_persisted` の stale marking を維持し、execution backend install 後に active stale candidates の restore pass を走らせる。
|
||||||
|
- Restore 成功時は `execution_handle`、connected binding/status、run state、Workdir status、snapshot、restore event を commit する専用経路を持つ。
|
||||||
|
- Restore 失敗時は `execution_handle = None` のまま、stale status と `worker_execution_restore_failed` diagnostic を残す。
|
||||||
|
- Pending metadata で resolved `segment_id` がない場合は、少なくとも no-history / no-initial-input Worker と initial-input 境界が曖昧な Worker を区別する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- fs-backed Runtime の再起動後、valid metadata / session log を持つ active Worker が live execution handle 付きで restored connected になり、`send_input` が成功する。
|
||||||
|
- Worker metadata missing / corrupt / pending conflict などの restore failure は Runtime startup を止めず、該当 Worker を stale のまま clear diagnostic として表示する。
|
||||||
|
- Pending no-history Worker の restore behavior がテストされ、initial input を二重投入しない。
|
||||||
|
- Workdir restore は persisted Workdir binding を再利用し、新しい Workdir を作らない。
|
||||||
|
- Spawn と restore が同じ observation bridge behavior を共有し、frontend / WebSocket observation が restored Worker の状態変化を受け取れる。
|
||||||
3
.yoi/tickets/00001KXB7V4WY/resolution.md
Normal file
3
.yoi/tickets/00001KXB7V4WY/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXB7V4WY` (`Runtime restart-crossing Worker restore path`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
97
.yoi/tickets/00001KXB7V4WY/thread.md
Normal file
97
.yoi/tickets/00001KXB7V4WY/thread.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-12T13:21:38Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-12T14:16:14Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:16:14Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-12T14:38:35Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Runtime restart-crossing Worker restore path.
|
||||||
|
|
||||||
|
- Added explicit `Restore` execution operation and `WorkerExecutionRestoreRequest` / backend restore API.
|
||||||
|
- Added Runtime restore pass for fs-backed Runtime startup after execution backend install.
|
||||||
|
- Persisted connected mappings remain stale on load until a real backend handle is restored.
|
||||||
|
- Restore success commits a live execution handle, connected status, Workdir status, and `WorkerExecutionRestored` event.
|
||||||
|
- Restore failure keeps the Worker stale and records `worker_execution_restore_failed` diagnostics without failing Runtime startup.
|
||||||
|
- Worker runtime backend can restore controllers through `RuntimeWorkerFactory::restore_controller`, using `Worker::restore_from_worker_metadata_with_context` and a conservative pending/no-history fallback.
|
||||||
|
- Spawn and restore share event bridge/handle registration behavior.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt`
|
||||||
|
- `cargo check -q -p worker-runtime`
|
||||||
|
- `cargo check -q -p worker-runtime --features fs-store`
|
||||||
|
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-12T14:38:56Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:05Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:05Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXB7V4WY` (`Runtime restart-crossing Worker restore path`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXD6VFJ8/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXD6VFJ8/artifacts/.gitkeep
Normal file
32
.yoi/tickets/00001KXD6VFJ8/item.md
Normal file
32
.yoi/tickets/00001KXD6VFJ8/item.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
title: 'Remove Runtime-owned runtime_id'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T07:42:49Z'
|
||||||
|
updated_at: '2026-07-13T12:05:05Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T07:43:36Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Remote Runtime は backend から URL で到達される実体であり、backend 側の runtime id / alias は workspace-server が endpoint を識別するための名前である。Runtime process / Worker は、その backend alias を感知する必要がない。
|
||||||
|
|
||||||
|
現在は worker-runtime crate の `RuntimeId` / `WorkerRef { runtime_id, worker_id }` が Runtime 内部、fs-store、REST server、Worker 名生成に入り込んでおり、`--runtime-id` を指定しないと restart crossing restore が成立しない。これは backend の識別子を Runtime 実体に渡している設計で、remote alias と Runtime 内部永続化 namespace が混ざっている。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `worker-runtime` の Runtime 実装から Runtime-owned `runtime_id` を削除する。
|
||||||
|
- `worker-runtime` の Worker identity は Runtime-local `WorkerId` / `WorkerRef` にする。
|
||||||
|
- Worker / Worker controller / restore 用 deterministic Worker name は backend alias や Runtime id に依存しない。
|
||||||
|
- REST runtime server の `--runtime-id` 指定を削除する。
|
||||||
|
- fs-store は runtime id ごとのディレクトリ分割をやめ、指定された root 自体を単一 Runtime store として扱う。
|
||||||
|
- Workspace backend の runtime id / alias は workspace-server 側の endpoint identifier として残し、Runtime 内部へ渡さない。
|
||||||
|
- 既存の frontend / workspace-server API で必要な runtime id は workspace-server projection 層で付与する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --store fs --fs-root <dir>` が `--runtime-id` なしで durable Runtime として起動する。
|
||||||
|
- 同じ fs-root で再起動した時、persisted Worker record が Runtime-local id で読める。
|
||||||
|
- `Runtime` 内部 API は Worker lookup に backend alias を要求しない。
|
||||||
|
- workspace-server remote runtime id は引き続き URL endpoint alias として機能する。
|
||||||
3
.yoi/tickets/00001KXD6VFJ8/resolution.md
Normal file
3
.yoi/tickets/00001KXD6VFJ8/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXD6VFJ8` (`Remove Runtime-owned runtime_id`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
100
.yoi/tickets/00001KXD6VFJ8/thread.md
Normal file
100
.yoi/tickets/00001KXD6VFJ8/thread.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T07:42:49Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T07:43:36Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T07:43:36Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T08:37:44Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented removal of Runtime-owned runtime_id from worker-runtime.
|
||||||
|
|
||||||
|
- Removed `RuntimeId` and made worker-runtime Worker authority Runtime-local (`WorkerRef { worker_id }`).
|
||||||
|
- Removed `runtime_id` from Runtime options, summaries, event cursors/subscriptions, Worker summaries/details, Runtime errors, persisted Runtime state, and Worker snapshots.
|
||||||
|
- Removed REST server `--runtime-id`; the process exposes a single Runtime identified externally by URL/backend alias only.
|
||||||
|
- Changed REST server default store selection to durable fs-store with a default user data root, keeping `--store memory` as the explicit throwaway mode.
|
||||||
|
- Changed fs-store layout so the supplied root is the Runtime store, not `root/runtimes/<runtime-id>`.
|
||||||
|
- Added legacy single-runtime layout migration from the old `runtimes/<encoded-runtime-id>/` layout into the root store when exactly one legacy Runtime directory exists.
|
||||||
|
- Changed deterministic Worker runtime names from `runtime-<runtime_id>-<worker_id>` to Runtime-local `worker-runtime-<worker_id>`.
|
||||||
|
- Kept workspace-server runtime ids as backend/endpoint aliases and adapted embedded/remote projections to attach those aliases at the workspace boundary instead of passing them into worker-runtime.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --runtime-id arc` rejects the removed option
|
||||||
|
- started `worker-runtime-rest-server` with `--fs-root /tmp/yoi-runtime-idless-test` and verified `/v1/runtime` reports `backend: fs_store` without runtime_id
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T08:37:44Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:05Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:05Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXD6VFJ8` (`Remove Runtime-owned runtime_id`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDCGNJE/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDCGNJE/artifacts/.gitkeep
Normal file
30
.yoi/tickets/00001KXDCGNJE/item.md
Normal file
30
.yoi/tickets/00001KXDCGNJE/item.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
title: 'Remove workspace and cwd from worker runtime server'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T09:21:46Z'
|
||||||
|
updated_at: '2026-07-13T12:05:04Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T09:22:30Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`worker-runtime-rest-server` は remote Runtime process として backend から URL で操作される。Workspace authority は workspace-server / backend 側が提供するため、Runtime process が `--workspace` / `--cwd` を受け取り、自身で workspace root や cwd を推測する必要はない。
|
||||||
|
|
||||||
|
現在の `--workspace` / `--cwd` は、古い local workspace 前提の名残であり、profile source archive / backend resource / working-directory binding による authority 境界と合っていない。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `worker-runtime-rest-server` の `--workspace` と `--cwd` option を削除する。
|
||||||
|
- Runtime-local Worker root は workspace ではなく Runtime-local durable/scratch root から導出する。
|
||||||
|
- Workdir が materialize された Worker だけが materialized Workdir root/cwd を受け取る。
|
||||||
|
- Workdirless Worker は workspace filesystem authority を持たないまま起動する。
|
||||||
|
- Backend-provided profile source / resource endpoint / working-directory binding の経路は維持する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `--workspace` / `--cwd` は help から消え、指定すると unknown argument として reject される。
|
||||||
|
- `worker-runtime-rest-server` は `--workspace` なしで起動できる。
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server` が通る。
|
||||||
|
- `cargo test -q -p yoi-workspace-server` が通る。
|
||||||
3
.yoi/tickets/00001KXDCGNJE/resolution.md
Normal file
3
.yoi/tickets/00001KXDCGNJE/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXDCGNJE` (`Remove workspace and cwd from worker runtime server`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
98
.yoi/tickets/00001KXDCGNJE/thread.md
Normal file
98
.yoi/tickets/00001KXDCGNJE/thread.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T09:21:46Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T09:22:30Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:22:30Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T09:31:37Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed `worker-runtime-rest-server` `--workspace` and `--cwd` options.
|
||||||
|
|
||||||
|
- Removed process config fields and CLI parsing for workspace/cwd.
|
||||||
|
- Runtime Worker factory now uses a Runtime-local `worker-root` under the Runtime root instead of a workspace root for workdirless Workers.
|
||||||
|
- Workdir bindings still provide their materialized root/cwd when a Worker has a Workdir.
|
||||||
|
- Help no longer lists `--workspace` / `--cwd`; specifying either now fails as an unknown argument.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --workspace /tmp` rejects the removed option
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --cwd /tmp` rejects the removed option
|
||||||
|
- Started `worker-runtime-rest-server` with only `--bind` and `--fs-root` and verified `/v1/runtime` responds with `backend: fs_store`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T09:31:37Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:04Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:04Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXDCGNJE` (`Remove workspace and cwd from worker runtime server`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDEVT13/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDEVT13/artifacts/.gitkeep
Normal file
33
.yoi/tickets/00001KXDEVT13/item.md
Normal file
33
.yoi/tickets/00001KXDEVT13/item.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
title: 'Normalize worker runtime filesystem options'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T10:02:48Z'
|
||||||
|
updated_at: '2026-07-13T12:05:03Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T10:04:08Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`worker-runtime-rest-server` の filesystem option は、`fs-store` feature によって使える永続化境界として見えるべきである。現在の `--worker-store-dir` / `--worker-metadata-dir` / `--worker-runtime-base-dir` は内部実装の分割を直接露出しており、`--store memory` も「store backend」ではなく「永続 store を使わない ephemeral mode」を表している。
|
||||||
|
|
||||||
|
`fs-store` feature 付き build では filesystem-backed store が default で動き、明示的に永続化を切る場合だけ `--no-store` とする。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `--fs-root` は filesystem-backed storage 全体の top-level root とする。
|
||||||
|
- `--fs-worker-dir` を追加し、Worker session / metadata / controller runtime / workdirless Worker root をまとめて導出する。
|
||||||
|
- `--fs-runtime-dir` を追加し、Runtime catalog / Worker list / execution mapping / events の保存先にする。
|
||||||
|
- `--workdir-target` を追加し、Workdir materialization target を指定できるようにする。
|
||||||
|
- `--store <fs>` は future backend switching 用に残し、`memory` は受け付けない。
|
||||||
|
- 旧 `--store memory` は `--no-store` に置き換える。
|
||||||
|
- 旧 `--worker-store-dir` / `--worker-metadata-dir` / `--worker-runtime-base-dir` は削除する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `--help` に新しい option 名が表示され、旧 worker path option は表示されない。
|
||||||
|
- `--store memory` は reject され、`--no-store` は Runtime catalog persistence を無効化する。
|
||||||
|
- `--fs-root <dir>` だけで `<dir>/runtime`、`<dir>/worker`、`<dir>/workdirs` が default 派生される。
|
||||||
|
- Worker Runtime server は新 option で起動できる。
|
||||||
|
- worker-runtime / workspace-server の関連テストと Nix build が通る。
|
||||||
3
.yoi/tickets/00001KXDEVT13/resolution.md
Normal file
3
.yoi/tickets/00001KXDEVT13/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXDEVT13` (`Normalize worker runtime filesystem options`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
102
.yoi/tickets/00001KXDEVT13/thread.md
Normal file
102
.yoi/tickets/00001KXDEVT13/thread.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:02:48Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T10:04:08Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:08Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T10:04:08Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented filesystem option cleanup for `worker-runtime-rest-server`.
|
||||||
|
|
||||||
|
- Kept `--store <fs>` as the only accepted store selector value.
|
||||||
|
- Added `--no-store` to represent the old ephemeral memory Runtime catalog mode.
|
||||||
|
- Changed `--fs-root` to mean the top-level filesystem-backed storage root.
|
||||||
|
- Added `--fs-worker-dir` for Worker storage, defaulting to `<fs-root>/worker`.
|
||||||
|
- Added `--fs-runtime-dir` for Runtime catalog storage, defaulting to `<fs-root>/runtime`.
|
||||||
|
- Added `--workdir-target` for Workdir materialization, defaulting to `<fs-root>/workdirs`.
|
||||||
|
- Removed the old `--worker-store-dir`, `--worker-metadata-dir`, and `--worker-runtime-base-dir` CLI options.
|
||||||
|
- Worker session store, Worker metadata store, Worker controller runtime base, and workdirless Worker root are now derived from `--fs-worker-dir`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo check -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --help`
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --store memory` rejects the old memory store selector
|
||||||
|
- `cargo run -q -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --worker-store-dir /tmp/x` rejects the removed worker path option
|
||||||
|
- Started `worker-runtime-rest-server` with `--fs-root /tmp/yoi-runtime-fs-options-test` and verified `/v1/runtime` responds with `backend: fs_store` while using the derived runtime directory.
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:04:09Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:03Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:03Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXDEVT13` (`Normalize worker runtime filesystem options`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDHAWEF/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDHAWEF/artifacts/.gitkeep
Normal file
25
.yoi/tickets/00001KXDHAWEF/item.md
Normal file
25
.yoi/tickets/00001KXDHAWEF/item.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: 'Show backend operation error details'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T10:45:59Z'
|
||||||
|
updated_at: '2026-07-13T12:05:02Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T10:46:30Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace cleanup などの backend operation failure が `operation failed; backend-private details were omitted` に置き換えられ、実際の失敗理由が UI に出ない。現在は local workspace backend / trusted local UI の開発中であり、原因調査のためには backend error message をそのまま表示できる必要がある。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Workspace API error response の backend operation message を固定の omitted 文言に置換しない。
|
||||||
|
- `workspace_cleanup_plan_stale` などの RuntimeOperationFailed message が UI/API response にそのまま乗る。
|
||||||
|
- 既存の diagnostic surface は維持する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `sanitize_backend_error` が入力 message を保持する。
|
||||||
|
- 既存の omitted-path 前提 test を新しい方針に合わせる。
|
||||||
|
- workspace-server tests が通る。
|
||||||
3
.yoi/tickets/00001KXDHAWEF/resolution.md
Normal file
3
.yoi/tickets/00001KXDHAWEF/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXDHAWEF` (`Show backend operation error details`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
89
.yoi/tickets/00001KXDHAWEF/thread.md
Normal file
89
.yoi/tickets/00001KXDHAWEF/thread.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:45:59Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T10:46:30Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:46:30Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T10:51:20Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Changed workspace-server backend operation error reporting to preserve backend messages instead of replacing them with the fixed `backend-private details were omitted` text.
|
||||||
|
|
||||||
|
- `sanitize_backend_error` now returns the original message.
|
||||||
|
- Updated the existing test to assert backend operation details are preserved.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:51:20Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:02Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:02Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXDHAWEF` (`Show backend operation error details`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDJ1V3D/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDJ1V3D/artifacts/.gitkeep
Normal file
25
.yoi/tickets/00001KXDJ1V3D/item.md
Normal file
25
.yoi/tickets/00001KXDJ1V3D/item.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: 'Preserve typed Workdir not-found errors'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T10:58:32Z'
|
||||||
|
updated_at: '2026-07-13T12:05:01Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T10:59:02Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workdir detail / sync で Runtime 側に存在しない Workdir を問い合わせた時、worker-runtime は `WorkingDirectoryDiagnostic { code: working_directory_not_found }` を一度作っているにもかかわらず、RuntimeError::InvalidRequest に変換して REST error code が `invalid_request` になっている。workspace-server 側は diagnostic code の substring match で not_found を推測しており、typed error が失われて `unknown` になる。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Worker Runtime REST error response に Workdir diagnostic code を preserve する。
|
||||||
|
- workspace-server は diagnostic message / substring ではなく typed diagnostic code の exact match で Workdir not-found を扱う。
|
||||||
|
- Runtime に存在しない Workdir は `unknown` ではなく `not_found` / NotFound として扱える。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `working_directory_not_found` が REST response の error.code として返る。
|
||||||
|
- workspace-server の `workdir_status_from_runtime_miss` が exact code match を使う。
|
||||||
|
- worker-runtime / workspace-server の関連テストが通る。
|
||||||
3
.yoi/tickets/00001KXDJ1V3D/resolution.md
Normal file
3
.yoi/tickets/00001KXDJ1V3D/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXDJ1V3D` (`Preserve typed Workdir not-found errors`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
95
.yoi/tickets/00001KXDJ1V3D/thread.md
Normal file
95
.yoi/tickets/00001KXDJ1V3D/thread.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T10:58:32Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T10:59:02Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T10:59:02Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T11:16:48Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed text/substring-based Workdir not-found handling.
|
||||||
|
|
||||||
|
- Added `RuntimeError::WorkingDirectory(WorkingDirectoryDiagnostic)` so worker-runtime preserves typed Workdir diagnostic codes instead of flattening them into `invalid_request`.
|
||||||
|
- Worker Runtime REST errors now return Workdir diagnostic codes such as `working_directory_not_found` as `error.code` and map that code to HTTP 404.
|
||||||
|
- Remote Runtime HTTP error mapping now preserves typed error codes from Runtime error bodies instead of remapping all HTTP 404 responses to a generic worker-not-found code.
|
||||||
|
- Embedded Runtime diagnostics also preserve typed Workdir diagnostic codes.
|
||||||
|
- `workdir_status_from_runtime_miss` now uses exact `diagnostic.code == "working_directory_not_found"` rather than substring matching.
|
||||||
|
- Added tests for preserving Workdir REST error codes and for exact Workdir miss classification.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:16:48Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:05:01Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:05:01Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXDJ1V3D` (`Preserve typed Workdir not-found errors`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDMM975/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDMM975/artifacts/.gitkeep
Normal file
27
.yoi/tickets/00001KXDMM975/item.md
Normal file
27
.yoi/tickets/00001KXDMM975/item.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
title: 'Allow not-found Workdir cleanup action'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-13T11:43:33Z'
|
||||||
|
updated_at: '2026-07-13T12:04:57Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T11:44:18Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workdir list では Runtime から `not_found` が確認できているが、cleanup plan では `file_status` が `notfound` になり、`workdir_record_delete` ではなく `workdir_dirty_discard` と判定される。そのため frontend の delete action が disabled になっている。
|
||||||
|
|
||||||
|
原因は `WorkingDirectoryStatusKind::NotFound` を `format!("{:?}").to_lowercase()` で string 化しているためで、serde の `snake_case` 表現 (`not_found`) とずれている。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Workdir cleanup plan の observed Runtime status string は Debug 表現ではなく canonical snake_case label を使う。
|
||||||
|
- `WorkingDirectoryStatusKind::NotFound` は `not_found` として扱われる。
|
||||||
|
- `not_found` Workdir candidate は `workdir_record_delete` action になり、blocking reason がなければ削除可能になる。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- cleanup plan API で not-found Workdir の `file_status` が `not_found` になる。
|
||||||
|
- cleanup plan API で not-found Workdir の `action` が `workdir_record_delete` になる。
|
||||||
|
- workspace-server tests が通る。
|
||||||
3
.yoi/tickets/00001KXDMM975/resolution.md
Normal file
3
.yoi/tickets/00001KXDMM975/resolution.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Ticket `00001KXDMM975` (`Allow not-found Workdir cleanup action`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
96
.yoi/tickets/00001KXDMM975/thread.md
Normal file
96
.yoi/tickets/00001KXDMM975/thread.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T11:43:33Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T11:44:18Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:44:18Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T11:51:11Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Fixed not-found Workdir cleanup plan classification.
|
||||||
|
|
||||||
|
Root cause:
|
||||||
|
- Cleanup plan converted `WorkingDirectoryStatusKind::NotFound` with `format!("{:?}").to_lowercase()`, producing `notfound` instead of the canonical API/status label `not_found`.
|
||||||
|
- The action classifier only recognized `missing` / `not_found`, so the candidate fell through to `workdir_dirty_discard`, which the frontend disables by design.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Added explicit `workdir_status_kind_label` mapping for `WorkingDirectoryStatusKind`.
|
||||||
|
- `NotFound` now maps to `not_found`, so cleanup plan candidates become `workdir_record_delete`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- targeted cleanup plan test
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T11:51:11Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-13T12:04:57Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-13T12:04:57Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Ticket `00001KXDMM975` (`Allow not-found Workdir cleanup action`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
|
||||||
|
|
||||||
|
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDR5EK4/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDR5EK4/artifacts/.gitkeep
Normal file
31
.yoi/tickets/00001KXDR5EK4/item.md
Normal file
31
.yoi/tickets/00001KXDR5EK4/item.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
title: 'Type Workdir cleanup plan status'
|
||||||
|
state: 'done'
|
||||||
|
created_at: '2026-07-13T12:45:21Z'
|
||||||
|
updated_at: '2026-07-13T12:56:55Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T12:46:05Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Runtime と workspace-server はどちらも Rust であり、Runtime catalog の Workdir status / cleanliness は `worker-runtime` の typed enum として共有できる。それにもかかわらず cleanup plan は `file_status: String` / `cleanliness: String` に落として判定しており、`WorkingDirectoryStatusKind::NotFound` が `notfound` になるバグを起こした。
|
||||||
|
|
||||||
|
Rust 内部の cleanup policy 判定では文字列化せず、typed enum / 明示的 parse 境界に閉じる。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `CleanupWorkdirCandidate.file_status` を文字列ではなく typed enum にする。
|
||||||
|
- `CleanupWorkdirCandidate.cleanliness` を文字列ではなく typed enum にする。
|
||||||
|
- Runtime observation 由来の Workdir status / cleanliness は `worker-runtime` の型から typed に変換する。
|
||||||
|
- DB registry 由来の status / cleanliness は明示的な parse 境界で typed に変換し、未知値は `unknown` として扱う。
|
||||||
|
- cleanup action 判定は string match ではなく enum match にする。
|
||||||
|
- JSON API では従来通り snake_case string として serialize される。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `not_found` Workdir が型付き判定で `workdir_record_delete` になる。
|
||||||
|
- `clean` Workdir が型付き cleanliness 判定で `workdir_clean_cleanup` になる。
|
||||||
|
- Rust 内部 cleanup policy に `format!("{:?}").to_lowercase()` や status string match が残らない。
|
||||||
|
- workspace-server tests と Nix build が通る。
|
||||||
76
.yoi/tickets/00001KXDR5EK4/thread.md
Normal file
76
.yoi/tickets/00001KXDR5EK4/thread.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T12:45:21Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T12:46:05Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:46:05Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T12:56:55Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Typed Workdir cleanup plan status and cleanliness.
|
||||||
|
|
||||||
|
- Added `CleanupWorkdirFileStatus` and `CleanupWorkdirCleanliness` typed enums with snake_case JSON serialization.
|
||||||
|
- `CleanupWorkdirCandidate.file_status` and `.cleanliness` now use those enums instead of `String`.
|
||||||
|
- Runtime-observed `WorkingDirectoryStatusKind` is converted directly into cleanup status without string formatting.
|
||||||
|
- Registry string fields are parsed only at the DB/API boundary; unknown values become `Unknown`.
|
||||||
|
- Cleanup action policy now uses enum matches/predicates rather than string matching.
|
||||||
|
- `NotFound` remains represented as `not_found` over JSON and maps to `workdir_record_delete` internally.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:56:55Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDTPXFD/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDTPXFD/artifacts/.gitkeep
Normal file
25
.yoi/tickets/00001KXDTPXFD/item.md
Normal file
25
.yoi/tickets/00001KXDTPXFD/item.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: 'Stabilize cleanup plan ordering'
|
||||||
|
state: 'done'
|
||||||
|
created_at: '2026-07-13T13:29:51Z'
|
||||||
|
updated_at: '2026-07-13T13:38:25Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T13:30:39Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workdir cleanup endpoint を実際に叩くと、直前に取得した cleanup plan の revision / digest を送っても `workspace_cleanup_plan_stale` になる。cleanup plan の候補順序が HashMap / DB / merge の順序に依存しており、GET と POST 内部再生成で同じ集合でも digest が変わっている可能性が高い。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Cleanup plan の worker / workdir candidates を digest 計算前に deterministic に並べる。
|
||||||
|
- API response と execute 時再生成の plan digest が同一集合で安定する。
|
||||||
|
- not_found Workdir の `workdir_record_delete` が削除 endpoint で実行できる。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- cleanup plan を連続生成しても digest が安定する。
|
||||||
|
- 直前 plan の revision/digest を使った cleanup execution が stale 扱いにならない。
|
||||||
|
- workspace-server tests が通る。
|
||||||
82
.yoi/tickets/00001KXDTPXFD/thread.md
Normal file
82
.yoi/tickets/00001KXDTPXFD/thread.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T13:29:51Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T13:30:39Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:30:39Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-13T13:38:25Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Stabilized cleanup plan candidate ordering and verified the not-found Workdir deletion endpoint.
|
||||||
|
|
||||||
|
Root cause:
|
||||||
|
- Cleanup plan digest includes worker/workdir candidate arrays.
|
||||||
|
- Candidate order was not normalized before digest calculation, so GET `/cleanup-plan` and POST `/cleanup-executions` could compute different digests for the same target set.
|
||||||
|
- The POST then failed with `workspace_cleanup_plan_stale`, preventing `workdir_record_delete` execution.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Sort worker cleanup candidates by `target_id` before digest calculation.
|
||||||
|
- Sort Workdir cleanup candidates by `target_id` before digest calculation.
|
||||||
|
|
||||||
|
Endpoint verification:
|
||||||
|
- Fetched `GET /api/w/0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6/runtimes/arc/cleanup-plan`.
|
||||||
|
- Posted the returned revision/digest and all `file_status: not_found` Workdir target ids to `POST /cleanup-executions`.
|
||||||
|
- Received HTTP 200 and four `workdir_record_delete` results with `status: deleted`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo check -q`
|
||||||
|
- `cargo test -q -p yoi-workspace-server`
|
||||||
|
- targeted cleanup plan test
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:38:25Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KXDWAXTS/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXDWAXTS/artifacts/.gitkeep
Normal file
27
.yoi/tickets/00001KXDWAXTS/item.md
Normal file
27
.yoi/tickets/00001KXDWAXTS/item.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
title: 'Stop Worker before cleanup deletion'
|
||||||
|
state: 'inprogress'
|
||||||
|
created_at: '2026-07-13T13:58:15Z'
|
||||||
|
updated_at: '2026-07-13T13:58:54Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-13T13:58:54Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Worker cleanup execution で selected Worker を削除すると、Runtime 側では Worker status がまだ active/running のため `delete_worker` が rejected し、workspace-server は `workspace_cleanup_worker_runtime_delete_rejected: Runtime did not delete selected Worker` を返す。
|
||||||
|
|
||||||
|
Cleanup の Worker delete action は、対象が cleanup plan で許可された場合、Runtime record を削除する前に Runtime 側 Worker を stopped に遷移させてから delete する必要がある。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Worker cleanup execution は Runtime delete の前に `stop_worker` を実行する。
|
||||||
|
- stop が rejected/error の場合は typed diagnostic を返して registry record を削除しない。
|
||||||
|
- stop 後の delete が成功した場合だけ backend registry row を削除する。
|
||||||
|
- 既に stopped / missing など delete 可能なケースを壊さない。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Runtime が active Worker の delete を直接 reject するケースでも cleanup execution が stop -> delete の順に実行できる。
|
||||||
|
- workspace-server tests が通る。
|
||||||
42
.yoi/tickets/00001KXDWAXTS/thread.md
Normal file
42
.yoi/tickets/00001KXDWAXTS/thread.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-13T13:58:15Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-13T13:58:54Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T13:58:54Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::execution::WorkerExecutionStatus;
|
use crate::execution::WorkerExecutionStatus;
|
||||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
use crate::interaction::WorkerInput;
|
use crate::interaction::WorkerInput;
|
||||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -103,20 +103,11 @@ pub enum MaterializerKind {
|
||||||
LocalGitWorktree,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryRequest {
|
pub struct WorkingDirectoryRequest {
|
||||||
pub repository: WorkingDirectoryRepository,
|
pub repository: WorkingDirectoryRepository,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub materializer: MaterializerKind,
|
pub materializer: MaterializerKind,
|
||||||
#[serde(default)]
|
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
||||||
/// Backend can create canonical registry rows before materialization.
|
/// Backend can create canonical registry rows before materialization.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -134,8 +125,10 @@ pub struct WorkingDirectoryClaim {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkingDirectoryStatusKind {
|
pub enum WorkingDirectoryStatusKind {
|
||||||
Active,
|
Active,
|
||||||
Removed,
|
|
||||||
CleanupPending,
|
CleanupPending,
|
||||||
|
Corrupted,
|
||||||
|
NotFound,
|
||||||
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -152,16 +145,17 @@ pub struct WorkingDirectorySummary {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub requested_selector: Option<String>,
|
pub requested_selector: Option<String>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_commit: Option<String>,
|
pub resolved_commit: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_tree: Option<String>,
|
pub resolved_tree: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cleanup_policy: Option<String>,
|
|
||||||
pub status: WorkingDirectoryStatusKind,
|
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
|
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
|
||||||
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -215,7 +209,6 @@ impl WorkerStatus {
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub execution: WorkerExecutionStatus,
|
pub execution: WorkerExecutionStatus,
|
||||||
|
|
@ -230,7 +223,6 @@ pub struct WorkerSummary {
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerDetail {
|
pub struct WorkerDetail {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub execution: WorkerExecutionStatus,
|
pub execution: WorkerExecutionStatus,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,19 @@
|
||||||
use crate::execution::WorkerExecutionResult;
|
use crate::execution::WorkerExecutionResult;
|
||||||
use crate::identity::{RuntimeId, WorkerId};
|
use crate::identity::WorkerId;
|
||||||
|
use crate::working_directory::WorkingDirectoryDiagnostic;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Errors returned by the embedded Runtime API.
|
/// Errors returned by the embedded Runtime API.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuntimeError {
|
pub enum RuntimeError {
|
||||||
#[error("runtime {runtime_id} is stopped")]
|
#[error("runtime is stopped")]
|
||||||
RuntimeStopped { runtime_id: RuntimeId },
|
RuntimeStopped,
|
||||||
|
|
||||||
#[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}")]
|
#[error("initial worker input must be user input, got {kind}")]
|
||||||
InvalidInitialInputKind { kind: String },
|
InvalidInitialInputKind { kind: String },
|
||||||
|
|
||||||
#[error("worker {worker_id} was not found in runtime {runtime_id}")]
|
#[error("worker {worker_id} was not found")]
|
||||||
WorkerNotFound {
|
WorkerNotFound { worker_id: WorkerId },
|
||||||
runtime_id: RuntimeId,
|
|
||||||
worker_id: WorkerId,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("worker {worker_id} has no execution backend: {message}")]
|
#[error("worker {worker_id} has no execution backend: {message}")]
|
||||||
WorkerExecutionUnavailable {
|
WorkerExecutionUnavailable {
|
||||||
|
|
@ -56,6 +39,9 @@ pub enum RuntimeError {
|
||||||
#[error("invalid request: {0}")]
|
#[error("invalid request: {0}")]
|
||||||
InvalidRequest(String),
|
InvalidRequest(String),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
WorkingDirectory(#[from] WorkingDirectoryDiagnostic),
|
||||||
|
|
||||||
#[error("config bundle `{bundle_id}` was not found")]
|
#[error("config bundle `{bundle_id}` was not found")]
|
||||||
ConfigBundleMissing { bundle_id: String },
|
ConfigBundleMissing { bundle_id: String },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ pub enum WorkerExecutionRunState {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerExecutionOperation {
|
pub enum WorkerExecutionOperation {
|
||||||
Spawn,
|
Spawn,
|
||||||
|
Restore,
|
||||||
Input,
|
Input,
|
||||||
Stop,
|
Stop,
|
||||||
Cancel,
|
Cancel,
|
||||||
|
|
@ -291,6 +292,20 @@ pub struct WorkerExecutionSpawnRequest {
|
||||||
pub config_bundle: Option<ConfigBundle>,
|
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.
|
/// Result of backend Worker spawn/initialization.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum WorkerExecutionSpawnResult {
|
pub enum WorkerExecutionSpawnResult {
|
||||||
|
|
@ -313,6 +328,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
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(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
_request: &WorkingDirectoryRequest,
|
_request: &WorkingDirectoryRequest,
|
||||||
|
|
@ -394,6 +419,19 @@ impl WorkerExecutionBackendRef {
|
||||||
self.backend.spawn_worker(request)
|
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(
|
pub(crate) fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::config_bundle::ConfigBundle;
|
||||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::execution::WorkerExecutionStatus;
|
use crate::execution::WorkerExecutionStatus;
|
||||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
|
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::WorkerObservationEvent;
|
use crate::observation::WorkerObservationEvent;
|
||||||
|
|
@ -16,10 +16,10 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
const RUNTIMES_DIR: &str = "runtimes";
|
|
||||||
const RUNTIME_FILE: &str = "runtime.json";
|
const RUNTIME_FILE: &str = "runtime.json";
|
||||||
const EVENTS_FILE: &str = "events.jsonl";
|
const EVENTS_FILE: &str = "events.jsonl";
|
||||||
const WORKERS_DIR: &str = "workers";
|
const WORKERS_DIR: &str = "workers";
|
||||||
|
const LEGACY_RUNTIMES_DIR: &str = "runtimes";
|
||||||
const WORKER_FILE: &str = "worker.json";
|
const WORKER_FILE: &str = "worker.json";
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
const OBSERVATIONS_FILE: &str = "observations.jsonl";
|
const OBSERVATIONS_FILE: &str = "observations.jsonl";
|
||||||
|
|
@ -29,9 +29,8 @@ static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
/// Options for constructing a filesystem-backed Runtime store.
|
/// Options for constructing a filesystem-backed Runtime store.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct FsRuntimeStoreOptions {
|
pub struct FsRuntimeStoreOptions {
|
||||||
/// Root directory containing all Runtime-scoped store data.
|
/// Root directory containing this Runtime's store data.
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub runtime_id: Option<RuntimeId>,
|
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub limits: RuntimeLimits,
|
pub limits: RuntimeLimits,
|
||||||
}
|
}
|
||||||
|
|
@ -40,23 +39,19 @@ impl FsRuntimeStoreOptions {
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: root.into(),
|
root: root.into(),
|
||||||
runtime_id: None,
|
|
||||||
display_name: None,
|
display_name: None,
|
||||||
limits: RuntimeLimits::default(),
|
limits: RuntimeLimits::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filesystem persistence boundary for Worker Runtime state.
|
/// Filesystem persistence boundary for one Worker Runtime state.
|
||||||
///
|
///
|
||||||
/// Authority is the typed `runtime_id + worker_id` pair. Those ids are encoded
|
/// Authority is Runtime-local typed Worker identity. Legacy pod paths, socket
|
||||||
/// into path components only after validation; legacy pod paths, socket paths,
|
/// paths, and session paths are deliberately not part of the layout or lookup API.
|
||||||
/// and session paths are deliberately not part of the layout or lookup API.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct FsRuntimeStore {
|
pub struct FsRuntimeStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
runtime_id: RuntimeId,
|
|
||||||
runtime_dir: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsRuntimeStore {
|
impl FsRuntimeStore {
|
||||||
|
|
@ -64,12 +59,8 @@ impl FsRuntimeStore {
|
||||||
&self.root
|
&self.root
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runtime_id(&self) -> &RuntimeId {
|
|
||||||
&self.runtime_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runtime_dir(&self) -> &Path {
|
pub fn runtime_dir(&self) -> &Path {
|
||||||
&self.runtime_dir
|
&self.root
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read persisted Runtime events directly from the event log with the same
|
/// Read persisted Runtime events directly from the event log with the same
|
||||||
|
|
@ -80,12 +71,6 @@ impl FsRuntimeStore {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
max_limit: usize,
|
max_limit: usize,
|
||||||
) -> Result<RuntimeEventBatch, RuntimeError> {
|
) -> 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 {
|
if limit > max_limit {
|
||||||
return Err(RuntimeError::LimitTooLarge {
|
return Err(RuntimeError::LimitTooLarge {
|
||||||
requested: limit,
|
requested: limit,
|
||||||
|
|
@ -109,49 +94,33 @@ impl FsRuntimeStore {
|
||||||
let has_more = events.iter().any(|event| event.id >= next_event_id);
|
let has_more = events.iter().any(|event| event.id >= next_event_id);
|
||||||
|
|
||||||
Ok(RuntimeEventBatch {
|
Ok(RuntimeEventBatch {
|
||||||
runtime_id: self.runtime_id.clone(),
|
cursor: EventCursor { next_event_id },
|
||||||
cursor: EventCursor {
|
|
||||||
runtime_id: self.runtime_id.clone(),
|
|
||||||
next_event_id,
|
|
||||||
},
|
|
||||||
events: selected,
|
events: selected,
|
||||||
has_more,
|
has_more,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open_or_create(
|
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> {
|
||||||
root: PathBuf,
|
let existed = root.exists();
|
||||||
runtime_id: RuntimeId,
|
if existed && !root.is_dir() {
|
||||||
) -> 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 {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "open runtime store",
|
operation: "open runtime store",
|
||||||
path: runtime_dir,
|
path: root,
|
||||||
message: "runtime path exists but is not a directory".to_string(),
|
message: "runtime path exists but is not a directory".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::create_dir_all(runtime_dir.join(WORKERS_DIR)).map_err(|source| {
|
if existed {
|
||||||
RuntimeError::StoreIo {
|
migrate_legacy_single_runtime_layout(&root)?;
|
||||||
operation: "create runtime store",
|
}
|
||||||
path: runtime_dir.join(WORKERS_DIR),
|
|
||||||
source,
|
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
|
||||||
}
|
operation: "create runtime store",
|
||||||
|
path: root.join(WORKERS_DIR),
|
||||||
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let store = Self {
|
let store = Self { root };
|
||||||
root,
|
|
||||||
runtime_id,
|
|
||||||
runtime_dir,
|
|
||||||
};
|
|
||||||
let state = if existed {
|
let state = if existed {
|
||||||
Some(store.load_runtime_state()?)
|
Some(store.load_runtime_state()?)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -192,6 +161,18 @@ impl FsRuntimeStore {
|
||||||
Ok(())
|
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> {
|
pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> {
|
||||||
if let Some(worker_ref) = &event.worker_ref {
|
if let Some(worker_ref) = &event.worker_ref {
|
||||||
self.ensure_worker_ref(worker_ref)?;
|
self.ensure_worker_ref(worker_ref)?;
|
||||||
|
|
@ -215,10 +196,10 @@ impl FsRuntimeStore {
|
||||||
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
|
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
|
||||||
let runtime_path = self.runtime_path();
|
let runtime_path = self.runtime_path();
|
||||||
let mut snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
|
let mut snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
|
||||||
snapshot.validate(&self.runtime_id, &runtime_path)?;
|
snapshot.validate(&runtime_path)?;
|
||||||
|
|
||||||
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
||||||
let workers_dir = self.runtime_dir.join(WORKERS_DIR);
|
let workers_dir = self.root.join(WORKERS_DIR);
|
||||||
if !workers_dir.exists() {
|
if !workers_dir.exists() {
|
||||||
return Err(RuntimeError::StoreMissing {
|
return Err(RuntimeError::StoreMissing {
|
||||||
operation: "read workers",
|
operation: "read workers",
|
||||||
|
|
@ -274,10 +255,7 @@ impl FsRuntimeStore {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if worker_snapshot
|
if worker_snapshot.validate(&worker_snapshot_path).is_err() {
|
||||||
.validate(&self.runtime_id, &worker_snapshot_path)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
record_worker_load_diagnostic(
|
record_worker_load_diagnostic(
|
||||||
&mut snapshot,
|
&mut snapshot,
|
||||||
Some(worker_snapshot.worker_ref.clone()),
|
Some(worker_snapshot.worker_ref.clone()),
|
||||||
|
|
@ -343,30 +321,20 @@ 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(())
|
||||||
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 {
|
fn runtime_path(&self) -> PathBuf {
|
||||||
self.runtime_dir.join(RUNTIME_FILE)
|
self.root.join(RUNTIME_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn events_path(&self) -> PathBuf {
|
fn events_path(&self) -> PathBuf {
|
||||||
self.runtime_dir.join(EVENTS_FILE)
|
self.root.join(EVENTS_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
|
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
|
||||||
self.runtime_dir
|
self.root.join(WORKERS_DIR).join(worker_id.to_string())
|
||||||
.join(WORKERS_DIR)
|
|
||||||
.join(encoded_component(worker_id.as_str()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
|
|
@ -383,7 +351,6 @@ pub(crate) struct OpenedFsRuntimeStore {
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct PersistedRuntimeState {
|
pub(crate) struct PersistedRuntimeState {
|
||||||
pub(crate) runtime_id: RuntimeId,
|
|
||||||
pub(crate) display_name: Option<String>,
|
pub(crate) display_name: Option<String>,
|
||||||
pub(crate) status: RuntimeStatus,
|
pub(crate) status: RuntimeStatus,
|
||||||
pub(crate) limits: RuntimeLimits,
|
pub(crate) limits: RuntimeLimits,
|
||||||
|
|
@ -411,7 +378,6 @@ pub(crate) struct PersistedWorkerRecord {
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
struct RuntimeSnapshot {
|
struct RuntimeSnapshot {
|
||||||
schema_version: u32,
|
schema_version: u32,
|
||||||
runtime_id: RuntimeId,
|
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
backend: RuntimeBackendKind,
|
backend: RuntimeBackendKind,
|
||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
|
|
@ -444,7 +410,6 @@ impl RuntimeSnapshot {
|
||||||
fn from_persisted(state: &PersistedRuntimeState) -> Self {
|
fn from_persisted(state: &PersistedRuntimeState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
runtime_id: state.runtime_id.clone(),
|
|
||||||
display_name: state.display_name.clone(),
|
display_name: state.display_name.clone(),
|
||||||
backend: RuntimeBackendKind::FsStore,
|
backend: RuntimeBackendKind::FsStore,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
|
|
@ -457,7 +422,7 @@ impl RuntimeSnapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
|
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
|
||||||
if self.schema_version != SCHEMA_VERSION {
|
if self.schema_version != SCHEMA_VERSION {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "read runtime snapshot",
|
operation: "read runtime snapshot",
|
||||||
|
|
@ -468,16 +433,6 @@ impl RuntimeSnapshot {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if &self.runtime_id != expected_runtime_id {
|
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
|
||||||
operation: "read runtime snapshot",
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!(
|
|
||||||
"runtime snapshot id {} does not match requested runtime {}",
|
|
||||||
self.runtime_id, expected_runtime_id
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if self.backend != RuntimeBackendKind::FsStore {
|
if self.backend != RuntimeBackendKind::FsStore {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "read runtime snapshot",
|
operation: "read runtime snapshot",
|
||||||
|
|
@ -495,7 +450,6 @@ impl RuntimeSnapshot {
|
||||||
#[cfg(feature = "ws-server")] observation_events: Vec<WorkerObservationEvent>,
|
#[cfg(feature = "ws-server")] observation_events: Vec<WorkerObservationEvent>,
|
||||||
) -> PersistedRuntimeState {
|
) -> PersistedRuntimeState {
|
||||||
PersistedRuntimeState {
|
PersistedRuntimeState {
|
||||||
runtime_id: self.runtime_id,
|
|
||||||
display_name: self.display_name,
|
display_name: self.display_name,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
limits: self.limits,
|
limits: self.limits,
|
||||||
|
|
@ -537,7 +491,7 @@ impl WorkerSnapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> {
|
fn validate(&self, path: &Path) -> Result<(), RuntimeError> {
|
||||||
if self.schema_version != SCHEMA_VERSION {
|
if self.schema_version != SCHEMA_VERSION {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "read worker snapshot",
|
operation: "read worker snapshot",
|
||||||
|
|
@ -548,16 +502,6 @@ impl WorkerSnapshot {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if self.worker_ref.runtime_id != *expected_runtime_id {
|
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
|
||||||
operation: "read worker snapshot",
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!(
|
|
||||||
"worker belongs to runtime {}, expected {}",
|
|
||||||
self.worker_ref.runtime_id, expected_runtime_id
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if self.worker_ref.worker_id != self.worker_id {
|
if self.worker_ref.worker_id != self.worker_id {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "read worker snapshot",
|
operation: "read worker snapshot",
|
||||||
|
|
@ -583,27 +527,81 @@ impl WorkerSnapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_dir(root: &Path, runtime_id: &RuntimeId) -> PathBuf {
|
fn migrate_legacy_single_runtime_layout(root: &Path) -> Result<(), RuntimeError> {
|
||||||
root.join(RUNTIMES_DIR)
|
if root.join(RUNTIME_FILE).exists() {
|
||||||
.join(encoded_component(runtime_id.as_str()))
|
return Ok(());
|
||||||
|
}
|
||||||
|
let legacy_root = root.join(LEGACY_RUNTIMES_DIR);
|
||||||
|
if !legacy_root.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for entry in fs::read_dir(&legacy_root).map_err(|source| RuntimeError::StoreIo {
|
||||||
|
operation: "read legacy runtime store root",
|
||||||
|
path: legacy_root.clone(),
|
||||||
|
source,
|
||||||
|
})? {
|
||||||
|
let entry = entry.map_err(|source| RuntimeError::StoreIo {
|
||||||
|
operation: "read legacy runtime store entry",
|
||||||
|
path: legacy_root.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.join(RUNTIME_FILE).is_file() {
|
||||||
|
candidates.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if candidates.len() > 1 {
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "migrate legacy runtime store",
|
||||||
|
path: legacy_root,
|
||||||
|
message: "multiple legacy runtime directories exist; choose a concrete fs root"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy_dir = candidates.remove(0);
|
||||||
|
rename_if_exists(
|
||||||
|
&legacy_dir.join(RUNTIME_FILE),
|
||||||
|
&root.join(RUNTIME_FILE),
|
||||||
|
"migrate legacy runtime snapshot",
|
||||||
|
)?;
|
||||||
|
rename_if_exists(
|
||||||
|
&legacy_dir.join(EVENTS_FILE),
|
||||||
|
&root.join(EVENTS_FILE),
|
||||||
|
"migrate legacy runtime events",
|
||||||
|
)?;
|
||||||
|
rename_if_exists(
|
||||||
|
&legacy_dir.join(WORKERS_DIR),
|
||||||
|
&root.join(WORKERS_DIR),
|
||||||
|
"migrate legacy runtime workers",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encoded_component(value: &str) -> String {
|
fn rename_if_exists(src: &Path, dst: &Path, operation: &'static str) -> Result<(), RuntimeError> {
|
||||||
let mut encoded = String::with_capacity(3 + value.len() * 2);
|
if !src.exists() {
|
||||||
encoded.push_str("id-");
|
return Ok(());
|
||||||
for byte in value.as_bytes() {
|
|
||||||
encoded.push(hex_digit(byte >> 4));
|
|
||||||
encoded.push(hex_digit(byte & 0x0f));
|
|
||||||
}
|
}
|
||||||
encoded
|
if dst.exists() {
|
||||||
}
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation,
|
||||||
fn hex_digit(value: u8) -> char {
|
path: dst.to_path_buf(),
|
||||||
match value {
|
message: format!(
|
||||||
0..=9 => (b'0' + value) as char,
|
"cannot migrate {} because destination already exists",
|
||||||
10..=15 => (b'a' + (value - 10)) as char,
|
src.display()
|
||||||
_ => unreachable!("hex digit nybble is always <= 15"),
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
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>
|
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::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
||||||
use crate::management::{RuntimeLimits, RuntimeSummary};
|
use crate::management::{RuntimeLimits, RuntimeSummary, WorkerDeleteResult};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
|
|
@ -50,9 +50,6 @@ pub struct RuntimeHttpServerConfig {
|
||||||
/// Address for the Runtime process to bind. Use a loopback address unless a
|
/// Address for the Runtime process to bind. Use a loopback address unless a
|
||||||
/// trusted backend proxy explicitly owns network exposure.
|
/// trusted backend proxy explicitly owns network exposure.
|
||||||
pub bind_addr: SocketAddr,
|
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`.
|
/// Optional display label surfaced by `GET /v1/runtime`.
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
/// Bounded Runtime API limits.
|
/// Bounded Runtime API limits.
|
||||||
|
|
@ -68,7 +65,6 @@ impl Default for RuntimeHttpServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind_addr: default_runtime_http_bind_addr(),
|
bind_addr: default_runtime_http_bind_addr(),
|
||||||
runtime_id: None,
|
|
||||||
display_name: None,
|
display_name: None,
|
||||||
limits: RuntimeLimits::default(),
|
limits: RuntimeLimits::default(),
|
||||||
store: RuntimeHttpStoreSelection::Memory,
|
store: RuntimeHttpStoreSelection::Memory,
|
||||||
|
|
@ -81,7 +77,6 @@ impl fmt::Debug for RuntimeHttpServerConfig {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("RuntimeHttpServerConfig")
|
f.debug_struct("RuntimeHttpServerConfig")
|
||||||
.field("bind_addr", &self.bind_addr)
|
.field("bind_addr", &self.bind_addr)
|
||||||
.field("runtime_id", &self.runtime_id)
|
|
||||||
.field("display_name", &self.display_name)
|
.field("display_name", &self.display_name)
|
||||||
.field("limits", &self.limits)
|
.field("limits", &self.limits)
|
||||||
.field("store", &self.store)
|
.field("store", &self.store)
|
||||||
|
|
@ -118,10 +113,9 @@ pub async fn serve_runtime_http(
|
||||||
|
|
||||||
/// Build the REST router for an existing Runtime.
|
/// Build the REST router for an existing Runtime.
|
||||||
///
|
///
|
||||||
/// Handlers delegate to [`Runtime`] methods and keep Worker authority as
|
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
|
||||||
/// `(runtime_id, worker_id)`. The path contains only a Runtime-local
|
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
||||||
/// `worker_id`; the server supplies its own Runtime id instead of accepting a
|
/// accepted or forwarded as Runtime authority.
|
||||||
/// legacy pod/socket/session path as authority.
|
|
||||||
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
|
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
|
||||||
let state = RuntimeHttpState {
|
let state = RuntimeHttpState {
|
||||||
runtime,
|
runtime,
|
||||||
|
|
@ -147,7 +141,10 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
|
||||||
get(get_working_directory).delete(cleanup_working_directory),
|
get(get_working_directory).delete(cleanup_working_directory),
|
||||||
)
|
)
|
||||||
.route("/v1/workers", get(list_workers).post(create_worker))
|
.route("/v1/workers", get(list_workers).post(create_worker))
|
||||||
.route("/v1/workers/{worker_id}", get(get_worker))
|
.route(
|
||||||
|
"/v1/workers/{worker_id}",
|
||||||
|
get(get_worker).delete(delete_worker),
|
||||||
|
)
|
||||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||||
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
||||||
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
|
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
|
||||||
|
|
@ -219,6 +216,12 @@ pub struct RuntimeHttpWorkerResponse {
|
||||||
pub worker: WorkerDetail,
|
pub worker: WorkerDetail,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Worker delete response.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpWorkerDeleteResponse {
|
||||||
|
pub worker: WorkerDeleteResult,
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker input acknowledgement response.
|
/// Worker input acknowledgement response.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpWorkerInputResponse {
|
pub struct RuntimeHttpWorkerInputResponse {
|
||||||
|
|
@ -461,6 +464,18 @@ async fn get_worker(
|
||||||
Ok(Json(RuntimeHttpWorkerResponse { 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(
|
async fn create_worker(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
body: Result<Json<CreateWorkerRequest>, JsonRejection>,
|
body: Result<Json<CreateWorkerRequest>, JsonRejection>,
|
||||||
|
|
@ -691,18 +706,18 @@ async fn cancel_worker(
|
||||||
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
|
fn worker_ref_for(
|
||||||
let worker_id = WorkerId::new(worker_id).ok_or_else(|| {
|
_runtime: &Runtime,
|
||||||
|
worker_id: String,
|
||||||
|
) -> Result<WorkerRef, RuntimeHttpRestError> {
|
||||||
|
let worker_id = WorkerId::parse(&worker_id).ok_or_else(|| {
|
||||||
RuntimeHttpRestError::new(
|
RuntimeHttpRestError::new(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"invalid_worker_id",
|
"invalid_worker_id",
|
||||||
"worker_id must not be empty",
|
"worker_id must be an unsigned integer",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let runtime_id = runtime
|
Ok(WorkerRef::new(worker_id))
|
||||||
.runtime_id()
|
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
|
||||||
Ok(WorkerRef::new(runtime_id, worker_id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_optional_lifecycle_request(
|
fn parse_optional_lifecycle_request(
|
||||||
|
|
@ -746,15 +761,15 @@ async fn require_local_token(
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct RuntimeHttpRestError {
|
struct RuntimeHttpRestError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
code: &'static str,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeHttpRestError {
|
impl RuntimeHttpRestError {
|
||||||
fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
|
fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
code,
|
code: code.into(),
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -786,7 +801,7 @@ impl IntoResponse for RuntimeHttpRestError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let body = RuntimeHttpErrorResponse {
|
let body = RuntimeHttpErrorResponse {
|
||||||
error: RuntimeHttpErrorDetail {
|
error: RuntimeHttpErrorDetail {
|
||||||
code: self.code.to_string(),
|
code: self.code,
|
||||||
message: self.message,
|
message: self.message,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -799,7 +814,12 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||||
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
|
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
RuntimeError::RuntimeStopped { .. }
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if diagnostic.code == "working_directory_not_found" =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
RuntimeError::RuntimeStopped
|
||||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||||
| RuntimeError::ExecutionBackendUnavailable { .. }
|
| RuntimeError::ExecutionBackendUnavailable { .. }
|
||||||
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
|
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
|
||||||
|
|
@ -809,8 +829,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||||
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
||||||
| RuntimeError::InvalidProfileSelector { .. }
|
| RuntimeError::InvalidProfileSelector { .. }
|
||||||
| RuntimeError::UnsupportedConfigDeclaration { .. }
|
| RuntimeError::UnsupportedConfigDeclaration { .. }
|
||||||
| RuntimeError::WrongRuntime { .. }
|
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
||||||
| RuntimeError::WrongRuntimeCursor { .. } => StatusCode::BAD_REQUEST,
|
|
||||||
RuntimeError::StoreIo { .. }
|
RuntimeError::StoreIo { .. }
|
||||||
| RuntimeError::StoreMissing { .. }
|
| RuntimeError::StoreMissing { .. }
|
||||||
| RuntimeError::StoreCorrupt { .. }
|
| RuntimeError::StoreCorrupt { .. }
|
||||||
|
|
@ -818,26 +837,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn code_for_runtime_error(error: &RuntimeError) -> &'static str {
|
fn code_for_runtime_error(error: &RuntimeError) -> String {
|
||||||
match error {
|
match error {
|
||||||
RuntimeError::RuntimeStopped { .. } => "runtime_stopped",
|
RuntimeError::RuntimeStopped => "runtime_stopped".to_string(),
|
||||||
RuntimeError::WrongRuntime { .. } => "wrong_runtime",
|
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
|
||||||
RuntimeError::WrongRuntimeCursor { .. } => "wrong_runtime_cursor",
|
RuntimeError::WorkerExecutionUnavailable { .. } => {
|
||||||
RuntimeError::WorkerNotFound { .. } => "worker_not_found",
|
"worker_execution_unavailable".to_string()
|
||||||
RuntimeError::WorkerExecutionUnavailable { .. } => "worker_execution_unavailable",
|
}
|
||||||
RuntimeError::ExecutionBackendUnavailable { .. } => "execution_backend_unavailable",
|
RuntimeError::ExecutionBackendUnavailable { .. } => {
|
||||||
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected",
|
"execution_backend_unavailable".to_string()
|
||||||
RuntimeError::LimitTooLarge { .. } => "limit_too_large",
|
}
|
||||||
RuntimeError::InvalidRequest(_) => "invalid_request",
|
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(),
|
||||||
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind",
|
RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(),
|
||||||
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing",
|
RuntimeError::InvalidRequest(_) => "invalid_request".to_string(),
|
||||||
RuntimeError::ConfigBundleDigestMismatch { .. } => "config_bundle_digest_mismatch",
|
RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(),
|
||||||
RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector",
|
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(),
|
||||||
RuntimeError::UnsupportedConfigDeclaration { .. } => "unsupported_config_declaration",
|
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing".to_string(),
|
||||||
RuntimeError::StoreIo { .. } => "store_io",
|
RuntimeError::ConfigBundleDigestMismatch { .. } => {
|
||||||
RuntimeError::StoreMissing { .. } => "store_missing",
|
"config_bundle_digest_mismatch".to_string()
|
||||||
RuntimeError::StoreCorrupt { .. } => "store_corrupt",
|
}
|
||||||
RuntimeError::StatePoisoned => "state_poisoned",
|
RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector".to_string(),
|
||||||
|
RuntimeError::UnsupportedConfigDeclaration { .. } => {
|
||||||
|
"unsupported_config_declaration".to_string()
|
||||||
|
}
|
||||||
|
RuntimeError::StoreIo { .. } => "store_io".to_string(),
|
||||||
|
RuntimeError::StoreMissing { .. } => "store_missing".to_string(),
|
||||||
|
RuntimeError::StoreCorrupt { .. } => "store_corrupt".to_string(),
|
||||||
|
RuntimeError::StatePoisoned => "state_poisoned".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1017,8 +1043,8 @@ mod tests {
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let created: RuntimeHttpWorkerResponse = read_json(response).await;
|
let created: RuntimeHttpWorkerResponse = read_json(response).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
created.worker.worker_ref.runtime_id,
|
created.worker.worker_ref.worker_id,
|
||||||
runtime.runtime_id().unwrap()
|
created.worker.worker_id
|
||||||
);
|
);
|
||||||
|
|
||||||
let input = WorkerInput::user("hello from backend");
|
let input = WorkerInput::user("hello from backend");
|
||||||
|
|
@ -1107,12 +1133,27 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_errors_use_typed_rest_error_shape() {
|
async fn runtime_errors_use_typed_rest_error_shape() {
|
||||||
let app = runtime_http_router(Runtime::new_memory(), None);
|
let app = runtime_http_router(Runtime::new_memory(), None);
|
||||||
let response = empty_request(app, Method::GET, "/v1/workers/worker-missing").await;
|
let response = empty_request(app, Method::GET, "/v1/workers/999").await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||||
assert_eq!(error.error.code, "worker_not_found");
|
assert_eq!(error.error.code, "worker_not_found");
|
||||||
assert!(error.error.message.contains("worker-missing"));
|
assert!(error.error.message.contains("999"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
||||||
|
let error =
|
||||||
|
RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
message: "working directory missing-workdir was not found".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
code_for_runtime_error(&error),
|
||||||
|
"working_directory_not_found"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,26 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Public Runtime identity.
|
|
||||||
///
|
|
||||||
/// This is the first half of Worker authority. Runtime APIs that operate on a
|
|
||||||
/// Worker require this id alongside a [`WorkerId`]; socket paths, session paths,
|
|
||||||
/// and display names are deliberately not authority.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct RuntimeId(String);
|
|
||||||
|
|
||||||
impl RuntimeId {
|
|
||||||
pub fn new(value: impl Into<String>) -> Option<Self> {
|
|
||||||
let value = value.into();
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(Self(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn generated(sequence: u64) -> Self {
|
|
||||||
Self(format!("runtime-mem-{sequence:016x}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for RuntimeId {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
self.0.fmt(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime-local Worker identity.
|
/// Runtime-local Worker identity.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct WorkerId(String);
|
pub struct WorkerId(u64);
|
||||||
|
|
||||||
impl WorkerId {
|
impl WorkerId {
|
||||||
pub fn new(value: impl Into<String>) -> Option<Self> {
|
pub fn new(value: u64) -> Self {
|
||||||
let value = value.into();
|
Self(value)
|
||||||
if value.trim().is_empty() {
|
}
|
||||||
None
|
|
||||||
} else {
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
Some(Self(value))
|
value.parse::<u64>().ok().map(Self)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generated(sequence: u64) -> Self {
|
pub(crate) fn generated(sequence: u64) -> Self {
|
||||||
Self(format!("worker-{sequence:08x}"))
|
Self(sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_u64(&self) -> u64 {
|
||||||
&self.0
|
self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,18 +30,14 @@ impl fmt::Display for WorkerId {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete public authority reference for Worker operations.
|
/// Runtime-local authority reference for Worker operations.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct WorkerRef {
|
pub struct WorkerRef {
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerRef {
|
impl WorkerRef {
|
||||||
pub fn new(runtime_id: RuntimeId, worker_id: WorkerId) -> Self {
|
pub fn new(worker_id: WorkerId) -> Self {
|
||||||
Self {
|
Self { worker_id }
|
||||||
runtime_id,
|
|
||||||
worker_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||||
};
|
};
|
||||||
use worker_runtime::identity::RuntimeId;
|
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
||||||
use worker_runtime::{Runtime, RuntimeOptions};
|
use worker_runtime::{Runtime, RuntimeOptions};
|
||||||
|
|
@ -67,17 +66,11 @@ fn run() -> Result<(), ProcessError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
let mut factory = ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
|
let fs_paths = config.resolved_fs_paths();
|
||||||
.with_cwd(config.cwd.clone());
|
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
||||||
if let Some(store_dir) = config.worker_store_dir.clone() {
|
.with_store_dir(fs_paths.worker_dir.join("sessions"))
|
||||||
factory = factory.with_store_dir(store_dir);
|
.with_worker_metadata_dir(fs_paths.worker_dir.join("metadata"))
|
||||||
}
|
.with_runtime_base_dir(fs_paths.worker_dir.join("runtime"));
|
||||||
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() {
|
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||||
factory = factory.with_resource_client(Arc::new(
|
factory = factory.with_resource_client(Arc::new(
|
||||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||||
|
|
@ -89,12 +82,11 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
if let Some(profile) = config.profile.clone() {
|
if let Some(profile) = config.profile.clone() {
|
||||||
factory = factory.with_profile(profile);
|
factory = factory.with_profile(profile);
|
||||||
}
|
}
|
||||||
let working_directory_root = working_directory_runtime_root(config);
|
|
||||||
let backend = Arc::new(
|
let backend = Arc::new(
|
||||||
WorkerRuntimeExecutionBackend::new(factory)
|
WorkerRuntimeExecutionBackend::new(factory)
|
||||||
.map_err(ProcessError::WorkerAdapter)?
|
.map_err(ProcessError::WorkerAdapter)?
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||||
working_directory_root,
|
fs_paths.workdir_target.clone(),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -105,7 +97,6 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
}
|
}
|
||||||
RuntimeHttpStoreSelection::Fs { root } => {
|
RuntimeHttpStoreSelection::Fs { root } => {
|
||||||
let mut options = FsRuntimeStoreOptions::new(root.clone());
|
let mut options = FsRuntimeStoreOptions::new(root.clone());
|
||||||
options.runtime_id = config.http.runtime_id.clone();
|
|
||||||
options.display_name = config.http.display_name.clone();
|
options.display_name = config.http.display_name.clone();
|
||||||
options.limits = config.http.limits.clone();
|
options.limits = config.http.limits.clone();
|
||||||
Runtime::with_fs_store_and_execution_backend(options, backend)
|
Runtime::with_fs_store_and_execution_backend(options, backend)
|
||||||
|
|
@ -117,37 +108,23 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_runtime_root(config: &ProcessConfig) -> PathBuf {
|
|
||||||
match &config.http.store {
|
|
||||||
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
|
|
||||||
_ => config
|
|
||||||
.worker_runtime_base_dir
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(default_working_directory_runtime_root),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_working_directory_runtime_root() -> PathBuf {
|
|
||||||
manifest::paths::data_dir()
|
|
||||||
.map(|data_dir| data_dir.join("worker-runtime"))
|
|
||||||
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||||
RuntimeOptions {
|
RuntimeOptions {
|
||||||
runtime_id: config.runtime_id.clone(),
|
|
||||||
display_name: config.display_name.clone(),
|
display_name: config.display_name.clone(),
|
||||||
limits: config.limits.clone(),
|
limits: config.limits.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_fs_root() -> PathBuf {
|
||||||
|
manifest::paths::data_dir().unwrap_or_else(|| env::temp_dir().join("yoi"))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
|
fn parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
{
|
{
|
||||||
let mut config = ProcessConfig::default()?;
|
let mut config = ProcessConfig::default()?;
|
||||||
let mut store = StoreArg::Memory;
|
|
||||||
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
|
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
|
||||||
|
|
||||||
while let Some(arg) = args.pop_front() {
|
while let Some(arg) = args.pop_front() {
|
||||||
|
|
@ -160,31 +137,19 @@ where
|
||||||
ProcessError::usage(format!("invalid --bind socket address `{value}`: {error}"))
|
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" => {
|
"--display-name" => {
|
||||||
config.http.display_name = Some(take_value(&flag, inline_value, &mut args)?);
|
config.http.display_name = Some(take_value(&flag, inline_value, &mut args)?);
|
||||||
}
|
}
|
||||||
"--workspace" => {
|
"--fs-worker-dir" => {
|
||||||
config.workspace_root = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
|
config.fs_worker_dir =
|
||||||
}
|
|
||||||
"--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)?));
|
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||||
}
|
}
|
||||||
"--worker-metadata-dir" => {
|
"--fs-runtime-dir" => {
|
||||||
config.worker_metadata_dir =
|
config.fs_runtime_dir =
|
||||||
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||||
}
|
}
|
||||||
"--worker-runtime-base-dir" => {
|
"--workdir-target" => {
|
||||||
config.worker_runtime_base_dir =
|
config.workdir_target =
|
||||||
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
|
||||||
}
|
}
|
||||||
"--backend-resource-endpoint" => {
|
"--backend-resource-endpoint" => {
|
||||||
|
|
@ -199,21 +164,19 @@ where
|
||||||
}
|
}
|
||||||
"--store" => {
|
"--store" => {
|
||||||
let value = take_value(&flag, inline_value, &mut args)?;
|
let value = take_value(&flag, inline_value, &mut args)?;
|
||||||
store = match value.as_str() {
|
if !matches!(value.as_str(), "fs" | "fs-store") {
|
||||||
"memory" => StoreArg::Memory,
|
return Err(ProcessError::usage(format!(
|
||||||
"fs" | "fs-store" => StoreArg::Fs { root: None },
|
"unsupported --store `{value}`; only `fs` is accepted (use --no-store for ephemeral mode)"
|
||||||
_ => {
|
)));
|
||||||
return Err(ProcessError::usage(format!(
|
}
|
||||||
"unsupported --store `{value}`; expected `memory` or `fs`"
|
}
|
||||||
)));
|
"--no-store" => {
|
||||||
}
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
};
|
config.no_store = true;
|
||||||
}
|
}
|
||||||
"--fs-root" => {
|
"--fs-root" => {
|
||||||
let value = take_value(&flag, inline_value, &mut args)?;
|
let value = take_value(&flag, inline_value, &mut args)?;
|
||||||
store = StoreArg::Fs {
|
config.fs_root = Some(PathBuf::from(value));
|
||||||
root: Some(PathBuf::from(value)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
"--local-token" => {
|
"--local-token" => {
|
||||||
let value = take_value(&flag, inline_value, &mut args)?;
|
let value = take_value(&flag, inline_value, &mut args)?;
|
||||||
|
|
@ -248,34 +211,10 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_store_selection(&mut config.http, store)?;
|
apply_store_selection(&mut config);
|
||||||
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))
|
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> {
|
fn split_flag_value(arg: String) -> Result<(String, Option<String>), ProcessError> {
|
||||||
if !arg.starts_with('-') {
|
if !arg.starts_with('-') {
|
||||||
return Err(ProcessError::usage(format!(
|
return Err(ProcessError::usage(format!(
|
||||||
|
|
@ -301,65 +240,84 @@ fn take_value(
|
||||||
.ok_or_else(|| ProcessError::usage(format!("{flag} requires a 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> {
|
fn parse_usize_flag(flag: &str, value: String) -> Result<usize, ProcessError> {
|
||||||
value
|
value
|
||||||
.parse::<usize>()
|
.parse::<usize>()
|
||||||
.map_err(|error| ProcessError::usage(format!("invalid {flag} value `{value}`: {error}")))
|
.map_err(|error| ProcessError::usage(format!("invalid {flag} value `{value}`: {error}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_store_selection(
|
fn apply_store_selection(config: &mut ProcessConfig) {
|
||||||
config: &mut RuntimeHttpServerConfig,
|
if config.no_store {
|
||||||
store: StoreArg,
|
config.http.store = RuntimeHttpStoreSelection::Memory;
|
||||||
) -> Result<(), ProcessError> {
|
} else {
|
||||||
match store {
|
let runtime_dir = config.resolved_fs_paths().runtime_dir;
|
||||||
StoreArg::Memory => {
|
config.http.store = RuntimeHttpStoreSelection::Fs { root: runtime_dir };
|
||||||
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct ProcessConfig {
|
struct ProcessConfig {
|
||||||
http: RuntimeHttpServerConfig,
|
http: RuntimeHttpServerConfig,
|
||||||
workspace_root: PathBuf,
|
fs_root: Option<PathBuf>,
|
||||||
cwd: PathBuf,
|
fs_worker_dir: Option<PathBuf>,
|
||||||
worker_store_dir: Option<PathBuf>,
|
fs_runtime_dir: Option<PathBuf>,
|
||||||
worker_metadata_dir: Option<PathBuf>,
|
workdir_target: Option<PathBuf>,
|
||||||
worker_runtime_base_dir: Option<PathBuf>,
|
no_store: bool,
|
||||||
backend_resource_endpoint: Option<String>,
|
backend_resource_endpoint: Option<String>,
|
||||||
backend_resource_token: Option<String>,
|
backend_resource_token: Option<String>,
|
||||||
profile: 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 {
|
impl ProcessConfig {
|
||||||
fn default() -> Result<Self, ProcessError> {
|
fn default() -> Result<Self, ProcessError> {
|
||||||
let workspace_root = env::current_dir()?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http: RuntimeHttpServerConfig::default(),
|
http: RuntimeHttpServerConfig::default(),
|
||||||
cwd: workspace_root.clone(),
|
fs_root: None,
|
||||||
workspace_root,
|
fs_worker_dir: None,
|
||||||
worker_store_dir: None,
|
fs_runtime_dir: None,
|
||||||
worker_metadata_dir: None,
|
workdir_target: None,
|
||||||
worker_runtime_base_dir: None,
|
no_store: false,
|
||||||
backend_resource_endpoint: None,
|
backend_resource_endpoint: None,
|
||||||
backend_resource_token: None,
|
backend_resource_token: None,
|
||||||
profile: None,
|
profile: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
fn resolved_fs_paths(&self) -> FsPathConfig {
|
||||||
enum StoreArg {
|
let root = self.fs_root.clone().unwrap_or_else(default_fs_root);
|
||||||
Memory,
|
FsPathConfig {
|
||||||
Fs { root: Option<PathBuf> },
|
worker_dir: self
|
||||||
|
.fs_worker_dir
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| root.join("worker")),
|
||||||
|
runtime_dir: self
|
||||||
|
.fs_runtime_dir
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| root.join("runtime")),
|
||||||
|
workdir_target: self
|
||||||
|
.workdir_target
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| root.join("workdirs")),
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -413,27 +371,27 @@ impl From<std::io::Error> for ProcessError {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
"Usage: worker-runtime-rest-server [OPTIONS]\n\n\
|
r#"Usage: worker-runtime-rest-server [OPTIONS]
|
||||||
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\
|
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
|
||||||
Options:\n\
|
Browsers must not connect to this Runtime process directly.
|
||||||
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)\n\
|
|
||||||
--runtime-id <ID> Runtime authority id (default: generated)\n\
|
Options:
|
||||||
--display-name <NAME> Runtime display name\n\
|
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)
|
||||||
--workspace <PATH> Workspace root used for spawned Workers (default: cwd)\n\
|
--display-name <NAME> Runtime display name
|
||||||
--cwd <PATH> Process cwd used for spawned Workers (default: workspace)\n\
|
--profile <SELECTOR> Force spawned Workers to use a Profile selector
|
||||||
--profile <SELECTOR> Force spawned Workers to use a Profile selector\n\
|
--fs-root <PATH> Filesystem-backed storage root (default: user data dir)
|
||||||
--worker-store-dir <PATH> Worker session store directory\n\
|
--fs-worker-dir <PATH> Worker storage directory (default: <fs-root>/worker)
|
||||||
--worker-metadata-dir <PATH> Worker metadata directory\n\
|
--fs-runtime-dir <PATH> Runtime catalog directory (default: <fs-root>/runtime)
|
||||||
--worker-runtime-base-dir <PATH> Worker controller runtime directory\n\
|
--workdir-target <PATH> Workdir materialization target (default: <fs-root>/workdirs)
|
||||||
--backend-resource-endpoint <URL> Internal Backend resource fetch endpoint for resource handles\n\
|
--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\n\
|
--backend-resource-token <TOKEN> Optional bearer token for the Backend resource fetch endpoint
|
||||||
--store <memory|fs> Runtime catalog store selection (default: memory)\n\
|
--store <fs> Runtime catalog store kind (default: fs)
|
||||||
--fs-root <PATH> Runtime catalog filesystem store root\n\
|
--no-store Disable Runtime catalog persistence for ephemeral runs
|
||||||
--local-token <TOKEN> Minimal local bearer token placeholder\n\
|
--local-token <TOKEN> Minimal local bearer token placeholder
|
||||||
--local-token-env <ENV> Read local bearer token placeholder from env\n\
|
--local-token-env <ENV> Read local bearer token placeholder from env
|
||||||
--max-event-batch-items <N> Override event batch limit\n\
|
--max-event-batch-items <N> Override event batch limit
|
||||||
-h, --help Show this help"
|
-h, --help Show this help"#
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -456,28 +414,26 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_relative_workspace_for_worker_spawn() {
|
fn rejects_removed_workspace_cwd_and_worker_path_options() {
|
||||||
let current_dir = env::current_dir().unwrap().canonicalize().unwrap();
|
assert!(parse_args(["--workspace", "/tmp/workspace"]).is_err());
|
||||||
let config = parse_args(["--workspace", ".", "--cwd", "."])
|
assert!(parse_args(["--cwd", "/tmp/workspace"]).is_err());
|
||||||
.unwrap()
|
assert!(parse_args(["--worker-store-dir", "/tmp/sessions"]).is_err());
|
||||||
.unwrap();
|
assert!(parse_args(["--worker-metadata-dir", "/tmp/metadata"]).is_err());
|
||||||
|
assert!(parse_args(["--worker-runtime-base-dir", "/tmp/runtime"]).is_err());
|
||||||
assert_eq!(config.workspace_root, current_dir);
|
|
||||||
assert_eq!(config.cwd, current_dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_memory_runtime_process_config() {
|
fn rejects_store_memory_in_favor_of_no_store() {
|
||||||
|
assert!(parse_args(["--store", "memory"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_runtime_process_config() {
|
||||||
let config = parse_args([
|
let config = parse_args([
|
||||||
"--bind",
|
"--bind",
|
||||||
"127.0.0.1:0",
|
"127.0.0.1:0",
|
||||||
"--runtime-id=runtime-alpha",
|
|
||||||
"--display-name",
|
"--display-name",
|
||||||
"Runtime Alpha",
|
"Runtime Alpha",
|
||||||
"--workspace",
|
|
||||||
"/tmp/workspace",
|
|
||||||
"--cwd",
|
|
||||||
"/tmp/workspace/subdir",
|
|
||||||
"--profile",
|
"--profile",
|
||||||
"builtin:coder",
|
"builtin:coder",
|
||||||
"--local-token-env",
|
"--local-token-env",
|
||||||
|
|
@ -491,13 +447,8 @@ mod tests {
|
||||||
let config = parse_args([
|
let config = parse_args([
|
||||||
"--bind",
|
"--bind",
|
||||||
"127.0.0.1:0",
|
"127.0.0.1:0",
|
||||||
"--runtime-id=runtime-alpha",
|
|
||||||
"--display-name",
|
"--display-name",
|
||||||
"Runtime Alpha",
|
"Runtime Alpha",
|
||||||
"--workspace",
|
|
||||||
"/tmp/workspace",
|
|
||||||
"--cwd",
|
|
||||||
"/tmp/workspace/subdir",
|
|
||||||
"--profile",
|
"--profile",
|
||||||
"builtin:coder",
|
"builtin:coder",
|
||||||
"--local-token-env",
|
"--local-token-env",
|
||||||
|
|
@ -510,25 +461,42 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap());
|
assert_eq!(config.http.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.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.profile.as_deref(), Some("builtin:coder"));
|
||||||
assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
|
assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_fs_store_runtime_process_config() {
|
fn parses_fs_store_runtime_process_config() {
|
||||||
let config = parse_args(["--store", "fs", "--fs-root", "/tmp/runtime-store"])
|
let config = parse_args([
|
||||||
.unwrap()
|
"--store",
|
||||||
.unwrap();
|
"fs",
|
||||||
|
"--fs-root",
|
||||||
|
"/tmp/yoi",
|
||||||
|
"--fs-worker-dir",
|
||||||
|
"/tmp/yoi-worker",
|
||||||
|
"--fs-runtime-dir",
|
||||||
|
"/tmp/yoi-runtime",
|
||||||
|
"--workdir-target",
|
||||||
|
"/tmp/yoi-workdirs",
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
config.http.store,
|
config.http.store,
|
||||||
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/runtime-store")
|
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/yoi-runtime")
|
||||||
|
));
|
||||||
|
let paths = config.resolved_fs_paths();
|
||||||
|
assert_eq!(paths.worker_dir, PathBuf::from("/tmp/yoi-worker"));
|
||||||
|
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_store_disables_runtime_catalog_persistence() {
|
||||||
|
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
config.http.store,
|
||||||
|
RuntimeHttpStoreSelection::Memory
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::identity::RuntimeId;
|
use crate::identity::WorkerId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Runtime backend kind.
|
/// Runtime backend kind.
|
||||||
|
|
@ -35,7 +35,6 @@ impl Default for RuntimeLimits {
|
||||||
/// Options used to construct an embedded memory Runtime.
|
/// Options used to construct an embedded memory Runtime.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeOptions {
|
pub struct RuntimeOptions {
|
||||||
pub runtime_id: Option<RuntimeId>,
|
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub limits: RuntimeLimits,
|
pub limits: RuntimeLimits,
|
||||||
}
|
}
|
||||||
|
|
@ -43,17 +42,25 @@ pub struct RuntimeOptions {
|
||||||
impl Default for RuntimeOptions {
|
impl Default for RuntimeOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
runtime_id: None,
|
|
||||||
display_name: None,
|
display_name: None,
|
||||||
limits: RuntimeLimits::default(),
|
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.
|
/// Management-plane summary for a Runtime.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub backend: RuntimeBackendKind,
|
pub backend: RuntimeBackendKind,
|
||||||
pub status: RuntimeStatus,
|
pub status: RuntimeStatus,
|
||||||
|
|
@ -63,6 +70,10 @@ pub struct RuntimeSummary {
|
||||||
pub cancelled_worker_count: usize,
|
pub cancelled_worker_count: usize,
|
||||||
pub diagnostic_count: usize,
|
pub diagnostic_count: usize,
|
||||||
pub limits: RuntimeLimits,
|
pub limits: RuntimeLimits,
|
||||||
|
#[serde(default = "unknown_platform_component")]
|
||||||
|
pub os: String,
|
||||||
|
#[serde(default = "unknown_platform_component")]
|
||||||
|
pub arch: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker_creation_available: bool,
|
pub worker_creation_available: bool,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
use crate::identity::{RuntimeId, WorkerRef};
|
use crate::identity::WorkerRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Event cursor. `next_event_id` is the first event id that should be returned
|
/// Event cursor. `next_event_id` is the first event id that should be returned
|
||||||
/// by the next poll.
|
/// by the next poll.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct EventCursor {
|
pub struct EventCursor {
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub next_event_id: u64,
|
pub next_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,7 +12,6 @@ pub struct EventCursor {
|
||||||
/// poll-only so HTTP/WS/SSE dependencies are not pulled into this crate.
|
/// poll-only so HTTP/WS/SSE dependencies are not pulled into this crate.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct EventSubscription {
|
pub struct EventSubscription {
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub cursor: EventCursor,
|
pub cursor: EventCursor,
|
||||||
pub mode: EventSubscriptionMode,
|
pub mode: EventSubscriptionMode,
|
||||||
}
|
}
|
||||||
|
|
@ -39,14 +37,15 @@ pub enum RuntimeEventKind {
|
||||||
RuntimeStarted,
|
RuntimeStarted,
|
||||||
RuntimeStopped,
|
RuntimeStopped,
|
||||||
WorkerCreated,
|
WorkerCreated,
|
||||||
|
WorkerExecutionRestored,
|
||||||
WorkerInputAccepted,
|
WorkerInputAccepted,
|
||||||
WorkerStopped,
|
WorkerStopped,
|
||||||
WorkerCancelled,
|
WorkerCancelled,
|
||||||
|
WorkerDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeEventBatch {
|
pub struct RuntimeEventBatch {
|
||||||
pub runtime_id: RuntimeId,
|
|
||||||
pub cursor: EventCursor,
|
pub cursor: EventCursor,
|
||||||
pub events: Vec<RuntimeEvent>,
|
pub events: Vec<RuntimeEvent>,
|
||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::identity::{RuntimeId, WorkerId};
|
use crate::identity::WorkerId;
|
||||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -179,14 +179,14 @@ impl ProfileSourceArchiveCache {
|
||||||
|
|
||||||
pub fn build_profile_source_archive_fetch_request(
|
pub fn build_profile_source_archive_fetch_request(
|
||||||
handle: BackendResourceHandle,
|
handle: BackendResourceHandle,
|
||||||
runtime_id: &RuntimeId,
|
runtime_id: &str,
|
||||||
worker_id: Option<&WorkerId>,
|
worker_id: Option<&WorkerId>,
|
||||||
) -> BackendResourceFetchRequest {
|
) -> BackendResourceFetchRequest {
|
||||||
let audit_correlation_id = handle.audit_correlation_id.clone();
|
let audit_correlation_id = handle.audit_correlation_id.clone();
|
||||||
BackendResourceFetchRequest {
|
BackendResourceFetchRequest {
|
||||||
handle,
|
handle,
|
||||||
runtime_id: runtime_id.as_str().to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
worker_id: worker_id.map(|id| id.to_string()),
|
||||||
audit_correlation_id,
|
audit_correlation_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,12 +15,13 @@ use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkingDirectoryRequest,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
WorkingDirectoryStatus,
|
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionRunState,
|
||||||
|
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||||
};
|
};
|
||||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||||
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
||||||
|
|
@ -33,11 +34,12 @@ use protocol::{Method, Segment, WorkerStatus};
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
use session_store::{CombinedStore, FsWorkerStore};
|
use session_store::{CombinedStore, FsWorkerStore};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use worker::{
|
use worker::{
|
||||||
Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext,
|
Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||||
WorkspaceId,
|
WorkerWorkspaceContext, WorkspaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||||
|
|
@ -51,6 +53,11 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
) -> Result<WorkerHandle, String>;
|
) -> 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
|
/// Production factory that resolves a normal Worker profile and spawns it under
|
||||||
|
|
@ -58,7 +65,6 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProfileRuntimeWorkerFactory {
|
pub struct ProfileRuntimeWorkerFactory {
|
||||||
profile_base_dir: PathBuf,
|
profile_base_dir: PathBuf,
|
||||||
cwd: PathBuf,
|
|
||||||
store_dir: Option<PathBuf>,
|
store_dir: Option<PathBuf>,
|
||||||
worker_metadata_dir: Option<PathBuf>,
|
worker_metadata_dir: Option<PathBuf>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
|
|
@ -71,7 +77,6 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
pub fn new(profile_base_dir: impl Into<PathBuf>) -> Self {
|
pub fn new(profile_base_dir: impl Into<PathBuf>) -> Self {
|
||||||
let profile_base_dir = profile_base_dir.into();
|
let profile_base_dir = profile_base_dir.into();
|
||||||
Self {
|
Self {
|
||||||
cwd: profile_base_dir.clone(),
|
|
||||||
profile_base_dir,
|
profile_base_dir,
|
||||||
store_dir: None,
|
store_dir: None,
|
||||||
worker_metadata_dir: None,
|
worker_metadata_dir: None,
|
||||||
|
|
@ -82,11 +87,6 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
|
|
||||||
self.cwd = cwd.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
|
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
|
||||||
self.store_dir = Some(store_dir.into());
|
self.store_dir = Some(store_dir.into());
|
||||||
self
|
self
|
||||||
|
|
@ -139,12 +139,12 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
.ok_or_else(|| "could not resolve worker runtime directory".to_string())
|
.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 {
|
fn runtime_worker_name(request: &WorkerExecutionSpawnRequest) -> String {
|
||||||
format!(
|
Self::runtime_worker_name_for_ref(&request.worker_ref)
|
||||||
"runtime-{}-{}",
|
|
||||||
sanitize_worker_name_component(request.worker_ref.runtime_id.as_str()),
|
|
||||||
request.worker_ref.worker_id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_profile_value(
|
fn runtime_profile_value(
|
||||||
|
|
@ -165,14 +165,21 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_profile<'a>(
|
fn runtime_profile_for_request<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
request: &'a WorkerExecutionSpawnRequest,
|
request: &'a CreateWorkerRequest,
|
||||||
) -> Option<std::borrow::Cow<'a, str>> {
|
) -> Option<std::borrow::Cow<'a, str>> {
|
||||||
if let Some(profile) = self.profile.as_deref() {
|
if let Some(profile) = self.profile.as_deref() {
|
||||||
return Some(std::borrow::Cow::Borrowed(profile));
|
return Some(std::borrow::Cow::Borrowed(profile));
|
||||||
}
|
}
|
||||||
Self::runtime_profile_value(&request.request.profile)
|
Self::runtime_profile_value(&request.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_profile<'a>(
|
||||||
|
&'a self,
|
||||||
|
request: &'a WorkerExecutionSpawnRequest,
|
||||||
|
) -> Option<std::borrow::Cow<'a, str>> {
|
||||||
|
self.runtime_profile_for_request(&request.request)
|
||||||
}
|
}
|
||||||
async fn resolve_profile_source_archive(
|
async fn resolve_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -220,19 +227,6 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_worker_name_component(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.chars()
|
|
||||||
.map(|ch| {
|
|
||||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
|
||||||
ch
|
|
||||||
} else {
|
|
||||||
'-'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum RuntimeWorkspaceBackendRef {
|
enum RuntimeWorkspaceBackendRef {
|
||||||
None,
|
None,
|
||||||
|
|
@ -397,6 +391,110 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
|
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
|
||||||
Ok(handle)
|
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 {
|
struct RuntimeWorkerExecution {
|
||||||
|
|
@ -532,6 +630,69 @@ where
|
||||||
.map(|_| WorkerExecutionResult::accepted(operation, accepted_run_state))
|
.map(|_| WorkerExecutionResult::accepted(operation, accepted_run_state))
|
||||||
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
|
.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> {
|
impl<F> Drop for WorkerRuntimeExecutionBackend<F> {
|
||||||
|
|
@ -694,53 +855,108 @@ where
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut events = handle.subscribe();
|
self.connect_handle(
|
||||||
let bridge_handle = handle.clone();
|
WorkerExecutionOperation::Spawn,
|
||||||
let busy = Arc::new(AtomicBool::new(false));
|
worker_ref,
|
||||||
let bridge_busy = busy.clone();
|
bridge_context,
|
||||||
if let Err(message) = self.spawn_on_adapter_runtime(async move {
|
handle,
|
||||||
loop {
|
working_directory,
|
||||||
match events.recv().await {
|
)
|
||||||
Ok(event) => {
|
}
|
||||||
let _ = bridge_context.publish_protocol_event(event);
|
|
||||||
if bridge_handle.shared_state.get_status() == WorkerStatus::Idle {
|
fn restore_worker(
|
||||||
bridge_busy.store(false, Ordering::SeqCst);
|
&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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) {
|
None if request.request.working_directory_request.is_some() => {
|
||||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
|
||||||
WorkerExecutionOperation::Spawn,
|
WorkerExecutionOperation::Restore,
|
||||||
message,
|
"persisted worker requested a working directory, but no persisted working directory binding is available to restore",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
None if request.request.working_directory.is_some() => {
|
||||||
|
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||||
|
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
|
||||||
|
WorkerExecutionOperation::Restore,
|
||||||
|
"persisted worker has a working directory claim, but no materializer is configured for this runtime backend",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let working_directory =
|
||||||
|
request.request.working_directory.as_ref().expect("checked");
|
||||||
|
match materializer.bind_working_directory(
|
||||||
|
&working_directory.working_directory_id,
|
||||||
|
working_directory.relative_cwd.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(binding) => {
|
||||||
|
request.working_directory = Some(binding.clone());
|
||||||
|
Some(binding)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return WorkerExecutionSpawnResult::Rejected(
|
||||||
|
WorkerExecutionResult::rejected(
|
||||||
|
WorkerExecutionOperation::Restore,
|
||||||
|
error.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let mut workers = match self.workers.lock() {
|
let factory = self.factory.clone();
|
||||||
Ok(workers) => workers,
|
let bridge_context = request.context.clone();
|
||||||
Err(_) => {
|
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(
|
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||||
WorkerExecutionOperation::Spawn,
|
WorkerExecutionOperation::Restore,
|
||||||
"worker adapter registry lock is poisoned",
|
message,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
workers.insert(
|
|
||||||
worker_ref.clone(),
|
|
||||||
RuntimeWorkerExecution {
|
|
||||||
handle,
|
|
||||||
busy,
|
|
||||||
working_directory: working_directory.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
WorkerExecutionSpawnResult::Connected {
|
self.connect_handle(
|
||||||
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
|
WorkerExecutionOperation::Restore,
|
||||||
run_state: WorkerExecutionRunState::Idle,
|
worker_ref,
|
||||||
working_directory: working_directory.map(|binding| binding.status()),
|
bridge_context,
|
||||||
}
|
handle,
|
||||||
|
working_directory,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_input(
|
fn dispatch_input(
|
||||||
|
|
@ -866,11 +1082,10 @@ mod tests {
|
||||||
|
|
||||||
use crate::Runtime as EmbeddedRuntime;
|
use crate::Runtime as EmbeddedRuntime;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector,
|
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
|
||||||
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||||
};
|
};
|
||||||
use crate::execution::WorkerExecutionContext;
|
use crate::execution::WorkerExecutionContext;
|
||||||
use crate::identity::RuntimeId;
|
|
||||||
use crate::management::RuntimeOptions;
|
use crate::management::RuntimeOptions;
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
use crate::working_directory::LocalGitWorktreeMaterializer;
|
use crate::working_directory::LocalGitWorktreeMaterializer;
|
||||||
|
|
@ -988,6 +1203,19 @@ mod tests {
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(handle)
|
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> {
|
fn core_filesystem_tool_names() -> BTreeSet<&'static str> {
|
||||||
|
|
@ -1114,21 +1342,16 @@ mod tests {
|
||||||
selector: Some(RepositorySelector::from("HEAD")),
|
selector: Some(RepositorySelector::from("HEAD")),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_worker_name_is_namespaced_by_runtime_id() {
|
fn runtime_worker_name_is_runtime_local() {
|
||||||
let runtime_id = RuntimeId::new("arc:remote".to_string()).unwrap();
|
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||||
let worker_ref = crate::identity::WorkerRef::new(
|
|
||||||
runtime_id,
|
|
||||||
crate::identity::WorkerId::new("worker-00000001".to_string()).unwrap(),
|
|
||||||
);
|
|
||||||
let request = WorkerExecutionSpawnRequest {
|
let request = WorkerExecutionSpawnRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
request: create_request("worker-00000001"),
|
request: create_request("1"),
|
||||||
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
|
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle: None,
|
config_bundle: None,
|
||||||
|
|
@ -1136,11 +1359,11 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
"runtime-arc-remote-worker-00000001"
|
"worker-runtime-1"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
"worker-00000001"
|
"00000001"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1191,14 +1414,9 @@ mod tests {
|
||||||
observed_cwds: observed_cwds.clone(),
|
observed_cwds: observed_cwds.clone(),
|
||||||
};
|
};
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
||||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
let runtime =
|
||||||
RuntimeOptions {
|
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||||
runtime_id: RuntimeId::new("embedded"),
|
.unwrap();
|
||||||
..RuntimeOptions::default()
|
|
||||||
},
|
|
||||||
Arc::new(backend),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
let detail = runtime.create_worker(create_request("chat")).unwrap();
|
let detail = runtime.create_worker(create_request("chat")).unwrap();
|
||||||
|
|
||||||
|
|
@ -1265,14 +1483,9 @@ mod tests {
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||||
runtime_base.path(),
|
runtime_base.path(),
|
||||||
));
|
));
|
||||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
let runtime =
|
||||||
RuntimeOptions {
|
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||||
runtime_id: RuntimeId::new("embedded-materialized"),
|
.unwrap();
|
||||||
..RuntimeOptions::default()
|
|
||||||
},
|
|
||||||
Arc::new(backend),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
let mut request = create_request("chat");
|
let mut request = create_request("chat");
|
||||||
request.working_directory_request = Some(working_directory_request(repo.path()));
|
request.working_directory_request = Some(working_directory_request(repo.path()));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
|
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
|
||||||
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
|
|
@ -23,7 +23,6 @@ pub struct WorkingDirectoryEvidence {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_tree: Option<String>,
|
pub resolved_tree: Option<String>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -31,10 +30,8 @@ pub struct WorkingDirectory {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
pub evidence: WorkingDirectoryEvidence,
|
pub evidence: WorkingDirectoryEvidence,
|
||||||
pub cleanup_target: WorkingDirectoryCleanupTarget,
|
pub cleanup_target: WorkingDirectoryCleanupTarget,
|
||||||
pub cleanup_policy: String,
|
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,12 +42,12 @@ impl WorkingDirectory {
|
||||||
repository_id: self.repository_id.clone(),
|
repository_id: self.repository_id.clone(),
|
||||||
requested_selector: self.evidence.requested_selector.clone(),
|
requested_selector: self.evidence.requested_selector.clone(),
|
||||||
materializer_kind: self.materializer_kind.clone(),
|
materializer_kind: self.materializer_kind.clone(),
|
||||||
dirty_state_policy: self.dirty_state_policy.clone(),
|
|
||||||
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
||||||
resolved_tree: self.evidence.resolved_tree.clone(),
|
resolved_tree: self.evidence.resolved_tree.clone(),
|
||||||
cleanup_target: Some(self.cleanup_target.clone()),
|
cleanup_target: Some(self.cleanup_target.clone()),
|
||||||
cleanup_policy: Some(self.cleanup_policy.clone()),
|
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
|
cleanliness: None,
|
||||||
|
primary_worker_id: None,
|
||||||
management_kind: None,
|
management_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +80,19 @@ impl WorkingDirectoryBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> WorkingDirectoryStatus {
|
pub fn status(&self) -> WorkingDirectoryStatus {
|
||||||
WorkingDirectoryStatus {
|
let mut working_directory = self.working_directory.clone();
|
||||||
summary: self.working_directory.status_summary(),
|
if working_directory.status == WorkingDirectoryStatusKind::Active
|
||||||
|
&& !binding_paths_are_available(self)
|
||||||
|
{
|
||||||
|
working_directory.status = WorkingDirectoryStatusKind::Corrupted;
|
||||||
}
|
}
|
||||||
|
let mut summary = working_directory.status_summary();
|
||||||
|
summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active {
|
||||||
|
Some(binding_cleanliness(self))
|
||||||
|
} else {
|
||||||
|
Some("unknown".to_string())
|
||||||
|
};
|
||||||
|
WorkingDirectoryStatus { summary }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +158,27 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
||||||
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
|
fn 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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LocalGitWorktreeMaterializer {
|
pub struct LocalGitWorktreeMaterializer {
|
||||||
runtime_root: PathBuf,
|
runtime_root: PathBuf,
|
||||||
|
|
@ -167,13 +195,8 @@ impl LocalGitWorktreeMaterializer {
|
||||||
&self.runtime_root
|
&self.runtime_root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
|
fn working_directory_id(_worker_ref: &WorkerRef, repository_id: &str) -> String {
|
||||||
format!(
|
next_working_directory_id(repository_id)
|
||||||
"{}-{}-{}",
|
|
||||||
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 {
|
fn working_directory_root(&self, working_directory_id: &str) -> PathBuf {
|
||||||
|
|
@ -182,6 +205,28 @@ impl LocalGitWorktreeMaterializer {
|
||||||
.join(working_directory_id)
|
.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(
|
fn write_record(
|
||||||
&self,
|
&self,
|
||||||
binding: &WorkingDirectoryBinding,
|
binding: &WorkingDirectoryBinding,
|
||||||
|
|
@ -237,7 +282,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: String,
|
working_directory_id: String,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
cleanup_policy: &str,
|
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(&working_directory_id)?;
|
validate_working_directory_id(&working_directory_id)?;
|
||||||
if request.materializer != MaterializerKind::LocalGitWorktree {
|
if request.materializer != MaterializerKind::LocalGitWorktree {
|
||||||
|
|
@ -255,12 +299,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
|
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
|
||||||
"working_directory_dirty_policy_unsupported",
|
|
||||||
"only clean_point_only dirty-state policy is supported in v0",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if is_remote_uri(&request.repository.uri) {
|
if is_remote_uri(&request.repository.uri) {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_remote_repository_unsupported",
|
"working_directory_remote_repository_unsupported",
|
||||||
|
|
@ -286,7 +324,7 @@ impl LocalGitWorktreeMaterializer {
|
||||||
if !status.trim().is_empty() {
|
if !status.trim().is_empty() {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_dirty_source_rejected",
|
"working_directory_dirty_source_rejected",
|
||||||
"clean_point_only working directory materialization rejects dirty source repository state",
|
"working directory materialization rejects dirty source repository state",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,7 +383,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
id: working_directory_id.clone(),
|
id: working_directory_id.clone(),
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
evidence: WorkingDirectoryEvidence {
|
evidence: WorkingDirectoryEvidence {
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
requested_selector: request
|
requested_selector: request
|
||||||
|
|
@ -356,14 +393,12 @@ impl LocalGitWorktreeMaterializer {
|
||||||
resolved_commit,
|
resolved_commit,
|
||||||
resolved_tree,
|
resolved_tree,
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
},
|
},
|
||||||
cleanup_target: WorkingDirectoryCleanupTarget {
|
cleanup_target: WorkingDirectoryCleanupTarget {
|
||||||
kind: "git_worktree".to_string(),
|
kind: "git_worktree".to_string(),
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
},
|
},
|
||||||
cleanup_policy: cleanup_policy.to_string(),
|
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
status: WorkingDirectoryStatusKind::Active,
|
||||||
};
|
};
|
||||||
let binding = WorkingDirectoryBinding {
|
let binding = WorkingDirectoryBinding {
|
||||||
|
|
@ -385,11 +420,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
|
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(working_directory_id, request)
|
||||||
working_directory_id,
|
|
||||||
request,
|
|
||||||
"remove_on_worker_stop",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(
|
fn create(
|
||||||
|
|
@ -400,11 +431,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
.backend_workdir_id
|
.backend_workdir_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(working_directory_id, request)
|
||||||
working_directory_id,
|
|
||||||
request,
|
|
||||||
"manual_or_worker_stop",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
|
|
@ -451,8 +478,9 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
if validate_working_directory_id(&working_directory_id).is_err() {
|
if validate_working_directory_id(&working_directory_id).is_err() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(status) = self.working_directory_status(&working_directory_id) {
|
match self.read_binding(&working_directory_id) {
|
||||||
statuses.push(status);
|
Ok(binding) => statuses.push(binding.status()),
|
||||||
|
Err(_) => statuses.push(self.corrupted_status(&working_directory_id)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statuses.sort_by(|left, right| {
|
statuses.sort_by(|left, right| {
|
||||||
|
|
@ -468,7 +496,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(working_directory_id)?;
|
validate_working_directory_id(working_directory_id)?;
|
||||||
Ok(self.read_binding(working_directory_id)?.status())
|
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||||
|
if !working_directory_root.exists() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_not_found",
|
||||||
|
"working directory working_directory was not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match self.read_binding(working_directory_id) {
|
||||||
|
Ok(binding) => Ok(binding.status()),
|
||||||
|
Err(_) => Ok(self.corrupted_status(working_directory_id)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_working_directory(
|
fn cleanup_working_directory(
|
||||||
|
|
@ -476,9 +514,35 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(working_directory_id)?;
|
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)?;
|
let binding = self.read_binding(working_directory_id)?;
|
||||||
self.cleanup(&binding)?;
|
self.cleanup(&binding)?;
|
||||||
self.working_directory_status(working_directory_id)
|
let mut summary = binding.working_directory.status_summary();
|
||||||
|
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||||
|
summary.cleanliness = Some("unknown".to_string());
|
||||||
|
if binding.working_directory_root.exists() {
|
||||||
|
fs::remove_dir_all(&binding.working_directory_root).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_record_cleanup_failed",
|
||||||
|
"failed to remove working directory record; backend-private path details were omitted",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(WorkingDirectoryStatus { summary })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
|
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
|
@ -518,19 +582,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
working_directory.status = if remove_result.is_ok() {
|
if remove_result.is_err() {
|
||||||
WorkingDirectoryStatusKind::Removed
|
working_directory.status = WorkingDirectoryStatusKind::CleanupPending;
|
||||||
} else {
|
let updated = WorkingDirectoryBinding {
|
||||||
WorkingDirectoryStatusKind::CleanupPending
|
working_directory,
|
||||||
};
|
root: binding.root.clone(),
|
||||||
let updated = WorkingDirectoryBinding {
|
cwd: binding.cwd.clone(),
|
||||||
working_directory,
|
working_directory_root: binding.working_directory_root.clone(),
|
||||||
root: binding.root.clone(),
|
source_repository_path: binding.source_repository_path.clone(),
|
||||||
cwd: binding.cwd.clone(),
|
};
|
||||||
working_directory_root: binding.working_directory_root.clone(),
|
let _ = self.write_record(&updated);
|
||||||
source_repository_path: binding.source_repository_path.clone(),
|
}
|
||||||
};
|
|
||||||
let _ = self.write_record(&updated);
|
|
||||||
remove_result
|
remove_result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -609,16 +671,13 @@ fn sanitize_path_component(value: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_working_directory_id(repository_id: &str) -> String {
|
fn next_working_directory_id(_repository_id: &str) -> String {
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|duration| duration.as_millis())
|
.map(|duration| duration.as_millis() as u64)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0x00ff_ffff;
|
||||||
format!(
|
format!("{now:013x}{sequence:06x}")
|
||||||
"alloc-{now}-{sequence}-{}",
|
|
||||||
sanitize_path_component(repository_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_working_directory_id(
|
fn validate_working_directory_id(
|
||||||
|
|
@ -686,7 +745,7 @@ fn validate_relative_cwd(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
|
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
|
||||||
use crate::identity::{RuntimeId, WorkerId};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
|
|
||||||
fn git(path: &Path, args: &[&str]) {
|
fn git(path: &Path, args: &[&str]) {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
|
|
@ -722,16 +781,12 @@ mod tests {
|
||||||
selector: Some(RepositorySelector::from("HEAD")),
|
selector: Some(RepositorySelector::from("HEAD")),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_ref(sequence: u64) -> WorkerRef {
|
fn worker_ref(sequence: u64) -> WorkerRef {
|
||||||
WorkerRef::new(
|
WorkerRef::new(WorkerId::generated(sequence))
|
||||||
RuntimeId::new("runtime-test").unwrap(),
|
|
||||||
WorkerId::generated(sequence),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -754,10 +809,6 @@ mod tests {
|
||||||
binding.working_directory.materializer_kind,
|
binding.working_directory.materializer_kind,
|
||||||
MaterializerKind::LocalGitWorktree
|
MaterializerKind::LocalGitWorktree
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
binding.working_directory.dirty_state_policy,
|
|
||||||
DirtyStatePolicy::CleanPointOnly
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
binding
|
binding
|
||||||
.working_directory_root()
|
.working_directory_root()
|
||||||
|
|
@ -786,7 +837,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dirty_source_is_rejected_by_clean_point_only_policy() {
|
fn dirty_source_is_rejected_by_materialization() {
|
||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
||||||
let runtime_root = tempfile::tempdir().unwrap();
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -797,7 +848,7 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(error.code, "working_directory_dirty_source_rejected");
|
assert_eq!(error.code, "working_directory_dirty_source_rejected");
|
||||||
assert!(error.message.contains("clean_point_only"));
|
assert!(error.message.contains("dirty source"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -911,7 +962,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cleanup_removes_worktree_and_updates_record() {
|
fn cleanup_working_directory_removes_worktree_and_record() {
|
||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
let runtime_root = tempfile::tempdir().unwrap();
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||||
|
|
@ -919,16 +970,14 @@ mod tests {
|
||||||
.materialize(&worker_ref(1), &request(repo.path()))
|
.materialize(&worker_ref(1), &request(repo.path()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let root = binding.root.clone();
|
let root = binding.root.clone();
|
||||||
|
let record_root = binding.working_directory_root().to_path_buf();
|
||||||
|
|
||||||
materializer.cleanup(&binding).unwrap();
|
let status = materializer
|
||||||
|
.cleanup_working_directory(&binding.working_directory.id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status.summary.status, WorkingDirectoryStatusKind::NotFound);
|
||||||
assert!(!root.exists());
|
assert!(!root.exists());
|
||||||
let raw = fs::read_to_string(
|
assert!(!record_root.exists());
|
||||||
binding
|
|
||||||
.working_directory_root()
|
|
||||||
.join(MATERIALIZATION_RECORD),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(raw.contains("removed"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,12 @@ use worker_runtime::execution::{
|
||||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerInputResponse,
|
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerDeleteResponse,
|
||||||
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
||||||
RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse,
|
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoryResponse,
|
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::{
|
use worker_runtime::interaction::{
|
||||||
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
|
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
|
||||||
};
|
};
|
||||||
|
|
@ -170,7 +168,6 @@ pub struct RuntimeCapabilitySummary {
|
||||||
pub can_get_worker: bool,
|
pub can_get_worker: bool,
|
||||||
pub can_spawn_worker: bool,
|
pub can_spawn_worker: bool,
|
||||||
pub can_stop_worker: bool,
|
pub can_stop_worker: bool,
|
||||||
pub can_accept_input: bool,
|
|
||||||
pub has_workspace_fs: bool,
|
pub has_workspace_fs: bool,
|
||||||
pub has_shell: bool,
|
pub has_shell: bool,
|
||||||
pub has_git: bool,
|
pub has_git: bool,
|
||||||
|
|
@ -223,7 +220,6 @@ pub struct WorkerImplementationSummary {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerCapabilitySummary {
|
pub struct WorkerCapabilitySummary {
|
||||||
pub can_accept_input: bool,
|
|
||||||
pub can_stop: bool,
|
pub can_stop: bool,
|
||||||
pub can_spawn_followup: bool,
|
pub can_spawn_followup: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +234,6 @@ pub struct WorkerSummary {
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
pub workspace: WorkerWorkspaceSummary,
|
pub workspace: WorkerWorkspaceSummary,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub status: String,
|
|
||||||
pub last_seen_at: Option<String>,
|
pub last_seen_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
|
@ -443,6 +438,15 @@ pub enum WorkerInputKind {
|
||||||
System,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerInputRequest {
|
pub struct WorkerInputRequest {
|
||||||
#[serde(default = "default_worker_input_kind")]
|
#[serde(default = "default_worker_input_kind")]
|
||||||
|
|
@ -671,6 +675,20 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
|
||||||
|
WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Unsupported,
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
worker_id: worker_id.to_string(),
|
||||||
|
deleted: false,
|
||||||
|
diagnostics: vec![diagnostic(
|
||||||
|
"worker_delete_unsupported",
|
||||||
|
DiagnosticSeverity::Info,
|
||||||
|
format!("runtime does not implement worker deletion for '{worker_id}'"),
|
||||||
|
)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: &str,
|
_worker_id: &str,
|
||||||
|
|
@ -1042,6 +1060,25 @@ impl RuntimeRegistry {
|
||||||
Ok(runtime.cancel_worker(worker_id, request))
|
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(
|
pub fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
|
|
@ -1089,10 +1126,7 @@ pub struct EmbeddedWorkerRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
|
fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
|
||||||
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
|
|
||||||
.expect("embedded runtime id is a non-empty literal");
|
|
||||||
EmbeddedRuntimeOptions {
|
EmbeddedRuntimeOptions {
|
||||||
runtime_id: Some(runtime_id),
|
|
||||||
display_name: Some("embedded".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
..EmbeddedRuntimeOptions::default()
|
..EmbeddedRuntimeOptions::default()
|
||||||
}
|
}
|
||||||
|
|
@ -1120,12 +1154,9 @@ impl EmbeddedWorkerRuntime {
|
||||||
store_root: impl Into<PathBuf>,
|
store_root: impl Into<PathBuf>,
|
||||||
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
) -> Result<Self, worker_runtime::error::RuntimeError> {
|
) -> 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(
|
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
|
||||||
FsRuntimeStoreOptions {
|
FsRuntimeStoreOptions {
|
||||||
root: store_root.into(),
|
root: store_root.into(),
|
||||||
runtime_id: Some(runtime_id),
|
|
||||||
display_name: Some("embedded".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
limits: EmbeddedRuntimeOptions::default().limits,
|
limits: EmbeddedRuntimeOptions::default().limits,
|
||||||
},
|
},
|
||||||
|
|
@ -1142,13 +1173,8 @@ impl EmbeddedWorkerRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
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 {
|
Self {
|
||||||
runtime_id,
|
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
||||||
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
|
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
|
||||||
runtime,
|
runtime,
|
||||||
execution_enabled: false,
|
execution_enabled: false,
|
||||||
|
|
@ -1157,18 +1183,7 @@ impl EmbeddedWorkerRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> {
|
fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> {
|
||||||
Some(EmbeddedWorkerRef::new(
|
Some(EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(worker_id)?))
|
||||||
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(
|
fn can_stop_embedded_worker(
|
||||||
|
|
@ -1182,17 +1197,16 @@ impl EmbeddedWorkerRuntime {
|
||||||
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
|
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
|
worker_id: summary.worker_ref.worker_id.to_string(),
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
|
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
|
||||||
role: embedded_profile_label(&summary.profile),
|
role: embedded_profile_label(&summary.profile),
|
||||||
profile: embedded_profile_label(&summary.profile),
|
profile: embedded_profile_label(&summary.profile),
|
||||||
workspace: WorkerWorkspaceSummary {
|
workspace: WorkerWorkspaceSummary {
|
||||||
visibility: "backend_internal".to_string(),
|
visibility: "backend_internal".to_string(),
|
||||||
identity: "runtime_registry_worker".to_string(),
|
identity: "runtime_registry_worker".to_string(),
|
||||||
},
|
},
|
||||||
state: embedded_worker_status_label(summary.status).to_string(),
|
state: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
||||||
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
|
@ -1202,8 +1216,6 @@ impl EmbeddedWorkerRuntime {
|
||||||
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
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_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -1219,17 +1231,16 @@ impl EmbeddedWorkerRuntime {
|
||||||
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: detail.worker_id.as_str().to_string(),
|
worker_id: detail.worker_id.to_string(),
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
label: safe_display_hint(detail.worker_id.as_str()),
|
label: safe_display_hint(&detail.worker_id.to_string()),
|
||||||
role: embedded_profile_label(&detail.profile),
|
role: embedded_profile_label(&detail.profile),
|
||||||
profile: embedded_profile_label(&detail.profile),
|
profile: embedded_profile_label(&detail.profile),
|
||||||
workspace: WorkerWorkspaceSummary {
|
workspace: WorkerWorkspaceSummary {
|
||||||
visibility: "backend_internal".to_string(),
|
visibility: "backend_internal".to_string(),
|
||||||
identity: "runtime_registry_worker".to_string(),
|
identity: "runtime_registry_worker".to_string(),
|
||||||
},
|
},
|
||||||
state: embedded_worker_status_label(detail.status).to_string(),
|
state: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
||||||
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
|
@ -1239,7 +1250,6 @@ impl EmbeddedWorkerRuntime {
|
||||||
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
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_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -1661,6 +1671,38 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
|
||||||
|
let Some(worker_ref) = self.worker_ref(worker_id) else {
|
||||||
|
return WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
worker_id: worker_id.to_string(),
|
||||||
|
deleted: false,
|
||||||
|
diagnostics: vec![diagnostic(
|
||||||
|
"embedded_worker_id_invalid",
|
||||||
|
DiagnosticSeverity::Warning,
|
||||||
|
"Worker id was empty and cannot be resolved".to_string(),
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
match self.runtime.delete_worker(&worker_ref) {
|
||||||
|
Ok(result) => WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Accepted,
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
worker_id: result.worker_id.to_string(),
|
||||||
|
deleted: result.deleted,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(error) => WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
worker_id: worker_id.to_string(),
|
||||||
|
deleted: false,
|
||||||
|
diagnostics: vec![embedded_runtime_diagnostic(&error)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
|
|
@ -1768,7 +1810,9 @@ impl RemoteRuntimeConfig {
|
||||||
display_name: display_name.into(),
|
display_name: display_name.into(),
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
cached_capabilities: remote_runtime_capabilities(200, false, false),
|
cached_capabilities: remote_runtime_capabilities(
|
||||||
|
200, false, false, "unknown", "unknown",
|
||||||
|
),
|
||||||
cached_status: "configured".to_string(),
|
cached_status: "configured".to_string(),
|
||||||
timeout: Duration::from_secs(10),
|
timeout: Duration::from_secs(10),
|
||||||
}
|
}
|
||||||
|
|
@ -1922,17 +1966,16 @@ impl RemoteWorkerRuntime {
|
||||||
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
|
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
|
worker_id: summary.worker_ref.worker_id.to_string(),
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
|
label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
|
||||||
role: None,
|
role: None,
|
||||||
profile: embedded_profile_label(&summary.profile),
|
profile: embedded_profile_label(&summary.profile),
|
||||||
workspace: WorkerWorkspaceSummary {
|
workspace: WorkerWorkspaceSummary {
|
||||||
visibility: "remote_runtime".to_string(),
|
visibility: "remote_runtime".to_string(),
|
||||||
identity: "runtime_registry_worker".to_string(),
|
identity: "runtime_registry_worker".to_string(),
|
||||||
},
|
},
|
||||||
state: embedded_worker_status_label(summary.status).to_string(),
|
state: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
||||||
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
|
@ -1942,7 +1985,6 @@ impl RemoteWorkerRuntime {
|
||||||
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
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_stop: runtime_worker_can_stop(true, summary.status, &summary.execution),
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -1958,17 +2000,16 @@ impl RemoteWorkerRuntime {
|
||||||
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: detail.worker_id.as_str().to_string(),
|
worker_id: detail.worker_id.to_string(),
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
label: safe_display_hint(detail.worker_id.as_str()),
|
label: safe_display_hint(&detail.worker_id.to_string()),
|
||||||
role: None,
|
role: None,
|
||||||
profile: embedded_profile_label(&detail.profile),
|
profile: embedded_profile_label(&detail.profile),
|
||||||
workspace: WorkerWorkspaceSummary {
|
workspace: WorkerWorkspaceSummary {
|
||||||
visibility: "remote_runtime".to_string(),
|
visibility: "remote_runtime".to_string(),
|
||||||
identity: "runtime_registry_worker".to_string(),
|
identity: "runtime_registry_worker".to_string(),
|
||||||
},
|
},
|
||||||
state: embedded_worker_status_label(detail.status).to_string(),
|
state: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
||||||
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
|
@ -1978,7 +2019,6 @@ impl RemoteWorkerRuntime {
|
||||||
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
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_stop: runtime_worker_can_stop(true, detail.status, &detail.execution),
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -2038,6 +2078,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
limit,
|
limit,
|
||||||
true,
|
true,
|
||||||
response.runtime.worker_creation_available,
|
response.runtime.worker_creation_available,
|
||||||
|
response.runtime.os,
|
||||||
|
response.runtime.arch,
|
||||||
),
|
),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
|
|
@ -2071,7 +2113,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
status: "configured".to_string(),
|
status: "configured".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: remote_runtime_capabilities(limit, true, false),
|
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
|
@ -2102,7 +2144,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
worker: Some(self.map_worker_detail(response.worker)),
|
worker: Some(self.map_worker_detail(response.worker)),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
Err(diagnostic) if diagnostic.code == "remote_worker_not_found" => WorkerLookupResult {
|
Err(diagnostic) if diagnostic.code == "worker_not_found" => WorkerLookupResult {
|
||||||
worker: None,
|
worker: None,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
|
|
@ -2198,11 +2240,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
.profile
|
.profile
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
.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(
|
let profile_source = match default_profile_source_archive_http_source(
|
||||||
&profile,
|
&profile,
|
||||||
&self.workspace_id,
|
&self.workspace_id,
|
||||||
runtime_id.as_ref(),
|
Some(self.runtime_id.as_str()),
|
||||||
&self.resource_broker,
|
&self.resource_broker,
|
||||||
&self.backend_base_url,
|
&self.backend_base_url,
|
||||||
) {
|
) {
|
||||||
|
|
@ -2316,6 +2357,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
|
||||||
|
match self
|
||||||
|
.delete_json::<RuntimeHttpWorkerDeleteResponse>(&format!("/v1/workers/{worker_id}"))
|
||||||
|
{
|
||||||
|
Ok(response) => WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Accepted,
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
worker_id: response.worker.worker_id.to_string(),
|
||||||
|
deleted: response.worker.deleted,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(diagnostic) => WorkerDeleteResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
worker_id: worker_id.to_string(),
|
||||||
|
deleted: false,
|
||||||
|
diagnostics: vec![diagnostic],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
|
|
@ -2365,7 +2427,6 @@ fn embedded_runtime_capabilities(
|
||||||
can_get_worker: available,
|
can_get_worker: available,
|
||||||
can_spawn_worker: available,
|
can_spawn_worker: available,
|
||||||
can_stop_worker: available && execution_enabled,
|
can_stop_worker: available && execution_enabled,
|
||||||
can_accept_input: available && execution_enabled,
|
|
||||||
has_workspace_fs: false,
|
has_workspace_fs: false,
|
||||||
has_shell: false,
|
has_shell: false,
|
||||||
has_git: false,
|
has_git: false,
|
||||||
|
|
@ -2414,18 +2475,6 @@ fn embedded_spawn_execution_failure_diagnostic(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_worker_can_accept_input(
|
|
||||||
execution_enabled: bool,
|
|
||||||
status: EmbeddedWorkerStatus,
|
|
||||||
execution: &WorkerExecutionStatus,
|
|
||||||
) -> bool {
|
|
||||||
execution_enabled
|
|
||||||
&& status == EmbeddedWorkerStatus::Running
|
|
||||||
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
|
|
||||||
&& execution.run_state == WorkerExecutionRunState::Idle
|
|
||||||
&& !execution_last_result_blocks_control(execution)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_worker_can_stop(
|
fn runtime_worker_can_stop(
|
||||||
execution_enabled: bool,
|
execution_enabled: bool,
|
||||||
status: EmbeddedWorkerStatus,
|
status: EmbeddedWorkerStatus,
|
||||||
|
|
@ -2532,7 +2581,7 @@ fn default_profile_source_archive_source(
|
||||||
fn default_profile_source_archive_http_source(
|
fn default_profile_source_archive_http_source(
|
||||||
profile: &ProfileSelector,
|
profile: &ProfileSelector,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: Option<&EmbeddedRuntimeId>,
|
runtime_id: Option<&str>,
|
||||||
resource_broker: &BackendResourceBroker,
|
resource_broker: &BackendResourceBroker,
|
||||||
backend_base_url: &str,
|
backend_base_url: &str,
|
||||||
) -> Result<ProfileSourceArchiveSource, String> {
|
) -> Result<ProfileSourceArchiveSource, String> {
|
||||||
|
|
@ -2570,7 +2619,7 @@ enum ProfileSourceArchiveTransport {
|
||||||
fn default_embedded_config_bundle(
|
fn default_embedded_config_bundle(
|
||||||
profile: &ProfileSelector,
|
profile: &ProfileSelector,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: Option<&EmbeddedRuntimeId>,
|
runtime_id: Option<&str>,
|
||||||
resource_broker: &BackendResourceBroker,
|
resource_broker: &BackendResourceBroker,
|
||||||
archive_transport: ProfileSourceArchiveTransport,
|
archive_transport: ProfileSourceArchiveTransport,
|
||||||
) -> Result<ConfigBundle, String> {
|
) -> Result<ConfigBundle, String> {
|
||||||
|
|
@ -2778,17 +2827,11 @@ fn remote_lifecycle_rejected(
|
||||||
|
|
||||||
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
||||||
match error {
|
match error {
|
||||||
EmbeddedRuntimeError::RuntimeStopped { .. } => diagnostic(
|
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
|
||||||
"embedded_runtime_stopped",
|
"embedded_runtime_stopped",
|
||||||
DiagnosticSeverity::Warning,
|
DiagnosticSeverity::Warning,
|
||||||
"Embedded Runtime is stopped".to_string(),
|
"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(
|
EmbeddedRuntimeError::WorkerNotFound { .. } => diagnostic(
|
||||||
"embedded_worker_not_found",
|
"embedded_worker_not_found",
|
||||||
DiagnosticSeverity::Warning,
|
DiagnosticSeverity::Warning,
|
||||||
|
|
@ -2817,6 +2860,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
|
||||||
DiagnosticSeverity::Warning,
|
DiagnosticSeverity::Warning,
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
),
|
),
|
||||||
|
EmbeddedRuntimeError::WorkingDirectory(workdir_diagnostic) => diagnostic(
|
||||||
|
workdir_diagnostic.code.clone(),
|
||||||
|
DiagnosticSeverity::Warning,
|
||||||
|
workdir_diagnostic.message.clone(),
|
||||||
|
),
|
||||||
EmbeddedRuntimeError::InvalidRequest(_)
|
EmbeddedRuntimeError::InvalidRequest(_)
|
||||||
| EmbeddedRuntimeError::ConfigBundleMissing { .. }
|
| EmbeddedRuntimeError::ConfigBundleMissing { .. }
|
||||||
| EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. }
|
| EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. }
|
||||||
|
|
@ -2877,6 +2925,8 @@ fn remote_runtime_capabilities(
|
||||||
limit: usize,
|
limit: usize,
|
||||||
available: bool,
|
available: bool,
|
||||||
worker_creation_available: bool,
|
worker_creation_available: bool,
|
||||||
|
os: impl Into<String>,
|
||||||
|
arch: impl Into<String>,
|
||||||
) -> RuntimeCapabilitySummary {
|
) -> RuntimeCapabilitySummary {
|
||||||
RuntimeCapabilitySummary {
|
RuntimeCapabilitySummary {
|
||||||
can_list_hosts: true,
|
can_list_hosts: true,
|
||||||
|
|
@ -2884,7 +2934,6 @@ fn remote_runtime_capabilities(
|
||||||
can_get_worker: available,
|
can_get_worker: available,
|
||||||
can_spawn_worker: available && worker_creation_available,
|
can_spawn_worker: available && worker_creation_available,
|
||||||
can_stop_worker: available,
|
can_stop_worker: available,
|
||||||
can_accept_input: available && worker_creation_available,
|
|
||||||
has_workspace_fs: false,
|
has_workspace_fs: false,
|
||||||
has_shell: false,
|
has_shell: false,
|
||||||
has_git: false,
|
has_git: false,
|
||||||
|
|
@ -2892,8 +2941,8 @@ fn remote_runtime_capabilities(
|
||||||
supports_backend_internal_tools: false,
|
supports_backend_internal_tools: false,
|
||||||
workspace_scope: "remote_runtime_backend_private".to_string(),
|
workspace_scope: "remote_runtime_backend_private".to_string(),
|
||||||
max_workers: limit,
|
max_workers: limit,
|
||||||
os: "remote".to_string(),
|
os: os.into(),
|
||||||
arch: "remote".to_string(),
|
arch: arch.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2945,7 +2994,8 @@ fn remote_http_status_diagnostic(
|
||||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||||
("remote_runtime_auth_failed", DiagnosticSeverity::Error)
|
("remote_runtime_auth_failed", DiagnosticSeverity::Error)
|
||||||
}
|
}
|
||||||
StatusCode::NOT_FOUND => ("remote_worker_not_found", DiagnosticSeverity::Warning),
|
_ if error.is_some() => (remote_code, DiagnosticSeverity::Warning),
|
||||||
|
StatusCode::NOT_FOUND => ("remote_runtime_not_found", DiagnosticSeverity::Warning),
|
||||||
StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => {
|
StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => {
|
||||||
("remote_runtime_unsupported", DiagnosticSeverity::Warning)
|
("remote_runtime_unsupported", DiagnosticSeverity::Warning)
|
||||||
}
|
}
|
||||||
|
|
@ -3104,7 +3154,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
||||||
identity: "unsupported".to_string(),
|
identity: "unsupported".to_string(),
|
||||||
},
|
},
|
||||||
state: "unsupported".to_string(),
|
state: "unsupported".to_string(),
|
||||||
status: "Worker runtime control is not wired yet".to_string(),
|
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
retention_state: "transient".to_string(),
|
retention_state: "transient".to_string(),
|
||||||
|
|
@ -3113,7 +3162,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
||||||
display_hint: "unsupported".to_string(),
|
display_hint: "unsupported".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
capabilities: WorkerCapabilitySummary {
|
||||||
can_accept_input: false,
|
|
||||||
can_stop: false,
|
can_stop: false,
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -3153,7 +3201,7 @@ mod tests {
|
||||||
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
|
let runtime_id = "runtime-test";
|
||||||
for selector in [
|
for selector in [
|
||||||
ProfileSelector::RuntimeDefault,
|
ProfileSelector::RuntimeDefault,
|
||||||
ProfileSelector::Builtin("builtin:companion".to_string()),
|
ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||||
|
|
@ -3165,7 +3213,7 @@ mod tests {
|
||||||
let bundle = default_embedded_config_bundle(
|
let bundle = default_embedded_config_bundle(
|
||||||
&selector,
|
&selector,
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
&broker,
|
&broker,
|
||||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||||
)
|
)
|
||||||
|
|
@ -3176,7 +3224,7 @@ mod tests {
|
||||||
.fetch_profile_source_archive(
|
.fetch_profile_source_archive(
|
||||||
worker_runtime::resource::BackendResourceFetchRequest {
|
worker_runtime::resource::BackendResourceFetchRequest {
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
runtime_id: runtime_id.as_str().to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: None,
|
worker_id: None,
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
},
|
},
|
||||||
|
|
@ -3204,11 +3252,11 @@ mod tests {
|
||||||
fn remote_default_bundle_inlines_profile_archive_for_standalone_runtime() {
|
fn remote_default_bundle_inlines_profile_archive_for_standalone_runtime() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
|
let runtime_id = "remote:test";
|
||||||
let bundle = default_embedded_config_bundle(
|
let bundle = default_embedded_config_bundle(
|
||||||
&ProfileSelector::Builtin("builtin:coder".to_string()),
|
&ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
&broker,
|
&broker,
|
||||||
ProfileSourceArchiveTransport::Inline,
|
ProfileSourceArchiveTransport::Inline,
|
||||||
)
|
)
|
||||||
|
|
@ -3229,11 +3277,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
|
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = EmbeddedRuntimeId::new("remote:test".to_string()).unwrap();
|
let runtime_id = "remote:test";
|
||||||
let source = default_profile_source_archive_http_source(
|
let source = default_profile_source_archive_http_source(
|
||||||
&ProfileSelector::Builtin("builtin:coder".to_string()),
|
&ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||||
"workspace-actual",
|
"workspace-actual",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
&broker,
|
&broker,
|
||||||
"http://127.0.0.1:8787/",
|
"http://127.0.0.1:8787/",
|
||||||
)
|
)
|
||||||
|
|
@ -3254,12 +3302,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_archive_rejects_unknown_selectors() {
|
fn embedded_archive_rejects_unknown_selectors() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
|
let runtime_id = "runtime-test";
|
||||||
assert!(
|
assert!(
|
||||||
default_embedded_config_bundle(
|
default_embedded_config_bundle(
|
||||||
&ProfileSelector::Builtin("builtin:missing".to_string()),
|
&ProfileSelector::Builtin("builtin:missing".to_string()),
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
&broker,
|
&broker,
|
||||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||||
)
|
)
|
||||||
|
|
@ -3269,7 +3317,7 @@ mod tests {
|
||||||
default_embedded_config_bundle(
|
default_embedded_config_bundle(
|
||||||
&ProfileSelector::Named("custom".to_string()),
|
&ProfileSelector::Named("custom".to_string()),
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
&broker,
|
&broker,
|
||||||
ProfileSourceArchiveTransport::BackendResourceHandle,
|
ProfileSourceArchiveTransport::BackendResourceHandle,
|
||||||
)
|
)
|
||||||
|
|
@ -3431,8 +3479,7 @@ mod tests {
|
||||||
visibility: "opaque".to_string(),
|
visibility: "opaque".to_string(),
|
||||||
identity: host_id.to_string(),
|
identity: host_id.to_string(),
|
||||||
},
|
},
|
||||||
state: "running".to_string(),
|
state: "available".to_string(),
|
||||||
status: "available".to_string(),
|
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
retention_state: "transient".to_string(),
|
retention_state: "transient".to_string(),
|
||||||
|
|
@ -3441,7 +3488,6 @@ mod tests {
|
||||||
display_hint: "test fixture".to_string(),
|
display_hint: "test fixture".to_string(),
|
||||||
},
|
},
|
||||||
capabilities: WorkerCapabilitySummary {
|
capabilities: WorkerCapabilitySummary {
|
||||||
can_accept_input: false,
|
|
||||||
can_stop: false,
|
can_stop: false,
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
|
@ -3471,7 +3517,6 @@ mod tests {
|
||||||
can_get_worker: true,
|
can_get_worker: true,
|
||||||
can_spawn_worker: false,
|
can_spawn_worker: false,
|
||||||
can_stop_worker: false,
|
can_stop_worker: false,
|
||||||
can_accept_input: false,
|
|
||||||
has_workspace_fs: false,
|
has_workspace_fs: false,
|
||||||
has_shell: false,
|
has_shell: false,
|
||||||
has_git: false,
|
has_git: false,
|
||||||
|
|
@ -3569,18 +3614,18 @@ mod tests {
|
||||||
Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing"
|
Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing"
|
||||||
));
|
));
|
||||||
|
|
||||||
let unknown_worker = registry.worker("runtime-a", "worker-missing").unwrap_err();
|
let unknown_worker = registry.worker("runtime-a", "999").unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unknown_worker,
|
unknown_worker,
|
||||||
RuntimeRegistryError::UnknownWorker {
|
RuntimeRegistryError::UnknownWorker {
|
||||||
runtime_id: "runtime-a".to_string(),
|
runtime_id: "runtime-a".to_string(),
|
||||||
worker_id: "worker-missing".to_string(),
|
worker_id: "999".to_string(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unknown_worker.into_error(),
|
unknown_worker.into_error(),
|
||||||
Error::UnknownWorker { runtime_id, worker_id }
|
Error::UnknownWorker { runtime_id, worker_id }
|
||||||
if runtime_id == "runtime-a" && worker_id == "worker-missing"
|
if runtime_id == "runtime-a" && worker_id == "999"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3655,7 +3700,6 @@ mod tests {
|
||||||
let spawned = runtime.spawn_worker(embedded_spawn_request());
|
let spawned = runtime.spawn_worker(embedded_spawn_request());
|
||||||
assert_eq!(spawned.state, WorkerOperationState::Accepted);
|
assert_eq!(spawned.state, WorkerOperationState::Accepted);
|
||||||
let worker = spawned.worker.expect("created embedded worker");
|
let worker = spawned.worker.expect("created embedded worker");
|
||||||
assert!(worker.capabilities.can_accept_input);
|
|
||||||
assert!(worker.capabilities.can_stop);
|
assert!(worker.capabilities.can_stop);
|
||||||
|
|
||||||
let input = runtime.send_input(
|
let input = runtime.send_input(
|
||||||
|
|
@ -3673,8 +3717,7 @@ mod tests {
|
||||||
.worker(&worker.worker_id)
|
.worker(&worker.worker_id)
|
||||||
.worker
|
.worker
|
||||||
.expect("worker detail");
|
.expect("worker detail");
|
||||||
if detail.status == "idle" {
|
if detail.state == "idle" {
|
||||||
assert!(detail.capabilities.can_accept_input);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -3707,7 +3750,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
||||||
assert!(embedded_summary.capabilities.can_spawn_worker);
|
assert!(embedded_summary.capabilities.can_spawn_worker);
|
||||||
assert!(embedded_summary.capabilities.can_accept_input);
|
|
||||||
|
|
||||||
let spawned = registry
|
let spawned = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
|
|
@ -3743,8 +3785,6 @@ mod tests {
|
||||||
assert_eq!(worker.workspace.identity, "runtime_registry_worker");
|
assert_eq!(worker.workspace.identity, "runtime_registry_worker");
|
||||||
assert_eq!(worker.implementation.kind, "embedded_worker_runtime");
|
assert_eq!(worker.implementation.kind, "embedded_worker_runtime");
|
||||||
assert_eq!(worker.profile.as_deref(), Some("builtin:coder"));
|
assert_eq!(worker.profile.as_deref(), Some("builtin:coder"));
|
||||||
assert!(worker.capabilities.can_accept_input);
|
|
||||||
|
|
||||||
let input = registry
|
let input = registry
|
||||||
.send_input(
|
.send_input(
|
||||||
EMBEDDED_RUNTIME_ID,
|
EMBEDDED_RUNTIME_ID,
|
||||||
|
|
@ -3888,7 +3928,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
|
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
|
||||||
let worker_json = worker_json("remote:primary", "worker-remote-1");
|
let worker_json = worker_json("remote:primary", "1");
|
||||||
let (base_url, server) = serve_mock_http(vec![
|
let (base_url, server) = serve_mock_http(vec![
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
|
|
@ -3899,19 +3939,19 @@ mod tests {
|
||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
"/v1/workers/worker-remote-1",
|
"/v1/workers/1",
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({ "worker": worker_json.clone() }).to_string(),
|
json!({ "worker": worker_json.clone() }).to_string(),
|
||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"POST",
|
"POST",
|
||||||
"/v1/workers/worker-remote-1/input",
|
"/v1/workers/1/input",
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({
|
json!({
|
||||||
"ack": {
|
"ack": {
|
||||||
"worker_ref": { "runtime_id": "remote:primary", "worker_id": "worker-remote-1" },
|
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"event_id": 8
|
"event_id": 8
|
||||||
}
|
}
|
||||||
|
|
@ -3936,24 +3976,20 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
let observation = registry
|
let observation = registry
|
||||||
.observation_source("remote:primary", "worker-remote-1")
|
.observation_source("remote:primary", "1")
|
||||||
.expect("remote runtime exposes backend-owned WS observation source");
|
.expect("remote runtime exposes backend-owned WS observation source");
|
||||||
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
|
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
|
||||||
else {
|
else {
|
||||||
panic!("remote runtime should expose a remote WS observation source");
|
panic!("remote runtime should expose a remote WS observation source");
|
||||||
};
|
};
|
||||||
assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
|
assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
|
||||||
assert!(
|
assert!(observation.endpoint.ends_with("/v1/workers/1/events/ws"));
|
||||||
observation
|
|
||||||
.endpoint
|
|
||||||
.ends_with("/v1/workers/worker-remote-1/events/ws")
|
|
||||||
);
|
|
||||||
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
|
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
|
||||||
|
|
||||||
let workers = registry.list_workers(10);
|
let workers = registry.list_workers(10);
|
||||||
assert_eq!(workers.items.len(), 1);
|
assert_eq!(workers.items.len(), 1);
|
||||||
assert_eq!(workers.items[0].runtime_id, "remote:primary");
|
assert_eq!(workers.items[0].runtime_id, "remote:primary");
|
||||||
assert_eq!(workers.items[0].worker_id, "worker-remote-1");
|
assert_eq!(workers.items[0].worker_id, "1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workers.items[0].implementation.kind,
|
workers.items[0].implementation.kind,
|
||||||
"remote_worker_runtime"
|
"remote_worker_runtime"
|
||||||
|
|
@ -3962,13 +3998,12 @@ mod tests {
|
||||||
workers.items[0].workspace.identity,
|
workers.items[0].workspace.identity,
|
||||||
"runtime_registry_worker"
|
"runtime_registry_worker"
|
||||||
);
|
);
|
||||||
assert!(workers.items[0].capabilities.can_accept_input);
|
|
||||||
assert!(workers.items[0].capabilities.can_stop);
|
assert!(workers.items[0].capabilities.can_stop);
|
||||||
|
|
||||||
let input = registry
|
let input = registry
|
||||||
.send_input(
|
.send_input(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-remote-1",
|
"1",
|
||||||
WorkerInputRequest {
|
WorkerInputRequest {
|
||||||
kind: WorkerInputKind::User,
|
kind: WorkerInputKind::User,
|
||||||
content: "hello remote".to_string(),
|
content: "hello remote".to_string(),
|
||||||
|
|
@ -4004,28 +4039,28 @@ mod tests {
|
||||||
"workers": [
|
"workers": [
|
||||||
worker_json_with_execution(
|
worker_json_with_execution(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-stale",
|
"1",
|
||||||
"stale",
|
"stale",
|
||||||
"unconnected",
|
"unconnected",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
worker_json_with_execution(
|
worker_json_with_execution(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-unconnected",
|
"2",
|
||||||
"unconnected",
|
"unconnected",
|
||||||
"unconnected",
|
"unconnected",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
worker_json_with_execution(
|
worker_json_with_execution(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-rejected",
|
"3",
|
||||||
"connected",
|
"connected",
|
||||||
"rejected",
|
"rejected",
|
||||||
Some("rejected"),
|
Some("rejected"),
|
||||||
),
|
),
|
||||||
worker_json_with_execution(
|
worker_json_with_execution(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-errored",
|
"4",
|
||||||
"connected",
|
"connected",
|
||||||
"errored",
|
"errored",
|
||||||
Some("errored"),
|
Some("errored"),
|
||||||
|
|
@ -4036,13 +4071,13 @@ mod tests {
|
||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
"/v1/workers/worker-stale",
|
"/v1/workers/1",
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({
|
json!({
|
||||||
"worker": worker_json_with_execution(
|
"worker": worker_json_with_execution(
|
||||||
"remote:primary",
|
"remote:primary",
|
||||||
"worker-stale",
|
"1",
|
||||||
"stale",
|
"stale",
|
||||||
"unconnected",
|
"unconnected",
|
||||||
None,
|
None,
|
||||||
|
|
@ -4067,26 +4102,20 @@ mod tests {
|
||||||
let workers = registry.list_workers(10);
|
let workers = registry.list_workers(10);
|
||||||
assert_eq!(workers.items.len(), 4);
|
assert_eq!(workers.items.len(), 4);
|
||||||
for worker in &workers.items {
|
for worker in &workers.items {
|
||||||
assert!(
|
|
||||||
!worker.capabilities.can_accept_input,
|
|
||||||
"{} should not be input-capable",
|
|
||||||
worker.worker_id
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
!worker.capabilities.can_stop,
|
!worker.capabilities.can_stop,
|
||||||
"{} should not be stoppable",
|
"{} should not be stoppable",
|
||||||
worker.worker_id
|
worker.worker_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(workers.items[0].status, "stale");
|
assert_eq!(workers.items[0].state, "stale");
|
||||||
assert_eq!(workers.items[1].status, "unconnected");
|
assert_eq!(workers.items[1].state, "unconnected");
|
||||||
assert_eq!(workers.items[2].status, "rejected");
|
assert_eq!(workers.items[2].state, "rejected");
|
||||||
assert_eq!(workers.items[3].status, "errored");
|
assert_eq!(workers.items[3].state, "errored");
|
||||||
|
|
||||||
let stale_detail = registry.worker("remote:primary", "worker-stale").unwrap();
|
let stale_detail = registry.worker("remote:primary", "1").unwrap();
|
||||||
assert!(!stale_detail.capabilities.can_accept_input);
|
|
||||||
assert!(!stale_detail.capabilities.can_stop);
|
assert!(!stale_detail.capabilities.can_stop);
|
||||||
assert_eq!(stale_detail.status, "stale");
|
assert_eq!(stale_detail.state, "stale");
|
||||||
|
|
||||||
server.join().expect("mock remote server finished");
|
server.join().expect("mock remote server finished");
|
||||||
}
|
}
|
||||||
|
|
@ -4169,7 +4198,7 @@ mod tests {
|
||||||
fn remote_runtime_auth_errors_map_to_typed_backend_error() {
|
fn remote_runtime_auth_errors_map_to_typed_backend_error() {
|
||||||
let (base_url, server) = serve_mock_http(vec![mock_response(
|
let (base_url, server) = serve_mock_http(vec![mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
"/v1/workers/worker-missing",
|
"/v1/workers/999",
|
||||||
true,
|
true,
|
||||||
401,
|
401,
|
||||||
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
|
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
|
||||||
|
|
@ -4190,7 +4219,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
let error = registry
|
let error = registry
|
||||||
.worker("remote:primary", "worker-missing")
|
.worker("remote:primary", "999")
|
||||||
.expect_err("auth failure is a backend operation error");
|
.expect_err("auth failure is a backend operation error");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
error,
|
error,
|
||||||
|
|
@ -4285,6 +4314,7 @@ mod tests {
|
||||||
"message": format!("{outcome} result")
|
"message": format!("{outcome} result")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let worker_id = worker_id.parse::<u64>().unwrap();
|
||||||
json!({
|
json!({
|
||||||
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
|
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
|
||||||
"runtime_id": runtime_id,
|
"runtime_id": runtime_id,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use chrono::{Duration, Utc};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use worker_runtime::identity::{RuntimeId, WorkerId};
|
use worker_runtime::identity::WorkerId;
|
||||||
use worker_runtime::profile_archive::ProfileSourceArchive;
|
use worker_runtime::profile_archive::ProfileSourceArchive;
|
||||||
use worker_runtime::resource::{
|
use worker_runtime::resource::{
|
||||||
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
|
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
|
||||||
|
|
@ -29,7 +29,7 @@ impl BackendResourceBroker {
|
||||||
pub fn issue_profile_source_archive_handle(
|
pub fn issue_profile_source_archive_handle(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
runtime_id: Option<&RuntimeId>,
|
runtime_id: Option<&str>,
|
||||||
worker_id: Option<&WorkerId>,
|
worker_id: Option<&WorkerId>,
|
||||||
archive: ProfileSourceArchive,
|
archive: ProfileSourceArchive,
|
||||||
) -> BackendResourceHandle {
|
) -> BackendResourceHandle {
|
||||||
|
|
@ -41,8 +41,8 @@ impl BackendResourceBroker {
|
||||||
kind: BackendResourceKind::ProfileSourceArchive,
|
kind: BackendResourceKind::ProfileSourceArchive,
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
scope_id: Some("workspace-profile-source".to_string()),
|
scope_id: Some("workspace-profile-source".to_string()),
|
||||||
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
|
runtime_id: runtime_id.map(|id| id.to_string()),
|
||||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
worker_id: worker_id.map(|id| id.to_string()),
|
||||||
resource_id: archive.reference.id.clone(),
|
resource_id: archive.reference.id.clone(),
|
||||||
digest: archive.reference.digest.clone(),
|
digest: archive.reference.digest.clone(),
|
||||||
operation: BackendResourceOperation::FetchArchive,
|
operation: BackendResourceOperation::FetchArchive,
|
||||||
|
|
@ -57,8 +57,8 @@ impl BackendResourceBroker {
|
||||||
profile_source_graph: Some(archive.reference.source_graph.clone()),
|
profile_source_graph: Some(archive.reference.source_graph.clone()),
|
||||||
};
|
};
|
||||||
let stored = StoredResource {
|
let stored = StoredResource {
|
||||||
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
|
runtime_id: runtime_id.map(|id| id.to_string()),
|
||||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
worker_id: worker_id.map(|id| id.to_string()),
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
archive,
|
archive,
|
||||||
};
|
};
|
||||||
|
|
@ -167,7 +167,7 @@ fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendReso
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use worker_runtime::identity::{RuntimeId, WorkerId};
|
use worker_runtime::identity::WorkerId;
|
||||||
use worker_runtime::profile_archive::{
|
use worker_runtime::profile_archive::{
|
||||||
ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex,
|
ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex,
|
||||||
};
|
};
|
||||||
|
|
@ -214,31 +214,31 @@ mod tests {
|
||||||
|
|
||||||
fn request(
|
fn request(
|
||||||
handle: BackendResourceHandle,
|
handle: BackendResourceHandle,
|
||||||
runtime_id: &RuntimeId,
|
runtime_id: &str,
|
||||||
worker_id: Option<&WorkerId>,
|
worker_id: Option<&WorkerId>,
|
||||||
) -> BackendResourceFetchRequest {
|
) -> BackendResourceFetchRequest {
|
||||||
BackendResourceFetchRequest {
|
BackendResourceFetchRequest {
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
handle,
|
handle,
|
||||||
runtime_id: runtime_id.as_str().to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: worker_id.map(|id| id.as_str().to_string()),
|
worker_id: worker_id.map(|id| id.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_issues_and_verifies_profile_source_archive_handles() {
|
fn broker_issues_and_verifies_profile_source_archive_handles() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
let runtime_id = "runtime-test";
|
||||||
let handle = broker.issue_profile_source_archive_handle(
|
let handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
None,
|
None,
|
||||||
archive(),
|
archive(),
|
||||||
);
|
);
|
||||||
let response = broker
|
let response = broker
|
||||||
.fetch_profile_source_archive(BackendResourceFetchRequest {
|
.fetch_profile_source_archive(BackendResourceFetchRequest {
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
runtime_id: runtime_id.as_str().to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: None,
|
worker_id: None,
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
})
|
})
|
||||||
|
|
@ -250,19 +250,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_rejects_runtime_mismatch() {
|
fn broker_rejects_runtime_mismatch() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_a = RuntimeId::new("runtime-a").unwrap();
|
let runtime_a = "runtime-a";
|
||||||
let handle = broker.issue_profile_source_archive_handle(
|
let handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_a),
|
Some(runtime_a),
|
||||||
None,
|
None,
|
||||||
archive(),
|
archive(),
|
||||||
);
|
);
|
||||||
let err = broker
|
let err = broker
|
||||||
.fetch_profile_source_archive(request(
|
.fetch_profile_source_archive(request(handle, "runtime-b", None))
|
||||||
handle,
|
|
||||||
&RuntimeId::new("runtime-b").unwrap(),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
|
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
|
||||||
}
|
}
|
||||||
|
|
@ -270,12 +266,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_rejects_worker_mismatch() {
|
fn broker_rejects_worker_mismatch() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
let runtime_id = "runtime-test";
|
||||||
let worker_a = WorkerId::new("worker-a").unwrap();
|
let worker_a = WorkerId::new(1);
|
||||||
let worker_b = WorkerId::new("worker-b").unwrap();
|
let worker_b = WorkerId::new(2);
|
||||||
let handle = broker.issue_profile_source_archive_handle(
|
let handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
Some(&worker_a),
|
Some(&worker_a),
|
||||||
archive(),
|
archive(),
|
||||||
);
|
);
|
||||||
|
|
@ -288,10 +284,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_rejects_expiry_extension_from_request_handle() {
|
fn broker_rejects_expiry_extension_from_request_handle() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
let runtime_id = "runtime-test";
|
||||||
let handle = broker.issue_profile_source_archive_handle(
|
let handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
None,
|
None,
|
||||||
archive(),
|
archive(),
|
||||||
);
|
);
|
||||||
|
|
@ -314,10 +310,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_rejects_policy_tampered_request_handle() {
|
fn broker_rejects_policy_tampered_request_handle() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
let runtime_id = "runtime-test";
|
||||||
let mut handle = broker.issue_profile_source_archive_handle(
|
let mut handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
None,
|
None,
|
||||||
archive(),
|
archive(),
|
||||||
);
|
);
|
||||||
|
|
@ -331,11 +327,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() {
|
fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() {
|
||||||
let broker = BackendResourceBroker::default();
|
let broker = BackendResourceBroker::default();
|
||||||
let runtime_id = RuntimeId::new("runtime-test").unwrap();
|
let runtime_id = "runtime-test";
|
||||||
let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize);
|
let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize);
|
||||||
let mut handle = broker.issue_profile_source_archive_handle(
|
let mut handle = broker.issue_profile_source_archive_handle(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
Some(&runtime_id),
|
Some(runtime_id),
|
||||||
None,
|
None,
|
||||||
archive,
|
archive,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,16 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "remove durable worker lifecycle state",
|
name: "remove durable worker lifecycle state",
|
||||||
apply: remove_worker_registry_legacy_live_state_column,
|
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 {
|
struct Migration {
|
||||||
|
|
@ -57,10 +67,8 @@ pub struct WorkspaceRecord {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerRegistryRecord {
|
pub struct WorkerRegistryRecord {
|
||||||
pub workspace_id: String,
|
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_id: String,
|
||||||
pub runtime_worker_id: String,
|
pub runtime_worker_id: u64,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
/// Retention state is explicit so `pinned` can be represented before prune exists.
|
/// Retention state is explicit so `pinned` can be represented before prune exists.
|
||||||
|
|
@ -92,7 +100,8 @@ pub struct WorkdirRegistryRecord {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerWorkdirLinkRecord {
|
pub struct WorkerWorkdirLinkRecord {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub worker_id: String,
|
pub runtime_id: String,
|
||||||
|
pub runtime_worker_id: u64,
|
||||||
pub workdir_id: String,
|
pub workdir_id: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub linked_at: String,
|
pub linked_at: String,
|
||||||
|
|
@ -107,15 +116,10 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||||
|
|
||||||
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
|
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
|
||||||
fn get_worker_registry(
|
fn get_worker_registry(
|
||||||
&self,
|
|
||||||
workspace_id: &str,
|
|
||||||
worker_id: &str,
|
|
||||||
) -> Result<Option<WorkerRegistryRecord>>;
|
|
||||||
fn get_worker_registry_by_runtime(
|
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
runtime_worker_id: &str,
|
runtime_worker_id: u64,
|
||||||
) -> Result<Option<WorkerRegistryRecord>>;
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
fn list_worker_registry(
|
fn list_worker_registry(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -125,11 +129,17 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||||
fn update_worker_retention(
|
fn update_worker_retention(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker_id: &str,
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
retention_state: &str,
|
retention_state: &str,
|
||||||
updated_at: &str,
|
updated_at: &str,
|
||||||
) -> Result<bool>;
|
) -> Result<bool>;
|
||||||
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool>;
|
fn delete_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
|
) -> Result<bool>;
|
||||||
|
|
||||||
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
|
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
|
||||||
fn get_workdir_registry(
|
fn get_workdir_registry(
|
||||||
|
|
@ -153,7 +163,8 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||||
fn list_worker_workdir_links(
|
fn list_worker_workdir_links(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker_id: &str,
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
fn list_workdir_worker_links(
|
fn list_workdir_worker_links(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -247,13 +258,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"INSERT INTO worker_registry (
|
r#"INSERT INTO worker_registry (
|
||||||
workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile,
|
workspace_id, runtime_id, runtime_worker_id, display_name, profile,
|
||||||
retention_state, transcript_ref, session_ref, summary_ref,
|
retention_state, transcript_ref, session_ref, summary_ref,
|
||||||
diagnostics_ref, created_at, updated_at
|
diagnostics_ref, created_at, updated_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||||
ON CONFLICT(workspace_id, worker_id) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, runtime_worker_id) DO UPDATE SET
|
||||||
runtime_id = excluded.runtime_id,
|
|
||||||
runtime_worker_id = excluded.runtime_worker_id,
|
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
profile = excluded.profile,
|
profile = excluded.profile,
|
||||||
retention_state = CASE
|
retention_state = CASE
|
||||||
|
|
@ -268,7 +277,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
updated_at = excluded.updated_at"#,
|
updated_at = excluded.updated_at"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
record.worker_id,
|
|
||||||
record.runtime_id,
|
record.runtime_id,
|
||||||
record.runtime_worker_id,
|
record.runtime_worker_id,
|
||||||
record.display_name,
|
record.display_name,
|
||||||
|
|
@ -287,26 +295,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_worker_registry(
|
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,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
runtime_worker_id: &str,
|
runtime_worker_id: u64,
|
||||||
) -> Result<Option<WorkerRegistryRecord>> {
|
) -> Result<Option<WorkerRegistryRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
|
|
@ -344,26 +336,38 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
fn update_worker_retention(
|
fn update_worker_retention(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker_id: &str,
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
retention_state: &str,
|
retention_state: &str,
|
||||||
updated_at: &str,
|
updated_at: &str,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let changed = conn.execute(
|
let changed = conn.execute(
|
||||||
r#"UPDATE worker_registry
|
r#"UPDATE worker_registry
|
||||||
SET retention_state = ?3, updated_at = ?4
|
SET retention_state = ?4, updated_at = ?5
|
||||||
WHERE workspace_id = ?1 AND worker_id = ?2"#,
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3"#,
|
||||||
params![workspace_id, worker_id, retention_state, updated_at],
|
params![
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
runtime_worker_id,
|
||||||
|
retention_state,
|
||||||
|
updated_at
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(changed > 0)
|
Ok(changed > 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool> {
|
fn delete_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
|
) -> Result<bool> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let changed = conn.execute(
|
let changed = conn.execute(
|
||||||
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2",
|
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3",
|
||||||
params![workspace_id, worker_id],
|
params![workspace_id, runtime_id, runtime_worker_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(changed > 0)
|
Ok(changed > 0)
|
||||||
})
|
})
|
||||||
|
|
@ -468,14 +472,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"INSERT INTO worker_workdir_links (
|
r#"INSERT INTO worker_workdir_links (
|
||||||
workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, runtime_worker_id, workdir_id, role) DO UPDATE SET
|
||||||
linked_at = excluded.linked_at,
|
linked_at = excluded.linked_at,
|
||||||
unlinked_at = excluded.unlinked_at"#,
|
unlinked_at = excluded.unlinked_at"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
record.worker_id,
|
record.runtime_id,
|
||||||
|
record.runtime_worker_id,
|
||||||
record.workdir_id,
|
record.workdir_id,
|
||||||
record.role,
|
record.role,
|
||||||
record.linked_at,
|
record.linked_at,
|
||||||
|
|
@ -489,17 +494,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
fn list_worker_workdir_links(
|
fn list_worker_workdir_links(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker_id: &str,
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: u64,
|
||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(
|
let rows = stmt.query_map(
|
||||||
params![workspace_id, worker_id],
|
params![workspace_id, runtime_id, runtime_worker_id],
|
||||||
read_worker_workdir_link_record,
|
read_worker_workdir_link_record,
|
||||||
)?;
|
)?;
|
||||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
|
@ -514,7 +520,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
|
|
@ -534,17 +540,18 @@ fn read_worker_workdir_link_record(
|
||||||
) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
|
) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
|
||||||
Ok(WorkerWorkdirLinkRecord {
|
Ok(WorkerWorkdirLinkRecord {
|
||||||
workspace_id: row.get(0)?,
|
workspace_id: row.get(0)?,
|
||||||
worker_id: row.get(1)?,
|
runtime_id: row.get(1)?,
|
||||||
workdir_id: row.get(2)?,
|
runtime_worker_id: row.get(2)?,
|
||||||
role: row.get(3)?,
|
workdir_id: row.get(3)?,
|
||||||
linked_at: row.get(4)?,
|
role: row.get(4)?,
|
||||||
unlinked_at: row.get(5)?,
|
linked_at: row.get(5)?,
|
||||||
|
unlinked_at: row.get(6)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_registry_select_sql(where_clause: &str) -> String {
|
fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \
|
"SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
|
||||||
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
|
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
|
||||||
created_at, updated_at FROM worker_registry {where_clause}"
|
created_at, updated_at FROM worker_registry {where_clause}"
|
||||||
)
|
)
|
||||||
|
|
@ -553,18 +560,17 @@ fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||||
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
|
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
|
||||||
Ok(WorkerRegistryRecord {
|
Ok(WorkerRegistryRecord {
|
||||||
workspace_id: row.get(0)?,
|
workspace_id: row.get(0)?,
|
||||||
worker_id: row.get(1)?,
|
runtime_id: row.get(1)?,
|
||||||
runtime_id: row.get(2)?,
|
runtime_worker_id: row.get(2)?,
|
||||||
runtime_worker_id: row.get(3)?,
|
display_name: row.get(3)?,
|
||||||
display_name: row.get(4)?,
|
profile: row.get(4)?,
|
||||||
profile: row.get(5)?,
|
retention_state: row.get(5)?,
|
||||||
retention_state: row.get(6)?,
|
transcript_ref: row.get(6)?,
|
||||||
transcript_ref: row.get(7)?,
|
session_ref: row.get(7)?,
|
||||||
session_ref: row.get(8)?,
|
summary_ref: row.get(8)?,
|
||||||
summary_ref: row.get(9)?,
|
diagnostics_ref: row.get(9)?,
|
||||||
diagnostics_ref: row.get(10)?,
|
created_at: row.get(10)?,
|
||||||
created_at: row.get(11)?,
|
updated_at: row.get(11)?,
|
||||||
updated_at: row.get(12)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -599,9 +605,8 @@ fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> {
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS worker_registry (
|
CREATE TABLE IF NOT EXISTS worker_registry (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
worker_id TEXT NOT NULL,
|
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
runtime_worker_id TEXT NOT NULL,
|
runtime_worker_id INTEGER NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
profile TEXT,
|
profile TEXT,
|
||||||
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
||||||
|
|
@ -611,8 +616,7 @@ CREATE TABLE IF NOT EXISTS worker_registry (
|
||||||
diagnostics_ref TEXT,
|
diagnostics_ref TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, worker_id),
|
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
|
||||||
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
|
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -623,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
|
||||||
repository_id TEXT NOT NULL,
|
repository_id TEXT NOT NULL,
|
||||||
selector TEXT,
|
selector TEXT,
|
||||||
resolved_commit TEXT,
|
resolved_commit TEXT,
|
||||||
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'missing', 'removed', 'failed')),
|
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
|
||||||
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
||||||
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
|
@ -634,13 +638,14 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS worker_workdir_links (
|
CREATE TABLE IF NOT EXISTS worker_workdir_links (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
worker_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
|
runtime_worker_id INTEGER NOT NULL,
|
||||||
workdir_id TEXT NOT NULL,
|
workdir_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
linked_at TEXT NOT NULL,
|
linked_at TEXT NOT NULL,
|
||||||
unlinked_at TEXT,
|
unlinked_at TEXT,
|
||||||
PRIMARY KEY (workspace_id, worker_id, workdir_id, role),
|
PRIMARY KEY (workspace_id, runtime_id, runtime_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, runtime_id, runtime_worker_id) REFERENCES worker_registry(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -649,7 +654,7 @@ CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
|
||||||
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
|
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
|
||||||
ON workdir_registry(workspace_id, updated_at DESC);
|
ON workdir_registry(workspace_id, updated_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
|
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
|
||||||
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC);
|
ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC);
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -802,7 +807,7 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
worker_id TEXT NOT NULL,
|
worker_id TEXT NOT NULL,
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
runtime_worker_id TEXT NOT NULL,
|
runtime_worker_id INTEGER NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
profile TEXT,
|
profile TEXT,
|
||||||
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
|
||||||
|
|
@ -835,6 +840,142 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
|
||||||
Ok(())
|
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<()> {
|
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -1058,7 +1199,7 @@ mod tests {
|
||||||
let db = dir.path().join("control-plane.sqlite");
|
let db = dir.path().join("control-plane.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 4);
|
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||||
|
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
|
|
@ -1070,7 +1211,7 @@ mod tests {
|
||||||
store.upsert_workspace(&record).await.unwrap();
|
store.upsert_workspace(&record).await.unwrap();
|
||||||
|
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
assert_eq!(reopened.schema_version().await.unwrap(), 4);
|
assert_eq!(reopened.schema_version().await.unwrap(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
|
|
@ -1190,7 +1331,6 @@ mod tests {
|
||||||
"worker_registry",
|
"worker_registry",
|
||||||
[
|
[
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
"worker_id",
|
|
||||||
"runtime_id",
|
"runtime_id",
|
||||||
"runtime_worker_id",
|
"runtime_worker_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
|
|
@ -1280,7 +1420,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 4);
|
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||||
|
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
|
|
@ -1381,13 +1521,12 @@ mod tests {
|
||||||
|
|
||||||
let worker = WorkerRegistryRecord {
|
let worker = WorkerRegistryRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
worker_id: "embedded/browser-1".to_string(),
|
|
||||||
runtime_id: "embedded".to_string(),
|
runtime_id: "embedded".to_string(),
|
||||||
runtime_worker_id: "browser-1".to_string(),
|
runtime_worker_id: 1,
|
||||||
display_name: "Browser 1".to_string(),
|
display_name: "Browser 1".to_string(),
|
||||||
profile: Some("builtin:companion".to_string()),
|
profile: Some("builtin:companion".to_string()),
|
||||||
retention_state: "pinned".to_string(),
|
retention_state: "pinned".to_string(),
|
||||||
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()),
|
transcript_ref: Some("runtime://embedded/workers/00000001/transcript".to_string()),
|
||||||
session_ref: None,
|
session_ref: None,
|
||||||
summary_ref: None,
|
summary_ref: None,
|
||||||
diagnostics_ref: None,
|
diagnostics_ref: None,
|
||||||
|
|
@ -1404,12 +1543,12 @@ mod tests {
|
||||||
|
|
||||||
let workdir = WorkdirRegistryRecord {
|
let workdir = WorkdirRegistryRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
workdir_id: "backend-2-repo".to_string(),
|
workdir_id: "0000019a00000000001".to_string(),
|
||||||
runtime_id: "embedded".to_string(),
|
runtime_id: "embedded".to_string(),
|
||||||
repository_id: "repo".to_string(),
|
repository_id: "repo".to_string(),
|
||||||
selector: Some("develop".to_string()),
|
selector: Some("develop".to_string()),
|
||||||
resolved_commit: Some("abcdef".to_string()),
|
resolved_commit: Some("abcdef".to_string()),
|
||||||
materialization_status: "removed".to_string(),
|
materialization_status: "not_found".to_string(),
|
||||||
cleanliness: "clean".to_string(),
|
cleanliness: "clean".to_string(),
|
||||||
management_kind: "backend_managed".to_string(),
|
management_kind: "backend_managed".to_string(),
|
||||||
created_at: "2".to_string(),
|
created_at: "2".to_string(),
|
||||||
|
|
@ -1433,7 +1572,8 @@ mod tests {
|
||||||
|
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
worker_id: worker.worker_id.clone(),
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
runtime_worker_id: worker.runtime_worker_id.clone(),
|
||||||
workdir_id: workdir.workdir_id.clone(),
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
role: "primary_cwd".to_string(),
|
role: "primary_cwd".to_string(),
|
||||||
linked_at: "4".to_string(),
|
linked_at: "4".to_string(),
|
||||||
|
|
@ -1443,13 +1583,13 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1")
|
.get_worker_registry("local-dev", "embedded", 1)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(expected_worker.clone())
|
Some(expected_worker.clone())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.get_workdir_registry("local-dev", "backend-2-repo")
|
.get_workdir_registry("local-dev", "0000019a00000000001")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(workdir.clone())
|
Some(workdir.clone())
|
||||||
);
|
);
|
||||||
|
|
@ -1465,7 +1605,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.list_worker_workdir_links("local-dev", "embedded/browser-1")
|
.list_worker_workdir_links("local-dev", "embedded", 1)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
vec![link]
|
vec![link]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,15 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-nav-item.disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-nav-item.disabled .item-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.worker-title-row {
|
.worker-title-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||||||
|
|
|
||||||
130
web/workspace/src/lib/workspace-alerts/WorkspaceAlerts.svelte
Normal file
130
web/workspace/src/lib/workspace-alerts/WorkspaceAlerts.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { dismissWorkspaceAlert, workspaceAlerts, type WorkspaceAlertLevel } from './store';
|
||||||
|
|
||||||
|
function label(level: WorkspaceAlertLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'success': return 'Success';
|
||||||
|
case 'info': return 'Info';
|
||||||
|
case 'warning': return 'Warning';
|
||||||
|
case 'error': return 'Error';
|
||||||
|
case 'system': return 'System';
|
||||||
|
case 'debug': return 'Debug';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="workspace-alerts" aria-live="polite" aria-atomic="false">
|
||||||
|
{#each $workspaceAlerts as alert (alert.id)}
|
||||||
|
<article class={`workspace-alert ${alert.level}`} role={alert.level === 'error' ? 'alert' : 'status'}>
|
||||||
|
<div class="workspace-alert-icon" aria-hidden="true">
|
||||||
|
{#if alert.level === 'success'}
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></svg>
|
||||||
|
{:else if alert.level === 'info'}
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>
|
||||||
|
{:else if alert.level === 'warning' || alert.level === 'error'}
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></svg>
|
||||||
|
{:else if alert.level === 'system'}
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88" /><path d="M14.12 3.88 16 2" /><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" /><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" /><path d="M12 20v-9" /><path d="M6.53 9C4.6 8.8 3 7.1 3 5" /><path d="M6 13H2" /><path d="M3 21c0-2.1 1.7-3.9 3.8-4" /><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" /><path d="M22 13h-4" /><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" /></svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="workspace-alert-body">
|
||||||
|
<strong>{alert.title ?? label(alert.level)}</strong>
|
||||||
|
<p>{alert.message}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="workspace-alert-dismiss" aria-label="Dismiss alert" onclick={() => dismissWorkspaceAlert(alert.id)}>×</button>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.workspace-alerts {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: min(28rem, calc(100vw - 2rem));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert {
|
||||||
|
--alert-color: var(--text);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.8rem 0.85rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--alert-color) 45%, var(--line));
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: color-mix(in srgb, var(--alert-color) 12%, var(--bg-raised) 88%);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert.success { --alert-color: oklch(70% 0.18 145); }
|
||||||
|
.workspace-alert.info { --alert-color: oklch(68% 0.16 245); }
|
||||||
|
.workspace-alert.warning { --alert-color: oklch(78% 0.18 85); }
|
||||||
|
.workspace-alert.error { --alert-color: oklch(62% 0.2 30); }
|
||||||
|
.workspace-alert.system { --alert-color: oklch(96% 0.01 260); }
|
||||||
|
.workspace-alert.debug { --alert-color: oklch(68% 0.18 305); }
|
||||||
|
|
||||||
|
.workspace-alert-icon {
|
||||||
|
color: var(--alert-color);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-icon svg {
|
||||||
|
display: block;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-body strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
color: var(--alert-color);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-body p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-dismiss {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-alert-dismiss:hover,
|
||||||
|
.workspace-alert-dismiss:focus-visible {
|
||||||
|
background: color-mix(in srgb, var(--alert-color) 18%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
web/workspace/src/lib/workspace-alerts/store.ts
Normal file
61
web/workspace/src/lib/workspace-alerts/store.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
export type WorkspaceAlertLevel = "success" | "info" | "warning" | "error" | "system" | "debug";
|
||||||
|
|
||||||
|
export type WorkspaceAlert = {
|
||||||
|
id: string;
|
||||||
|
level: WorkspaceAlertLevel;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertSubscriber = (alerts: WorkspaceAlert[]) => void;
|
||||||
|
|
||||||
|
let sequence = 0;
|
||||||
|
let alerts: WorkspaceAlert[] = [];
|
||||||
|
const subscribers = new Set<AlertSubscriber>();
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
const snapshot = alerts.slice();
|
||||||
|
for (const subscriber of subscribers) subscriber(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workspaceAlerts = {
|
||||||
|
subscribe(subscriber: AlertSubscriber): () => void {
|
||||||
|
subscribers.add(subscriber);
|
||||||
|
subscriber(alerts.slice());
|
||||||
|
return () => subscribers.delete(subscriber);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pushWorkspaceAlert(
|
||||||
|
level: WorkspaceAlertLevel,
|
||||||
|
message: string,
|
||||||
|
options: { title?: string; id?: string } = {},
|
||||||
|
): string {
|
||||||
|
const id = options.id ?? `${Date.now().toString(36)}-${(sequence++).toString(36)}`;
|
||||||
|
alerts = [
|
||||||
|
...alerts.filter((alert) => alert.id !== id),
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
title: options.title,
|
||||||
|
message,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
].slice(-8);
|
||||||
|
emit();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissWorkspaceAlert(id: string): void {
|
||||||
|
const next = alerts.filter((alert) => alert.id !== id);
|
||||||
|
if (next.length === alerts.length) return;
|
||||||
|
alerts = next;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWorkspaceAlerts(): void {
|
||||||
|
if (alerts.length === 0) return;
|
||||||
|
alerts = [];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const workersPage = await Deno.readTextFile(
|
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(
|
const workersNav = await Deno.readTextFile(
|
||||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
|
|
@ -24,8 +27,8 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
workspacePage.includes('href={`/w/${workspaceId}/runtimes`}') &&
|
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
|
||||||
workspacePage.includes('href={`/w/${workspaceId}/workers`}'),
|
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
|
||||||
"top workspace page should link to Runtimes and Workers pages",
|
"top workspace page should link to Runtimes and Workers pages",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -35,15 +38,16 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
||||||
workersPage.includes("<table class=\"workers-table\">") &&
|
workersPage.includes('<table class="workers-table">') &&
|
||||||
workersPage.includes("Open Console"),
|
workersPage.includes('class="icon-action"') &&
|
||||||
"dedicated Workers page should expose a table and attach action per Worker",
|
workersPage.includes('Delete ${worker.label}'),
|
||||||
|
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersNav.includes('href={`/w/${workspaceId}/workers`}') &&
|
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||||
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
|
workersNav.includes("filter(canShowWorkerInSidebar)") &&
|
||||||
workersNav.includes("aria-current"),
|
!workersNav.includes('aria-disabled="true"'),
|
||||||
"Workers sidebar should link to the Worker list page and target Console routes",
|
"Workers sidebar should link to the Worker list page and omit registry-only Workers",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
|
|
@ -54,12 +58,17 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
|
|
||||||
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
|
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
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(
|
assert(
|
||||||
consolePage.includes("seenObservationEventIds") &&
|
consolePage.includes("seenObservationEventIds") &&
|
||||||
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
|
consolePage.includes(
|
||||||
|
"rememberObservationEvent(frame.envelope.event_id)",
|
||||||
|
) &&
|
||||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
consolePage.includes("projectConsole(observedEvents.map") &&
|
||||||
!consolePage.includes("/transcript") &&
|
!consolePage.includes("/transcript") &&
|
||||||
!consolePage.includes("WorkerTranscriptProjection"),
|
!consolePage.includes("WorkerTranscriptProjection"),
|
||||||
|
|
@ -69,7 +78,10 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
||||||
|
|
||||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||||
const editor = await Deno.readTextFile(
|
const editor = await Deno.readTextFile(
|
||||||
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
|
new URL(
|
||||||
|
"../workspace-settings/DecodalSourceEditor.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -89,18 +101,27 @@ Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned
|
||||||
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
|
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const runtimesPage = await Deno.readTextFile(
|
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(
|
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(
|
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(
|
assert(
|
||||||
sidebar.includes("RuntimesNavSection") &&
|
sidebar.includes("RuntimesNavSection") &&
|
||||||
runtimesNav.includes('href={runtimesHref}') &&
|
runtimesNav.includes("href={runtimesHref}") &&
|
||||||
runtimesNav.includes("/runtimes"),
|
runtimesNav.includes("/runtimes"),
|
||||||
"sidebar should expose Runtime management navigation",
|
"sidebar should expose Runtime management navigation",
|
||||||
);
|
);
|
||||||
|
|
@ -123,18 +144,21 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
|
||||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const newWorkerPage = await Deno.readTextFile(
|
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(
|
assert(
|
||||||
workersNav.includes('href={`/w/${workspaceId}/workers/new`}') &&
|
workersNav.includes("href={`/w/${workspaceId}/workers/new`}") &&
|
||||||
!workersNav.includes('worker-launch-form') &&
|
!workersNav.includes("worker-launch-form") &&
|
||||||
!workersNav.includes('createWorker()'),
|
!workersNav.includes("createWorker()"),
|
||||||
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
newWorkerPage.includes('worker-launch-form') &&
|
newWorkerPage.includes("worker-launch-form") &&
|
||||||
newWorkerPage.includes('buildBrowserCreateWorkerRequest') &&
|
newWorkerPage.includes("buildBrowserCreateWorkerRequest") &&
|
||||||
newWorkerPage.includes("/workers/launch-options"),
|
newWorkerPage.includes("/workers/launch-options"),
|
||||||
"New Worker page should own launch options and creation form behavior",
|
"New Worker page should own launch options and creation form behavior",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
|
import { canShowWorkerInSidebar } from './workers';
|
||||||
import type { ListResponse, Worker } from './types';
|
import type { ListResponse, Worker } from './types';
|
||||||
|
|
||||||
const MAX_VISIBLE_WORKERS = 6;
|
const MAX_VISIBLE_WORKERS = 6;
|
||||||
|
|
@ -48,7 +49,9 @@
|
||||||
throw new Error(`workers request failed (${response.status})`);
|
throw new Error(`workers request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as ListResponse<Worker>;
|
const payload = (await response.json()) as ListResponse<Worker>;
|
||||||
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
|
workers = Array.isArray(payload.items)
|
||||||
|
? payload.items.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS)
|
||||||
|
: [];
|
||||||
if (workers.length === 0) {
|
if (workers.length === 0) {
|
||||||
placeholder = 'No workers reported by the current API.';
|
placeholder = 'No workers reported by the current API.';
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +109,7 @@
|
||||||
<span class="worker-task-title">-</span>
|
<span class="worker-task-title">-</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="item-meta">
|
<span class="item-meta">
|
||||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
|
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export type RuntimeCapabilities = {
|
||||||
can_get_worker: boolean;
|
can_get_worker: boolean;
|
||||||
can_spawn_worker: boolean;
|
can_spawn_worker: boolean;
|
||||||
can_stop_worker: boolean;
|
can_stop_worker: boolean;
|
||||||
can_accept_input: boolean;
|
|
||||||
has_workspace_fs: boolean;
|
has_workspace_fs: boolean;
|
||||||
has_shell: boolean;
|
has_shell: boolean;
|
||||||
has_git: boolean;
|
has_git: boolean;
|
||||||
|
|
@ -70,7 +69,6 @@ export type Host = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkerCapabilities = {
|
export type WorkerCapabilities = {
|
||||||
can_accept_input: boolean;
|
|
||||||
can_stop: boolean;
|
can_stop: boolean;
|
||||||
can_spawn_followup: boolean;
|
can_spawn_followup: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -84,7 +82,6 @@ export type Worker = {
|
||||||
profile?: string | null;
|
profile?: string | null;
|
||||||
workspace: { visibility: string; identity: string };
|
workspace: { visibility: string; identity: string };
|
||||||
state: string;
|
state: string;
|
||||||
status: string;
|
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
retention_state?: string;
|
retention_state?: string;
|
||||||
last_seen_at?: string | null;
|
last_seen_at?: string | null;
|
||||||
|
|
@ -123,12 +120,12 @@ export type WorkingDirectorySummary = {
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
requested_selector?: string | null;
|
requested_selector?: string | null;
|
||||||
materializer_kind: string;
|
materializer_kind: string;
|
||||||
dirty_state_policy: string;
|
|
||||||
resolved_commit: string;
|
resolved_commit: string;
|
||||||
resolved_tree?: string | null;
|
resolved_tree?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
cleanliness?: string | null;
|
||||||
|
primary_worker_id?: number | null;
|
||||||
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
|
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
|
||||||
cleanup_policy: string;
|
|
||||||
cleanup_target: {
|
cleanup_target: {
|
||||||
kind: string;
|
kind: string;
|
||||||
working_directory_id: string;
|
working_directory_id: string;
|
||||||
|
|
@ -220,10 +217,6 @@ export type BrowserWorkingDirectoryCreateRequest = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
selector?: string | null;
|
selector?: string | null;
|
||||||
policy?: {
|
|
||||||
dirty_state?: "clean_point_only";
|
|
||||||
cleanup?: "manual_or_worker_stop";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkerLaunchOptionsResponse = {
|
export type WorkerLaunchOptionsResponse = {
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ const options: WorkerLaunchOptionsResponse = {
|
||||||
repository_id: "repo",
|
repository_id: "repo",
|
||||||
requested_selector: "HEAD",
|
requested_selector: "HEAD",
|
||||||
materializer_kind: "local_git_worktree",
|
materializer_kind: "local_git_worktree",
|
||||||
dirty_state_policy: "clean_point_only",
|
|
||||||
resolved_commit: "0123456789abcdef",
|
resolved_commit: "0123456789abcdef",
|
||||||
status: "active",
|
status: "active",
|
||||||
cleanup_policy: "manual_or_worker_stop",
|
cleanliness: "clean",
|
||||||
|
primary_worker_id: null,
|
||||||
cleanup_target: {
|
cleanup_target: {
|
||||||
kind: "git_worktree",
|
kind: "git_worktree",
|
||||||
working_directory_id: "wd-1-repo",
|
working_directory_id: "wd-1-repo",
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,16 @@ export function defaultWorkerLaunchForm(
|
||||||
const preferredProfile =
|
const preferredProfile =
|
||||||
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
|
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
|
||||||
options?.profiles[0];
|
options?.profiles[0];
|
||||||
const preferredWorkingDirectory =
|
const availableWorkingDirectories = options?.working_directories.filter((directory) =>
|
||||||
options?.working_directories.find((directory) =>
|
directory.status === "active" &&
|
||||||
directory.status === "active"
|
directory.cleanliness === "clean" &&
|
||||||
) ??
|
directory.primary_worker_id == null
|
||||||
options?.working_directories[0];
|
) ?? [];
|
||||||
|
const selectedRuntime = current.runtime_id
|
||||||
|
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)
|
||||||
|
: preferredRuntime;
|
||||||
|
const workdirlessRuntime = selectedRuntime?.working_directory_required === false;
|
||||||
|
const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories[0];
|
||||||
const preferredRepository =
|
const preferredRepository =
|
||||||
options?.repositories.find((repository) =>
|
options?.repositories.find((repository) =>
|
||||||
repository.id === current.working_directory_repository_id
|
repository.id === current.working_directory_repository_id
|
||||||
|
|
@ -54,7 +59,7 @@ export function defaultWorkerLaunchForm(
|
||||||
? current.profile
|
? current.profile
|
||||||
: preferredProfile?.id || "",
|
: preferredProfile?.id || "",
|
||||||
initial_text: current.initial_text,
|
initial_text: current.initial_text,
|
||||||
working_directory_id: options?.working_directories.some(
|
working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some(
|
||||||
(directory) =>
|
(directory) =>
|
||||||
directory.working_directory_id === current.working_directory_id,
|
directory.working_directory_id === current.working_directory_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
61
web/workspace/src/lib/workspace-sidebar/workers.test.ts
Normal file
61
web/workspace/src/lib/workspace-sidebar/workers.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { canOpenWorkerConsole, canShowWorkerInSidebar } from "./workers.ts";
|
||||||
|
import type { Worker } from "./types.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker(overrides: Partial<Worker>): Worker {
|
||||||
|
return {
|
||||||
|
runtime_id: "arc",
|
||||||
|
worker_id: "1",
|
||||||
|
host_id: "host",
|
||||||
|
label: "worker-1",
|
||||||
|
role: null,
|
||||||
|
profile: null,
|
||||||
|
workspace: { visibility: "workspace", identity: "workspace" },
|
||||||
|
state: "running",
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "normal",
|
||||||
|
last_seen_at: null,
|
||||||
|
implementation: {
|
||||||
|
kind: "runtime_worker",
|
||||||
|
display_hint: "Runtime Worker",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
can_stop: true,
|
||||||
|
can_spawn_followup: false,
|
||||||
|
},
|
||||||
|
working_directory: null,
|
||||||
|
diagnostics: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("registry-only workers are not sidebar targets or console targets", () => {
|
||||||
|
const registryOnly = worker({
|
||||||
|
state: "missing",
|
||||||
|
implementation: {
|
||||||
|
kind: "backend_worker_registry",
|
||||||
|
display_hint: "Missing Worker",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
can_stop: false,
|
||||||
|
can_spawn_followup: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(canShowWorkerInSidebar(registryOnly), false);
|
||||||
|
assertEquals(canOpenWorkerConsole(registryOnly), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("live runtime workers are sidebar targets and console targets", () => {
|
||||||
|
const liveWorker = worker({ state: "running" });
|
||||||
|
assertEquals(canShowWorkerInSidebar(liveWorker), true);
|
||||||
|
assertEquals(canOpenWorkerConsole(liveWorker), true);
|
||||||
|
});
|
||||||
9
web/workspace/src/lib/workspace-sidebar/workers.ts
Normal file
9
web/workspace/src/lib/workspace-sidebar/workers.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Worker } from "./types";
|
||||||
|
|
||||||
|
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
||||||
|
return worker.implementation.kind !== "backend_worker_registry";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenWorkerConsole(worker: Worker): boolean {
|
||||||
|
return canShowWorkerInSidebar(worker);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import WorkspaceAlerts from '$lib/workspace-alerts/WorkspaceAlerts.svelte';
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
@ -8,6 +9,8 @@
|
||||||
let sidebarCollapsed = $state(false);
|
let sidebarCollapsed = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<WorkspaceAlerts />
|
||||||
|
|
||||||
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
|
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
|
||||||
<WorkspaceSidebar
|
<WorkspaceSidebar
|
||||||
workspace={data.workspace}
|
workspace={data.workspace}
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,6 @@
|
||||||
<dt>Runtime</dt>
|
<dt>Runtime</dt>
|
||||||
<dd><code>{host.runtime_id}</code></dd>
|
<dd><code>{host.runtime_id}</code></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>Scope</dt>
|
|
||||||
<dd>{host.capabilities.workspace_scope}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<dt>Platform</dt>
|
<dt>Platform</dt>
|
||||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
function runtimeScope(runtime: Runtime): string {
|
|
||||||
return runtime.capabilities.workspace_scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimePlatform(runtime: Runtime): string {
|
function runtimePlatform(runtime: Runtime): string {
|
||||||
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
|
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +36,6 @@
|
||||||
<th>Runtime</th>
|
<th>Runtime</th>
|
||||||
<th>Kind</th>
|
<th>Kind</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Scope</th>
|
|
||||||
<th>Platform</th>
|
<th>Platform</th>
|
||||||
<th>Capacity</th>
|
<th>Capacity</th>
|
||||||
<th>Workdirs</th>
|
<th>Workdirs</th>
|
||||||
|
|
@ -55,7 +50,6 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>{runtime.status}</td>
|
<td>{runtime.status}</td>
|
||||||
<td>{runtimeScope(runtime)}</td>
|
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{runtime.capabilities.max_workers} workers</td>
|
<td>{runtime.capabilities.max_workers} workers</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import type { CleanupWorkdirCandidate, WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
|
import type {
|
||||||
|
CleanupWorkdirCandidate,
|
||||||
|
RuntimeCleanupExecutionResponse,
|
||||||
|
RuntimeCleanupPlanResponse,
|
||||||
|
WorkingDirectorySummary,
|
||||||
|
} from '$lib/workspace-sidebar/types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let selectedCleanupTargets = $state(new Set<string>());
|
let cleanupBusyTarget = $state<string | null>(null);
|
||||||
let selectedWorkerCleanupTargets = $state(new Set<string>());
|
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
|
||||||
let confirmedDirtyTargets = $state(new Set<string>());
|
let workdirs = $state<WorkingDirectorySummary[]>([]);
|
||||||
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(
|
let runtimeLabel = $derived(
|
||||||
data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId,
|
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 {
|
function commitLabel(workdir: WorkingDirectorySummary): string {
|
||||||
return workdir.resolved_commit ? workdir.resolved_commit.slice(0, 12) : '—';
|
return workdir.resolved_commit ? workdir.resolved_commit.slice(0, 12) : '—';
|
||||||
|
|
@ -23,39 +31,35 @@
|
||||||
return workdir.requested_selector ?? 'HEAD';
|
return workdir.requested_selector ?? 'HEAD';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
|
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
|
||||||
if (candidate.action === 'workdir_dirty_discard') {
|
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
|
||||||
return candidate.cleanliness === 'dirty' ? 'Discard dirty workdir' : 'Discard unknown-state workdir';
|
}
|
||||||
|
|
||||||
|
function isDeleteDisabled(candidate: CleanupWorkdirCandidate): boolean {
|
||||||
|
return Boolean(candidate.blocking_reason) || candidate.action === 'workdir_dirty_discard' || cleanupBusyTarget !== 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record';
|
return fallback;
|
||||||
return 'Clean up verified-clean workdir';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelected(targetId: string): void {
|
async function deleteWorkdir(workdir: WorkingDirectorySummary, candidate: CleanupWorkdirCandidate): Promise<void> {
|
||||||
const next = new Set(selectedCleanupTargets);
|
if (!cleanupPlan || isDeleteDisabled(candidate)) return;
|
||||||
if (next.has(targetId)) next.delete(targetId);
|
cleanupBusyTarget = candidate.target_id;
|
||||||
else next.add(targetId);
|
|
||||||
selectedCleanupTargets = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWorkerSelected(targetId: string): void {
|
|
||||||
const next = new Set(selectedWorkerCleanupTargets);
|
|
||||||
if (next.has(targetId)) next.delete(targetId);
|
|
||||||
else next.add(targetId);
|
|
||||||
selectedWorkerCleanupTargets = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
||||||
|
|
@ -63,24 +67,32 @@
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
expected_plan_revision: data.cleanupPlan.revision,
|
expected_plan_revision: cleanupPlan.revision,
|
||||||
expected_plan_digest: data.cleanupPlan.digest,
|
expected_plan_digest: cleanupPlan.digest,
|
||||||
worker_target_ids: Array.from(selectedWorkerCleanupTargets),
|
worker_target_ids: [],
|
||||||
workdir_target_ids: Array.from(selectedCleanupTargets),
|
workdir_target_ids: [candidate.target_id],
|
||||||
confirm_dirty_discard_target_ids: Array.from(confirmedDirtyTargets),
|
confirm_dirty_discard_target_ids: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = (await response.json().catch(() => null)) as RuntimeCleanupExecutionResponse | unknown;
|
||||||
if (!response.ok) throw new Error(payload?.message ?? payload?.error ?? response.statusText);
|
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
|
||||||
cleanupStatus = `Executed ${payload?.results?.length ?? 0} cleanup action(s). Refresh to see the latest plan.`;
|
if (payload && typeof payload === 'object' && 'plan_after' in payload) {
|
||||||
selectedCleanupTargets = new Set();
|
cleanupPlan = (payload as RuntimeCleanupExecutionResponse).plan_after;
|
||||||
selectedWorkerCleanupTargets = new Set();
|
}
|
||||||
confirmedDirtyTargets = new Set();
|
const result = payload && typeof payload === 'object' && 'results' in payload
|
||||||
|
? (payload as RuntimeCleanupExecutionResponse).results.find((entry) => entry.target_id === candidate.target_id)
|
||||||
|
: undefined;
|
||||||
|
if (!result || result.status !== 'deleted') {
|
||||||
|
throw new Error(result?.message ?? 'Runtime did not delete the selected Workdir');
|
||||||
|
}
|
||||||
|
workdirs = workdirs.filter((item) => item.working_directory_id !== workdir.working_directory_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed';
|
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Workdir deletion failed', {
|
||||||
|
title: 'Workdir deletion failed',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cleanupBusy = false;
|
cleanupBusyTarget = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -96,6 +108,7 @@
|
||||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
||||||
<h1 id="workdirs-heading">Workdirs</h1>
|
<h1 id="workdirs-heading">Workdirs</h1>
|
||||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||||
|
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -103,71 +116,9 @@
|
||||||
<p class="section-state error">{data.workdirsError}</p>
|
<p class="section-state error">{data.workdirsError}</p>
|
||||||
{:else if !data.workdirs}
|
{:else if !data.workdirs}
|
||||||
<p class="section-state">Loading workdirs…</p>
|
<p class="section-state">Loading workdirs…</p>
|
||||||
{:else if data.workdirs.items.length === 0}
|
{:else if workdirs.length === 0}
|
||||||
<p class="section-state">No workdirs are visible for this Runtime.</p>
|
<p class="section-state">No workdirs are visible for this Runtime.</p>
|
||||||
{:else}
|
{: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">
|
<div class="table-wrap">
|
||||||
<table class="workdirs-table">
|
<table class="workdirs-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -177,20 +128,39 @@
|
||||||
<th>Selector</th>
|
<th>Selector</th>
|
||||||
<th>Commit</th>
|
<th>Commit</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Policy</th>
|
<th>Cleanliness</th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.workdirs.items as workdir}
|
{#each workdirs as workdir}
|
||||||
|
{@const cleanup = cleanupCandidate(workdir)}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{workdir.working_directory_id}</code></td>
|
<td><code>{workdir.working_directory_id}</code></td>
|
||||||
<td>{workdir.repository_id}</td>
|
<td>{workdir.repository_id}</td>
|
||||||
<td>{selectorLabel(workdir)}</td>
|
<td>{selectorLabel(workdir)}</td>
|
||||||
<td><code>{commitLabel(workdir)}</code></td>
|
<td><code>{commitLabel(workdir)}</code></td>
|
||||||
<td>{workdir.status}</td>
|
<td>{workdir.status}</td>
|
||||||
|
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span>{workdir.dirty_state_policy}</span>
|
{#if cleanup}
|
||||||
<small>{workdir.cleanup_policy}</small>
|
<button
|
||||||
|
class="icon-action danger"
|
||||||
|
type="button"
|
||||||
|
disabled={isDeleteDisabled(cleanup)}
|
||||||
|
aria-label={`Delete ${workdir.working_directory_id}`}
|
||||||
|
title={cleanup.action === 'workdir_dirty_discard' ? 'Dirty Workdirs must be cleaned before deletion' : (cleanup.blocking_reason ?? cleanup.reason)}
|
||||||
|
onclick={() => deleteWorkdir(workdir, cleanup)}
|
||||||
|
>
|
||||||
|
{#if cleanupBusyTarget === cleanup.target_id}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
{:else}
|
||||||
|
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -199,3 +169,55 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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,7 +10,8 @@
|
||||||
ClientWorkerEventWsFrame,
|
ClientWorkerEventWsFrame,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
Worker,
|
Worker,
|
||||||
WorkerInputResult
|
WorkerInputResult,
|
||||||
|
PodProtocolEvent
|
||||||
} from '$lib/workspace-sidebar/types';
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -32,6 +33,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let worker = $state<Worker | null>(null);
|
let worker = $state<Worker | null>(null);
|
||||||
|
let liveWorkerState = $state<string | null>(null);
|
||||||
let workerError = $state<string | null>(null);
|
let workerError = $state<string | null>(null);
|
||||||
let draft = $state('');
|
let draft = $state('');
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
|
|
@ -59,7 +61,9 @@
|
||||||
);
|
);
|
||||||
const lines = $derived(projection.lines);
|
const lines = $derived(projection.lines);
|
||||||
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
||||||
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
|
||||||
|
const inputReady = $derived(workerState === 'idle');
|
||||||
|
const canSend = $derived(inputReady && draft.trim().length > 0 && !sending);
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(path);
|
const response = await fetch(path);
|
||||||
|
|
@ -97,12 +101,15 @@
|
||||||
async function loadWorker(target: ConsoleTarget) {
|
async function loadWorker(target: ConsoleTarget) {
|
||||||
workerError = null;
|
workerError = null;
|
||||||
try {
|
try {
|
||||||
worker = await getJson<Worker>(
|
const payload = await getJson<Worker>(
|
||||||
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
|
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
|
||||||
);
|
);
|
||||||
|
worker = payload;
|
||||||
|
liveWorkerState = payload.state;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
workerError = error instanceof Error ? error.message : String(error);
|
workerError = error instanceof Error ? error.message : String(error);
|
||||||
worker = null;
|
worker = null;
|
||||||
|
liveWorkerState = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +139,7 @@
|
||||||
async function sendMessage(event: SubmitEvent) {
|
async function sendMessage(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const content = draft.trim();
|
const content = draft.trim();
|
||||||
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
if (!content || sending || !inputReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +152,7 @@
|
||||||
);
|
);
|
||||||
if (result.state === 'accepted') {
|
if (result.state === 'accepted') {
|
||||||
draft = '';
|
draft = '';
|
||||||
|
liveWorkerState = 'running';
|
||||||
} else {
|
} else {
|
||||||
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +163,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'snapshot':
|
||||||
|
case 'status':
|
||||||
|
return event.data.status;
|
||||||
|
case 'shutdown':
|
||||||
|
return 'shutdown';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
||||||
if (!targetWorker) {
|
if (!targetWorker) {
|
||||||
streamState = 'closed';
|
streamState = 'closed';
|
||||||
|
|
@ -182,6 +202,10 @@
|
||||||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
|
||||||
|
if (observedState) {
|
||||||
|
liveWorkerState = observedState;
|
||||||
|
}
|
||||||
observedEvents = [
|
observedEvents = [
|
||||||
...observedEvents,
|
...observedEvents,
|
||||||
{
|
{
|
||||||
|
|
@ -304,6 +328,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const target = consoleTarget;
|
const target = consoleTarget;
|
||||||
resetObservedEvents();
|
resetObservedEvents();
|
||||||
|
liveWorkerState = null;
|
||||||
streamDiagnostics = [];
|
streamDiagnostics = [];
|
||||||
advanceReloadToken();
|
advanceReloadToken();
|
||||||
void loadConsoleData(target);
|
void loadConsoleData(target);
|
||||||
|
|
@ -324,7 +349,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="console-header-actions">
|
<div class="console-header-actions">
|
||||||
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
||||||
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
{workerState} · stream {streamState}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
||||||
Details
|
Details
|
||||||
|
|
@ -430,7 +455,6 @@
|
||||||
<details class="metadata-details">
|
<details class="metadata-details">
|
||||||
<summary>Capabilities</summary>
|
<summary>Capabilities</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
|
|
||||||
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
||||||
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -461,7 +485,7 @@
|
||||||
id="worker-console-message"
|
id="worker-console-message"
|
||||||
aria-label="Console input"
|
aria-label="Console input"
|
||||||
bind:value={draft}
|
bind:value={draft}
|
||||||
disabled={!worker?.capabilities.can_accept_input || sending}
|
disabled={!inputReady || sending}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="composer-actions">
|
<div class="composer-actions">
|
||||||
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,141 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
import type { Worker } from '$lib/workspace-sidebar/types';
|
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
|
||||||
|
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace-sidebar/types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
type WorkerActionKind = 'pin' | 'delete';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let retentionStatus = $state<string | null>(null);
|
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
|
||||||
|
let workers = $state<Worker[]>([]);
|
||||||
|
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
cleanupPlans = data.cleanupPlans;
|
||||||
|
workers = data.workers?.items ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function workerKey(worker: Worker): string {
|
||||||
|
return `${worker.runtime_id}/${worker.worker_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionBusy(worker: Worker, kind: WorkerActionKind): boolean {
|
||||||
|
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsDisabled(): boolean {
|
||||||
|
return busyAction !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(payload: unknown, fallback: string): string {
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
if ('message' in payload && typeof payload.message === 'string') return payload.message;
|
||||||
|
if ('error' in payload) {
|
||||||
|
const error = payload.error;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message;
|
||||||
|
}
|
||||||
|
if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) {
|
||||||
|
const diagnostic = payload.diagnostics.find(
|
||||||
|
(entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string',
|
||||||
|
);
|
||||||
|
if (diagnostic) return diagnostic.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCleanupPlan(runtimeId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const plan = (await response.json()) as RuntimeCleanupPlanResponse;
|
||||||
|
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
|
||||||
|
}
|
||||||
|
|
||||||
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
|
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
|
||||||
retentionStatus = null;
|
if (busyAction) return;
|
||||||
const response = await fetch(
|
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
|
||||||
workspaceApiPath(
|
try {
|
||||||
data.workspaceId,
|
const response = await fetch(
|
||||||
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
|
workspaceApiPath(
|
||||||
),
|
data.workspaceId,
|
||||||
{ method: pinned ? 'PUT' : 'DELETE' },
|
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
|
||||||
);
|
),
|
||||||
const payload = await response.json().catch(() => null);
|
{ method: pinned ? 'PUT' : 'DELETE' },
|
||||||
if (!response.ok) {
|
);
|
||||||
retentionStatus = payload?.message ?? payload?.error ?? response.statusText;
|
const payload = await response.json().catch(() => null);
|
||||||
return;
|
if (!response.ok) {
|
||||||
|
pushWorkspaceAlert('error', errorMessage(payload, response.statusText), { title: 'Worker pin failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker.pinned = Boolean(payload?.pinned);
|
||||||
|
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
worker.pinned = Boolean(payload?.pinned);
|
|
||||||
worker.retention_state = payload?.retention_state ?? (worker.pinned ? 'pinned' : 'normal');
|
|
||||||
retentionStatus = `${worker.label} ${worker.pinned ? 'pinned' : 'unpinned'}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function workerStatus(worker: Worker): string {
|
function workerStatus(worker: Worker): string {
|
||||||
return `${worker.state} · ${worker.status}`;
|
return worker.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function workerProfile(worker: Worker): string {
|
function workerProfile(worker: Worker): string {
|
||||||
|
|
@ -52,8 +160,7 @@
|
||||||
<header class="workers-page-header">
|
<header class="workers-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="workers-heading">Workers</h1>
|
<h1 id="workers-heading">Workers</h1>
|
||||||
<p>Workers running or persisted for this workspace. Pinning only updates Backend retention.</p>
|
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
|
||||||
{#if retentionStatus}<p>{retentionStatus}</p>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
|
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -62,7 +169,7 @@
|
||||||
<p class="section-state error">{data.workersError}</p>
|
<p class="section-state error">{data.workersError}</p>
|
||||||
{:else if !data.workers}
|
{:else if !data.workers}
|
||||||
<p class="section-state">Loading Workers…</p>
|
<p class="section-state">Loading Workers…</p>
|
||||||
{:else if data.workers.items.length === 0}
|
{:else if workers.length === 0}
|
||||||
<p class="section-state">No Workers are visible.</p>
|
<p class="section-state">No Workers are visible.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap workers-table-wrap">
|
<div class="table-wrap workers-table-wrap">
|
||||||
|
|
@ -79,10 +186,17 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.workers.items as worker}
|
{#each workers as worker}
|
||||||
|
{@const cleanup = cleanupCandidate(worker)}
|
||||||
|
{@const canDelete = cleanup && !cleanup.blocking_reason}
|
||||||
|
{@const anyActionDisabled = actionsDisabled()}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong>{worker.label}</strong>
|
{#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>
|
<small><code>{worker.worker_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{worker.runtime_id}</code></td>
|
<td><code>{worker.runtime_id}</code></td>
|
||||||
|
|
@ -91,10 +205,40 @@
|
||||||
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
||||||
<td>{workerDirectory(worker)}</td>
|
<td>{workerDirectory(worker)}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
<div class="worker-actions" aria-label={`Actions for ${worker.label}`}>
|
||||||
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
<button
|
||||||
{worker.pinned ? 'Unpin' : 'Pin'}
|
class="icon-action"
|
||||||
</button>
|
type="button"
|
||||||
|
disabled={anyActionDisabled}
|
||||||
|
aria-label={worker.pinned ? `Unpin ${worker.label}` : `Pin ${worker.label}`}
|
||||||
|
title={worker.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
onclick={() => setPinned(worker, !worker.pinned)}
|
||||||
|
>
|
||||||
|
{#if isActionBusy(worker, 'pin')}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
{:else if worker.pinned}
|
||||||
|
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" /></svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if cleanup}
|
||||||
|
<button
|
||||||
|
class="icon-action danger"
|
||||||
|
type="button"
|
||||||
|
disabled={!canDelete || anyActionDisabled}
|
||||||
|
aria-label={`Delete ${worker.label}`}
|
||||||
|
title={cleanup.blocking_reason ?? cleanup.reason}
|
||||||
|
onclick={() => deleteWorker(worker, cleanup)}
|
||||||
|
>
|
||||||
|
{#if isActionBusy(worker, 'delete')}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
{:else}
|
||||||
|
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -103,3 +247,78 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||||
import type { ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
|
@ -7,10 +7,28 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/workers"),
|
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 {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
workers: workers.data,
|
workers: workers.data,
|
||||||
workersError: workers.error,
|
workersError: workers.error,
|
||||||
|
cleanupPlans,
|
||||||
|
cleanupPlanErrors,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,16 @@
|
||||||
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
||||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||||
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
||||||
let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected));
|
let hasSelectedExistingWorkdir = $derived(Boolean(
|
||||||
|
workingDirectoryId && !isNewWorkingDirectorySelected && !selectedRuntimeAllowsNoWorkdir,
|
||||||
|
));
|
||||||
|
let availableWorkingDirectories = $derived(
|
||||||
|
selectedRuntimeAllowsNoWorkdir
|
||||||
|
? []
|
||||||
|
: (options?.working_directories ?? []).filter((directory) =>
|
||||||
|
directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null
|
||||||
|
),
|
||||||
|
);
|
||||||
let canStartWorker = $derived(Boolean(
|
let canStartWorker = $derived(Boolean(
|
||||||
runtimeId &&
|
runtimeId &&
|
||||||
profile &&
|
profile &&
|
||||||
|
|
@ -65,6 +74,13 @@
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedRuntimeAllowsNoWorkdir && workingDirectoryId) {
|
||||||
|
workingDirectoryId = '';
|
||||||
|
relativeCwd = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||||
loading = true;
|
loading = true;
|
||||||
optionsError = null;
|
optionsError = null;
|
||||||
|
|
@ -109,6 +125,10 @@
|
||||||
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedRuntimeAllowsNoWorkdir) {
|
||||||
|
submitError = { message: 'embedded Runtime does not create workdirs', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!workingDirectoryRepositoryId) {
|
if (!workingDirectoryRepositoryId) {
|
||||||
submitError = { message: 'select a repository before creating a workdir', diagnostics: [] };
|
submitError = { message: 'select a repository before creating a workdir', diagnostics: [] };
|
||||||
return;
|
return;
|
||||||
|
|
@ -124,7 +144,6 @@
|
||||||
runtime_id: runtimeId,
|
runtime_id: runtimeId,
|
||||||
repository_id: workingDirectoryRepositoryId,
|
repository_id: workingDirectoryRepositoryId,
|
||||||
selector: workingDirectorySelector || null,
|
selector: workingDirectorySelector || null,
|
||||||
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -245,16 +264,16 @@
|
||||||
<span>Run at</span>
|
<span>Run at</span>
|
||||||
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
|
||||||
{#if selectedRuntimeAllowsNoWorkdir}
|
{#if selectedRuntimeAllowsNoWorkdir}
|
||||||
<option value="">No workdir · embedded conversation only</option>
|
<option value="">No workdir</option>
|
||||||
{:else}
|
{:else}
|
||||||
<option value="" disabled>Select workdir</option>
|
<option value="" disabled>Select workdir</option>
|
||||||
|
{#each availableWorkingDirectories as directory}
|
||||||
|
<option value={directory.working_directory_id}>
|
||||||
|
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
<option value={NEW_WORKING_DIRECTORY_VALUE}>New workdir…</option>
|
||||||
{/if}
|
{/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>
|
|
||||||
</select>
|
</select>
|
||||||
<span>in</span>
|
<span>in</span>
|
||||||
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user