merge: orchestration
This commit is contained in:
commit
cab6a5300d
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Add Backend Worker/Workdir registry and link model'
|
title: 'Add Backend Worker/Workdir registry and link model'
|
||||||
state: 'inprogress'
|
state: 'closed'
|
||||||
created_at: '2026-07-10T15:53:02Z'
|
created_at: '2026-07-10T15:53:02Z'
|
||||||
updated_at: '2026-07-10T16:40:17Z'
|
updated_at: '2026-07-10T17:38:20Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-10T16:10:57Z'
|
queued_at: '2026-07-10T16:10:57Z'
|
||||||
|
|
|
||||||
35
.yoi/tickets/00001KX6BPY7M/resolution.md
Normal file
35
.yoi/tickets/00001KX6BPY7M/resolution.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
Backend Worker/Workdir registry and link model を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend SQLite schema に `worker_registry`, `workdir_registry`, `worker_workdir_links` を追加。
|
||||||
|
- Worker registry / Workdir registry / Worker-Workdir link の typed store API と projection を追加。
|
||||||
|
- Backend 経由の Workdir 作成で Runtime materialization 前に Backend-managed pending row を作成。
|
||||||
|
- Backend / Runtime scoped Worker creation で Worker registry row、Workdir registry row、Worker-Workdir link を同期。
|
||||||
|
- Worker list は Runtime observation を同期したうえで Backend `worker_registry` / link authority から projection。
|
||||||
|
- Worker stop は canonical registry row を削除せず、Worker lifecycle と linked Workdir status を同期。
|
||||||
|
- Workdir list / launch options は Backend-managed rows を基準にし、`runtime_unmanaged` Workdirs は managed Browser list/options に混ぜず runtime/diagnostic projection で区別可能にした。
|
||||||
|
- `pinned` retention が通常 Runtime sync で `normal` に clobber されないよう保護。
|
||||||
|
- Browser-facing summaries に safe `management_kind` を追加し、raw Runtime materialized path を Backend registry / Browser response に保存・表示しない方針を維持。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は 5 blocker で `request_changes`。
|
||||||
|
- `8695089f fix: sync backend worker registry projections` で主要 blocker を修正。
|
||||||
|
- 2回目 review は unmanaged Workdir が managed list/options に混ざる blocker で `request_changes`。
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists` で残 blocker を修正。
|
||||||
|
- 最終 focused re-review は `approve`。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `03652f82 merge: worker workdir registry`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Non-blocking follow-up noted by reviewer:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are safe, but a future cleanup can project detail responses through Backend records for more uniform diagnostics。
|
||||||
|
|
@ -197,3 +197,264 @@ Implementation progress report:
|
||||||
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
|
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T16:52:25Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External review result: request_changes
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
|
||||||
|
1. Browser Worker list が Backend registry based になっていない。
|
||||||
|
- Ticket は Worker/Workdir lists を Backend-managed registry + Runtime observation merge に基づかせることを要求している。
|
||||||
|
- `workers_response` はまだ `api.runtime.list_workers(limit)` を呼び、live Runtime items を opportunistic に記録したうえで `items: runtime_workers.items` を返している(`crates/workspace-server/src/server.rs:2529-2546`)。
|
||||||
|
- `worker_registry` / `worker_workdir_links` を read していないため、Backend-only / stopped / archive rows を list できず、relation authority が Browser Worker list に使われていない。
|
||||||
|
|
||||||
|
2. Runtime Worker creation path が Worker registry / Workdir registry / links を書いていない。
|
||||||
|
- `create_runtime_worker` は `working_directory_request` を解決して `api.runtime.spawn_worker` に forward し、Runtime result を返すだけ(`server.rs:2269-2285`)。
|
||||||
|
- Runtime materialization 前の pending Backend Workdir row、`record_worker_summary`、Worker-Workdir link がない。
|
||||||
|
- Browser `/api/workers` path のみ existing Workdir を record/link-select している(`server.rs:2141-2170`)。
|
||||||
|
- Backend-created/internal Runtime Workers with materialized workdirs が untracked になり、「Backend worker creation writes/updates Worker registry and Worker-Workdir link」 acceptance に違反する。
|
||||||
|
|
||||||
|
3. Stopped Worker -> removed/missing Workdir relationship が Backend registry から安定して答えられない。
|
||||||
|
- `stop_runtime_worker` は Runtime stop を forward して返すだけ(`server.rs:2330-2339`)。
|
||||||
|
- Runtime cleanup 後に Worker lifecycle、Workdir materialization status、link state を Backend SQLite に更新しない。
|
||||||
|
- `working_directory_summaries` は `list_managed_workdir_registry` だけを読む(`server.rs:3242-3249`)ため、Runtime stop が bound workdir を remove しても Backend record は stale `present` のままになり得る。
|
||||||
|
|
||||||
|
4. Runtime Workdir observation / unmanaged distinction が partial。
|
||||||
|
- runtime-scoped workdir list は raw Runtime summaries を直接返す(`server.rs:1184-1200`)。
|
||||||
|
- workspace-scoped list は Backend-managed rows のみ返す。
|
||||||
|
- `runtime_unmanaged` rows は detail / worker-create paths で opportunistic に作られるだけで、list 時に sync/typed されない。
|
||||||
|
- Browser summaries も management kind を露出していないため、unmanaged/external Runtime Workdirs を Backend-managed Workdirs と区別して visible/diagnosable にする requirement を満たしていない。
|
||||||
|
|
||||||
|
5. pinned retention が sync で clobber され得る。
|
||||||
|
- schema は `pinned` を保存できるが、`record_worker_summary` は常に `retention_state: "normal"` を書く(`server.rs:3290-3318`)。
|
||||||
|
- `upsert_worker_registry` は conflict 時に `retention_state = excluded.retention_state` で上書きする(`store.rs:223-242`)。
|
||||||
|
- pinned Worker への後続 Runtime observation / list sync が pinned を normal に downgrade できるため、`pinned` が durable retention metadata として保護されていない。
|
||||||
|
|
||||||
|
Non-blocking follow-ups:
|
||||||
|
- schema 方向性自体は妥当。`worker_registry`, `workdir_registry`, `worker_workdir_links` は typed checks / FK relations 付きで存在する(`store.rs:488-545`)。
|
||||||
|
- Backend workdir creation が Runtime materialization 前に pending Backend row を作る点は良い(`server.rs:1276-1327`)。
|
||||||
|
- inspected paths では raw materialized Runtime paths が新 Backend registry records に保存されている箇所は見当たらなかった。
|
||||||
|
- tests は schema roundtrip / pinned storage / removed Workdir / link basics を cover しているが、上記 blocker paths(runtime worker spawn with workdir, stop cleanup syncing, Backend-registry Worker list projection, unmanaged list diagnostics, pinned preservation under sync)は不足。
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- Ticket item/thread including routing decision, IntentPacket, implementation report を確認。
|
||||||
|
- implementation commit `3e5546ea` の `git show --name-only` / `git show --check` を確認。
|
||||||
|
- implementation worktree 内だけで branch/status を確認。
|
||||||
|
- `store.rs`, `server.rs`, `catalog.rs`, `worker_backend.rs`, `working_directory.rs` を review。
|
||||||
|
- acceptance blockers が inspection で明確だったため、full test/build matrix は再実行していない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:14:02Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up implementation progress report after request_changes:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6BPY7M-worker-workdir` completed the focused fix pass and committed follow-up commit `8695089fba346b8f10d55016b8328337e1c37445 fix: sync backend worker registry projections` on branch `work/00001KX6BPY7M-worker-workdir-registry`。
|
||||||
|
- Implementation worktree status was clean at Orchestrator inspection。
|
||||||
|
- Orchestrator inspected branch log / commit stats / `git diff --check 86f652a7..HEAD` in the implementation worktree。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Browser Worker list now projects from Backend `worker_registry` rows after Runtime observation sync, preserving Backend-only stopped/archive rows。
|
||||||
|
- Worker-Workdir relationships are projected from `worker_workdir_links` as authority。
|
||||||
|
- Runtime-scoped Worker spawn now writes pending Backend-managed Workdir rows, Worker registry rows, and Worker-Workdir links。
|
||||||
|
- Worker stop now updates Backend Worker lifecycle and syncs linked Workdir status to present/removed/missing without deleting canonical rows。
|
||||||
|
- Runtime Workdir observation/listing now syncs Runtime observations into Backend registry and projects safe `management_kind` (`backend_managed` / `runtime_unmanaged`)。
|
||||||
|
- Existing `pinned` Worker retention is protected from ordinary Runtime sync/upsert clobbering。
|
||||||
|
- Tests were added/strengthened for archive/link projection, unmanaged Workdir projection, raw path redaction, removed Workdir preservation, and pinned retention preservation。
|
||||||
|
- Frontend type exposure for Workdir `management_kind` was added。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused external re-review against commits `3e5546ea..8695089f` and the whole Ticket acceptance criteria before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T17:22:14Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
Focused re-review result: request_changes
|
||||||
|
|
||||||
|
Evidence reviewed:
|
||||||
|
- Implementation worktree is on `work/00001KX6BPY7M-worker-workdir-registry` at `8695089fba346b8f10d55016b8328337e1c37445`, clean。
|
||||||
|
- Follow-up fixes most prior blockers:
|
||||||
|
- Worker list now syncs Runtime observations, then projects from `worker_registry` / `worker_workdir_links` (`workers_response`, `merge_worker_registry_projection`)。
|
||||||
|
- Runtime Worker create now writes pending Backend Workdir rows, records Worker rows, and links selected/materialized Workdirs。
|
||||||
|
- Stop path now updates Worker lifecycle and attempts linked Workdir status sync。
|
||||||
|
- Store upsert preserves existing `pinned` when a Runtime sync supplies `normal`。
|
||||||
|
- New registry records still do not store raw materialized Runtime paths。
|
||||||
|
|
||||||
|
Blocker:
|
||||||
|
- Unmanaged Runtime Workdirs are now mixed into normal Backend-managed Browser lists / launch options。
|
||||||
|
- Ticket requires Runtime-direct/unmanaged Workdirs to be distinguishable and “not mixed into managed lists” (`item.md:61`)。
|
||||||
|
- Follow-up syncs unknown Runtime Workdirs as `runtime_unmanaged`, but `working_directory_summaries()` returns all `workdir_registry` rows via `list_workdir_registry()` (`server.rs:3300-3308`)。
|
||||||
|
- This feeds Browser launch options (`server.rs:3278`) and the workspace-scoped Workdirs list。
|
||||||
|
- The worker-new UI select iterates `options.working_directories` without filtering or labeling `management_kind`, so a `runtime_unmanaged` Workdir can appear/select like a normal managed Workdir。
|
||||||
|
- This is not a safe typed UI distinction and does not satisfy the Backend-managed vs unmanaged boundary。
|
||||||
|
|
||||||
|
Non-blocking notes:
|
||||||
|
- Runtime-scoped Workdir lists being typed from registry is a good direction。
|
||||||
|
- Consider ensuring detail/cleanup/create responses also project through `workdir_summary_from_record`; several paths still return Runtime summaries with `management_kind: None`, even after storing a typed Backend record。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:32:02Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Second follow-up implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6BPY7M-worker-workdir` completed the focused unmanaged-workdir fix and committed `239f93b084fdcc2201e17fcdbc23eb0140531f16 fix: keep unmanaged workdirs out of managed lists` on branch `work/00001KX6BPY7M-worker-workdir-registry`。
|
||||||
|
- Implementation worktree status was clean at Orchestrator inspection。
|
||||||
|
- Orchestrator inspected branch log / commit stats / `git diff --check 86f652a7..HEAD` in the implementation worktree。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Workspace-scoped Browser Workdir summaries / launch options now include only `backend_managed` Workdir registry rows。
|
||||||
|
- Runtime-direct/unmanaged Workdirs remain visible through runtime-scoped projection with explicit `management_kind: runtime_unmanaged`。
|
||||||
|
- Tests prove:
|
||||||
|
- `runtime_unmanaged` rows are excluded from managed Browser Workdir summaries / launch options。
|
||||||
|
- unmanaged rows remain distinguishable in runtime projection。
|
||||||
|
- store-level managed listing excludes unmanaged rows while all-row listing still includes them。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused re-review on remaining unmanaged-list blocker and full acceptance status before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T17:35:29Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Focused re-review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Follow-up `239f93b084fdcc2201e17fcdbc23eb0140531f16` is present on the requested branch/worktree; worktree was clean during inspection。
|
||||||
|
- Remaining blocker is fixed:
|
||||||
|
- Workspace-scoped Browser launch options still use `working_directory_summaries()`, and that now lists only `backend_managed` rows after Runtime observation sync (`server.rs:3279-3308`)。
|
||||||
|
- Store filtering is explicit in `list_managed_workdir_registry()` with `management_kind = 'backend_managed'` (`store.rs:376-392`)。
|
||||||
|
- Runtime-scoped Workdir projection still lists registry rows for the Runtime and preserves explicit `management_kind`, so `runtime_unmanaged` remains diagnosable (`server.rs:3310-3317`, `workdir_summary_from_record`)。
|
||||||
|
- Previous blockers sanity-checked:
|
||||||
|
- Worker list syncs Runtime observations then projects from Backend `worker_registry` / link authority (`server.rs:2564-2583`, `merge_worker_registry_projection`)。
|
||||||
|
- Runtime Worker create prepares pending Workdir rows, syncs Worker observations, and links Workdirs (`server.rs:2295-2352`)。
|
||||||
|
- Stop path preserves canonical records and updates Worker/linked Workdir statuses without deleting registry rows (`server.rs:2399-2443`)。
|
||||||
|
- Pinned retention is protected from normal Runtime sync by store upsert `CASE` logic (`store.rs:220-243`)。
|
||||||
|
- New Backend registry structs/schema store ids/status/metadata only; inspected Browser summaries do not expose raw Runtime materialized paths。
|
||||||
|
- Focused validation run:
|
||||||
|
- `cargo test -p yoi-workspace-server --lib workdir -- --nocapture` passed 3/3 relevant tests, including managed-list exclusion, unmanaged runtime projection, and pinned/registry roundtrip coverage。
|
||||||
|
|
||||||
|
Non-blocking note:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are now safe, but projecting details through Backend records would make diagnostics more uniformly typed later。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:38:03Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation after the final focused fix。
|
||||||
|
- Merged branch `work/00001KX6BPY7M-worker-workdir-registry` into orchestration branch with merge commit `03652f82 merge: worker workdir registry`。
|
||||||
|
- Ticket record conflicts from child branch were resolved in favor of Orchestrator-authoritative Ticket records; implementation code changes were merged。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commits:
|
||||||
|
- `3e5546ea feat: add backend worker workdir registry`
|
||||||
|
- `8695089f fix: sync backend worker registry projections`
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T17:38:06Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T17:38:20Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T17:38:20Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Backend Worker/Workdir registry and link model を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend SQLite schema に `worker_registry`, `workdir_registry`, `worker_workdir_links` を追加。
|
||||||
|
- Worker registry / Workdir registry / Worker-Workdir link の typed store API と projection を追加。
|
||||||
|
- Backend 経由の Workdir 作成で Runtime materialization 前に Backend-managed pending row を作成。
|
||||||
|
- Backend / Runtime scoped Worker creation で Worker registry row、Workdir registry row、Worker-Workdir link を同期。
|
||||||
|
- Worker list は Runtime observation を同期したうえで Backend `worker_registry` / link authority から projection。
|
||||||
|
- Worker stop は canonical registry row を削除せず、Worker lifecycle と linked Workdir status を同期。
|
||||||
|
- Workdir list / launch options は Backend-managed rows を基準にし、`runtime_unmanaged` Workdirs は managed Browser list/options に混ぜず runtime/diagnostic projection で区別可能にした。
|
||||||
|
- `pinned` retention が通常 Runtime sync で `normal` に clobber されないよう保護。
|
||||||
|
- Browser-facing summaries に safe `management_kind` を追加し、raw Runtime materialized path を Backend registry / Browser response に保存・表示しない方針を維持。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は 5 blocker で `request_changes`。
|
||||||
|
- `8695089f fix: sync backend worker registry projections` で主要 blocker を修正。
|
||||||
|
- 2回目 review は unmanaged Workdir が managed list/options に混ざる blocker で `request_changes`。
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists` で残 blocker を修正。
|
||||||
|
- 最終 focused re-review は `approve`。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `03652f82 merge: worker workdir registry`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Non-blocking follow-up noted by reviewer:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are safe, but a future cleanup can project detail responses through Backend records for more uniform diagnostics。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{"id":"orch-plan-20260710-164538-1","ticket_id":"00001KX6CRVBE","kind":"after","related_ticket":"00001KX6BPY7M","note":"Ticket body explicitly states this is a follow-up to `00001KX6BPY7M`. The prerequisite registry/link model is currently inprogress on branch `work/00001KX6BPY7M-worker-workdir-registry` and under external review. Start this cleanup/delete Ticket only after that branch is approved/merged or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T16:45:38Z"}
|
||||||
|
{"id":"orch-plan-20260710-164549-2","ticket_id":"00001KX6CRVBE","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. `00001KX6CRVBE` is otherwise well specified, but it depends on the Backend Worker/Workdir registry/link model from `00001KX6BPY7M`, which is currently inprogress/review. Starting now would duplicate/conflict with store schema, server APIs, and Worker/Workdir projections. Leave queued until `00001KX6BPY7M` is approved/merged; then re-route/start from updated orchestration branch.","author":"orchestrator","at":"2026-07-10T16:45:49Z"}
|
||||||
|
{"id":"orch-plan-20260710-173850-3","ticket_id":"00001KX6CRVBE","kind":"accepted_plan","accepted_plan":{"summary":"Implement manual Worker/Workdir delete and cleanup operations on top of the merged Backend Worker/Workdir registry/link model. Keep cleanup manual-first with stale revision/digest checks, pinned protection, running-linked Workdir blocks, dirty discard confirmation, raw path redaction, and Browser plan preview/execution surfaces.","branch":"work/00001KX6CRVBE-manual-cleanup","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup","role_plan":"Orchestrator accepts queued Ticket after prerequisite `00001KX6BPY7M` was approved, merged, validated, closed, and its implementation worktree/branch cleaned. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/validation/close authority."},"author":"orchestrator","at":"2026-07-10T17:38:50Z"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Add manual delete and cleanup operations for Workers and workdirs'
|
title: 'Add manual delete and cleanup operations for Workers and workdirs'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-07-10T16:11:33Z'
|
created_at: '2026-07-10T16:11:33Z'
|
||||||
updated_at: '2026-07-10T16:45:14Z'
|
updated_at: '2026-07-10T18:37:40Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-10T16:45:14Z'
|
queued_at: '2026-07-10T16:45:14Z'
|
||||||
|
|
|
||||||
31
.yoi/tickets/00001KX6CRVBE/resolution.md
Normal file
31
.yoi/tickets/00001KX6CRVBE/resolution.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
Manual Worker/Workdir delete and cleanup operations を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend Worker registry row の pin/unpin API を追加し、Worker list/detail に `pinned` / `retention_state` を返すようにした。
|
||||||
|
- Runtime ごとの manual cleanup plan API を追加し、Backend Worker/Workdir/link registry と Runtime observation に基づいて Worker delete / Workdir cleanup/delete candidates を生成するようにした。
|
||||||
|
- Plan candidate は action kind、reason/blocking reason、linked Worker/Workdir ids、pinned state、cleanliness/file status、running-link status、safe な reclaim bytes placeholder を含む。
|
||||||
|
- Manual cleanup execution API は expected plan revision/digest を要求し、stale plan を拒否する。
|
||||||
|
- Pinned Worker/history delete、running-linked Workdir cleanup、dirty/unknown Workdir の confirmation なし discard を拒否する。
|
||||||
|
- Removed/missing Workdir registry record delete は安全条件のもとで separate action として扱う。
|
||||||
|
- Runtime-observed/materialized Workdirs は trusted clean evidence なしに `clean` とせず、`unknown` として扱い、normal clean cleanup ではなく explicit discard confirmation path に乗せる。
|
||||||
|
- Browser UI に Runtime Workdirs cleanup preview/execution と Worker pin/unpin controls を追加した。
|
||||||
|
- UI では verified-clean cleanup と dirty/unknown-state discard を区別し、raw Runtime materialized path を表示しない。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は dirty Workdir safety の実データ経路が不十分として `request_changes`。
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs` で observed Workdir を `unknown` 扱いにし、unknown/dirty cleanup に explicit confirmation を要求するよう修正。
|
||||||
|
- focused re-review は `approve`。
|
||||||
|
- `estimated_reclaim_bytes: None` は safe size source が未実装のため許容と判断された。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
|
|
@ -78,4 +78,282 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T16:46:04Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: blocked_by_dependency_or_missing_authority
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket is a manual delete / cleanup follow-up that depends on the Backend Worker/Workdir registry and link authority from `00001KX6BPY7M`。
|
||||||
|
- `00001KX6BPY7M` is currently `inprogress` on branch `work/00001KX6BPY7M-worker-workdir-registry` and under external review。
|
||||||
|
- This Ticket touches the same store schema, server APIs, Worker/Workdir projections, retention/managed-unmanaged semantics, and cleanup safety rules。
|
||||||
|
- Starting this Ticket now would create a high-conflict parallel branch and could implement delete/cleanup behavior against an unreviewed registry model。
|
||||||
|
- Therefore this routing pass leaves the Ticket queued and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6CRVBE)`: 0 typed relations, but Ticket body/order context makes `00001KX6BPY7M` a practical implementation prerequisite。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6CRVBE)`: prior record 0 件だったため、今回 `after 00001KX6BPY7M` と waiting-capacity note を記録。
|
||||||
|
- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KX6BPY7M` 1件。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- `00001KX6BPY7M` の implementation/review/merge outcome を待つ。
|
||||||
|
- registry/link model が merge されたら、この Ticket を再 routing し、updated orchestration branch から start する。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- 人間が `00001KX6BPY7M` とこの Ticket を同一 combined worktree で統合実装する方針に切り替えたい場合。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T17:39:00Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: accept for implementation
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Previously recorded prerequisite `00001KX6BPY7M` has now been reviewed, merged into orchestration branch, final-validated, closed, and its child implementation worktree/branch cleaned up。
|
||||||
|
- `TicketRelationQuery(00001KX6CRVBE)` shows no typed blockers。
|
||||||
|
- Queue contains this Ticket and there are no other `inprogress` Tickets。
|
||||||
|
- Ticket is concrete and well scoped: manual Worker delete / Workdir cleanup plan and execution on top of Backend Worker/Workdir registry/link authority。
|
||||||
|
|
||||||
|
Accepted plan:
|
||||||
|
- Create dedicated implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup` on branch `work/00001KX6CRVBE-manual-cleanup` from current orchestration branch。
|
||||||
|
- Route implementation to sibling Coder Pod。
|
||||||
|
- Route external review to sibling Reviewer Pod after Coder commit/validation。
|
||||||
|
- Orchestrator retains merge, final validation, Ticket close, and cleanup authority。
|
||||||
|
|
||||||
|
Design constraints for implementation:
|
||||||
|
- Cleanup/delete is manual-first only; no automatic prune policy。
|
||||||
|
- Worker lifecycle remains `running -> stopped -> delete`; do not introduce `archived` lifecycle state。
|
||||||
|
- `pinned` Worker/history is protected in plan generation and execution。
|
||||||
|
- Workdir file cleanup blocks when linked to running Workers。
|
||||||
|
- Dirty Workdir discard is explicit and separate from ordinary clean cleanup。
|
||||||
|
- Execution must reject stale plan revision/digest。
|
||||||
|
- Browser-facing responses/UI must not expose raw Runtime paths。
|
||||||
|
- Workdir/Worker canonical records should remain Backend/registry based and safe after execution。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T17:39:03Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Prerequisite registry/link model is now closed and merged. Accepting queued manual cleanup Ticket for implementation before creating worktree or spawning role Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:13:40Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6CRVBE-manual-cleanup` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup` on branch `work/00001KX6CRVBE-manual-cleanup`。
|
||||||
|
- Implementation commit: `ba44391acfdb8076c06ced9324b74d6cc63910ab feat: add manual worker workdir cleanup`。
|
||||||
|
- Orchestrator inspected the worktree status, branch log, commit stats, and `git diff --check 5f762a6c..HEAD`; worktree was clean and diff check passed。
|
||||||
|
|
||||||
|
Coder-reported implementation summary:
|
||||||
|
- Added Backend Worker retention APIs for pin/unpin registry rows; Worker list/detail carries `pinned` / `retention_state`; pin/unpin mutates Backend registry only and does not affect Runtime processes。
|
||||||
|
- Added manual Runtime cleanup plan API with separate Worker delete and Workdir cleanup/delete candidates, action kind, reason/blocking reason, linked ids, pinned state, cleanliness/file status, running-link state, and safe reclaim-byte placeholder。
|
||||||
|
- Added manual cleanup execution API requiring expected plan revision/digest; rejects stale plans, pinned Worker/history delete, running-linked Workdir cleanup, and dirty cleanup without explicit discard confirmation; allows safe removed/missing Workdir registry deletion。
|
||||||
|
- Added Browser UI for manual cleanup preview/execution on Runtime Workdirs page and Backend-only pin/unpin controls on Worker page。
|
||||||
|
- Added focused tests for plan generation, stale rejection, pinned protection, running-linked Workdir blocking, dirty confirmation, removed/missing record delete, and raw path redaction。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Route to external Reviewer Pod before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T18:19:11Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External review result: request_changes
|
||||||
|
|
||||||
|
Blocker:
|
||||||
|
- Dirty Workdir safety is not implemented against real Runtime/filesystem state。
|
||||||
|
- Cleanup plan decides `WorkdirDirtyDiscard` solely from `WorkdirRegistryRecord.cleanliness` (`crates/workspace-server/src/server.rs:1689-1695`)。
|
||||||
|
- Runtime observation sync always writes `cleanliness: "clean"` for observed Workdirs (`server.rs:4179-4199`) and there is no code outside tests that records `"dirty"` (`rg "cleanliness.*dirty|dirty.*cleanliness"` only finds plan/test use)。
|
||||||
|
- Execution then treats such targets as `WorkdirCleanCleanup` and calls Runtime cleanup without dirty confirmation (`server.rs:1851-1852`)。
|
||||||
|
- Runtime cleanup uses forced removal (`crates/worker-runtime/src/working_directory.rs:505-508`)。
|
||||||
|
- This violates acceptance items requiring safe dirty/clean status representation and explicit confirmation before dirty discard。
|
||||||
|
|
||||||
|
Test gap:
|
||||||
|
- The dirty confirmation test seeds a synthetic registry row with `cleanliness = "dirty"` (`server.rs:5436-5449`, `5551-5580`), so it does not catch the real path where Runtime/workdir sync overwrites or fails to detect dirty files。
|
||||||
|
|
||||||
|
Positive evidence:
|
||||||
|
- pin/unpin API/routes and retention projection exist (`server.rs:1544-1584`, `2032-2072`, `4008-4022`)。
|
||||||
|
- plan/execution endpoints include digest/revision stale checks and pinned/running-link blocking (`server.rs:1766-1840`)。
|
||||||
|
- UI preview/execution and pin controls are present without raw path fields。
|
||||||
|
|
||||||
|
Non-blocking note:
|
||||||
|
- `estimated_reclaim_bytes` is present but always `None`; confirm whether “when safe” requires actual byte estimation before approval。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:30:52Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up implementation progress report after request_changes:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6CRVBE-manual-cleanup` completed the dirty/unknown Workdir safety fix and committed `361569a69efcba929b59b9c48113add5499d5068 fix: require discard confirmation for unknown workdirs`。
|
||||||
|
- Orchestrator inspected the implementation worktree status, branch log, commit stats, and `git diff --check 5f762a6c..HEAD`; worktree was clean and diff check passed。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Runtime-observed/materialized Workdirs are no longer marked `clean` without trusted clean evidence。
|
||||||
|
- Observed Workdirs now sync as `cleanliness: "unknown"`。
|
||||||
|
- Cleanup plan downgrades stale `clean` registry rows to `unknown` when real Runtime observation exists。
|
||||||
|
- Present Workdirs with `cleanliness: "unknown"` or `dirty` are classified as discard-confirmation targets, not normal clean cleanup。
|
||||||
|
- Direct Workdir cleanup rejects unknown/dirty Workdirs and requires the cleanup execution API with explicit discard confirmation。
|
||||||
|
- Browser UI labels distinguish verified-clean cleanup from dirty/unknown-state discard。
|
||||||
|
- `estimated_reclaim_bytes` remains intentionally `None` unless a safe estimate is available。
|
||||||
|
|
||||||
|
Regression tests added/updated:
|
||||||
|
- Real observed Workdir without verified clean evidence is classified as `unknown` and `WorkdirDirtyDiscard`。
|
||||||
|
- Normal/direct cleanup is rejected for unknown Workdir state。
|
||||||
|
- Dirty/unknown discard requires explicit confirmation。
|
||||||
|
- Stale `clean` registry state is downgraded when Runtime observation exists。
|
||||||
|
- Existing path-safe cleanup test uses explicit cleanup execution confirmation。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused re-review on the dirty/unknown Workdir blocker and overall acceptance status before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T18:36:11Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Focused re-review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Dirty/unknown blocker is fixed。
|
||||||
|
- Runtime/materialized Workdirs now sync as `cleanliness: "unknown"` (`crates/workspace-server/src/server.rs:4208`; pending create also around `1400`)。
|
||||||
|
- Cleanup planning downgrades any live-observed non-dirty row to unknown before action selection (`server.rs:1689-1701`)。
|
||||||
|
- Unknown/dirty present Workdirs become `WorkdirDirtyDiscard` with explicit-confirmation reasons (`server.rs:1721-1725`)。
|
||||||
|
- Direct Workdir DELETE rejects that path before calling Runtime cleanup (`server.rs:1491-1509`)。
|
||||||
|
- Execution still checks expected revision + digest for staleness (`server.rs:1780-1788`), blocks pinned/running-linked Workdirs (`server.rs:1703-1707`, `1834-1839`), and requires `confirm_dirty_discard_target_ids` before forced Runtime cleanup for dirty/unknown targets (`server.rs:1842-1850`)。
|
||||||
|
- Normal clean cleanup is only reachable for `clean` candidates (`server.rs:1698-1699`, `1860-1861`)。
|
||||||
|
|
||||||
|
No observed regressions in reviewed acceptance points:
|
||||||
|
- Pinned Worker deletion remains blocked (`server.rs:1811-1816`)。
|
||||||
|
- Workdir record delete remains separate for removed/missing (`server.rs:1696-1697`, `1869-1879`)。
|
||||||
|
- Plan responses expose ids/status/reasons/links/retention but not raw paths (`server.rs:620-633`)。
|
||||||
|
- UI labels dirty/unknown discard distinctly from verified-clean cleanup with a separate confirm checkbox (`web/workspace/.../workdirs/+page.svelte`)。
|
||||||
|
- `estimated_reclaim_bytes: None` is acceptable here because no safe size source is implemented/exposed; the optional field is present and avoids raw-path traversal/leakage。
|
||||||
|
|
||||||
|
Validation performed by reviewer:
|
||||||
|
- `cargo test -p yoi-workspace-server cleanup --lib` passed。
|
||||||
|
- Targeted regression tests `observed_workdir_without_verified_clean_evidence_requires_discard_confirmation` and `stale_clean_registry_row_is_downgraded_by_real_observation_before_cleanup` passed。
|
||||||
|
- `git diff --check HEAD^..HEAD` passed。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:37:23Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation after the dirty/unknown Workdir safety fix。
|
||||||
|
- Merged branch `work/00001KX6CRVBE-manual-cleanup` into orchestration branch with merge commit `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Ticket record conflicts from child branch were resolved in favor of Orchestrator-authoritative Ticket records; implementation code changes were merged。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commits:
|
||||||
|
- `ba44391a feat: add manual worker workdir cleanup`
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T18:37:27Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T18:37:40Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T18:37:40Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Manual Worker/Workdir delete and cleanup operations を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend Worker registry row の pin/unpin API を追加し、Worker list/detail に `pinned` / `retention_state` を返すようにした。
|
||||||
|
- Runtime ごとの manual cleanup plan API を追加し、Backend Worker/Workdir/link registry と Runtime observation に基づいて Worker delete / Workdir cleanup/delete candidates を生成するようにした。
|
||||||
|
- Plan candidate は action kind、reason/blocking reason、linked Worker/Workdir ids、pinned state、cleanliness/file status、running-link status、safe な reclaim bytes placeholder を含む。
|
||||||
|
- Manual cleanup execution API は expected plan revision/digest を要求し、stale plan を拒否する。
|
||||||
|
- Pinned Worker/history delete、running-linked Workdir cleanup、dirty/unknown Workdir の confirmation なし discard を拒否する。
|
||||||
|
- Removed/missing Workdir registry record delete は安全条件のもとで separate action として扱う。
|
||||||
|
- Runtime-observed/materialized Workdirs は trusted clean evidence なしに `clean` とせず、`unknown` として扱い、normal clean cleanup ではなく explicit discard confirmation path に乗せる。
|
||||||
|
- Browser UI に Runtime Workdirs cleanup preview/execution と Worker pin/unpin controls を追加した。
|
||||||
|
- UI では verified-clean cleanup と dirty/unknown-state discard を区別し、raw Runtime materialized path を表示しない。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は dirty Workdir safety の実データ経路が不十分として `request_changes`。
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs` で observed Workdir を `unknown` 扱いにし、unknown/dirty cleanup に explicit confirmation を要求するよう修正。
|
||||||
|
- focused re-review は `approve`。
|
||||||
|
- `estimated_reclaim_bytes: None` は safe size source が未実装のため許容と判断された。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ pub struct WorkingDirectoryRequest {
|
||||||
pub materializer: MaterializerKind,
|
pub materializer: MaterializerKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
pub dirty_state_policy: DirtyStatePolicy,
|
||||||
|
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
||||||
|
/// Backend can create canonical registry rows before materialization.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backend_workdir_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -158,6 +162,10 @@ pub struct WorkingDirectorySummary {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_policy: Option<String>,
|
pub cleanup_policy: Option<String>,
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
|
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
|
||||||
|
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub management_kind: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1036,6 +1036,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ impl WorkingDirectory {
|
||||||
cleanup_target: Some(self.cleanup_target.clone()),
|
cleanup_target: Some(self.cleanup_target.clone()),
|
||||||
cleanup_policy: Some(self.cleanup_policy.clone()),
|
cleanup_policy: Some(self.cleanup_policy.clone()),
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
|
management_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -395,7 +396,10 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
&self,
|
&self,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
let working_directory_id = next_working_directory_id(&request.repository.id);
|
let working_directory_id = request
|
||||||
|
.backend_workdir_id
|
||||||
|
.clone()
|
||||||
|
.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,
|
working_directory_id,
|
||||||
request,
|
request,
|
||||||
|
|
@ -719,6 +723,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,10 @@ pub struct WorkerSummary {
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub last_seen_at: Option<String>,
|
pub last_seen_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retention_state: String,
|
||||||
pub implementation: WorkerImplementationSummary,
|
pub implementation: WorkerImplementationSummary,
|
||||||
pub capabilities: WorkerCapabilitySummary,
|
pub capabilities: WorkerCapabilitySummary,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -1191,6 +1195,8 @@ impl EmbeddedWorkerRuntime {
|
||||||
status: 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,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "embedded_worker_runtime".to_string(),
|
kind: "embedded_worker_runtime".to_string(),
|
||||||
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
||||||
|
|
@ -1226,6 +1232,8 @@ impl EmbeddedWorkerRuntime {
|
||||||
status: 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,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "embedded_worker_runtime".to_string(),
|
kind: "embedded_worker_runtime".to_string(),
|
||||||
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
display_hint: "backend-internal worker-runtime Worker".to_string(),
|
||||||
|
|
@ -1927,6 +1935,8 @@ impl RemoteWorkerRuntime {
|
||||||
status: 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,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "remote_worker_runtime".to_string(),
|
kind: "remote_worker_runtime".to_string(),
|
||||||
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
||||||
|
|
@ -1961,6 +1971,8 @@ impl RemoteWorkerRuntime {
|
||||||
status: 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,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "remote_worker_runtime".to_string(),
|
kind: "remote_worker_runtime".to_string(),
|
||||||
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
|
||||||
|
|
@ -3094,6 +3106,8 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
||||||
state: "unsupported".to_string(),
|
state: "unsupported".to_string(),
|
||||||
status: "Worker runtime control is not wired yet".to_string(),
|
status: "Worker runtime control is not wired yet".to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "placeholder".to_string(),
|
kind: "placeholder".to_string(),
|
||||||
display_hint: "unsupported".to_string(),
|
display_hint: "unsupported".to_string(),
|
||||||
|
|
@ -3420,6 +3434,8 @@ mod tests {
|
||||||
state: "running".to_string(),
|
state: "running".to_string(),
|
||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "transient".to_string(),
|
||||||
implementation: WorkerImplementationSummary {
|
implementation: WorkerImplementationSummary {
|
||||||
kind: "fixture".to_string(),
|
kind: "fixture".to_string(),
|
||||||
display_hint: "test fixture".to_string(),
|
display_hint: "test fixture".to_string(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "align legacy workspace bootstrap with schema v0",
|
name: "align legacy workspace bootstrap with schema v0",
|
||||||
apply: align_legacy_bootstrap_schema,
|
apply: align_legacy_bootstrap_schema,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 3,
|
||||||
|
name: "backend worker workdir registry schema",
|
||||||
|
apply: create_worker_workdir_registry_tables,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
|
|
@ -44,11 +49,113 @@ pub struct WorkspaceRecord {
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerRegistryRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
/// Backend-owned archival Worker id. In v0 it is derived from runtime_id + runtime_worker_id.
|
||||||
|
pub worker_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub runtime_worker_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
pub lifecycle_state: String,
|
||||||
|
/// Retention state is explicit so `pinned` can be represented before prune exists.
|
||||||
|
pub retention_state: String,
|
||||||
|
pub transcript_ref: Option<String>,
|
||||||
|
pub session_ref: Option<String>,
|
||||||
|
pub summary_ref: Option<String>,
|
||||||
|
pub diagnostics_ref: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkdirRegistryRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub workdir_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub selector: Option<String>,
|
||||||
|
pub resolved_commit: Option<String>,
|
||||||
|
pub materialization_status: String,
|
||||||
|
pub cleanliness: String,
|
||||||
|
/// `backend_managed` rows are authored by this Backend; `runtime_unmanaged` is for diagnostics only.
|
||||||
|
pub management_kind: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerWorkdirLinkRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub workdir_id: String,
|
||||||
|
pub role: String,
|
||||||
|
pub linked_at: String,
|
||||||
|
pub unlinked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ControlPlaneStore: Send + Sync {
|
pub trait ControlPlaneStore: Send + Sync {
|
||||||
async fn schema_version(&self) -> Result<i64>;
|
async fn schema_version(&self) -> Result<i64>;
|
||||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
||||||
|
|
||||||
|
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
|
||||||
|
fn get_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
|
fn get_worker_registry_by_runtime(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
|
fn list_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkerRegistryRecord>>;
|
||||||
|
fn update_worker_retention(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
retention_state: &str,
|
||||||
|
updated_at: &str,
|
||||||
|
) -> Result<bool>;
|
||||||
|
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool>;
|
||||||
|
|
||||||
|
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
|
||||||
|
fn get_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkdirRegistryRecord>>;
|
||||||
|
fn list_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>>;
|
||||||
|
fn list_managed_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>>;
|
||||||
|
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||||
|
|
||||||
|
fn upsert_worker_workdir_link(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
|
||||||
|
fn list_worker_workdir_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
|
fn list_workdir_worker_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -131,6 +238,421 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO worker_registry (
|
||||||
|
workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile,
|
||||||
|
lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref,
|
||||||
|
diagnostics_ref, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||||
|
ON CONFLICT(workspace_id, worker_id) DO UPDATE SET
|
||||||
|
runtime_id = excluded.runtime_id,
|
||||||
|
runtime_worker_id = excluded.runtime_worker_id,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
profile = excluded.profile,
|
||||||
|
lifecycle_state = excluded.lifecycle_state,
|
||||||
|
retention_state = CASE
|
||||||
|
WHEN worker_registry.retention_state = 'pinned' AND excluded.retention_state = 'normal'
|
||||||
|
THEN worker_registry.retention_state
|
||||||
|
ELSE excluded.retention_state
|
||||||
|
END,
|
||||||
|
transcript_ref = excluded.transcript_ref,
|
||||||
|
session_ref = excluded.session_ref,
|
||||||
|
summary_ref = excluded.summary_ref,
|
||||||
|
diagnostics_ref = excluded.diagnostics_ref,
|
||||||
|
updated_at = excluded.updated_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.worker_id,
|
||||||
|
record.runtime_id,
|
||||||
|
record.runtime_worker_id,
|
||||||
|
record.display_name,
|
||||||
|
record.profile,
|
||||||
|
record.lifecycle_state,
|
||||||
|
record.retention_state,
|
||||||
|
record.transcript_ref,
|
||||||
|
record.session_ref,
|
||||||
|
record.summary_ref,
|
||||||
|
record.diagnostics_ref,
|
||||||
|
record.created_at,
|
||||||
|
record.updated_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
worker_registry_select_sql("WHERE workspace_id = ?1 AND worker_id = ?2").as_str(),
|
||||||
|
params![workspace_id, worker_id],
|
||||||
|
read_worker_registry_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_worker_registry_by_runtime(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
worker_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3",
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
params![workspace_id, runtime_id, runtime_worker_id],
|
||||||
|
read_worker_registry_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkerRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = worker_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 ORDER BY updated_at DESC LIMIT ?2",
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(sql.as_str())?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![workspace_id, limit as i64],
|
||||||
|
read_worker_registry_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_worker_retention(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
retention_state: &str,
|
||||||
|
updated_at: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let changed = conn.execute(
|
||||||
|
r#"UPDATE worker_registry
|
||||||
|
SET retention_state = ?3, updated_at = ?4
|
||||||
|
WHERE workspace_id = ?1 AND worker_id = ?2"#,
|
||||||
|
params![workspace_id, worker_id, retention_state, updated_at],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let changed = conn.execute(
|
||||||
|
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2",
|
||||||
|
params![workspace_id, worker_id],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO workdir_registry (
|
||||||
|
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
|
||||||
|
materialization_status, cleanliness, management_kind, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||||
|
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
|
||||||
|
runtime_id = excluded.runtime_id,
|
||||||
|
repository_id = excluded.repository_id,
|
||||||
|
selector = excluded.selector,
|
||||||
|
resolved_commit = excluded.resolved_commit,
|
||||||
|
materialization_status = excluded.materialization_status,
|
||||||
|
cleanliness = excluded.cleanliness,
|
||||||
|
management_kind = excluded.management_kind,
|
||||||
|
updated_at = excluded.updated_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.workdir_id,
|
||||||
|
record.runtime_id,
|
||||||
|
record.repository_id,
|
||||||
|
record.selector,
|
||||||
|
record.resolved_commit,
|
||||||
|
record.materialization_status,
|
||||||
|
record.cleanliness,
|
||||||
|
record.management_kind,
|
||||||
|
record.created_at,
|
||||||
|
record.updated_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkdirRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
workdir_registry_select_sql("WHERE workspace_id = ?1 AND workdir_id = ?2").as_str(),
|
||||||
|
params![workspace_id, workdir_id],
|
||||||
|
read_workdir_registry_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = workdir_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 ORDER BY updated_at DESC LIMIT ?2",
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(sql.as_str())?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![workspace_id, limit as i64],
|
||||||
|
read_workdir_registry_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_managed_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = workdir_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 AND management_kind = 'backend_managed' ORDER BY updated_at DESC LIMIT ?2",
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(sql.as_str())?;
|
||||||
|
let rows = stmt.query_map(params![workspace_id, limit as i64], read_workdir_registry_record)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let changed = conn.execute(
|
||||||
|
"DELETE FROM workdir_registry WHERE workspace_id = ?1 AND workdir_id = ?2",
|
||||||
|
params![workspace_id, workdir_id],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_worker_workdir_link(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO worker_workdir_links (
|
||||||
|
workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
|
||||||
|
linked_at = excluded.linked_at,
|
||||||
|
unlinked_at = excluded.unlinked_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.worker_id,
|
||||||
|
record.workdir_id,
|
||||||
|
record.role,
|
||||||
|
record.linked_at,
|
||||||
|
record.unlinked_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_worker_workdir_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL
|
||||||
|
ORDER BY linked_at DESC"#,
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![workspace_id, worker_id],
|
||||||
|
read_worker_workdir_link_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_workdir_worker_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
||||||
|
ORDER BY linked_at DESC"#,
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![workspace_id, workdir_id],
|
||||||
|
read_worker_workdir_link_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_worker_workdir_link_record(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
|
||||||
|
Ok(WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
worker_id: row.get(1)?,
|
||||||
|
workdir_id: row.get(2)?,
|
||||||
|
role: row.get(3)?,
|
||||||
|
linked_at: row.get(4)?,
|
||||||
|
unlinked_at: row.get(5)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \
|
||||||
|
lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
|
||||||
|
created_at, updated_at FROM worker_registry {where_clause}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
|
||||||
|
Ok(WorkerRegistryRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
worker_id: row.get(1)?,
|
||||||
|
runtime_id: row.get(2)?,
|
||||||
|
runtime_worker_id: row.get(3)?,
|
||||||
|
display_name: row.get(4)?,
|
||||||
|
profile: row.get(5)?,
|
||||||
|
lifecycle_state: row.get(6)?,
|
||||||
|
retention_state: row.get(7)?,
|
||||||
|
transcript_ref: row.get(8)?,
|
||||||
|
session_ref: row.get(9)?,
|
||||||
|
summary_ref: row.get(10)?,
|
||||||
|
diagnostics_ref: row.get(11)?,
|
||||||
|
created_at: row.get(12)?,
|
||||||
|
updated_at: row.get(13)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_registry_select_sql(where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, \
|
||||||
|
materialization_status, cleanliness, management_kind, created_at, updated_at \
|
||||||
|
FROM workdir_registry {where_clause}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_workdir_registry_record(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<WorkdirRegistryRecord> {
|
||||||
|
Ok(WorkdirRegistryRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
workdir_id: row.get(1)?,
|
||||||
|
runtime_id: row.get(2)?,
|
||||||
|
repository_id: row.get(3)?,
|
||||||
|
selector: row.get(4)?,
|
||||||
|
resolved_commit: row.get(5)?,
|
||||||
|
materialization_status: row.get(6)?,
|
||||||
|
cleanliness: row.get(7)?,
|
||||||
|
management_kind: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
updated_at: row.get(10)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_registry (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
runtime_worker_id TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
profile TEXT,
|
||||||
|
lifecycle_state TEXT NOT NULL,
|
||||||
|
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, worker_id),
|
||||||
|
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workdir_registry (
|
||||||
|
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', 'missing', 'removed', 'failed')),
|
||||||
|
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
||||||
|
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, workdir_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_workdir_links (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
workdir_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
linked_at TEXT NOT NULL,
|
||||||
|
unlinked_at TEXT,
|
||||||
|
PRIMARY KEY (workspace_id, worker_id, workdir_id, role),
|
||||||
|
FOREIGN KEY (workspace_id, worker_id) REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
|
||||||
|
ON worker_registry(workspace_id, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
|
||||||
|
ON workdir_registry(workspace_id, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
|
||||||
|
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configure_sqlite(conn: &Connection) -> Result<()> {
|
fn configure_sqlite(conn: &Connection) -> Result<()> {
|
||||||
|
|
@ -488,7 +1010,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(), 2);
|
assert_eq!(store.schema_version().await.unwrap(), 3);
|
||||||
|
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
|
|
@ -500,7 +1022,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(), 2);
|
assert_eq!(reopened.schema_version().await.unwrap(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
|
|
@ -527,6 +1049,9 @@ mod tests {
|
||||||
"ticket_worker_links",
|
"ticket_worker_links",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"audit_events",
|
"audit_events",
|
||||||
|
"worker_registry",
|
||||||
|
"workdir_registry",
|
||||||
|
"worker_workdir_links",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
tables.contains(expected),
|
tables.contains(expected),
|
||||||
|
|
@ -688,7 +1213,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(), 2);
|
assert_eq!(store.schema_version().await.unwrap(), 3);
|
||||||
|
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
|
|
@ -773,6 +1298,115 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let db = temp.path().join("workspace.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
let workspace = WorkspaceRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
display_name: "Local Dev".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_workspace(&workspace).await.unwrap();
|
||||||
|
|
||||||
|
let worker = WorkerRegistryRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
worker_id: "embedded/browser-1".to_string(),
|
||||||
|
runtime_id: "embedded".to_string(),
|
||||||
|
runtime_worker_id: "browser-1".to_string(),
|
||||||
|
display_name: "Browser 1".to_string(),
|
||||||
|
profile: Some("builtin:companion".to_string()),
|
||||||
|
lifecycle_state: "idle".to_string(),
|
||||||
|
retention_state: "pinned".to_string(),
|
||||||
|
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()),
|
||||||
|
session_ref: None,
|
||||||
|
summary_ref: None,
|
||||||
|
diagnostics_ref: None,
|
||||||
|
created_at: "2".to_string(),
|
||||||
|
updated_at: "2".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_worker_registry(&worker).unwrap();
|
||||||
|
let mut runtime_sync_worker = worker.clone();
|
||||||
|
runtime_sync_worker.lifecycle_state = "running".to_string();
|
||||||
|
runtime_sync_worker.retention_state = "normal".to_string();
|
||||||
|
runtime_sync_worker.updated_at = "5".to_string();
|
||||||
|
store.upsert_worker_registry(&runtime_sync_worker).unwrap();
|
||||||
|
let mut expected_worker = worker.clone();
|
||||||
|
expected_worker.lifecycle_state = "running".to_string();
|
||||||
|
expected_worker.updated_at = "5".to_string();
|
||||||
|
|
||||||
|
let workdir = WorkdirRegistryRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
workdir_id: "backend-2-repo".to_string(),
|
||||||
|
runtime_id: "embedded".to_string(),
|
||||||
|
repository_id: "repo".to_string(),
|
||||||
|
selector: Some("develop".to_string()),
|
||||||
|
resolved_commit: Some("abcdef".to_string()),
|
||||||
|
materialization_status: "removed".to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
management_kind: "backend_managed".to_string(),
|
||||||
|
created_at: "2".to_string(),
|
||||||
|
updated_at: "3".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_workdir_registry(&workdir).unwrap();
|
||||||
|
let unmanaged_workdir = WorkdirRegistryRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
workdir_id: "runtime-direct".to_string(),
|
||||||
|
runtime_id: "embedded".to_string(),
|
||||||
|
repository_id: "repo".to_string(),
|
||||||
|
selector: Some("feature".to_string()),
|
||||||
|
resolved_commit: Some("123456".to_string()),
|
||||||
|
materialization_status: "present".to_string(),
|
||||||
|
cleanliness: "unknown".to_string(),
|
||||||
|
management_kind: "runtime_unmanaged".to_string(),
|
||||||
|
created_at: "3".to_string(),
|
||||||
|
updated_at: "4".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
|
||||||
|
|
||||||
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
worker_id: worker.worker_id.clone(),
|
||||||
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
|
role: "primary_cwd".to_string(),
|
||||||
|
linked_at: "4".to_string(),
|
||||||
|
unlinked_at: None,
|
||||||
|
};
|
||||||
|
store.upsert_worker_workdir_link(&link).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1")
|
||||||
|
.unwrap(),
|
||||||
|
Some(expected_worker.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workdir_registry("local-dev", "backend-2-repo")
|
||||||
|
.unwrap(),
|
||||||
|
Some(workdir.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.list_workdir_registry("local-dev", 10).unwrap(),
|
||||||
|
vec![unmanaged_workdir.clone(), workdir.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_managed_workdir_registry("local-dev", 10)
|
||||||
|
.unwrap(),
|
||||||
|
vec![workdir]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links("local-dev", "embedded/browser-1")
|
||||||
|
.unwrap(),
|
||||||
|
vec![link]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn table_names(conn: &Connection) -> BTreeSet<String> {
|
fn table_names(conn: &Connection) -> BTreeSet<String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export type Worker = {
|
||||||
workspace: { visibility: string; identity: string };
|
workspace: { visibility: string; identity: string };
|
||||||
state: string;
|
state: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
retention_state?: string;
|
||||||
last_seen_at?: string | null;
|
last_seen_at?: string | null;
|
||||||
implementation: { kind: string; display_hint: string };
|
implementation: { kind: string; display_hint: string };
|
||||||
capabilities: WorkerCapabilities;
|
capabilities: WorkerCapabilities;
|
||||||
|
|
@ -124,6 +126,7 @@ export type WorkingDirectorySummary = {
|
||||||
resolved_commit: string;
|
resolved_commit: string;
|
||||||
resolved_tree?: string | null;
|
resolved_tree?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
|
||||||
cleanup_policy: string;
|
cleanup_policy: string;
|
||||||
cleanup_target: {
|
cleanup_target: {
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|
@ -144,6 +147,70 @@ export type BrowserWorkingDirectoryListResponse = {
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CleanupTargetKind =
|
||||||
|
| "worker_delete"
|
||||||
|
| "workdir_clean_cleanup"
|
||||||
|
| "workdir_dirty_discard"
|
||||||
|
| "workdir_record_delete";
|
||||||
|
|
||||||
|
export type CleanupWorkerCandidate = {
|
||||||
|
target_id: string;
|
||||||
|
action: CleanupTargetKind;
|
||||||
|
worker_id: string;
|
||||||
|
runtime_worker_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
reason: string;
|
||||||
|
blocking_reason?: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
retention_state: string;
|
||||||
|
lifecycle_state: string;
|
||||||
|
linked_workdir_ids: string[];
|
||||||
|
running_linked: boolean;
|
||||||
|
estimated_reclaim_bytes?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CleanupWorkdirCandidate = {
|
||||||
|
target_id: string;
|
||||||
|
action: CleanupTargetKind;
|
||||||
|
workdir_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
reason: string;
|
||||||
|
blocking_reason?: string | null;
|
||||||
|
linked_worker_ids: string[];
|
||||||
|
linked_running_worker_ids: string[];
|
||||||
|
running_linked: boolean;
|
||||||
|
pinned_linked: boolean;
|
||||||
|
file_status: string;
|
||||||
|
cleanliness: string;
|
||||||
|
estimated_reclaim_bytes?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeCleanupPlanResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
generated_at: string;
|
||||||
|
revision: string;
|
||||||
|
digest: string;
|
||||||
|
workers: CleanupWorkerCandidate[];
|
||||||
|
workdirs: CleanupWorkdirCandidate[];
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeCleanupExecutionResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
executed_at: string;
|
||||||
|
results: {
|
||||||
|
target_id: string;
|
||||||
|
action: CleanupTargetKind;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}[];
|
||||||
|
plan_after: RuntimeCleanupPlanResponse;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserWorkerWorkingDirectorySelection = {
|
export type BrowserWorkerWorkingDirectorySelection = {
|
||||||
working_directory_id: string;
|
working_directory_id: string;
|
||||||
relative_cwd?: string | null;
|
relative_cwd?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WorkingDirectorySummary } from '$lib/workspace-sidebar/types';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
|
import type { CleanupWorkdirCandidate, 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 selectedWorkerCleanupTargets = $state(new Set<string>());
|
||||||
|
let confirmedDirtyTargets = $state(new Set<string>());
|
||||||
|
let cleanupStatus = $state<string | null>(null);
|
||||||
|
let cleanupBusy = $state(false);
|
||||||
|
let cleanupCandidates = $derived(data.cleanupPlan?.workdirs ?? []);
|
||||||
|
let workerCleanupCandidates = $derived(data.cleanupPlan?.workers ?? []);
|
||||||
let runtimeLabel = $derived(
|
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,
|
||||||
);
|
);
|
||||||
|
|
@ -14,6 +22,67 @@
|
||||||
function selectorLabel(workdir: WorkingDirectorySummary): string {
|
function selectorLabel(workdir: WorkingDirectorySummary): string {
|
||||||
return workdir.requested_selector ?? 'HEAD';
|
return workdir.requested_selector ?? 'HEAD';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupLabel(candidate: CleanupWorkdirCandidate): string {
|
||||||
|
if (candidate.action === 'workdir_dirty_discard') {
|
||||||
|
return candidate.cleanliness === 'dirty' ? 'Discard dirty workdir' : 'Discard unknown-state workdir';
|
||||||
|
}
|
||||||
|
if (candidate.action === 'workdir_record_delete') return 'Delete missing/removed record';
|
||||||
|
return 'Clean up verified-clean workdir';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(targetId: string): void {
|
||||||
|
const next = new Set(selectedCleanupTargets);
|
||||||
|
if (next.has(targetId)) next.delete(targetId);
|
||||||
|
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 {
|
||||||
|
const response = await fetch(
|
||||||
|
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(data.runtimeId)}/cleanup-executions`),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_plan_revision: data.cleanupPlan.revision,
|
||||||
|
expected_plan_digest: data.cleanupPlan.digest,
|
||||||
|
worker_target_ids: Array.from(selectedWorkerCleanupTargets),
|
||||||
|
workdir_target_ids: Array.from(selectedCleanupTargets),
|
||||||
|
confirm_dirty_discard_target_ids: Array.from(confirmedDirtyTargets),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.message ?? payload?.error ?? response.statusText);
|
||||||
|
cleanupStatus = `Executed ${payload?.results?.length ?? 0} cleanup action(s). Refresh to see the latest plan.`;
|
||||||
|
selectedCleanupTargets = new Set();
|
||||||
|
selectedWorkerCleanupTargets = new Set();
|
||||||
|
confirmedDirtyTargets = new Set();
|
||||||
|
} catch (error) {
|
||||||
|
cleanupStatus = error instanceof Error ? error.message : 'Cleanup failed';
|
||||||
|
} finally {
|
||||||
|
cleanupBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -37,6 +106,68 @@
|
||||||
{:else if data.workdirs.items.length === 0}
|
{:else if data.workdirs.items.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.lifecycle_state}; {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>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import type {
|
||||||
BrowserWorkingDirectoryListResponse,
|
BrowserWorkingDirectoryListResponse,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
Runtime,
|
Runtime,
|
||||||
|
RuntimeCleanupPlanResponse,
|
||||||
} from "$lib/workspace-sidebar/types";
|
} 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 }) => {
|
||||||
const runtimeId = params.runtimeId;
|
const runtimeId = params.runtimeId;
|
||||||
const [runtimes, workdirs] = await Promise.all([
|
const [runtimes, workdirs, cleanupPlan] = await Promise.all([
|
||||||
loadJson<ListResponse<Runtime>>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")),
|
loadJson<ListResponse<Runtime>>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")),
|
||||||
loadJson<BrowserWorkingDirectoryListResponse>(
|
loadJson<BrowserWorkingDirectoryListResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
|
|
@ -17,6 +18,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
loadJson<RuntimeCleanupPlanResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -26,5 +31,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
runtimesError: runtimes.error,
|
runtimesError: runtimes.error,
|
||||||
workdirs: workdirs.data,
|
workdirs: workdirs.data,
|
||||||
workdirsError: workdirs.error,
|
workdirsError: workdirs.error,
|
||||||
|
cleanupPlan: cleanupPlan.data,
|
||||||
|
cleanupPlanError: cleanupPlan.error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 type { Worker } from '$lib/workspace-sidebar/types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
let retentionStatus = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
|
||||||
|
retentionStatus = null;
|
||||||
|
const response = await fetch(
|
||||||
|
workspaceApiPath(
|
||||||
|
data.workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
|
||||||
|
),
|
||||||
|
{ method: pinned ? 'PUT' : 'DELETE' },
|
||||||
|
);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
retentionStatus = payload?.message ?? payload?.error ?? response.statusText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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} · ${worker.status}`;
|
||||||
|
|
@ -31,7 +52,8 @@
|
||||||
<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.</p>
|
<p>Workers running or persisted for this workspace. Pinning only 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>
|
||||||
|
|
@ -51,6 +73,7 @@
|
||||||
<th>Runtime</th>
|
<th>Runtime</th>
|
||||||
<th>Profile</th>
|
<th>Profile</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Retention</th>
|
||||||
<th>Workdir</th>
|
<th>Workdir</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -65,9 +88,13 @@
|
||||||
<td><code>{worker.runtime_id}</code></td>
|
<td><code>{worker.runtime_id}</code></td>
|
||||||
<td>{workerProfile(worker)}</td>
|
<td>{workerProfile(worker)}</td>
|
||||||
<td>{workerStatus(worker)}</td>
|
<td>{workerStatus(worker)}</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>
|
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
||||||
|
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
||||||
|
{worker.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user