Compare commits

...

60 Commits

Author SHA1 Message Date
da631029cb
feat: add explicit workspace init 2026-07-02 18:20:38 +09:00
5002be498f
ticket: plan workspace init command 2026-07-02 18:03:55 +09:00
a123db83ad
feat: install workspace backend config template 2026-07-02 01:38:47 +09:00
8b1e4b721f
ticket: close workspace backend config schema 2026-07-02 00:48:28 +09:00
5a8d18ec27
feat: add workspace backend config schema 2026-07-02 00:48:17 +09:00
52b445e6bc
ticket: define workspace backend config schema 2026-07-02 00:30:24 +09:00
9ae9b0a043
ui: filter worker console protocol noise 2026-07-01 23:32:39 +09:00
11d94d3866
merge: worker runtime orchestration 2026-06-29 11:53:52 +09:00
3a41581c5c
ticket: record old pod crate cleanup cleanup 2026-06-29 05:10:57 +09:00
0bdb55e658
ticket: close old pod crate cleanup 2026-06-29 05:10:33 +09:00
83d433bf7e
merge: old pod crate cleanup 2026-06-29 05:04:59 +09:00
9582a1761d
ticket: approve old pod crate cleanup 2026-06-29 05:04:52 +09:00
9b0fe97cd9
ticket: record e2e worker metadata root fix 2026-06-29 05:02:34 +09:00
fdd902d534
fix: align e2e worker metadata root 2026-06-29 05:01:56 +09:00
c8a5b8d53d
ticket: request e2e worker metadata root fix 2026-06-29 05:00:17 +09:00
95523dc66f
ticket: record old pod cleanup wording fix 2026-06-29 04:55:15 +09:00
c46e880b50
fix: finish worker wording cleanup 2026-06-29 04:54:33 +09:00
5e08d328d5
ticket: route old pod cleanup fixes 2026-06-29 04:51:41 +09:00
4f57beda7a
ticket: request old pod cleanup wording fixes 2026-06-29 04:51:07 +09:00
094a728b8a
ticket: start old pod crate cleanup review 2026-06-29 04:46:34 +09:00
a5be6b758c
ticket: record old pod crate cleanup implementation 2026-06-29 04:46:06 +09:00
17a9488a4a
refactor: remove old pod crates 2026-06-29 04:44:55 +09:00
1fa85fb89c
ticket: start old pod crate cleanup 2026-06-29 04:18:47 +09:00
0fd99075f0
ticket: accept old pod crate cleanup 2026-06-29 04:17:41 +09:00
9eda90067e
ticket: record embedded runtime fs-store cleanup 2026-06-29 04:16:57 +09:00
44be40451b
ticket: close embedded runtime fs-store 2026-06-29 04:16:35 +09:00
888e7b68e5
merge: embedded runtime fs-store 2026-06-29 04:15:25 +09:00
ac42a6477b
ticket: approve embedded runtime fs-store 2026-06-29 04:15:17 +09:00
24118a0f3c
ticket: start embedded runtime fs-store review 2026-06-29 04:06:13 +09:00
6871c19f39
ticket: record embedded runtime fs-store implementation 2026-06-29 04:05:51 +09:00
736b05c670
feat: persist embedded workspace runtime in fs store 2026-06-29 04:05:02 +09:00
6a448de618
ticket: start embedded runtime fs-store 2026-06-29 03:41:03 +09:00
e3ad8b6e08
ticket: accept embedded runtime fs-store 2026-06-29 03:40:26 +09:00
d65bc9395e
ticket: record runtime worker launch cleanup 2026-06-29 03:39:30 +09:00
88ef627b6b
ticket: close runtime worker launch unification 2026-06-29 03:39:07 +09:00
bdb339fab8
merge: runtime worker launch unification 2026-06-29 03:37:45 +09:00
b246cd97f7
ticket: approve runtime worker launch unification 2026-06-29 03:37:41 +09:00
9546542c15
ticket: record remote runtime projection fix 2026-06-29 03:35:17 +09:00
ba7f9d2ee8
workspace: respect remote execution projection 2026-06-29 03:34:16 +09:00
7f0255baf5
ticket: request remote runtime projection fix 2026-06-29 03:17:11 +09:00
4dfe2394c5
ticket: record runtime worker launch fix 2026-06-29 03:12:06 +09:00
c29d10b67b
runtime: persist execution binding projection 2026-06-29 03:11:16 +09:00
f340c6badc
ticket: request runtime worker launch changes 2026-06-29 02:51:17 +09:00
ea9f637747
ticket: start runtime worker launch review 2026-06-29 02:46:36 +09:00
c060f5fe50
ticket: record runtime worker launch implementation 2026-06-29 02:46:11 +09:00
14bb4934a6
runtime: unify worker creation path 2026-06-29 02:44:52 +09:00
eaf17ecb1b
ticket: block embedded runtime fs-store on launch unification 2026-06-29 02:12:45 +09:00
4df3585190
chore: ignore worktree directories 2026-06-29 02:12:29 +09:00
7d751abc0f
ticket: queue 00001KW76E8EG 2026-06-29 02:12:16 +09:00
df8ada7003
merge: sync orchestration before queue 00001KW76E8EG 2026-06-29 02:12:16 +09:00
300a032562
ticket: ready embedded runtime fs-store 2026-06-29 02:11:13 +09:00
b34bbf6482
ticket: block old pod crate cleanup on launch unification 2026-06-29 01:51:26 +09:00
445a40d5e7
ticket: queue 00001KW7835H0 2026-06-29 01:51:00 +09:00
539ee7ef72
merge: sync orchestration before queue 00001KW7835H0 2026-06-29 01:51:00 +09:00
c2941af88a
ticket: start runtime worker launch unification 2026-06-29 01:49:14 +09:00
9c8fb7dea6
ticket: accept runtime worker launch unification 2026-06-29 01:48:30 +09:00
262fc7f257
ticket: ready remove old pod crates 2026-06-29 01:48:14 +09:00
ecc876b6c1
ticket: queue 00001KW7726H9 2026-06-29 01:47:42 +09:00
91e5fb41b5
ticket: ready runtime worker startup path 2026-06-29 01:45:14 +09:00
0602efd3b7
ui: simplify worker console 2026-06-28 22:41:32 +09:00
107 changed files with 6050 additions and 1467 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
/target
/result
/.direnv
/.worktree
.worktree
*.local*
.env

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260628-171232-1","ticket_id":"00001KW76E8EG","kind":"blocked_by","related_ticket":"00001KW7726H9","note":"`00001KW76E8EG` depends on `00001KW7726H9`; embedded Runtime fs-store persistence should persist the canonical Runtime Worker creation model rather than an embedded-only bypass. `00001KW7726H9` is currently inprogress, so this Ticket remains queued and must not start a child worktree/Pod until that dependency is merged/validated/closed or an explicit replan changes the dependency.","author":"yoi-orchestrator","at":"2026-06-28T17:12:32Z"}
{"id":"orch-plan-20260628-184007-2","ticket_id":"00001KW76E8EG","kind":"accepted_plan","note":"`00001KW7726H9` is now closed after merge/validation, so this Ticket's dependency is resolved. `00001KW7835H0` remains queued and is related to this fs-store migration; run fs-store persistence first before old Pod crate removal.","accepted_plan":{"summary":"`00001KW7726H9` が closed になったため、Workspace Backend embedded Runtime を memory-backed から `worker-runtime` fs-store backed に切り替える。Runtime store root は user data 配下の workspace-id 別 local state とし、Browser-facing API には出さない。Backend restart 後に catalog / worker snapshot / transcript / ConfigBundle store が復元され、live execution handle は connected として偽装せず stale/unconnected diagnostic として扱う。","branch":"work/00001KW76E8EG-embedded-runtime-fs-store","worktree":"/home/hare/Projects/yoi/.worktree/00001KW76E8EG-embedded-runtime-fs-store","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server`, `crates/worker-runtime`, 必要に応じて `crates/worker`, root Cargo/package files の focused write scope を委譲する。reviewer Worker は read-only で fs-store feature enablement、ServerConfig store-root authority、restart restore behavior、ConfigBundle/transcript restore、stale execution diagnostic、Browser-facing non-leak、memory-backed production bypass absence を確認する。merge/validation/done/cleanup は Orchestrator が行う。現時点では `StopPod` は使用せず、cleanup は child worktree/branch のみ行う。"},"author":"yoi-orchestrator","at":"2026-06-28T18:40:07Z"}

View File

@ -0,0 +1,37 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KW76E8EG",
"kind": "depends_on",
"target": "00001KW55B33B",
"note": "fs-store persistence applies after embedded Runtime is connected to worker crate execution",
"author": "yoi ticket",
"at": "2026-06-28T13:25:27Z"
},
{
"ticket_id": "00001KW76E8EG",
"kind": "depends_on",
"target": "00001KW7726H9",
"note": "fs-store persistence should persist the canonical Runtime Worker creation model rather than an embedded-only bypass",
"author": "yoi ticket",
"at": "2026-06-28T13:36:24Z"
},
{
"ticket_id": "00001KW76E8EG",
"kind": "related",
"target": "00001KVZQHPNY",
"note": "Profile/config bundle sync state must persist in the Runtime store",
"author": "yoi ticket",
"at": "2026-06-28T13:25:27Z"
},
{
"ticket_id": "00001KW76E8EG",
"kind": "related",
"target": "00001KW55B33H",
"note": "Workspace Companion should reuse durable embedded Worker identity across backend restart",
"author": "yoi ticket",
"at": "2026-06-28T13:25:27Z"
}
]
}

View File

@ -0,0 +1,220 @@
---
title: 'Workspace Backendのembedded Runtimeをfs-store永続化に切り替える'
state: 'closed'
created_at: '2026-06-28T13:34:29Z'
updated_at: '2026-06-28T19:16:51Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-28T17:12:16Z'
---
## 背景
Workspace Backend の embedded Runtime は現在、`EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)` で memory-backed Runtime として起動している。そのため Backend を再起動すると Runtime catalog、Worker snapshot、transcript、ConfigBundle store が失われる。
`worker-runtime` 側には既に fs-store backed Runtime が存在する。
- `Runtime::with_fs_store(...)`
- `Runtime::with_fs_store_and_execution_backend(...)`
- `FsRuntimeStoreOptions { root, runtime_id, display_name, limits }`
一方で Workspace Backend 側の embedded wrapper には fs-store backed constructor が無く、`WorkspaceApi::new_with_execution_backend(...)` は memory-backed constructor を呼んでいる。
この Ticket では、Workspace Backend embedded Runtime を `worker-runtime` fs-store に接続し、Backend restart 後も Runtime record と transcript projection が残るようにする。
## 依存関係
- `00001KW7726H9 Runtime Worker起動経路を正規のExecution/ConfigBundle経路に一本化する` の設計に従う。
- この Ticket は fs-store 接続と restart persistence を扱う。canonical create request の field 設計や旧 Pod store 削除はこの Ticket の主対象にしない。
## 調査済みの現状
### Workspace Backend 側
- `crates/workspace-server/src/hosts.rs`
- `WorkspaceApi::new_with_execution_backend(...)``EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)` を呼んでいる。
- embedded spawn path は `WorkerSpawnRequest` を Runtime `CreateWorkerRequest` に変換している。
- `crates/workspace-server/src/server.rs`
- `ServerConfig``workspace_root`、`database_path`、host URLs などを持つ。
- Runtime fs-store root はまだ持っていない。
- `crates/workspace-server/src/main.rs`
- `WorkspaceIdentity::load_or_init(...)` で workspace_id は取得済み。
- 現状の `ServerConfig::local_dev(...)` には Runtime store root が渡されていない。
- `crates/workspace-server/Cargo.toml`
- `worker-runtime` dependency は `features = ["ws-server"]`
- fs-store を使うには `fs-store` feature も有効化する必要がある。
### worker-runtime 側
- `crates/worker-runtime/src/runtime.rs`
- `Runtime::with_fs_store_and_execution_backend(options, backend)` が存在する。
- persisted state restore 時、Worker の live execution handle は復元せず `WorkerExecutionStatus::unconnected()` / `execution_handle: None` になる。
- `crates/worker-runtime/src/fs_store.rs`
- store root 配下に runtime_id 別の directory を作る。
- runtime snapshot、worker snapshot、events、transcript entry を保存する。
- `crates/worker-runtime/src/config_bundle.rs`
- `ConfigBundle``metadata.id``metadata.digest` を持つ。
- fs-store backed Runtime の persisted runtime state に ConfigBundle store も含まれる。
## 目的
- Workspace Backend embedded Runtime を memory-backed から fs-store backed に切り替える。
- Backend restart 後に Runtime catalog / Worker snapshot / transcript / ConfigBundle store が復元される。
- live execution handle は再起動で復元できないものとして扱い、connected Worker のように見せない。
- Runtime store root は Browser-facing API に出さない。
- `.yoi/tickets` など project record と Runtime local state を混ぜない。
## 実装方針
### 1. workspace-server で worker-runtime fs-store feature を有効化する
`crates/workspace-server/Cargo.toml``worker-runtime` dependency に `fs-store` feature を追加する。
```toml
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
```
これにより `Runtime::with_fs_store_and_execution_backend(...)``FsRuntimeStoreOptions` を Workspace Backend から使えるようにする。
### 2. Runtime store root を ServerConfig に持たせる
`ServerConfig` に embedded Runtime 用の store root を追加する。
候補名:
```rust
pub embedded_runtime_store_root: PathBuf
```
決定事項:
- store root は Backend local state であり、Browser-facing API に出さない。
- store root は `.yoi/tickets` 配下に置かない。
- tests は temp dir を明示的に渡す。
- CLI/default path は `WorkspaceIdentity.workspace_id` を使って workspace ごとに衝突しない path にする。
推奨 default:
```text
<YOI_DATA_DIR or YOI_HOME or ~/.yoi>/workspace-server/<workspace_id>/worker-runtime
```
`workspace-server` crate が `manifest::paths::data_dir()` を使えない場合は、実装時に `manifest` dependency を追加するか、`main.rs` で default を解決して `ServerConfig` に注入する。workspace root 直下の git-tracked project record と混ぜないことを優先する。
### 3. EmbeddedWorkerRuntime に fs-store constructor を追加する
`EmbeddedWorkerRuntime` に fs-store backed constructor を追加する。
候補名:
```rust
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(...)
```
この constructor は内部で次を呼ぶ。
```rust
Runtime::with_fs_store_and_execution_backend(
FsRuntimeStoreOptions {
root: embedded_runtime_store_root,
runtime_id: embedded_runtime_id,
display_name,
limits,
},
execution_backend,
)
```
runtime_id は embedded Runtime として安定した値にする。
- 既存の `EmbeddedWorkerRuntime::runtime_id()` / embedded Runtime id 方針がある場合はそれに従う。
- workspace ごとの identity は store root 側で分けるため、runtime_id に raw workspace path を入れない。
- runtime_id が変わると既存 fs-store を復元できないため、restart 間で安定させる。
### 4. WorkspaceApi の constructor を fs-store backed に切り替える
`WorkspaceApi::new_with_execution_backend(...)` は memory constructor ではなく、fs-store constructor を呼ぶ。
変更前の趣旨:
```rust
EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)
```
変更後の趣旨:
```rust
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(...)
```
この変更により、Workspace Backend 経由の embedded Runtime catalog / worker snapshot / transcript / ConfigBundle が fs-store に保存される。
### 5. restart 後の execution status を明示する
`worker-runtime` の restore は現状、Worker の live execution handle を復元せず `WorkerExecutionStatus::unconnected()` にする。これは正しい前提として扱う。
この Ticket では次を確認・必要に応じて補強する。
- Backend restart 後の Worker は catalog / transcript 上は復元される。
- live execution handle は復元済みのように見せない。
- input-capable として扱えない場合は `unconnected` / stale execution mapping / typed diagnostic として表示・API 応答される。
- restart 後に同じ WorkerId を使って fake response を生成しない。
### 6. ConfigBundle store の復元を確認する
fs-store backed Runtime は persisted runtime state に ConfigBundle store を持つ。Workspace Backend 側で embedded Runtime を fs-store に切り替えた後、Backend restart で ConfigBundle が失われないことを test で確認する。
この Ticket では ConfigBundle create API の再設計は行わない。`00001KW7726H9` の canonical create 設計と矛盾しないよう、現行 store の復元だけを確認する。
## Test plan
Focused tests を `yoi-workspace-server` 側に追加する。
### embedded Runtime fs-store restore
1. temp dir を store root として `WorkspaceApi` / embedded Runtime を起動する。
2. Runtime に ConfigBundle を store する。
3. Worker を作成し、transcript に entry を追加する。
4. WorkspaceApi / Runtime を drop する。
5. 同じ store root で WorkspaceApi / Runtime を再作成する。
6. Runtime catalog に同じ Worker が復元されることを確認する。
7. transcript entry が復元されることを確認する。
8. ConfigBundle が復元されることを確認する。
9. execution status が live connected として偽装されていないことを確認する。
### memory-backed bypass absence
- Workspace Backend の production constructor が `new_memory_with_execution_backend(...)` を呼ばないことを test または grep-resistant な unit test で確認する。
- memory-backed Runtime は test helper / explicit in-memory constructor に限定する。
### store root isolation
- store root が `ServerConfig` から注入されることを確認する。
- Browser-facing API response に store root が出ないことを確認する。
- `.yoi/tickets` 配下を Runtime fs-store root にしないことを確認する。
## 受け入れ条件
- `crates/workspace-server``worker-runtime``fs-store` feature を有効化している。
- `ServerConfig` が embedded Runtime store root を持ち、Workspace Backend 起動時に明示的に渡される。
- CLI/default path が user data 配下の workspace_id 別 Runtime store root を使う。
- `EmbeddedWorkerRuntime` に fs-store backed constructor が追加されている。
- Workspace Backend の通常 constructor が `EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)` を呼ばない。
- embedded Runtime が `Runtime::with_fs_store_and_execution_backend(...)` を通って起動する。
- Backend restart 後に Runtime catalog / Worker snapshot / transcript / ConfigBundle store が復元される。
- restart 後の live execution handle は復元済みとして扱われず、unconnected / stale diagnostic として扱われる。
- Browser-facing API に Runtime store root / raw path / execution handle が漏れない。
- Focused tests が embedded fs-store restore、ConfigBundle restore、transcript restore、execution unconnected restore、memory-backed bypass absence を確認する。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
- `cargo check -p yoi` が通る。
- `git diff --check` が通る。
- `nix build .#yoi --no-link` が通る。
## 対象外
- canonical Runtime Worker create request の field 確定。
- per-worker cwd / tool scope の新 API。
- remote Runtime execution provisioning。
- live execution handle の restart 後自動再接続。
- 旧 Pod store / pod-registry の削除。

View File

@ -0,0 +1,27 @@
Workspace Backend embedded Runtime を memory-backed から fs-store backed `worker-runtime` へ切り替え、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `workspace-server``worker-runtime``fs-store` feature を有効化。
- `ServerConfig``embedded_runtime_store_root` を追加。
- default store root を user data 配下の `workspace-server/<workspace_id>/embedded-runtime` に設定し、project records / `.yoi/tickets` と混ぜない設計にした。
- 通常の `WorkspaceApi::new` / `new_with_execution_backend` 経路を fs-store backed `EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(...)` に切り替え。
- `Runtime::with_fs_store_and_execution_backend(...)` を使う constructor を追加。
- Backend restart 後に Runtime catalog / Worker snapshot / transcript / ConfigBundle store が復元されることを focused tests で確認。
- restart 後の live execution handle は connected として偽装せず、stale/unconnected diagnostic/projection として扱う。
- Browser-facing API に Runtime store root / raw path / execution handle を出さないことを確認。
- production path が memory-backed runtime に戻らないよう通常 constructor を fs-store backed にした。
Integrated commit:
- `736b05c6 feat: persist embedded workspace runtime in fs store`
- merge: `888e7b68 merge: embedded runtime fs-store`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success (`38 passed`)
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.

View File

@ -0,0 +1,371 @@
<!-- event: create author: "yoi ticket" at: 2026-06-28T13:24:27Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: plan author: hare at: 2026-06-28T17:01:47Z -->
## Plan
Workspace Backend embedded Runtime の fs-store 永続化について詳細化した。
調査済み事実:
- `worker-runtime` には `Runtime::with_fs_store_and_execution_backend(...)``FsRuntimeStoreOptions` が存在する。
- `workspace-server` は現在 `worker-runtime``ws-server` feature のみで使っており、`fs-store` feature が有効ではない。
- `WorkspaceApi::new_with_execution_backend(...)``EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)` を呼んでいる。
- `ServerConfig` は Runtime store root を持っていない。
- `worker-runtime` の restore は live execution handle を復元せず `WorkerExecutionStatus::unconnected()` / `execution_handle: None` にする。
実装方針:
- `workspace-server``worker-runtime``fs-store` feature を有効化する。
- `ServerConfig` に embedded Runtime store root を追加する。
- CLI/default path は user data 配下の workspace_id 別 path にする。
- `EmbeddedWorkerRuntime` に fs-store backed constructor を追加し、`Runtime::with_fs_store_and_execution_backend(...)` を通す。
- Workspace Backend の通常 constructor から memory-backed Runtime を使う経路を外す。
- restart 後は catalog / transcript / ConfigBundle を復元し、live execution handle は connected として偽装しない。
---
<!-- event: intake_summary author: hare at: 2026-06-28T17:11:08Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-28T17:11:08Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-28T17:12:16Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T17:12:39Z -->
## Decision
Routing decision:
Workspace Dashboard Queue authorized Orchestrator routing, so the Ticket and current workspace state were inspected.
Findings:
- Ticket state: `queued`
- Outgoing dependency: `depends_on 00001KW7726H9`
- Blocking Ticket `00001KW7726H9` is currently `inprogress`.
- Current inprogress Tickets: `00001KW7726H9`
- Orchestration worktree: clean
Decision:
- Leave `00001KW76E8EG` queued and do not create a child worktree / role Pod yet.
- Reason: embedded Runtime fs-store persistence should persist the canonical Runtime Worker creation model, not an embedded-only bypass. That canonical creation path is currently being implemented in `00001KW7726H9`.
Next routing condition:
- Re-evaluate this Ticket after `00001KW7726H9` is merged, validated, and closed, or after an explicit replan changes the dependency.
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T18:40:18Z -->
## Decision
Routing decision:
`00001KW7726H9` has been merged, validated, closed, and cleaned up, so this Ticket's dependency is now resolved. Current workspace state was inspected before implementation side effects.
Findings:
- Ticket state: `queued`
- Dependencies on `00001KW55B33B` and `00001KW7726H9` are resolved/closed.
- Current `inprogress` Tickets: 0
- Orchestration worktree: clean
- `00001KW7835H0` remains queued and is related to this fs-store migration. Run embedded Runtime fs-store persistence first, then re-evaluate old Pod crate removal.
Decision:
- Accept this Ticket for implementation and transition `queued -> inprogress` before child worktree / Pod side effects.
Plan:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KW76E8EG-embedded-runtime-fs-store`
- Branch: `work/00001KW76E8EG-embedded-runtime-fs-store`
- Coder Worker will implement the Workspace Backend embedded Runtime fs-store switch and focused restart/persistence tests.
- Reviewer Worker will verify fs-store feature enablement, ServerConfig store-root authority, restart restore behavior, ConfigBundle/transcript restore, stale execution diagnostic, Browser-facing non-leak, and production memory-backed bypass absence.
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child worktree/branch unless explicitly reauthorized.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T18:40:23Z from: queued to: inprogress reason: dependencies_closed_and_queue_continues field: state -->
## State changed
Dependencies `00001KW55B33B` and `00001KW7726H9` are closed, no Ticket is currently inprogress, and the orchestration worktree is clean. Accepting implementation before child worktree / role Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T18:40:58Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW76E8EG-embedded-runtime-fs-store`
- Branch: `work/00001KW76E8EG-embedded-runtime-fs-store`
- Coder Worker spawned: `yoi-coder-00001KW76E8EG-fs-store`
Scope notes:
- Coder write scope is limited to `crates/workspace-server/**`, `crates/worker-runtime/**`, `crates/worker/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Implementation focus:
- enable `worker-runtime` `fs-store` feature in `workspace-server`
- add `ServerConfig` embedded Runtime store root
- default store root under user data / workspace id, not under project records or `.yoi/tickets`
- add fs-store backed `EmbeddedWorkerRuntime` constructor using `Runtime::with_fs_store_and_execution_backend`
- normal WorkspaceApi constructor uses fs-store, not `new_memory_with_execution_backend`
- restart restores Runtime catalog / Worker snapshot / transcript / ConfigBundle store
- live execution handle is not falsely connected after restart; stale/unconnected diagnostic/projection remains explicit
- Browser-facing APIs do not expose store root / raw path / execution handle
- tests cover fs-store restore, ConfigBundle/transcript restore, execution stale/unconnected restore, memory-backed production bypass absence, and store-root non-leak
---
<!-- event: implementation_report author: yoi-coder-00001KW76E8EG-fs-store at: 2026-06-28T19:05:47Z -->
## Implementation report
Implementation report:
実装・検証・commit 完了。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `736b05c6 feat: persist embedded workspace runtime in fs store`
変更概要:
- `workspace-server``worker-runtime``fs-store` feature を有効化。
- `ServerConfig``embedded_runtime_store_root` を追加し、既定値を user data 配下の `workspace-server/<workspace_id>/embedded-runtime` に設定。
- 通常の `WorkspaceApi` 生成経路を `EmbeddedWorkerRuntime::new_memory_with_execution_backend` から `new_fs_store_with_execution_backend` に切り替え。
- `Runtime::with_fs_store_and_execution_backend` を使う fs-store backed constructor を追加。
- 再起動後の Worker は live handle 接続済みと偽らず、`stale` / `unconnected` の診断を Browser API projection に残すようにした。
- store root / raw path / handle が Browser API に出ないことをテスト。
- `package.nix``cargoHash` を更新。
追加・更新テスト:
- fs-store restart restore:
- catalog / Worker snapshot
- ConfigBundle
- transcript
- stale execution projection / input rejection
- store root isolation / non-leak
- 既存 workspace-server tests は明示的 temp store root を使うよう調整。
Validation:
- `cargo fmt`: success
- `cargo fmt -- --check`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi`: success
作業後状態:
- branch: `work/00001KW76E8EG-embedded-runtime-fs-store`
- `git status --short`: clean
Residual risks:
- 特記すべき未解決リスクなし。再起動後の実行 handle は復元せず stale/unconnected として扱う設計どおり。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:06:09Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned for `00001KW76E8EG` implementation.
- Reviewer Worker: `yoi-reviewer-00001KW76E8EG-fs-store`
- Review target commit: `736b05c6 feat: persist embedded workspace runtime in fs store`
- Review mode: read-only implementation worktree review
Review focus:
- `workspace-server` enables `worker-runtime` `fs-store` feature
- `ServerConfig` has authoritative embedded Runtime store root defaulting under user data / workspace id, not project records
- normal `WorkspaceApi` uses fs-store backed `EmbeddedWorkerRuntime`, not memory-backed runtime
- restart restores catalog / Worker snapshot / ConfigBundle / transcript
- execution handle after restart is stale/unconnected diagnostic, not falsely connected
- Browser APIs do not leak store root / raw path / execution handle
- production path cannot accidentally use memory-backed runtime
- focused tests cover these behaviors
---
<!-- event: review author: yoi-reviewer-00001KW76E8EG-fs-store at: 2026-06-28T19:15:12Z status: approve -->
## Review: approve
approve
Ticket `00001KW76E8EG` の意図・受け入れ条件に対して、実装は妥当です。ブロッカーは見つかりませんでした。
## 確認した主な根拠
- `workspace-server``worker-runtime``fs-store` feature を有効化していることを確認。
- `crates/workspace-server/Cargo.toml`
- `ServerConfig``embedded_runtime_store_root` が追加され、通常の `local_dev` 設定では project records / `.yoi/tickets` ではなく、ユーザーデータ配下の workspace id ベースの領域を既定 root にしていることを確認。
- `crates/workspace-server/src/server.rs`
- 通常の `WorkspaceApi::new` / `new_with_execution_backend` 経路が `EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(...)` を使い、memory store ではなく fs-store backed runtime を構築していることを確認。
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/hosts.rs`
- `EmbeddedWorkerRuntime` 側で `EmbeddedRuntimeStore::fs_store(store_root)` + `WorkerRuntime::with_options(..., RuntimeOptions::ws_server())` を使う経路が追加されていることを確認。
- restart 後の復元について、テストで以下が確認されていることを確認。
- catalog / worker snapshot が復元される
- `ConfigBundle` が復元される
- transcript が復元される
- restart 後の execution handle は接続済み扱いにならず、stale/unconnected diagnostic となる
- restart 後に input を送っても接続済み実行 handle として扱われない
- Browser/API projection について、serialized response に store root/raw path/handle などが混入しないことを確認するテストが追加されていることを確認。
- production 経路については、通常 `WorkspaceApi` の生成経路が fs-store を使っており、memory-backed runtime に戻る変更は見当たりませんでした。memory constructor は低レベル/テスト用途として残っていますが、通常 server path からは使われていません。
## 実行した検証
read-only 方針に合わせ、worktree 内のソース/Ticket は変更せず、`CARGO_TARGET_DIR` を `/run/user/.../target-review` に逃がして検証しました。
- `git diff --check HEAD^ HEAD`: success
- `cargo fmt --all -- --check`: success
- `CARGO_TARGET_DIR=/run/user/1000/yoi/yoi-reviewer-00001KW76E8EG-fs-store/target-review cargo test -p yoi-workspace-server`: success
- `CARGO_TARGET_DIR=/run/user/1000/yoi/yoi-reviewer-00001KW76E8EG-fs-store/target-review cargo test -p worker-runtime --features ws-server,fs-store`: success
- `CARGO_TARGET_DIR=/run/user/1000/yoi/yoi-reviewer-00001KW76E8EG-fs-store/target-review cargo check -p yoi`: success
検証後の `git status --short` も clean でした。
## 残リスク
- `nix build .#yoi` は reviewer 側では再実行していません。ただし `Cargo.lock` / `package.nix` の更新と Rust 側の check/test は整合しており、報告されているビルド系コマンドは十分 plausible と判断します。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:16:09Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commit:
- `736b05c6 feat: persist embedded workspace runtime in fs store`
Merge commit:
- `888e7b68 merge: embedded runtime fs-store`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success (`38 passed`)
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success (`26 passed` + rest server tests)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Outcome:
- Workspace Backend embedded Runtime now uses fs-store backed `worker-runtime` for normal `WorkspaceApi` construction.
- `ServerConfig` carries `embedded_runtime_store_root`, with default user-data/workspace-id local state path rather than project records / `.yoi/tickets`.
- Restart restores catalog / Worker snapshot / ConfigBundle / transcript.
- Restart does not falsely restore live execution handles as connected; stale/unconnected diagnostic/projection remains explicit.
- Browser-facing APIs do not expose Runtime store root / raw path / execution handle.
- Production path no longer uses `new_memory_with_execution_backend`; memory constructor remains for explicit low-level/test use only.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T19:16:18Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success (`38 passed`)
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-28T19:16:31Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-28T19:16:31Z status: closed -->
## 完了
Workspace Backend embedded Runtime を memory-backed から fs-store backed `worker-runtime` へ切り替え、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `workspace-server``worker-runtime``fs-store` feature を有効化。
- `ServerConfig``embedded_runtime_store_root` を追加。
- default store root を user data 配下の `workspace-server/<workspace_id>/embedded-runtime` に設定し、project records / `.yoi/tickets` と混ぜない設計にした。
- 通常の `WorkspaceApi::new` / `new_with_execution_backend` 経路を fs-store backed `EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(...)` に切り替え。
- `Runtime::with_fs_store_and_execution_backend(...)` を使う constructor を追加。
- Backend restart 後に Runtime catalog / Worker snapshot / transcript / ConfigBundle store が復元されることを focused tests で確認。
- restart 後の live execution handle は connected として偽装せず、stale/unconnected diagnostic/projection として扱う。
- Browser-facing API に Runtime store root / raw path / execution handle を出さないことを確認。
- production path が memory-backed runtime に戻らないよう通常 constructor を fs-store backed にした。
Integrated commit:
- `736b05c6 feat: persist embedded workspace runtime in fs store`
- merge: `888e7b68 merge: embedded runtime fs-store`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success (`38 passed`)
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:16:51Z -->
## Implementation report
Cleanup report:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Role Pod shutdown was intentionally skipped.
- Child implementation worktree was removed:
- `/home/hare/Projects/yoi/.worktree/00001KW76E8EG-embedded-runtime-fs-store`
- Child implementation branch was removed:
- `work/00001KW76E8EG-embedded-runtime-fs-store`
- No root/original workspace cleanup was performed.
- Orchestration worktree is clean after worktree/branch cleanup.
Routing note:
- `00001KW7835H0` can now be re-evaluated because its related/dependency prerequisites in this queue chain are complete.
---

View File

@ -0,0 +1,225 @@
# Runtime Worker creation API field audit
調査日: 2026-06-28
対象 Ticket: `00001KW7726H9`
## 方針
未検証の field を API 案として固定しない。以下は現在の実装に存在する型と field を確認し、canonical Runtime Worker creation に残せるかを判定したもの。
参照した主な実装:
- `crates/worker-runtime/src/catalog.rs`
- `crates/worker-runtime/src/runtime.rs`
- `crates/worker-runtime/src/config_bundle.rs`
- `crates/worker-runtime/src/execution.rs`
- `crates/worker-runtime/src/fs_store.rs`
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/server.rs`
- `crates/worker/src/runtime_adapter.rs`
- `crates/protocol/src/lib.rs`
## 現在の Runtime `CreateWorkerRequest`
現在の型は `worker_runtime::catalog::CreateWorkerRequest`
```rust
pub struct CreateWorkerRequest {
pub intent: WorkerIntent,
pub profile: ProfileSelector,
pub config_bundle: Option<ConfigBundleRef>,
pub requested_capabilities: Vec<CapabilityRequest>,
pub workspace_refs: Vec<WorkspaceRef>,
pub mount_refs: Vec<MountRef>,
}
```
### `intent: WorkerIntent`
- source: Workspace Backend / direct Runtime HTTP caller。
- current use: Worker summary/detail projection、worker snapshot persistence、diagnostic label。`Task.objective` は non-empty validation のみ。
- scope/access: なし。execution backend の実行条件ではない。
- visibility: Runtime metadata と Browser-facing projection に出る。
- persistence/projection: `PersistedWorkerRecord.request``WorkerSnapshot.request` に保存される。
- retry: idempotency には使われていない。
- validation: `Task.objective` の空文字のみ。
- verdict: canonical Runtime create request からは外す。Workspace Companion / Ticket role / Objective などは Backend launch context と initial input / tools 側で扱う。Runtime projection に必要な表示情報があれば、別途検証した display/projection field として定義する。
### `profile: ProfileSelector`
- source: Workspace Backend。現状は Browser-facing `WorkerSpawnRequest.profile` からも来得る。
- current use: ConfigBundle compatibility check、`worker::runtime_adapter::ProfileRuntimeWorkerFactory::runtime_profile` が実 Worker profile resolution に使う。
- scope/access: なし。ただし実 Worker の Profile 選択に直結する。
- visibility: Runtime metadata と Browser-facing projection に出る。
- persistence/projection: `CreateWorkerRequest` ごと worker snapshot に保存される。
- retry: 同じ selector なら同じ意味になり得るが、Profile 内容の時点依存は ConfigBundle identity で固定する必要がある。
- validation: `ConfigBundle` がある場合は bundle 内に profile があるか確認。`Named` は synced bundle がないと拒否。
- verdict: Runtime create request に残す候補。ただし Browser が自由入力する field ではなく、Backend が launch context から決定する。ConfigBundle identity と組み合わせて Profile 内容を固定すること。
### `config_bundle: Option<ConfigBundleRef>`
- source: 現状は caller が `ConfigBundleRef { id, digest }` を渡す。Workspace Backend の `WorkerSpawnRequest` にも露出している。
- current use: Runtime が `config_bundles` store から id で lookup し、digest 一致と profile compatibility を検証する。
- scope/access: なし。secret 値や raw path は含まない。
- visibility: Runtime metadata と Browser-facing projection に出る。
- persistence/projection: request として worker snapshot に保存される。ConfigBundle 本体は runtime snapshot の `config_bundles` に保存される。
- retry: id+digest は stable。`LatestForProfile` のような時点依存 selector は存在しないし入れない。
- validation: id / digest の形式、store presence、digest mismatch、profile containment。
- verdict: Runtime create request に残すなら、Backend が直前に `SyncConfigBundle` / availability check で得た id+digest だけを渡す形に限定する。Browser-facing launch request からは外す。`Option` のまま runtime default に落ちる経路は canonical path として再検証が必要。
### `requested_capabilities: Vec<CapabilityRequest>`
- source: 現状は Browser-facing `WorkerSpawnRequest.requested_capabilities` からも来得る。embedded spawn は空の場合に `read` を補う。
- current use: Runtime summary/detail の count/projection、request persistence。execution backend / Worker factory はこの field を実行制限として使っていない。
- scope/access: 現時点では access control ではない。validation は capability name non-empty のみ。
- visibility: Runtime detail に出る。
- persistence/projection: request として worker snapshot に保存される。
- retry: idempotency には使われていない。
- validation: name non-empty のみ。
- verdict: canonical Runtime create request からは外す。必要な declaration は ConfigBundle、実際の制限は既存 execution backend / tool host / scope 設定側で扱う。未システム化の requested capability を API field として残さない。
### `workspace_refs: Vec<WorkspaceRef>`
- source: caller supplied opaque refs。
- current use: Runtime detail projection と persistence。execution backend / Worker factory は使っていない。
- scope/access: 現時点では access control ではない。
- visibility: Runtime detail に出る。
- persistence/projection: request として worker snapshot に保存される。
- retry: 意味は caller convention 依存。
- validation: 実質なし。
- verdict: canonical Runtime create request から削除する。working directory / cwd / workspace root を Runtime metadata にしない。
### `mount_refs: Vec<MountRef>`
- source: caller supplied opaque refs。
- current use: Runtime detail projection と persistence。execution backend / Worker factory は使っていない。
- scope/access: 現時点では access control ではない。
- visibility: Runtime detail に出る。
- persistence/projection: request として worker snapshot に保存される。
- retry: 意味は caller convention 依存。
- validation: 実質なし。
- verdict: canonical Runtime create request から削除する。mount/materialization を Runtime create payload にしない。
## 現在の Browser-facing `WorkerSpawnRequest`
現在の型は `workspace-server::hosts::WorkerSpawnRequest`
```rust
pub struct WorkerSpawnRequest {
pub intent: WorkerSpawnIntent,
pub requested_worker_name: Option<String>,
pub acceptance: WorkerSpawnAcceptanceRequirement,
pub profile: Option<ProfileSelector>,
pub config_bundle: Option<ConfigBundleRef>,
pub requested_capabilities: Vec<CapabilityRequest>,
}
```
### `intent: WorkerSpawnIntent`
- source: Browser / Workspace Backend caller。
- current use: embedded adapter が Runtime `WorkerIntent` と default Profile に変換する。
- verdict: Browser-facing launch context として残す。Runtime create request へはそのまま渡さない。
### `requested_worker_name: Option<String>`
- source: Browser-facing caller。
- current use: embedded runtime v0 では ignored diagnostic。WorkerId は Runtime sequence で割り当て。
- verdict: canonical flow で必要なら display/projection として再設計する。Worker identity authority にはしない。
### `acceptance: WorkerSpawnAcceptanceRequirement`
- source: Browser-facing caller / Backend launch policy。
- current use: embedded Runtime は `SocketReady` を拒否し、`RunAccepted` は diagnostic のみ。Runtime create API には存在しない。
- verdict: Browser/Backend launch policy として残す候補。Runtime create request field にはしない。acceptance evidence は Backend が create/input/observe 結果から構成する。
### `profile: Option<ProfileSelector>`
- source: Browser-facing caller からも渡せる。
- current use: Runtime create `profile` にそのまま渡る。
- verdict: Browser-facing API で自由に渡す field としては再検証が必要。canonical flow では Backend が role/target から決定し、Runtime create には Backend-decided profile を渡す。
### `config_bundle: Option<ConfigBundleRef>`
- source: Browser-facing caller からも渡せる。
- current use: Runtime create にそのまま渡る。
- verdict: Browser-facing API から削除する。Backend が ConfigBundle を決定・sync/check し、その結果の stable identity を Runtime create に渡す。
### `requested_capabilities: Vec<CapabilityRequest>`
- source: Browser-facing caller からも渡せる。
- current use: Runtime create にそのまま渡る。実 execution 制限には使われていない。
- verdict: Browser-facing API から削除するか Backend-only policy input に閉じる。Runtime create にそのまま流さない。
## ConfigBundle sync / store
- `ConfigBundle``metadata.id``metadata.digest` を持つ。
- `ConfigBundle::computed_digest()` が content digest を計算する。
- `Runtime::store_config_bundle()` は validate 後、runtime state の `config_bundles: BTreeMap<String, ConfigBundle>` に保存し、`ConfigBundleAvailability { reference: ConfigBundleRef { id, digest }, summary }` を返す。
- fs-store では ConfigBundle 本体は runtime snapshot に保存される。
Verdict:
- create caller が Runtime store を探索する必要はない。Backend が bundle を決めて sync/check し、返った id+digest を使う形なら妥当。
- Browser-facing launch に ConfigBundleRef を出す必要はない。
- `LatestForProfile` のような runtime-local latest selector は入れない。
## Initial input / Segment
- protocol `Segment` の既存 variant は `Text` / `Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke` / `Unknown`
- `TicketRef` / `ObjectiveRef` variant は現在存在しない。
- Runtime `WorkerInput``kind` + `content: String`。Runtime transcript も `TranscriptEntry.content: String`
- Runtime adapter は `WorkerInput.content``Segment::text(content)` にして `Method::Run` に渡している。
Verdict:
- initial input を `Vec<Segment>` に寄せる方針は protocol 慣例として妥当だが、現状の Runtime input/transcript は String なので、そのまま field を追加するだけでは不十分。
- Ticket / Objective を Segment として渡すなら、既存 variant で text にするか、protocol に record ref variant を追加する必要がある。未実装の `TicketRef` / `ObjectiveRef` を API field として前提にしない。
- `System` initial message は不要。role prompt / system-level instruction は Profile / ConfigBundle 側で解決する。
## Execution / workspace scope
- `WorkerExecutionSpawnRequest``worker_ref`、`request: CreateWorkerRequest`、`context: WorkerExecutionContext` を持つ。
- `WorkerExecutionContext` は observation publish と worker_ref を持つだけで、cwd / workspace root / scope を持たない。
- `worker::runtime_adapter::ProfileRuntimeWorkerFactory``workspace_root``cwd` を factory に保持し、request field から per-worker cwd を受け取っていない。
- Workspace Backend の embedded runtime 初期化は `WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone())`
Verdict:
- 現在は per-worker execution binding API は存在しない。
- `ExecutionBindingRef` は最終型名ではなく、今の Ticket では「Runtime create payload に raw cwd/workspace/tool scope を入れない」という境界だけを確定する。
- per-worker cwd / tool scope が必要なら、まず execution backend / factory boundary を設計・実装する必要がある。
## Worker creation persistence / transaction
- worker-runtime fs-store は runtime snapshot、worker snapshot、event、transcript を保存する。
- worker snapshot は現在 `CreateWorkerRequest` 全体を保存する。
- `Runtime::create_worker()` は現在、Worker record を insert/persist してから execution backend spawn を呼ぶ。
- spawn が rejected/errored になっても Worker は残り、execution status に失敗が記録される。
- execution backend がない Runtime でも Worker は catalog-only として作れる。
Verdict:
- canonical invariant として「input-capable Worker は execution backend 接続済み」を守るには、現在の create order / failure handling を変更する必要がある。
- 永続化しない選択肢はない。検証すべきなのは、既存正規 store/projection のどこに何を保存し、spawn failure をどう診断表示するか。
- catalog-only Worker を通常 Worker として公開する現経路は canonical path では禁止または explicit non-input-capable diagnostic に分類する。
## 現時点で API field として固定してよいもの / だめなもの
固定してよい境界:
- Runtime create は WorkerId を発行する唯一の経路にする。
- Runtime create は Browser-facing launch とは別の内部境界にする。
- Runtime create は synced ConfigBundle identity を検証する。
- Runtime create は raw workspace path / cwd / socket / credential / session path を受け取らない。
- Runtime create は initial input を Worker creation transaction と同じ単位で扱う。
- Runtime create は execution backend に接続できない Worker を通常 input-capable Worker として公開しない。
まだ field として固定しないもの:
- `launch_id`: existing idempotency key がないため、source/persistence/retry contract を設計してから。
- `ConfigBundleRef` / digest field: Runtime-internal create では sync response 由来の id+digest が候補だが、Browser-facing には出さない。既存 store contract と migration を確認して確定。
- `ExecutionBindingRef`: 現在の execution backend boundary に存在しないため、field として固定しない。
- `Vec<Segment>` initial input: direction は妥当だが Runtime input/transcript が String なので、protocol/store変更を伴う。field だけ先に固定しない。
- `display`: 現在は intent/profile 由来の projection。必要なら projection設計として検証してから。
- `acceptance`: Backend launch policy。Runtime create field として固定しない。

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260628-164808-1","ticket_id":"00001KW7726H9","kind":"accepted_plan","note":"Workspace Dashboard Queue authorized routing. Ticket has no blocker relations, related upstream execution boundary/adapter/Companion Tickets are closed, no current inprogress Tickets, and orchestration worktree is clean.","accepted_plan":{"summary":"Runtime Worker 起動経路を Browser-facing launch と Runtime worker creation に分離し、Worker creation を正規の Execution/ConfigBundle 経路へ一本化する。実装前に `api-field-audit.md` と現行 create/catalog/fs-store/config bundle/execution backend/Segment/Workspace launch 経路を再確認し、最終 request field は実装内で検証して確定する。embedded / Companion / remote-facing の Worker 作成 bypass をなくし、未接続 input-capable Worker、fake/providerless response、raw workspace/cwd/tool scope/secret/path leak を防ぐ。","branch":"work/00001KW7726H9-runtime-worker-launch-unification","worktree":"/home/hare/Projects/yoi/.worktree/00001KW7726H9-runtime-worker-launch-unification","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime`, `crates/worker`, `crates/workspace-server`, 必要に応じて `web/workspace`, `resources/profiles`, `resources/prompts`, root Cargo/package files の focused write scope を委譲する。reviewer Worker は read-only で API 境界、ConfigBundle sync 分離、execution binding 境界、transaction/persistence/retry、Browser-facing non-leak、fake response absence、既存 related Tickets との整合を確認する。merge/validation/done/cleanup は Orchestrator が行う。現時点では `StopPod` は使用せず、cleanup は child worktree/branch のみ行う。"},"author":"yoi-orchestrator","at":"2026-06-28T16:48:08Z"}

View File

@ -0,0 +1,29 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KW7726H9",
"kind": "related",
"target": "00001KW55B32Y",
"note": "execution backend boundary is part of the canonical Worker creation contract",
"author": "yoi ticket",
"at": "2026-06-28T13:36:24Z"
},
{
"ticket_id": "00001KW7726H9",
"kind": "related",
"target": "00001KW55B33B",
"note": "embedded worker crate adapter must be entered through the canonical creation path",
"author": "yoi ticket",
"at": "2026-06-28T13:36:24Z"
},
{
"ticket_id": "00001KW7726H9",
"kind": "related",
"target": "00001KW55B33H",
"note": "Workspace Companion bootstrap must not bypass canonical Runtime Worker creation",
"author": "yoi ticket",
"at": "2026-06-28T13:36:24Z"
}
]
}

View File

@ -0,0 +1,247 @@
---
title: 'Runtime Worker起動経路を正規のExecution/ConfigBundle経路に一本化する'
state: 'closed'
created_at: '2026-06-28T13:35:21Z'
updated_at: '2026-06-28T18:39:24Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-28T16:47:42Z'
---
## 背景
Runtime 上の Worker 起動経路が増え、一覧には出るが入力できない Worker、execution backend につながっていない Worker、再起動で消える Worker が作れてしまう状態になっている。
Worker を作る手順は、embedded、remote、Workspace Companion、Web Console のどれから呼ばれても同じであるべき。違いは配置や通信方式に閉じ込め、Worker 作成そのものは正規の API を通す。
この Ticket は、最終 API の field を疑似定義するものではない。`CreateWorkerRequest` などの具体 field は、既存型・永続化・retry・transport・scope 境界を確認したうえで実装時に確定する。ここでは API 境界、責務、検証すべき性質を定義する。
## 目的
- Runtime 上の Worker 作成手順を 1 本にする。
- 作られた Worker は、入力・観測・実行・履歴保存がそろった状態にする。
- Workspace Backend / Companion / Web Console が独自に Worker 風のものを作らないようにする。
- Runtime に workspace filesystem access / tool execution scope を持たせない。
- working directory や Ticket / Objective の意味は Backend 側で扱い、Runtime の Worker identity に混ぜない。
- Worker creation を中間状態が残りにくい transactional API にする。
- 未検証の request field を API 案として固定しない。
## やり取りの全体像
外部から見た Worker 起動は、Workspace Backend への 1 回の launch request にする。Browser / Web Console は Runtime の内部 config store、execution binding、cwd、tool scope、secret、Runtime endpoint を知らない。
```text
Browser / Web Console
-> Workspace Backend: launch worker
Workspace Backend
-> Runtime: ensure/sync ConfigBundle if needed
-> execution host / tool host: create execution binding if needed
-> Runtime: create worker
```
Runtime の Worker creation は 1 request / 1 transaction として扱う。途中で必要な前提が欠けていた場合は typed error を返し、入力不能な Worker や fake response は作らない。
## 検証済み field audit
現在の実装に存在する Worker creation / launch 関連 field は、`artifacts/api-field-audit.md` で調査済み。以後この Ticket で API field を追加・削除・改名する場合は、その artifact と同じ観点で検証してから行う。
主な結論:
- 現行 Runtime `CreateWorkerRequest``intent` / `requested_capabilities` / `workspace_refs` / `mount_refs` は canonical Runtime create request には残さない。
- 現行 Browser-facing `WorkerSpawnRequest.config_bundle` / `requested_capabilities` は Runtime create にそのまま流さない。
- ConfigBundle は Browser-facing launch から選ばせず、Backend が決定・sync/check した stable identity だけを Runtime create 境界で扱う。
- `ExecutionBindingRef` は現行実装に存在しないため、最終 field として固定しない。現時点で確定するのは、Runtime create payload に raw cwd / workspace / tool scope を入れない境界だけ。
- initial input を `Vec<Segment>` に寄せる方針は妥当だが、現行 Runtime input / transcript は String なので、field だけ先に固定しない。
## API 境界
### Browser-facing launch
Browser-facing API は product semantics を受け取る。これは Runtime の Worker creation API ではない。
Browser-facing launch で扱ってよい情報:
- Profile selector または role selector。
- role / display name などの表示・選択情報。
- 対象 Ticket / Objective など Backend launch context。
- acceptance policy など、Backend が解釈して Runtime create に落とし込む方針。
Browser-facing launch に出してはいけない情報:
- Runtime endpoint / credential / socket path / session path。
- raw workspace path / cwd。
- tool scope / tool access の具体設定。
- Runtime 内部の config store 状態。
- execution binding の中身。
### ConfigBundle sync
`ConfigBundle` 本体は Worker creation payload に混ぜない。Backend が Profile などから ConfigBundle を決定し、必要なら Runtime に同期する。
ただし、Runtime create request が何を持つかは実装時に検証して決める。特に `ConfigBundleRef` のような参照を渡す案は、request を出す側が Runtime 内部の config store を把握する必要を生む可能性があるため、無条件に採用しない。
API 確定時に満たすべき条件:
- create caller が Runtime 内部 store を探索・選択しなくてよい。
- retry / duplicate launch で同じ ConfigBundle を指せる。
- Runtime restart 後に、どの ConfigBundle で Worker を作ったか診断できる。
- Runtime は create 時に ConfigBundle の存在、内容同一性、Profile compatibility、provider / secret / declaration の整合を検証できる。
- `LatestForProfile` のような時点依存の選択を Worker creation の再現性に混ぜない。
候補としては、Backend が同期した bundle について Runtime から返る stable identity を Runtime create に渡す方式がある。ただしこれは Browser-facing API に出さず、既存の ConfigBundle store / digest 型 / sync API に従って実装時に確定する。
### Execution binding
working directory、cwd、tool scope、Ticket backend access は Runtime に渡さない。embedded Runtime も例外ではなく、Runtime 自体は workspace filesystem access を持たない。
Workspace Backend は launch context をもとに execution host / tool host 側へ binding を作る。binding は execution backend が Worker を起動するときに使う opaque handle であり、Runtime が中身を解釈するものではない。
API 確定時に満たすべき条件:
- binding 作成は Runtime API ではなく、Backend / execution host / tool host 側の内部 API として扱う。
- Runtime は binding の cwd / raw workspace path / tool scope / Ticket backend path を metadata として保持しない。
- Runtime は binding の中身を解釈して file operation を行わない。
- file/tool access の制限は execution backend / tool host 側で行う。
- remote Runtime が local workspace の binding を使えない場合は、binding の中身を Runtime create request に展開せず typed error にする。
- 未使用 binding が残る場合に備え、execution host 側で lease / TTL / explicit cleanup のいずれかを持つ。
`ExecutionBindingRef` という名前は候補であり、最終型名ではない。現時点で専用システムが存在しない概念を API field として新設しない。既存の execution backend 接続型や tool host の scope 設定を確認してから決める。
### Initial input
initial input は Worker creation と同じ transaction で transcript に入る。別 store に先置きする `InitialInputRef` / `PrepareInitialInput` は作らない方針とする。
initial input は独自 message 型ではなく、既存 protocol の `Segment` / user submission 表現に寄せる。`System` message は作らない。role prompt / system-level instruction は Profile / ConfigBundle 側で解決する。
API 確定時に満たすべき条件:
- Ticket / Objective の要約文は通常の text segment として扱える。
- Ticket / Objective など読み直し可能な record は、別 field ではなく Segment の既存または追加 variant で表せる。
- Worker 側では通常の user submission と同じ解決経路で context 化される。
- Backend claim、RuntimeRegistry の振り分け、raw workspace path、working directory の作成方針、execution binding は conversation context に入らない。
既存 `Segment` の variant と変換経路を確認し、不足があればこの Ticket の実装内で Segment 側を拡張するか、別 Ticket に分ける。
### Runtime worker creation
Runtime worker creation は、同期済み ConfigBundle、execution binding、initial input を結びつけ、execution backend に接続した Worker を登録する唯一の経路にする。
ただし、最終的な `CreateWorkerRequest` の field はこの Ticket 本文では固定しない。実装前に各 field を次の観点で検証し、Ticket thread または実装 report に残す。Worker creation の永続化は必須であり、永続化しない worker creation path は作らない。
各 field の検証観点:
- source: 誰がその値を作るか。
- scope/access: その値は既存の access/scope 制限に関わるか、単なる識別子・表示情報か。
- visibility: Browser-facing API / Runtime metadata / Worker conversation / execution backend のどこに見えてよいか。
- persistence/projection: Runtime restart 後に、既存の正規 store のどの record / projection に何を保存し、どの情報を復元・診断に使うか。store を選ぶ項目ではない。`no` は選択肢にしない。raw handle などを保存できない場合でも、対応する durable identity / projection / diagnostic record を持つ。
- retry: retry / duplicate launch で同じ意味になるか。
- validation: Runtime create 時に何を検証できるか。
- existing type: 既存型・既存 protocol に対応物があるか。
- failure: 不足・不整合時にどの typed error になるか。
現時点で確定している制約:
- WorkerId を発行するのは Runtime worker creation だけ。
- Worker creation は 1 request / 1 transaction として扱う。
- Worker creation の結果は worker-runtime fs-store / transcript store / catalog の既存正規 store に永続化される。永続化しない Worker は通常 Worker として作らない。
- Runtime は workspace filesystem access / tool execution scope を持たない。
- Runtime は fake / providerless assistant response を生成しない。
- Runtime は execution backend 未接続の Worker を通常 Worker として公開しない。
## transaction と失敗処理
Runtime worker creation は概ね次を 1 transaction として行う。ただし具体的な保存順序は実装時に worker-runtime fs-store / execution backend / transcript store の既存 contract を確認して決める。永続化しない起動はこの Ticket の対象にしない。
```text
create worker:
1. retry / duplicate launch を扱う。
2. ConfigBundle を検証する。
3. execution binding を execution backend に渡して接続可能か確認する。
4. WorkerId / TranscriptId を割り当てる。
5. initial input を user submission として transcript に保存する。
6. Runtime catalog に Worker を登録する。
7. execution backend に接続する。
8. response を返す。
```
途中失敗した場合は、WorkerId を公開しないか、公開済みなら failed creation として明示的に診断できる状態にする。入力不能な Worker や fake response は作らない。
想定される typed error は実装時に既存 error 型と合わせて確定する。少なくとも次の分類は必要になる。
- duplicate / retry conflict。
- ConfigBundle missing / mismatch / incompatible。
- provider / secret / declaration 不足。
- execution binding missing / unsupported。
- execution backend unavailable。
- persistence failure。
## 境界
- Runtime は workspace filesystem access / tool execution scope を持たない。
- embedded Runtime でも Runtime 自体に workspace filesystem access を許可しない。
- raw workspace path / cwd / socket path / credential / session path は Browser-facing API に出さない。
- Ticket id / Objective id は Runtime の Worker identity にしない。
- `ConfigBundle` 本体は Worker creation payload にしない。
- `InitialInputRef` は作らず、initial input は Worker creation request に inline する。
- `ExecutionEnvironmentRef` のように Runtime が working directory materialization を持つように見える型は避ける。
- execution backend に接続できないものを通常 Worker として一覧に出さない。
## 既存経路の整理
- embedded Runtime は上記の作成手順を通る。ただし embedded Runtime に workspace filesystem access を付与しない。
- remote Runtime も同じ worker creation contract を使う。execution binding を使えない段階では typed error で拒否する。
- Companion 専用の作成・送信経路が残る場合も、内部ではこの worker creation API の薄い呼び出しにする。
- Web Console は `runtime_id + worker_id` を対象に入力・観測するだけにし、Worker 作成の特別扱いを持たない。
## 保存と再起動
- Worker 作成時に Worker id、参照した ConfigBundle identity、execution binding identity、transcript id、initial input を保存する。保存しない選択肢は取らない。
- 保存する identity が access/scope を与える値にならないようにする。たとえば execution binding identity は Runtime の filesystem access ではなく、stale execution mapping の診断と execution backend reconnect のための参照として扱う。
- 再起動後に live execution handle を復元できない場合は、stale execution mapping として診断する。
- ConfigBundle、execution binding、履歴のどれかが見つからない場合は typed diagnostic にし、黙って別 Worker を作らない。
## 対象外
- Web Console の見た目変更。
- 複数利用者の auth / permission / redaction policy の完成。
- provider / secret store UI の新設。
- remote Runtime process supervisor の完成。
- remote Runtime の execution provisioning の完成。
- `pod_store` など旧内部 store 名の全面 rename。
## 実装前に必ず確認するもの
初回調査結果は `artifacts/api-field-audit.md` に記録済み。実装時はその調査結果を更新し、コード変更に合わせて以下を再確認する。
- 現在の `worker-runtime` の create / catalog / fs-store / execution backend mapping 型。
- 現在の Workspace Backend launch API と embedded Runtime 呼び出し箇所。
- ConfigBundle store / digest / sync の既存型と責務。
- existing `Segment` / user submission / transcript append の型と変換経路。
- execution host / tool host / scope 設定の既存境界。
- retry / duplicate launch に使える既存 idempotency key または request id。
## 受け入れ条件
- Browser-facing launch request と Runtime worker creation request の責務差が code/docs/tests 上で明確になっている。
- Runtime が workspace filesystem access / tool execution scope を持たないことが code/docs/tests 上で明確になっている。
- ConfigBundle sync と worker creation payload が分離されている。
- worker creation caller が Runtime 内部 config store を探索・選択する必要がない。
- ConfigBundle の識別方法が content identity / digest / 既存 store contract の観点で検証され、thread または実装 report に記録されている。
- execution binding 作成経路が Backend / execution host 側に定義され、Runtime API として扱われていない。
- initial input は Worker creation request に inline され、`InitialInputRef` / `PrepareInitialInput` のような中間 API を作らない。
- initial input は既存 `Segment` / user submission 表現に寄せられ、Ticket / Objective record 参照も Segment 経路で扱われる。
- Runtime worker creation request の各 field について、source / scope-access / visibility / persistence-projection / retry / validation / existing type / failure が検証されている。persistence-projection は保存有無や store 選択ではなく、既存の正規 store のどの record / projection に何を保存・復元・診断するかを確認する項目である。
- Runtime worker creation が 1 request / 1 transaction として扱われ、WorkerId を発行する唯一の API になっている。
- embedded Worker / Workspace Companion / remote-facing Worker creation が同じ作成手順を使う。
- input-capable Worker が execution backend 未接続になる経路がない。
- fake / providerless assistant response を生成する Worker 起動 bypass がない。
- ConfigBundle / Profile / provider / secret / execution binding 不足が typed diagnostic になる。
- Runtime catalog / transcript / observation / persistence initialization が Worker creation と一貫しており、永続化しない通常 Worker 作成経路がない。
- Browser-facing API に Runtime endpoint / credential / socket path / session path / raw execution handle / raw workspace path が漏れない。
- Focused tests が Worker 作成成功、duplicate launch retry、missing ConfigBundle rejection、missing execution binding rejection、execution backend connected Worker、stale execution mapping diagnostic、Companion bootstrap path を確認する。
- `cargo test -p worker-runtime --features ws-server` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `git diff --check` が通る。
- `nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,33 @@
Runtime Worker 起動経路を canonical `ConfigBundleRef + ExecutionBackend` 経路へ一本化し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- Runtime `CreateWorkerRequest``config_bundle_ref` と user-only initial input 中心の内部作成契約へ整理。
- Browser-facing launch semantics と Runtime worker creation request を分離。
- raw workspace / cwd / tool scope / config store / secret / socket / path 類を Runtime create request に含めない境界にした。
- ConfigBundle missing / digest mismatch / profile mismatch を typed error / diagnostic として扱う。
- execution backend 未接続では Worker 作成を拒否し、input-capable Worker が backend 未接続になる経路を塞いだ。
- create 成功時のみ catalog / transcript / event を永続化し、spawn / initial input dispatch rejection は rollback。
- execution binding identity を raw handle / secret / path / socket を含まない non-authority projection として永続化。
- restore 後に live handle がない persisted execution mapping は `stale` として diagnostic `worker_execution_mapping_stale` を出す。
- System initial input を Runtime boundary で拒否し、launch/create が system transcript を注入できないようにした。
- Workspace embedded / Companion / remote-facing creation を canonical Runtime create path に寄せた。
- Remote Runtime projection でも execution status を見て `can_accept_input` / `can_stop` を計算し、stale / unconnected / rejected / errored Workers を input-capable として出さないようにした。
- fake/providerless response bypass は導入していない。
Integrated commits:
- `14bb4934a6374eea64591035e5342088ab0ccd09 runtime: unify worker creation path`
- `c29d10b67bfff1f4a7a1b2742ec05fe63b80c054 runtime: persist execution binding projection`
- `ba7f9d2ee83a946820cc234e847b6531b4a141f3 workspace: respect remote execution projection`
- merge: `bdb339fa merge: runtime worker launch unification`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "fs-store ws-server"`: success
- `cargo test -p yoi-workspace-server`: success (`36 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.

View File

@ -0,0 +1,720 @@
<!-- event: create author: "yoi ticket" at: 2026-06-28T13:35:21Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: decision author: hare at: 2026-06-28T14:42:12Z -->
## Decision
Runtime Worker creation API から Backend/Orchestrator intent と raw workspace context を外す。
決定:
- `intent` は Runtime の authority ではなく Backend-side launch context とする。Workspace Companion / Ticket role / Orchestrator routing / Objective は Backend が Profile 選択、ConfigBundle 選択、ExecutionEnvironment 準備、initial context/tool authority 付与に使う。
- ConfigBundle 本体は create payload で送らない。Runtime は事前同期済み ConfigBundle store を ref/digest で参照し、create 時に availability / digest / profile compatibility / provider / secret / authority を検証する。
- working directory / workspace root / tool scope は Runtime create request の raw field にしない。Backend が worktree/cwd/tool authority/project record access/ticket backend access を準備し、Runtime には opaque `ExecutionEnvironmentRef` を渡す。
- Ticket / Objective は Runtime Worker identity metadata ではなく、initial context と tool-readable records として Worker に与える。Worker が知るべきなのは対象 Ticket/Objective の内容と使える tools/scope であり、Backend claim id や RuntimeRegistry routing ではない。
Runtime create request の中心は Profile selector、ConfigBundle ref、ExecutionEnvironmentRef、labels/role、execution/acceptance requirement に絞る。
---
<!-- event: decision author: hare at: 2026-06-28T15:07:01Z -->
## Decision
前回の記述は「API に入れないもの」の列挙に寄りすぎていたため、Ticket 本体を API 設計中心に書き直した。
修正内容:
- 横文字中心の見出しと説明を減らし、起動手順と作る API の形を前面に出した。
- ConfigBundle 同期、ExecutionEnvironment 準備、InitialInput 準備、Worker 作成を別 API として整理した。
- `CreateWorkerRequest` の具体的なフィールド案を明記した。
- `WorkerWorkspaceRef` は Worker 作成 API に入れず、working directory は `ExecutionEnvironmentRef` として execution backend だけが解決する内部参照にした。
- Ticket / Objective は Worker identity ではなく、initial input と tool-readable records で渡す形にした。後続 decision で initial input は `CreateWorkerRequest` に inline する方針へ更新した。
---
<!-- event: decision author: hare at: 2026-06-28T15:11:43Z -->
## Decision
前回の修正で domain term まで不自然に和訳していたため、Ticket 本体の用語を修正した。
修正方針:
- ConfigBundle / ExecutionEnvironment / InitialInput / execution backend / tool authority / provider / secret / Browser-facing API などの domain term はそのまま使う。
- 説明文は日本語にするが、API 名・crate/domain 用語は訳語を作らない。
- 独自訳は作らず、`tool authority` に統一する。
---
<!-- event: decision author: hare at: 2026-06-28T15:36:39Z -->
## Decision
Worker 起動 protocol の整理を更新した。
決定:
- Browser-facing launch は Workspace Backend への 1 request とする。Browser / Web Console は ConfigBundleRef、ExecutionEnvironmentRef、cwd、tool authority、secret、Runtime endpoint を知らない。
- Runtime の Worker creation は `CreateWorker` 1 request / 1 transaction とする。WorkerId を発行するのは `CreateWorker` だけ。
- ConfigBundle sync は create 前の冪等な前提準備であり、Worker を作らない。
- ExecutionEnvironment 準備は Backend / Runtime 内部の前提準備であり、Worker に見せる protocol ではない。未使用参照には lease / TTL / cleanup を持たせる。
- initial input は `CreateWorkerRequest` に inline する。`PrepareInitialInput` / `InitialInputRef` は作らない。
- `launch_id``CreateWorkerRequest` に入れ、retry / duplicate request を安定して扱えるようにする。
---
<!-- event: decision author: hare at: 2026-06-28T16:03:14Z -->
## Decision
embedded Runtime の authority 境界を踏まえて、ExecutionEnvironmentRef 方針を撤回し、ExecutionBindingRef 方針に更新した。
決定:
- Runtime は file operation authority / tool authority を持たない。embedded Runtime でも例外にしない。
- working directory / cwd / tool scope / Ticket backend authority は Runtime create request に入れない。
- Backend は execution host / tool host 側に execution binding を作る。これは Runtime API ではなく、Browser-facing API にも Worker の conversation context にも出さない。
- `CreateWorkerRequest``ExecutionEnvironmentRef` ではなく opaque な `ExecutionBindingRef` を受け取る。
- `ExecutionBindingRef` は Runtime に file access を許可する ref ではなく、Runtime が execution backend に接続するときに渡す handle とする。
- binding の cwd / tool authority / Ticket backend access の enforcement は execution backend / tool host 側で行う。
- remote Runtime が binding を使えない場合は、binding の中身を Runtime create request に展開せず、typed error で拒否する。
---
<!-- event: decision author: hare at: 2026-06-28T16:05:07Z -->
## Decision
InitialInput の表現を protocol Segment に寄せる。
決定:
- initial input 用の独自 message 型は作らず、`CreateWorkerRequest.initial_input` は `Vec<Segment>` とする。
- `System` message は作らない。role prompt / system-level instruction は Profile / ConfigBundle 側で解決する。
- readable record 用の別 field は作らない。Ticket / Objective などの読み直し可能な参照は Segment variant として表す。
- Ticket / Objective の要約文は `Segment::Text`、参照は `Segment::TicketRef` / `Segment::ObjectiveRef` 相当で表し、通常の user submission と同じ解決経路で扱う。
---
<!-- event: decision author: hare at: 2026-06-28T16:16:17Z -->
## Decision
未検証の pseudo API field を Ticket 本体から外し、API 確定を実装時の検証事項として委譲する方針に更新した。
決定:
- この Ticket 本文では `CreateWorkerRequest` の最終 field を固定しない。
- API field を定義する場合は、各 field ごとに source / authority / visibility / persistence / retry / validation / existing type / failure を検証する。
- `ConfigBundleRef` のような参照を create request に渡す案は、caller が Runtime 内部 config store を把握する必要を生む可能性があるため未採用とする。
- ConfigBundle の識別方法は、content identity / digest / 既存 store contract を確認して実装時に確定する。
- initial input は既存 Segment / user submission 表現に寄せる方針だけを残し、独自 pseudo type は置かない。
- ExecutionBinding も最終型名ではなく、Runtime が file authority を持たず execution backend へ渡す opaque handle が必要、という境界だけを残す。
---
<!-- event: decision author: hare at: 2026-06-28T16:26:48Z -->
## Decision
`authority` を未実装の API 概念として扱っていた箇所を修正した。
決定:
- 現段階で専用システム化されていない authority / capability を Runtime Worker creation API の field として新設しない。
- Ticket 本体では `authority` 検証ではなく、既存の source / scope-access / visibility / persistence / retry / validation / existing type / failure を検証する方針にする。
- Runtime が持たないものは `file operation authority` ではなく、より具体的に workspace filesystem access / tool execution scope と表現する。
- file/tool access の制限は、既存の execution backend / tool host / scope 設定の境界を確認して実装時に接続する。
---
<!-- event: decision author: hare at: 2026-06-28T16:30:04Z -->
## Decision
Worker creation field の検証観点にある persistence の意味を修正した。
決定:
- 通常 Worker creation で「永続化しない」は選択肢にしない。
- field 検証の persistence は yes/no ではなく、既存の正規 store の record / projection に何を保存し、restart 後に何を復元・診断するかを確認する `persistence/projection` とする。
- raw handle などを保存できない場合でも、対応する durable identity / projection / diagnostic record を持つ。
- Runtime catalog / transcript / observation / persistence initialization が Worker creation と一貫していることを受け入れ条件に残し、永続化しない通常 Worker 作成経路を禁止する。
---
<!-- event: decision author: hare at: 2026-06-28T16:33:05Z -->
## Decision
`persistence/projection` の意味を再修正した。
決定:
- store は任意選択の対象ではない。
- Worker creation の保存先は既存の正規 store / projection に従う。主対象は worker-runtime fs-store、transcript store、Runtime catalog など既存 contract で決まるもの。
- field 検証では store を選ぶのではなく、既存の正規 store のどの record / projection に何を保存し、restart 後に何を復元・診断するかを確認する。
---
<!-- event: decision author: hare at: 2026-06-28T16:38:02Z -->
## Decision
現在実装に存在する Worker creation / launch 関連 field を調査し、`artifacts/api-field-audit.md` に記録した。
検証した主な対象:
- Runtime `CreateWorkerRequest``intent` / `profile` / `config_bundle` / `requested_capabilities` / `workspace_refs` / `mount_refs`
- Browser-facing `WorkerSpawnRequest``intent` / `requested_worker_name` / `acceptance` / `profile` / `config_bundle` / `requested_capabilities`
- ConfigBundle sync/store/digest の現行 contract。
- protocol `Segment` と Runtime `WorkerInput` / transcript の現行差分。
- execution backend spawn request と per-worker cwd/scope boundary の不在。
- worker-runtime fs-store / worker snapshot / transcript persistence と現在の create/spawn 順序。
主な結論:
- 未検証 field を API 案として固定しない。
- 現行 Runtime `CreateWorkerRequest``intent` / `requested_capabilities` / `workspace_refs` / `mount_refs` は canonical Runtime create request には残さない。
- Browser-facing `config_bundle` / `requested_capabilities` は Runtime create にそのまま流さない。
- `ExecutionBindingRef` は現行実装に存在しないため、最終 field として固定しない。
- initial input を `Vec<Segment>` に寄せる方針は妥当だが、現行 Runtime input/transcript は String なので field だけ先に固定しない。
---
<!-- event: intake_summary author: hare at: 2026-06-28T16:41:17Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-28T16:41:17Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-28T16:47:42Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T16:48:19Z -->
## Decision
Routing decision:
Workspace Dashboard Queue authorized Orchestrator routing. Current state was inspected before implementation side effects.
Findings:
- Ticket state: `queued`
- Blocker relations: none
- Related Tickets `00001KW55B32Y`, `00001KW55B33B`, `00001KW55B33H` are prior execution-boundary / adapter / Companion work and are closed.
- Current `inprogress` Tickets: 0
- Orchestration worktree: clean
Decision:
- Accept this Ticket for implementation and transition `queued -> inprogress` before child worktree / Pod side effects.
Plan:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KW7726H9-runtime-worker-launch-unification`
- Branch: `work/00001KW7726H9-runtime-worker-launch-unification`
- Coder Worker will inspect `.yoi/tickets/00001KW7726H9/item.md` and `artifacts/api-field-audit.md`, then implement the canonical Worker launch/creation path using current code as authority.
- Reviewer Worker will verify API boundary separation, ConfigBundle sync / Runtime creation separation, execution binding boundary, transaction/persistence/retry behavior, Browser-facing non-leak, no fake/providerless response, and focused tests.
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child worktree/branch unless explicitly reauthorized.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T16:48:25Z from: queued to: inprogress reason: authorized_unblocked_queue_acceptance field: state -->
## State changed
Workspace Dashboard Queue authorized routing. The Ticket has no blocker relations, related prerequisite Tickets are closed, no other Ticket is inprogress, and the orchestration worktree is clean. Accepting implementation before child worktree / role Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T16:49:08Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW7726H9-runtime-worker-launch-unification`
- Branch: `work/00001KW7726H9-runtime-worker-launch-unification`
- Coder Worker spawned: `yoi-coder-00001KW7726H9-launch-unify`
Scope notes:
- Coder write scope is limited to `crates/worker-runtime/**`, `crates/worker/**`, `crates/workspace-server/**`, `web/workspace/**`, `resources/profiles/**`, `resources/prompts/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if needed.
- Coder is instructed to read the Ticket and `artifacts/api-field-audit.md` from the worktree and not to modify Ticket records.
- Coder is instructed not to operate in root/orchestration worktrees.
Implementation focus:
- Browser-facing launch semantics and Runtime worker creation request responsibilities must be separated.
- ConfigBundle sync and worker creation payload must remain separate.
- Runtime must not receive raw workspace path / cwd / tool scope / secret / Runtime endpoint / execution binding internals.
- Runtime worker creation must be persistent/transactional and typed-fail on missing ConfigBundle / execution binding / provider / secret.
- embedded / Companion / remote-facing creation must use the same canonical path.
- input-capable Workers must have an execution backend.
- no fake/providerless assistant response bypass.
- final request fields must be justified against source / scope-access / visibility / persistence-projection / retry / validation / existing type / failure.
---
<!-- event: implementation_report author: yoi-coder-00001KW7726H9-launch-unify at: 2026-06-28T17:46:07Z -->
## Implementation report
Implementation report:
実装・検証・commit 完了。merge / close / cleanup は未実施。
Commit:
- `14bb4934a6374eea64591035e5342088ab0ccd09 runtime: unify worker creation path`
実装概要:
- Runtime Worker 作成 API を canonical な `ConfigBundle + ExecutionBackend` 経路に統一。
- `worker-runtime::catalog::CreateWorkerRequest` を Runtime 内部の作成契約として絞り込み。
- 採用フィールド:
- `profile: ProfileSelector`
- `config_bundle: ConfigBundleRef`(必須)
- `initial_input: Option<WorkerInput>`
- 除外フィールド:
- Browser / product intent
- requested capabilities
- workspace refs / mount refs
- cwd / workspace path / tool scope / config store / secret / socket / raw path 類
- `CreateWorkerRequest::default()` / providerless / tools-less 作成経路を廃止し、input-capable Worker 作成には execution backend を必須化。
- ConfigBundle 未同期 / digest mismatch / profile 未宣言 / execution backend 不在 / spawn-input dispatch rejection を typed diagnostics / errors として扱うように変更。
- 作成は成功時のみ catalog / transcript / event を永続化し、spawn / initial input dispatch 失敗時は in-memory Worker と create event を rollback。
- Workspace 側で Browser-facing launch semantics と Runtime create request を分離。
- Browser-facing `WorkerSpawnRequest` は intent / requested name / acceptance / profile / initial input の範囲に限定。
- Workspace host が backend-resolved ConfigBundle を同期し、その `ConfigBundleRef` で Runtime create を呼び出す。
- Embedded / Companion / remote-facing creation が同じ Runtime create path を通るように調整。
- providerless / fake response 前提のテストを更新し、execution backend を持つ経路または typed rejection を確認するテストに変更。
API field rationale:
- `profile`
- source: launch backend が決定する profile selector
- visibility: opaque selector であり raw config/body/path/secret を含まない
- validation: synced ConfigBundle が selector を宣言していることを Runtime 側で検証
- persistence: Worker projection に durable selector として保存可能
- `config_bundle`
- source: Runtime create 前に backend が `store_config_bundle` / remote sync で確定する opaque ref
- visibility: `id + digest` のみで config store path や secret は露出しない
- validation: missing / digest mismatch / profile mismatch を typed error 化
- retry: 同じ bundle ref で再試行可能
- failure: missing ConfigBundle は create 前に fail closed
- `initial_input`
- source: Browser/product launch intent から通常の Worker input として明示的に渡す
- visibility: ユーザー/システム input 本体のみ。scope/path/secret は含めない
- persistence: 作成成功時に transcript に同一 transaction として保存
- failure: initial dispatch が execution backend に rejected された場合は create rollback
- 除外フィールド:
- `intent`, `requested_capabilities`, `workspace_refs`, `mount_refs`, raw cwd/path/config/secret は Runtime create API から除外
- 理由: Browser/product/backend policy 層の semantics であり、Runtime Worker の canonical durable create request に混ぜると visibility/persistence/retry/error 境界が崩れるため
Validation:
- `cargo fmt`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi`: success
Residual risks / review focus:
- E2E の実プロセス spawn テストは既存方針どおり未整備なので、実 worker process 起動の完全統合は unit/integration レベルの backend mock と `nix build` での確認に留まる。
- create 成功後の永続化失敗時に、すでに execution backend が外部 worker を spawn 済みであるケースの完全補償は今後さらに詰める余地がある。今回の実装では spawn / initial input rejection は rollback し、成功時だけ durable persistence する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T17:46:31Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned for `00001KW7726H9` implementation.
- Reviewer Worker: `yoi-reviewer-00001KW7726H9-launch-unify`
- Review target commit: `14bb4934a6374eea64591035e5342088ab0ccd09 runtime: unify worker creation path`
- Review mode: read-only implementation worktree review
Review focus:
- Runtime worker creation uses canonical ConfigBundle + ExecutionBackend path
- Browser-facing launch semantics are separated from Runtime `CreateWorkerRequest`
- create request fields are justified against source / scope-access / visibility / persistence-projection / retry / validation / existing type / failure
- no raw workspace / cwd / tool scope / config store / secret / socket / path leaks
- ConfigBundle missing / digest mismatch / profile mismatch / execution backend missing / provider-secret failure produce typed diagnostics
- input-capable Workers require execution backend
- create is persistent/transactional enough, with rollback on spawn / initial input rejection
- embedded / Companion / remote-facing creation share the canonical path
- no fake/providerless assistant response bypass
---
<!-- event: review author: yoi-reviewer-00001KW7726H9-launch-unify at: 2026-06-28T17:51:13Z status: request_changes -->
## Review: request changes
request_changes
読み取り専用で Ticket `00001KW7726H9`、`artifacts/api-field-audit.md`、target commit `14bb4934a6374eea64591035e5342088ab0ccd09` の実装を確認した。重い検証コマンドは再実行していない。`git diff --check HEAD^..HEAD` は追加出力なし。
## Blockers
### 1. 永続化要件の `execution binding identity` と stale execution mapping 診断が未実装
Ticket の保存・再起動要件では、Worker 作成時に `execution binding identity` を保存し、再起動後に live execution handle を復元できない場合は stale execution mapping として typed diagnostic にする必要がある。
しかし実装では、永続化 record に execution binding 情報が保存されていない。
- `crates/worker-runtime/src/runtime.rs:1467-1477`
- `PersistedWorkerRecord``worker_ref`, `worker_id`, `status`, `request`, `transcript` などは保存されているが、`WorkerExecutionStatus` / binding id / execution handle identity が落ちている。
- `crates/worker-runtime/src/runtime.rs:978-1010`
- restore 時に全 Worker が `WorkerExecutionStatus::unconnected()` / `execution_handle: None` にされる。
- その際、`diagnostics` は persisted diagnostics をそのまま引き継ぐだけで、stale execution mapping の diagnostic を追加していない。
- `Runtime::diagnostics()``state.diagnostics.clone()` を返すのみで、restore された unconnected Worker を typed diagnostic として表面化しない。
これにより、次の意図を満たしていない。
- Worker 作成時に execution binding identity を保存する。
- 保存 identity は権限値ではなく stale execution mapping / reconnect の参照として扱う。
- 再起動後に live execution handle を復元できない場合は stale execution mapping として診断する。
- missing config / execution binding / history を typed diagnostic にし、黙って別 Worker を作らない。
現状は「黙って unconnected に戻す」挙動なので、Runtime worker creation の永続性・復元診断要件として blocker。
### 2. 初期 input で `System` を受け入れて transcript に保存できる
Ticket / API audit の方針では、Browser launch の initial input はユーザー投入分であり、launch/create 時に System message を作らない設計だった。System instruction / role prompt は ConfigBundle/Profile 側に寄せる意図である。
しかし現在の実装では、CreateWorkerRequest の `initial_input``WorkerInput` のままなので `WorkerInputKind::System` を受け取れる。
- `crates/worker-runtime/src/catalog.rs`
- `CreateWorkerRequest { config_bundle_ref, initial_input: Option<WorkerInput> }`
- `crates/worker-runtime/src/runtime.rs:261-265`
- `WorkerInputKind::System => TranscriptRole::System` として initial transcript に保存される。
- `crates/workspace-server/src/hosts.rs`
- Browser-facing `WorkerSpawnRequest.initial_input``EmbeddedWorkerInput` 経由で system/user を表現できる形になっている。
これにより、Browser/API caller が Worker 作成時に system transcript を注入できるため、Ticket の「Browser launch semantics と Runtime CreateWorkerRequest を分離し、initial input はユーザー投入分に限定する」意図と矛盾する。少なくとも create / launch initial input では User-only 型にするか、Runtime boundary で `System` を typed rejection する必要がある。
## 確認できた良い点
- Runtime `CreateWorkerRequest` から workspace / cwd / tool scope / config store / secret / socket / path 由来の大きな launch fields は削られ、基本的に `config_bundle_ref` + `initial_input` に寄っている。
- ConfigBundle missing / digest mismatch / profile mismatch は `RuntimeError` と diagnostic で扱う方向になっている。
- execution backend 不在時は providerless / fake Worker を返さず、create が失敗する経路になっている。
- embedded Workspace Server 側の spawn は ConfigBundle を Runtime に sync してから canonical Runtime create に寄せている。
---
<!-- event: implementation_report author: yoi-coder-00001KW7726H9-launch-unify at: 2026-06-28T18:12:02Z -->
## Implementation report
Change-response implementation report:
Reviewer blockers を修正し、追加 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `c29d10b67bfff1f4a7a1b2742ec05fe63b80c054 runtime: persist execution binding projection`
変更内容:
- Execution binding identity の永続化
- `WorkerExecutionBindingIdentity { backend_id }` を追加。
- raw handle / secret / path / socket を含まない非権威 durable projection として保存。
- Worker persisted record に `WorkerExecutionStatus` を保存し、execution status / binding projection が restore 後に消えないようにした。
- stale execution mapping diagnostic
- fs-store restore 時、persisted execution binding が存在するが live execution handle を復元できない場合に Worker execution status を `stale` として投影。
- typed diagnostic `worker_execution_mapping_stale` を追加。
- stale projection は input-capable と誤認されないよう `WorkerExecutionBackendKind::Stale` として扱う。
- System initial input の拒否
- `CreateWorkerRequest.initial_input``WorkerInputKind::System` が来た場合、Runtime boundary で `RuntimeError::InvalidInitialInputKind` として拒否。
- System / role instructions は Profile / ConfigBundle に属し、launch/create initial input として transcript に保存されないようにした。
- Browser-facing embedded launch でも typed diagnostic `embedded_worker_initial_input_kind_invalid` として返す。
- Tests
- System initial input が reject され、Worker / WorkerCreated event が persisted されないことを確認。
- fs-store restore 後に stale execution mapping diagnostic が出ること、binding identity が raw authority ではなく backend id projection として残ることを確認。
- embedded runtime launch の System initial input reject を確認。
Validation:
- `cargo fmt`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `cargo test -p worker-runtime --features fs-store,ws-server`: success
- `nix build .#yoi`: success
Residual risks / review focus:
- restore 後の execution binding は非権威 projection であり、live handle は復元しない。再接続 / reattach 実処理は別設計範囲。
- stale Worker は binding identity と diagnostic を保持するが input-capable とは扱わないため、UI/API 側では stale diagnostic を見て明示的な再接続 / 再作成導線を出す必要がある。
---
<!-- event: review author: yoi-reviewer-00001KW7726H9-launch-unify at: 2026-06-28T18:17:07Z status: request_changes -->
## Review: request changes
request_changes
読み取り専用で再レビューした。前回指摘した Runtime 本体側の 2 点は概ね修正されているが、元の受け入れ条件に対する blocker が 1 件残っている。
## Blocker
### Remote Runtime projection が stale / unconnected Worker を input-capable として公開する
Runtime fs-store restore 側では、persisted execution binding があるが live handle が戻らない場合に `WorkerExecutionBackendKind::Stale``worker_execution_mapping_stale` diagnostic を出す実装になっている。
- `crates/worker-runtime/src/runtime.rs:993-1008`
- persisted binding + `Connected` を restore 時に `Stale` 化し、`worker_execution_mapping_stale` diagnostic を追加。
- `crates/worker-runtime/src/execution.rs:18-23`, `148-156`, `173-181`
- `Stale` backend kind と durable binding projection が追加されている。
しかし Workspace Server の remote Runtime projection では、Runtime HTTP から返る `WorkerSummary` / `WorkerDetail``execution` を無視して、常に `can_accept_input: true` にしている。
- `crates/workspace-server/src/hosts.rs:1683-1704`
- remote `map_worker_summary()``summary.execution` を見ずに `can_accept_input: true`
- `crates/workspace-server/src/hosts.rs:1715-1736`
- remote `map_worker_detail()``detail.execution` を見ずに `can_accept_input: true`
- 対照的に embedded projection は `can_accept_embedded_input()``status == Running` かつ `execution.backend == Connected` などを確認している(`hosts.rs:981-990`, `993-1015`, `1026-1048`)。
このため、remote-facing Worker creation / listing では、Runtime 側で stale と診断された Worker でも Workspace API 上は input-capable と見えてしまう。Ticket の以下の要件を満たしていない。
- `input-capable Worker が execution backend 未接続になる経路がない`
- `Stale is not input-capable`
- `embedded Worker / Workspace Companion / remote-facing Worker creation が同じ作成手順を使う`
remote projection でも Runtime response の `execution.backend`, `run_state`, `status`, `last_result` を使って embedded と同等に `can_accept_input` / `can_stop` を計算する必要がある。
## 前回 blocker の確認状況
- Execution binding identity persistence は Runtime record に `execution` が保存されるようになっており、`WorkerExecutionBindingIdentity { backend_id }` も raw handle / socket / path / secret を保持していない。
- restore 時の stale diagnostic は追加されている。
- `CreateWorkerRequest.initial_input``System` は Runtime で `InvalidInitialInputKind` として拒否され、transcript には user role のみで作成される経路になっている。
- create は execution backend 未接続なら拒否し、fake/providerless response を返さない方向である。
重い validationcargo / nixは再実行していない。reported validation は未再現。
---
<!-- event: implementation_report author: yoi-coder-00001KW7726H9-launch-unify at: 2026-06-28T18:35:12Z -->
## Implementation report
Change-response implementation report:
Reviewer の remote Runtime projection blocker を修正し、追加 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `ba7f9d2ee83a946820cc234e847b6531b4a141f3 workspace: respect remote execution projection`
Note:
- Coder output initially reported a different SHA prefix, but `git rev-parse HEAD` confirmed the actual commit above.
変更内容:
- `crates/workspace-server/src/hosts.rs` の remote Runtime projection で、Runtime HTTP の `WorkerSummary` / `WorkerDetail.execution` を反映するように修正。
- remote projection の `can_accept_input` / `can_stop` は embedded projection と同様に以下を確認する。
- Worker status が `running`
- `execution.backend == connected`
- input は `execution.run_state == idle`
- `rejected` / `errored` / `unconnected` / `stopped` などの実行状態は操作不可
- `last_result``rejected` / `errored` / `unsupported` の場合は操作不可
- remote Worker の表示 status も execution status を反映し、`stale` / `unconnected` / `rejected` / `errored` を投影する。
- remote Runtime の stale / unconnected / rejected / errored projection が input-capable / stoppable にならないことを確認する tests を追加。
- 既存 remote connected projection test に、connected idle の場合だけ input / stop capable になる確認を追加。
Validation:
- `cargo fmt`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi`: success
Residual risks / review focus:
- remote Runtime の `execution` projection を信頼して UI/API capability を決めるため、remote 側が古い schema や不正な execution state を返す場合は deserialize / request failure として扱われる。
- stale / unconnected は明示的に input-capable ではなくなるため、既存 UI が「remote worker は常に入力可」と仮定している場合は表示 / 操作導線の調整が必要になる可能性がある。
---
<!-- event: review author: yoi-reviewer-00001KW7726H9-launch-unify at: 2026-06-28T18:37:36Z status: approve -->
## Review: approve
approve
読み取り専用で再レビューした。HEAD は `ba7f9d2ee83a946820cc234e847b6531b4a141f3 workspace: respect remote execution projection`。`git diff --check c29d10b..ba7f9d2` は追加出力なし。cargo / nix 系の reported validation は再実行していない。
## 確認結果
前回 blocker だった remote Runtime projection は解消されている。
- `crates/workspace-server/src/hosts.rs:1670-1693`
- remote `map_worker_summary()` が Runtime HTTP response の `summary.execution` を参照し、`can_accept_input` / `can_stop` を計算するようになっている。
- `crates/workspace-server/src/hosts.rs:1703-1726`
- remote `map_worker_detail()``detail.execution` を使っている。
- `crates/workspace-server/src/hosts.rs:2106-2134`
- `runtime_worker_can_accept_input()``Running` + `Connected` + `Idle` + rejected / errored / unsupported last_result なしを要求。
- `runtime_worker_can_stop()``Running` + `Connected` を要求し、stopped / rejected / errored / unconnected run_state と blocking last_result を拒否。
- `crates/workspace-server/src/hosts.rs:2155-2175`
- display status も `Stale` / `Unconnected` / `Rejected` / `Errored` を反映する。
- `crates/workspace-server/src/hosts.rs:3384-3475`
- stale / unconnected / rejected / errored の remote projection が input-capable / stoppable にならない test が追加されている。
- 既存 remote positive path でも connected idle worker が input / stop capable であることが確認されている。
これにより、`stale` / execution backend 未接続 Worker が Workspace API 上で input-capable として見える問題は解消されている。
## 総合判断
前回までの blockerexecution binding projection persistence、stale diagnostic、System initial input rejection、remote stale projectionについて、現在の差分で受け入れ条件を満たしていると判断する。現時点で concrete blocker はない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T18:38:41Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commits:
- `14bb4934a6374eea64591035e5342088ab0ccd09 runtime: unify worker creation path`
- `c29d10b67bfff1f4a7a1b2742ec05fe63b80c054 runtime: persist execution binding projection`
- `ba7f9d2ee83a946820cc234e847b6531b4a141f3 workspace: respect remote execution projection`
Merge commit:
- `bdb339fa merge: runtime worker launch unification`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "fs-store ws-server"`: success
- `cargo test -p yoi-workspace-server`: success (`36 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Outcome:
- Runtime Worker creation is unified around canonical `ConfigBundleRef + ExecutionBackend` create semantics.
- Browser-facing launch semantics are separated from Runtime `CreateWorkerRequest`.
- Runtime create request no longer carries raw workspace/cwd/tool-scope/config-store/secret/socket/path semantics.
- ConfigBundle missing/digest/profile mismatch and execution backend missing/provider failures are typed diagnostics/errors.
- Input-capable Workers require execution backend connection.
- Execution binding identity is persisted only as non-authority projection and restore without live handle surfaces stale execution mapping diagnostics.
- System initial input is rejected at Runtime boundary and not persisted as launch-created system transcript.
- Remote Runtime projection respects execution state so stale/unconnected/rejected/errored Workers are not input-capable.
- fake/providerless response bypass was not introduced.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T18:38:48Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "fs-store ws-server"`: success
- `cargo test -p yoi-workspace-server`: success (`36 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-28T18:39:03Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-28T18:39:03Z status: closed -->
## 完了
Runtime Worker 起動経路を canonical `ConfigBundleRef + ExecutionBackend` 経路へ一本化し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- Runtime `CreateWorkerRequest``config_bundle_ref` と user-only initial input 中心の内部作成契約へ整理。
- Browser-facing launch semantics と Runtime worker creation request を分離。
- raw workspace / cwd / tool scope / config store / secret / socket / path 類を Runtime create request に含めない境界にした。
- ConfigBundle missing / digest mismatch / profile mismatch を typed error / diagnostic として扱う。
- execution backend 未接続では Worker 作成を拒否し、input-capable Worker が backend 未接続になる経路を塞いだ。
- create 成功時のみ catalog / transcript / event を永続化し、spawn / initial input dispatch rejection は rollback。
- execution binding identity を raw handle / secret / path / socket を含まない non-authority projection として永続化。
- restore 後に live handle がない persisted execution mapping は `stale` として diagnostic `worker_execution_mapping_stale` を出す。
- System initial input を Runtime boundary で拒否し、launch/create が system transcript を注入できないようにした。
- Workspace embedded / Companion / remote-facing creation を canonical Runtime create path に寄せた。
- Remote Runtime projection でも execution status を見て `can_accept_input` / `can_stop` を計算し、stale / unconnected / rejected / errored Workers を input-capable として出さないようにした。
- fake/providerless response bypass は導入していない。
Integrated commits:
- `14bb4934a6374eea64591035e5342088ab0ccd09 runtime: unify worker creation path`
- `c29d10b67bfff1f4a7a1b2742ec05fe63b80c054 runtime: persist execution binding projection`
- `ba7f9d2ee83a946820cc234e847b6531b4a141f3 workspace: respect remote execution projection`
- merge: `bdb339fa merge: runtime worker launch unification`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "fs-store ws-server"`: success
- `cargo test -p yoi-workspace-server`: success (`36 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T18:39:24Z -->
## Implementation report
Cleanup report:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Role Pod shutdown was intentionally skipped.
- Child implementation worktree was removed:
- `/home/hare/Projects/yoi/.worktree/00001KW7726H9-runtime-worker-launch-unification`
- Child implementation branch was removed:
- `work/00001KW7726H9-runtime-worker-launch-unification`
- No root/original workspace cleanup was performed.
- Orchestration worktree is clean after worktree/branch cleanup.
Routing note:
- Tickets that were queued behind `00001KW7726H9` can now be re-evaluated.
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260628-165114-1","ticket_id":"00001KW7835H0","kind":"blocked_by","related_ticket":"00001KW7726H9","note":"`00001KW7835H0` depends on `00001KW7726H9`; old Pod registry/store removal should follow the canonical Runtime Worker creation authority decision. `00001KW7726H9` is currently inprogress, so this Ticket remains queued and must not start a child worktree/Pod until that dependency is merged/validated/closed or an explicit replan changes the dependency.","author":"yoi-orchestrator","at":"2026-06-28T16:51:14Z"}
{"id":"orch-plan-20260628-191721-2","ticket_id":"00001KW7835H0","kind":"accepted_plan","note":"Dependency `00001KW7726H9` is closed and related fs-store persistence Ticket `00001KW76E8EG` is also closed. No current inprogress Tickets and orchestration worktree is clean, so this final queued Ticket is unblocked.","accepted_plan":{"summary":"`00001KW7726H9` と `00001KW76E8EG` が closed になったため、旧 `pod-registry` / `pod-store` crate を削除し、必要な Worker metadata/session persistence を Worker/Runtime/session-store 側へ移す。`crates/pod` は既に存在しないため不在確認と再導入防止を行う。Runtime Worker identity / creation / persistence は worker-runtime fs-store と execution backend mapping を正とし、旧 Pod registry/store/path/socket/name を active authority として残さない。","branch":"work/00001KW7835H0-old-pod-crate-cleanup","worktree":"/home/hare/Projects/yoi/.worktree/00001KW7835H0-old-pod-crate-cleanup","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に旧 Pod crate 削除・依存移管に必要な focused write scope (`crates/pod-registry`, `crates/pod-store`, `crates/worker`, `crates/tui`, `crates/yoi`, `crates/worker-runtime`, `crates/session-store`, `crates/manifest`, root Cargo/package files など) を委譲する。reviewer Worker は read-only で dependency audit、crate removal、metadata/session persistence 移管、Runtime authority invariant、UI/CLI/prompt/test wording cleanup、validation を確認する。merge/validation/done/cleanup は Orchestrator が行う。現時点では `StopPod` は使用せず、cleanup は child worktree/branch のみ行う。"},"author":"yoi-orchestrator","at":"2026-06-28T19:17:21Z"}

View File

@ -0,0 +1,29 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KW7835H0",
"kind": "depends_on",
"target": "00001KW7726H9",
"note": "Pod registry/store removal should follow the canonical Runtime Worker creation authority decision",
"author": "yoi ticket",
"at": "2026-06-28T13:54:49Z"
},
{
"ticket_id": "00001KW7835H0",
"kind": "related",
"target": "00001KVZG9BMS",
"note": "Pod wording audit and crate removal share active Pod concept cleanup",
"author": "yoi ticket",
"at": "2026-06-28T13:54:49Z"
},
{
"ticket_id": "00001KW7835H0",
"kind": "related",
"target": "00001KW76E8EG",
"note": "Runtime fs-store persistence replaces old Pod metadata authority",
"author": "yoi ticket",
"at": "2026-06-28T13:54:49Z"
}
]
}

View File

@ -0,0 +1,144 @@
---
title: '旧Pod関連クレートを削除しWorker/Runtime storeへ整理する'
state: 'closed'
created_at: '2026-06-28T13:53:21Z'
updated_at: '2026-06-28T20:10:50Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-28T16:51:00Z'
---
## 背景
Runtime / Worker への移行後も、workspace には旧 Pod 実行系由来の `pod-registry` / `pod-store` crate と、それらに依存する TUI / worker / yoi CLI 経路が残っている。`pod-registry` は旧 Pod process / socket / delegated scope cleanup の registry であり、canonical Runtime Worker creation / fs-store / execution backend mapping の authority にすべきではない。
一方で `pod-store` には、名前は Pod だが現在の `worker` crate がまだ内部永続化として使っている `WorkerMetadataStore` / `FsWorkerStore` / `CombinedStore` などが含まれている。これをそのまま Runtime authority として持ち込むのは誤りだが、必要な Worker metadata/session persistence は Worker/Runtime 側の正しい crate に移してから削除する必要がある。
この Ticket では、旧 Pod 関連 crate と依存経路を棚卸しし、何を削除し、何を Worker/Runtime の正規 store として残すかを明確にしたうえで、`pod-registry` / `pod-store` を workspace から削除する。
## 目的
- 旧 Pod registry / metadata store を Runtime Worker authority から完全に外す。
- `crates/pod` は既に workspace から消えているため、再導入しない。残存調査の対象は `pod-registry` / `pod-store` と、それらに依存して残っている旧 Spawn/Stop/metadata/discovery 経路とする。
- `crates/pod-registry` を削除する。
- `crates/pod-store` を削除する。ただし必要な Worker metadata/session store 機能は正しい名前・責務の crate へ移す。
- Runtime Worker creation / persistence は `worker-runtime` fs-store と execution backend mapping を正とする。
- 旧 Pod socket / pod name / pod registry path / spawned pod relation を Worker identity にしない。
## 残すもの / 消すもの
### 残す
- `crates/protocol`
- `protocol::Event` / `protocol::Method` など、Worker/TUI/Runtime の通信 protocol としてまだ現役のため残す。
- ただし Pod 専用 naming / semantics が残る場合は別途 rename 対象として扱う。
- `crates/llm-engine`
- LLM provider 呼び出し / streaming / completion 実行 engine として残す。
- Pod registry / Pod store authority を持たせない。
- `crates/worker`
- `llm-engine`、tools、memory、session、protocol event handling を束ねる agent/Worker orchestration layer として残す。
- ただし `pod-registry` / `pod-store` 依存は除去または Worker/Runtime store 依存へ置換する。
- `crates/worker-runtime`
- Runtime catalog / fs-store / Worker execution backend mapping の正規 authority として残す。
- `crates/session-store`
- Worker history/session persistence として必要な範囲を残す。
- Worker metadata/session persistence のうち、実 Worker 実行に必要な generic 機能
- `WorkerMetadataStore` 相当が必要なら、`pod-store` ではなく `worker` / `worker-runtime` / `session-store` の適切な場所へ移す。
### 既に消えている
- `crates/pod`
- workspace 上には既に存在しない。
- この Ticket では再導入を禁止し、残っている `pod-registry` / `pod-store` と旧 Spawn/Stop/discovery/metadata 経路の削除に集中する。
### 消す
- `crates/pod-registry`
- 旧 Pod process registry / lock file / delegated scope allocation / cleanup authority。
- Runtime Worker creation authority として使わない。
- `crates/pod-store`
- crate としては削除する。
- `FsWorkerStore` / `WorkerMetadata` など必要な型は、Pod naming を捨てて正しい Worker store 境界へ移すか、不要なら削除する。
- `manifest::paths::pod_registry_path` など pod registry path surface。
- `worker::runtime::pod_registry` re-export。
- SpawnPod / StopPod / delegated Pod cleanup を前提にした tool / registry path / tests。
- TUI / yoi CLI の旧 Pod metadata inventory / cleanup 表示。
- 必要な UX は Runtime Worker API / RuntimeRegistry / worker-runtime fs-store へ移行する。
- Browser / Backend / Runtime API に旧 Pod metadata path / socket path / pod name を露出する経路。
## 要件
### Dependency audit
- workspace 内の `pod-registry` / `pod-store` dependency を全列挙する。
- 各依存を以下に分類する。
- delete: 旧 Pod lifecycle 専用で削除する。
- move/rename: generic Worker metadata/session store として必要なので移す。
- replace: RuntimeRegistry / worker-runtime fs-store / session-store に置き換える。
- historical/test-only: historical report や obsolete test fixture として扱う。
- audit 結果を Ticket thread または artifact に残す。
### Verify absent `pod` crate
- `crates/pod` は既に存在しないため、削除作業ではなく不在確認と再導入防止を行う。
- `Cargo.toml` workspace members / dependencies に `crates/pod` / `pod = ...` がないことを確認する。
- 旧 Pod feature adapter / Ticket tool adapter / HostAuthority adapter が必要機能として残っている場合は、既に他 crate へ移管済みか、別途正しい crate で実装されているかを確認する。
### Remove `pod-registry`
- `crates/pod-registry` を workspace members / dependencies から削除する。
- lockfile / package.nix / Nix source filtering を更新する。
- `pod_registry::default_registry_path` / lock / delegated scope APIs を呼ぶ経路を削除または Runtime/Worker authority へ置換する。
- StopPod / SpawnPod legacy cleanup は current develop の正規 path から削除し、before-worker hotfix が必要なら別 branch / 別 Ticket で扱う。
### Remove / replace `pod-store`
- `crates/pod-store` を workspace members / dependencies から削除する。
- `WorkerMetadata` / `WorkerMetadataStore` / `FsWorkerStore` / `CombinedStore` がまだ必要な場合は、Pod ではない名前と責務で移す。
- 移行先は実装時に選ぶが、Runtime Worker authority を歪めないこと。
- Runtime catalog / Worker identity は `worker-runtime` fs-store。
- execution session/history は `session-store` / `worker` internal store。
- Runtime Worker id と execution session mapping は Runtime fs-store 側。
- 旧 `~/.yoi/pods` metadata を正規 store として読まない。
### UI / CLI cleanup
- TUI / yoi CLI / worker cleanup CLI から旧 Pod inventory / metadata cleanup 依存を削除する。
- 必要な list / attach / cleanup UX は Runtime Worker API / RuntimeRegistry / worker-runtime fs-store を正とする。
- Help text / diagnostics / tests に `pod` が active concept として残らないようにする。
- Historical docs / reports は対象外にしてよいが、active prompt / UI / CLI / protocol naming は分類する。
### Runtime authority invariant
- Runtime Worker creation は canonical `CreateWorker` / ConfigBundle / ExecutionBackend / fs-store 経路を通る。
- `pod-registry` / `pod-store` が Runtime Worker identity、scope authority、socket authority、Browser-facing projection の source にならない。
- Worker として公開されるものは transcript / observation / execution / persistence contract を満たす。
## Non-goals
- before-worker tag 系列の StopPod hotfix。
- Historical report / old migration note の全面 rewrite。
- Public product rename。
- Full multi-user auth / permission / redaction policy。
- `protocol` crate の全面 rename。
- `worker` crate 自体の削除。
## 受け入れ条件
- `crates/pod` が workspace に存在せず、再導入されていないことが確認されている。
- `crates/pod-registry` が workspace から削除されている。
- `crates/pod-store` が workspace から削除されている。
- `Cargo.toml` workspace members / dependencies / `Cargo.lock` / `package.nix` が更新されている。
- `pod` / `pod-registry` / `pod-store` dependency が active crates に残っていない。
- `manifest::paths::pod_registry_path` など active pod registry path API が削除されている。
- Runtime Worker identity / creation / persistence が `worker-runtime` fs-store と execution backend mapping を正にしている。
- `worker` crate 実行に必要な metadata/session persistence が Pod naming / Pod authority に依存しない形へ移行されている。
- TUI / yoi CLI / tests が旧 Pod metadata inventory に依存していない。
- Active UI / CLI / prompt / test output に旧 Pod concept が正規 concept として残っていない。残る場合は legacy/internal/historical として分類されている。
- `cargo test -p worker-runtime --features ws-server` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo test -p worker` が通る。
- `cargo check -p yoi` が通る。
- `cd web/workspace && deno task check` が通る。
- `git diff --check` が通る。
- `nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,34 @@
旧 Pod 関連 crate を削除し、Worker/Runtime/session-store 境界へ整理した実装を reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `crates/pod-registry``crates/pod-store` を workspace / package / lock / source から削除。
- `crates/pod` は引き続き absent。
- `pod-store` 相当の Worker metadata persistence を `session-store::worker_metadata` へ移管。
- Worker metadata root を active Worker data root 側へ整理。
- `pod-registry` 相当の scope allocation 実装を `worker::runtime::worker_allocation` へ移管し、Runtime Worker identity / creation / durable persistence authority ではないことを明記。
- active CLI / tool / test artifact output を旧 Pod authority wording から Worker / allocation / metadata wording へ変更。
- TUI / yoi / worker の active old crate dependency を削除。
- E2E fixture の Worker metadata root を active `manifest::paths::data_dir()` default resolution と整合する `$HOME/.yoi/workers` に変更。
- Runtime Worker identity / creation / persistence authority は `worker-runtime` fs-store + execution backend mapping のまま維持。
- old Pod registry/store/socket/name を active authority として残す互換 alias は導入していない。
Integrated commits:
- `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a refactor: remove old pod crates`
- `c46e880b fix: finish worker wording cleanup`
- `fdd902d5 fix: align e2e worker metadata root`
- merge: `83d433bf merge: old pod crate cleanup`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker`: success
- `cargo test -p session-store`: success
- `cargo check -p yoi`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.

View File

@ -0,0 +1,590 @@
<!-- event: create author: "yoi ticket" at: 2026-06-28T13:53:21Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: decision author: hare at: 2026-06-28T14:20:04Z -->
## Decision
調査結果:
- `crates/pod` は現 workspace には存在しない。`Cargo.toml` workspace members / workspace dependencies にも `crates/pod` / `pod = ...` はない。したがって「pod crate を消す」作業は既に過去の移行で遂行済みで、この Ticket では不在確認と再導入防止を扱う。
- 残っている旧 Pod 関連 crate は `crates/pod-registry``crates/pod-store`
- これらが残っている理由は、旧 Pod UI crate ではなく、現 `worker` / `tui` / `yoi` の内部がまだ旧 store / registry を使っているため。
現時点の直接依存:
- `crates/worker/Cargo.toml`
- `pod-store = { workspace = true }`
- `pod-registry = { workspace = true }`
- `crates/tui/Cargo.toml`
- `pod-store = { workspace = true }`
- `pod-registry = { workspace = true }`
- `crates/yoi/Cargo.toml`
- `pod-store = { workspace = true }`
主な残存理由:
1. `worker` crate の Spawn/Stop/peer discovery/delegated scope 経路が `pod-registry` を使っている。
- `worker/src/spawn/tool.rs`: `delegate_scope`, `release_worker`, `default_registry_path`, `LockFileGuard`
- `worker/src/spawn/registry.rs`: delegated scope reclaim / release。
- `worker/src/worker.rs`: top-level install / adopt allocation / segment update / reclaim。
- `worker/src/discovery.rs`: segment lookup / peer discovery。
これは旧 child Pod/Worker process orchestration の実行時 authority が残っているため。
2. `worker` crate の metadata/session persistence が `pod-store` を generic Worker store として使い続けている。
- `worker/src/entrypoint.rs`: `FsWorkerStore`, `CombinedStore`
- `worker/src/runtime_adapter.rs`: `FsWorkerStore`, `CombinedStore`, `pod_store_dir`
- `worker/src/controller.rs`, `worker/src/worker.rs`, `worker/src/ticket_event_notify.rs`, tests。
これは名前は Pod だが、`WorkerMetadataStore` / `WorkerMetadata` / `FsWorkerStore` が現 Worker 実行にも必要な metadata/session store として残っているため。削除前に Worker/Runtime store 境界へ move/rename が必要。
3. `tui` crate が旧 local Worker inventory / picker / dashboard で `pod-store` / `pod-registry` を読んでいる。
- `tui/src/picker.rs`
- `tui/src/worker_list.rs`
- `tui/src/dashboard/mod.rs`
current Runtime API / Backend Worker list へ置換するか、旧 TUI inventory を削除する必要がある。
4. `yoi` CLI の session / worker cleanup が `pod-store` を読んでいる。
- `yoi/src/session_cli.rs`
- `yoi/src/worker_cleanup_cli.rs`
旧 metadata cleanup / referenced session discovery 用。Runtime/worker-runtime store へ置換するか、旧 cleanup CLI を削除する必要がある。
結論:
- `crates/pod` が残っているわけではない。既に消えている。
- `pod-registry` は旧 process/delegation/segment/socket 系 authority として `worker` と TUI に残っている。
- `pod-store` は旧名のまま generic Worker metadata/session store として `worker`、TUI、CLI に残っている。
- 削除作業は「pod crate の削除」ではなく、`pod-registry` の旧 orchestration authority 削除と、`pod-store` の必要部分を Worker/Runtime store に移管して crate を消す作業になる。
---
<!-- event: intake_summary author: hare at: 2026-06-28T14:24:09Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-28T14:24:09Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-28T16:51:00Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T16:51:22Z -->
## Decision
Routing decision:
Workspace Dashboard Queue authorized Orchestrator routing, so the Ticket and current workspace state were inspected.
Findings:
- Ticket state: `queued`
- Outgoing dependency: `depends_on 00001KW7726H9`
- Blocking Ticket `00001KW7726H9` is currently `inprogress`.
- Current inprogress Tickets: `00001KW7726H9`
- Orchestration worktree: clean
Decision:
- Leave `00001KW7835H0` queued and do not create a child worktree / role Pod yet.
- Reason: `pod-registry` / `pod-store` removal must follow the canonical Runtime Worker creation authority decision and implementation in `00001KW7726H9`. Starting both in parallel would risk deleting/moving store/registry paths while the canonical Worker creation API is still being shaped.
Next routing condition:
- Re-evaluate this Ticket after `00001KW7726H9` is merged, validated, and closed, or after an explicit replan changes the dependency.
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T19:17:30Z -->
## Decision
Routing decision:
The previously blocking dependency `00001KW7726H9` is now merged, validated, closed, and cleaned up. Related fs-store persistence Ticket `00001KW76E8EG` is also merged, validated, closed, and cleaned up. Current workspace state was inspected before implementation side effects.
Findings:
- Ticket state: `queued`
- Blocking dependency: `00001KW7726H9` is closed.
- Related persistence Ticket: `00001KW76E8EG` is closed.
- Current `inprogress` Tickets: 0
- Orchestration worktree: clean
Decision:
- Accept this Ticket for implementation and transition `queued -> inprogress` before child worktree / Pod side effects.
Plan:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KW7835H0-old-pod-crate-cleanup`
- Branch: `work/00001KW7835H0-old-pod-crate-cleanup`
- Coder Worker will remove old `pod-registry` / `pod-store` crates and migrate any remaining authority/persistence responsibilities to Worker/Runtime/session-store boundaries.
- Reviewer Worker will verify crate removal, dependency graph cleanup, Runtime authority invariants, stale compatibility avoidance, Browser/UI/CLI/prompt/test wording, and validation evidence.
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child worktree/branch unless explicitly reauthorized.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T19:17:36Z from: queued to: inprogress reason: dependencies_closed_and_queue_continues field: state -->
## State changed
Dependencies are closed, no Ticket is currently inprogress, and the orchestration worktree is clean. Accepting implementation before child worktree / role Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:18:26Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW7835H0-old-pod-crate-cleanup`
- Branch: `work/00001KW7835H0-old-pod-crate-cleanup`
- Coder Worker spawned: `yoi-coder-00001KW7835H0-pod-cleanup`
Scope notes:
- Coder write scope covers the old crate directories `crates/pod-registry`, `crates/pod-store`, affected active crates (`crates/worker`, `crates/tui`, `crates/yoi`, `crates/worker-runtime`, `crates/session-store`, `crates/manifest`), prompt/profile/web workspace paths, and root Cargo/package files.
- `crates/pod` is already absent; Coder is instructed to verify absence and prevent reintroduction.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Implementation focus:
- remove `crates/pod-registry` and `crates/pod-store` from workspace
- migrate/rename any remaining Worker metadata/session persistence to Worker/Runtime/session-store boundaries without active Pod authority naming
- remove old pod-registry path APIs and old Pod metadata inventory/cleanup dependencies from worker/tui/yoi where active
- ensure Runtime Worker identity / creation / persistence remains `worker-runtime` fs-store + execution backend mapping authority
- do not use old Pod registry/store/socket/name as active authority
- keep historical docs only if clearly historical
- validate dependency graph and run targeted tests/checks
---
<!-- event: implementation_report author: yoi-coder-00001KW7835H0-pod-cleanup at: 2026-06-28T19:45:57Z -->
## Implementation report
Implementation report:
実装完了。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a refactor: remove old pod crates`
実装内容:
- `crates/pod-registry` / `crates/pod-store` を workspace から削除。
- `Cargo.toml` / `Cargo.lock` / 各 crate dependency / `package.nix` から旧 crate 参照を除去。
- `crates/pod` も引き続き存在しないことを確認。
- `pod-registry` / `pod-store` / `crates/pod` 系の active 参照が残っていないことを `rg` で確認。
- `pod-store` の Worker metadata 永続化を `session-store` 境界へ移動。
- `session_store::worker_metadata` として `FsWorkerStore`, `WorkerMetadataStore`, `CombinedStore` 等を提供。
- active storage naming を Worker metadata に寄せ、metadata root も `data_dir/workers` 側へ変更。
- `pod-registry` の active crate 依存を削除。
- Worker 内部の `runtime::worker_allocation` モジュールへ、scope allocation 用の実装として分類・移設。
- module comment で「Runtime Worker identity / creation / durable persistence の authority ではない」ことを明記。
- manifest path API は `worker_allocation_path` / `worker_socket_path` 名へ置換。
- `tui` / `yoi` / `worker` の旧 crate 依存を置換。
- TUI の live worker inventory は旧 `pod-registry` crate に依存せず、renamed worker allocation table を read-only に読む形へ変更。
- 読み取り時は `fs4` の shared lock を使い、writer との競合で partial read しにくい形に修正。
- `package.nix``cargoHash` を更新。
依存監査サマリ:
- `crates/pod`: absent
- `crates/pod-registry`: deleted
- `crates/pod-store`: deleted
- workspace/package/lock/source 内の active `pod-registry` / `pod-store` / `crates/pod` 参照: なし
- Runtime Worker identity/creation/persistence authority は `worker-runtime` fs-store + execution backend mapping のまま維持し、旧 registry/store/socket/name を authority として使わない構成に整理。
Validation:
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker`: success
- `cargo test -p session-store`: success
- `cargo check -p yoi`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `git diff --cached --check`: success
- `nix build .#yoi`: success
Risks / notes:
- Worker metadata root を `data_dir/pods` 由来から `data_dir/workers` 側へ寄せたため、既存ローカル metadata の自動移行は入れていない。Ticket 方針どおり、旧 Pod authority naming 互換を増やさない実装。
- Historical docs は今回の active dependency cleanup の対象外だが、active crate/path/dependency 参照は除去済み。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:46:27Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned for `00001KW7835H0` implementation.
- Reviewer Worker: `yoi-reviewer-00001KW7835H0-pod-cleanup`
- Review target commit: `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a refactor: remove old pod crates`
- Review mode: read-only implementation worktree review
Review focus:
- `crates/pod-registry` and `crates/pod-store` are removed from workspace / Cargo / package / lock / active source references
- `crates/pod` remains absent and is not reintroduced
- needed Worker metadata/session persistence moved to Worker/Runtime/session-store boundaries without active Pod authority naming
- old pod-registry APIs are not active authority
- Runtime Worker identity / creation / persistence remains `worker-runtime` fs-store + execution backend mapping
- UI/CLI/TUI active wording and tests compile
- no stale compatibility alias preserves old Pod store authority
- validation evidence is plausible and sufficient
---
<!-- event: review author: yoi-reviewer-00001KW7835H0-pod-cleanup at: 2026-06-28T19:51:02Z status: request_changes -->
## Review: request changes
request_changes
対象 commit `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a` を read-only で確認した。worktree は clean、`git diff --check HEAD^ HEAD` は成功。重い `cargo` / `deno` / `nix` は reviewer では再実行せず、報告済み validation は diff/code 上で plausibility を確認した。
## 満たしている点
- `crates/pod` / `crates/pod-registry` / `crates/pod-store` のディレクトリは存在しない。
- `Cargo.toml` workspace members/default-members/dependencies、active crate `Cargo.toml`、`Cargo.lock`、`package.nix` から `pod-registry` / `pod-store` dependency は除去済み。
- `manifest::paths::pod_registry_path` / `pod_registry::...` / `pod_store::...` 参照は active source から消えている。
- `pod-store` 相当の `WorkerMetadataStore` / `FsWorkerStore` / `CombinedStore``session-store::worker_metadata` へ移され、`data_dir/workers` の Worker metadata と session log store に分離されている。
- `worker-runtime` 側は `RuntimeId + WorkerId` を authority とする fs-store / execution backend mapping が残っており、旧 pod path/socket/session path を authority にしない設計コメント・境界も確認した。
## Blockers
1. **active CLI output に旧 `pod` 名が残っている。**
`crates/yoi/src/worker_cleanup_cli.rs:242` / `253``yoi worker delete` の refusal 出力が `pod: {}` になっている。これは Ticket の「TUI / yoi CLI / worker cleanup CLI から旧 Pod metadata inventory / cleanup 依存を削除」「Active UI / CLI / prompt / test output に旧 Pod concept が正規 concept として残らない」に反する。`worker:` に直す必要がある。
2. **LLM/tool-visible な active Worker allocation error が旧 Pod authority wording のまま。**
`crates/worker/src/runtime/worker_allocation/error.rs:14,16,29,32` に以下が残っている:
- `pod name ... is already registered`
- `conflicts with pod ...`
- `pod ... is not registered`
- `session ... is already held by pod ...`
これらは `crates/worker/src/spawn/tool.rs:867-875``ToolError``e.to_string()` として流れるため、単なる内部コメントではなく active tool/user-facing error。旧 `pod-registry` crate は消えているが、移管先の active scope allocation path が旧 Pod 名で authority/error を表現しており、Ticket intent に対して未完了。
3. **active e2e harness artifact に旧 pod registry / metadata path が残っている。**
`tests/e2e/src/lib.rs:1624-1625``tested_yoi_pod_registry: ... pods.json``fixture_pod_metadata_root: ... pods` を書き出している。`tests/e2e` は workspace member なので、historical docs/report ではなく active test source/output と見なされる。現 Runtime/Worker path へ更新するか、legacy/historical fixture として明確に分類・隔離する必要がある。
上記を直したうえで、報告済み validation 一式(少なくとも `cargo check -p yoi`, `cargo test -p worker`, `cargo test -p worker-runtime --features ws-server`, `cargo test -p yoi-workspace-server`, `deno task check`, `git diff --check`, `nix build .#yoi --no-link`)を再確認すること。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T19:51:35Z -->
## Implementation report
Change-request routing:
Reviewer requested changes for active wording/test artifact leftovers. The original Coder scope did not include `tests/e2e`, so a focused fix Coder Worker was spawned with additional write scope for the specific blocker areas.
- Fix Coder Worker: `yoi-coder-00001KW7835H0-pod-fix`
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KW7835H0-old-pod-crate-cleanup`
Requested fixes:
- change active `yoi worker delete` refusal output from `pod:` to `worker:`
- rename active worker allocation tool/user-facing errors away from old Pod authority wording
- update active `tests/e2e` harness artifact names/paths away from old pod registry / pod metadata wording or clearly isolate as legacy if required
- run focused validation and commit fixes
---
<!-- event: implementation_report author: yoi-coder-00001KW7835H0-pod-fix at: 2026-06-28T19:55:08Z -->
## Implementation report
Change-response implementation report:
Reviewer blockers を修正し、追加 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `c46e880b fix: finish worker wording cleanup`
変更内容:
- `crates/yoi/src/worker_cleanup_cli.rs`
- `yoi worker delete` の refusal 出力を `pod:` から `worker:` に修正。
- `crates/worker/src/runtime/worker_allocation/error.rs`
- active/user-facing error 文言を旧 Pod 表現から Worker/allocation 表現に修正。
- `pod name`, `conflicts with pod`, `pod ... not registered`, `held by pod` を置換。
- `tests/e2e/src/lib.rs`
- E2E artifact 名と fixture metadata path を Worker/allocation/metadata 表現に更新。
- `tested_yoi_pod_registry` -> `tested_yoi_worker_allocation`
- `fixture_pod_metadata_root` -> `fixture_worker_metadata_root`
- fixture path `yoi/pods/...` -> `yoi/workers/...`
- helper 名を `write_blocking_worker_metadata` に更新。
Validation:
- `cargo fmt`: success
- `cargo test -p worker`: success
- `cargo check -p yoi`: success
- `cargo check -p yoi-e2e`: success
- `git diff --check`: success
- 対象旧表現の `rg` 確認: success
Final state:
- child worktree is clean.
---
<!-- event: review author: yoi-reviewer-00001KW7835H0-pod-cleanup at: 2026-06-28T20:00:09Z status: request_changes -->
## Review: request changes
request_changes
再レビューは read-only で実施した。`c46e880b` は確認済みで、worktree は clean、`git diff --check HEAD^ HEAD` は成功。重い `cargo` / `deno` / `nix` は read-only 指示のため再実行せず、報告 validation はコード上の整合性を確認した。
前回指摘分のうち、以下は修正済み:
- `crates/yoi/src/worker_cleanup_cli.rs` の active refusal output は `pod:` ではなく `worker:` になっている。
- `crates/worker/src/runtime/worker_allocation/error.rs` の user-facing error は Pod wording から Worker/allocation wording に変更されている。
- `tests/e2e/src/lib.rs` の artifact key / helper 名は `tested_yoi_worker_allocation` / `fixture_worker_metadata_root` / `write_blocking_worker_metadata` に変更されている。
- `crates/pod` / `crates/pod-registry` / `crates/pod-store` の crate directory / workspace dependency / lock / package 参照は残っていない。
## Blocker
### E2E fixture の Worker metadata root が active store と一致していない
`tests/e2e/src/lib.rs` は現在も Worker metadata を `XDG_DATA_HOME` 側へ書いている。
- `tests/e2e/src/lib.rs:1353-1354`
- `write_blocking_worker_metadata(&fixture.xdg_data_home, "workspace")?;`
- `write_blocking_worker_metadata(&fixture.xdg_data_home, "workspace-orchestrator")?;`
- `tests/e2e/src/lib.rs:1625`
- `"fixture_worker_metadata_root": self.xdg_data_home.join("yoi").join("workers")`
- `tests/e2e/src/lib.rs:2000-2003`
- `data_home.join("yoi").join("workers").join(worker_name)`
しかし active Worker metadata store は `manifest::paths::data_dir()` ベース:
- `crates/manifest/src/paths.rs:41-46`
- `data_dir()``YOI_DATA_DIR` / `YOI_HOME` / `HOME` を見る。`XDG_DATA_HOME` は使わない。
- `crates/worker/src/entrypoint.rs:487-494`
- worker metadata dir は `paths::data_dir().join("workers")`
- `crates/yoi/src/worker_cleanup_cli.rs:209-237`
- cleanup CLI も `paths::data_dir().join("workers")`
E2E harness は `HOME``XDG_DATA_HOME` を分けて渡しており、`YOI_DATA_DIR` は設定していない。そのため実際の active metadata root は fixture 上では概ね `$HOME/.yoi/workers` だが、現在の fixture/helper/artifact は `$XDG_DATA_HOME/yoi/workers` を指している。
これは表面上の naming は直っているが、移行後の Worker/session-store 境界と一致していないため、E2E fixture が正しい Worker metadata store をブロック・記録できない。Ticket の「必要な Worker metadata/session persistence を Worker/Runtime/session-store boundaries へ移す」「tests が旧 Pod metadata inventory に依存しない」に対して未完了。
修正案:
- `write_blocking_worker_metadata``fixture.home` または解決済み `data_dir` を渡し、`$HOME/.yoi/workers` に書く。
- または E2E subprocess に `YOI_DATA_DIR` を明示し、helper / artifact / subprocess env が同じ data root を使うように揃える。
- `fixture_worker_metadata_root` も同じ resolved active path を記録する。
補足: `YOI_POD_RUNTIME_COMMAND` の残存は既存設計上の dev executable-path override として扱い、今回の blocker にはしていない。
---
<!-- event: implementation_report author: yoi-coder-00001KW7835H0-pod-fix at: 2026-06-28T20:02:28Z -->
## Implementation report
Change-response implementation report:
Reviewer の E2E Worker metadata root blocker を修正し、追加 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `fdd902d5 fix: align e2e worker metadata root`
変更内容:
- `tests/e2e/src/lib.rs`
- E2E fixture の Worker metadata helper を active code の `manifest::paths::data_dir()` の既定解決に合わせた。
- `XDG_DATA_HOME/yoi/workers` ではなく、fixture の `HOME` から解決される `$HOME/.yoi/workers` を使用。
- `fixture_worker_metadata_root` artifact も同じ `$HOME/.yoi/workers` を記録するよう修正。
- `active_worker_metadata_root(&home)` helper を追加し、書き込み先と artifact 記録を同一関数で揃えた。
Validation:
- `cargo fmt`: success
- `cargo check -p yoi-e2e`: success
- `git diff --check`: success
- `cargo test -p yoi-e2e`: success
- `cargo check -p yoi`: success
Final state:
- child worktree is clean.
---
<!-- event: review author: yoi-reviewer-00001KW7835H0-pod-cleanup at: 2026-06-28T20:04:44Z status: approve -->
## Review: approve
approve
read-only で再レビューした。対象 HEAD は `fdd902d5 fix: align e2e worker metadata root`、worktree は clean。
確認した点:
- 前回 blocker の E2E Worker metadata root は解消済み。
- `tests/e2e/src/lib.rs``active_worker_metadata_root(&fixture.home)` を使い、helper write path と artifact path が同じ `$HOME/.yoi/workers` を参照している。
- `write_blocking_worker_metadata``active_worker_metadata_root(home)` 経由であり、`manifest::paths::data_dir()` の default HOME 解決と整合している。
- `fixture_worker_metadata_root` artifact も同じ helper 経由で記録されている。
- 前回までの blocker 修正も維持されている。
- `yoi worker delete` refusal output は `worker:`
- `worker_allocation` の user-facing error は Pod wording ではなく Worker/allocation wording。
- E2E artifact/helper 名は `tested_yoi_worker_allocation` / `fixture_worker_metadata_root` / `write_blocking_worker_metadata`
- 旧 crate / authority 参照の除去を再確認。
- `crates/pod`, `crates/pod-registry`, `crates/pod-store` は不在。
- workspace / active dependency / lock / package への `pod-registry` / `pod-store` 参照は見当たらない。
- `pod_registry_path`, `pod_registry::`, `pod_store::` など旧 API authority 参照も active source では見当たらない。
- `git diff --check HEAD^ HEAD`: success。
- 報告 validation`cargo fmt`, `cargo check -p yoi-e2e`, `cargo test -p yoi-e2e`, `cargo check -p yoi`)は、今回の diff と import/path 整合性から plausible。read-only 指示のため cargo 系は再実行していない。
ブロッカーはない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T20:10:03Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commits:
- `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a refactor: remove old pod crates`
- `c46e880b fix: finish worker wording cleanup`
- `fdd902d5 fix: align e2e worker metadata root`
Merge commit:
- `83d433bf merge: old pod crate cleanup`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker`: success
- `cargo test -p session-store`: success
- `cargo check -p yoi`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Outcome:
- `crates/pod-registry` and `crates/pod-store` were removed from workspace/package/lock/source.
- `crates/pod` remains absent.
- Worker metadata store responsibility moved to `session-store::worker_metadata` under Worker naming.
- Runtime allocation implementation now lives under `worker::runtime::worker_allocation` and is documented as scope allocation, not Runtime Worker identity/creation/durable-persistence authority.
- Active CLI/tool/test output was renamed away from old Pod authority wording.
- E2E worker metadata fixture now uses the active `$HOME/.yoi/workers` data root resolution rather than `XDG_DATA_HOME/yoi/workers`.
- Runtime Worker identity / creation / persistence remains `worker-runtime` fs-store + execution backend mapping authority.
- No stale compatibility alias preserving old Pod store authority was introduced.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T20:10:12Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker`: success
- `cargo test -p session-store`: success
- `cargo check -p yoi`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-28T20:10:27Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-28T20:10:27Z status: closed -->
## 完了
旧 Pod 関連 crate を削除し、Worker/Runtime/session-store 境界へ整理した実装を reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `crates/pod-registry``crates/pod-store` を workspace / package / lock / source から削除。
- `crates/pod` は引き続き absent。
- `pod-store` 相当の Worker metadata persistence を `session-store::worker_metadata` へ移管。
- Worker metadata root を active Worker data root 側へ整理。
- `pod-registry` 相当の scope allocation 実装を `worker::runtime::worker_allocation` へ移管し、Runtime Worker identity / creation / durable persistence authority ではないことを明記。
- active CLI / tool / test artifact output を旧 Pod authority wording から Worker / allocation / metadata wording へ変更。
- TUI / yoi / worker の active old crate dependency を削除。
- E2E fixture の Worker metadata root を active `manifest::paths::data_dir()` default resolution と整合する `$HOME/.yoi/workers` に変更。
- Runtime Worker identity / creation / persistence authority は `worker-runtime` fs-store + execution backend mapping のまま維持。
- old Pod registry/store/socket/name を active authority として残す互換 alias は導入していない。
Integrated commits:
- `17a9488a4aa0a3dc83be2d3360b6ffd8ffcaeb5a refactor: remove old pod crates`
- `c46e880b fix: finish worker wording cleanup`
- `fdd902d5 fix: align e2e worker metadata root`
- merge: `83d433bf merge: old pod crate cleanup`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo test -p worker`: success
- `cargo test -p session-store`: success
- `cargo check -p yoi`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T20:10:50Z -->
## Implementation report
Cleanup report:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Role Pod shutdown was intentionally skipped.
- Child implementation worktree was removed:
- `/home/hare/Projects/yoi/.worktree/00001KW7835H0-old-pod-crate-cleanup`
- Child implementation branch was removed:
- `work/00001KW7835H0-old-pod-crate-cleanup`
- No root/original workspace cleanup was performed.
- Orchestration worktree is clean after worktree/branch cleanup.
Queue note:
- The Dashboard-queued chain related to Runtime Worker launch/fs-store/old Pod crate cleanup is now complete/closed.
---

View File

@ -0,0 +1,207 @@
---
title: 'Workspace Backend設定ファイルのスキーマを定義する'
state: 'closed'
created_at: '2026-07-01T14:41:48Z'
updated_at: '2026-07-01T16:38:31Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-01T15:32:38Z'
---
## 背景
Workspace Backend は現在、`ServerConfig::local_dev(...)` と起動時の CLI 引数で listen address、frontend URL、DB path、embedded Runtime store root などを組み立てている。これだと設定項目が増えたときに CLI flag が管理漏れしやすく、config と data の境界も曖昧になりやすい。
Backend 設定は file schema として定義し、CLI flag 追加で増やさない。CLI は起動対象 workspace を決める最小入口に留め、Backend の運用設定は固定 path の config file から読む。
この Ticket では、Workspace Backend 設定ファイルの schema、読み込み位置、default、config/data 境界を定義する。
## 目的
- Workspace Backend の設定を TOML file schema として定義する。
- 新しい Backend 設定項目を CLI flag として増やさない。
- config file に入れるものと、Backend / Runtime が生成する data を明確に分ける。
- secrets や runtime-generated state を config file に混ぜない。
- 既存の `ServerConfig` を、file config から解決された runtime config として扱えるようにする。
## 方針
### CLI flag は増やさない
この Ticket では `--config` や個別設定用 CLI flag を追加しない。Backend config file は固定 path から読む。
既存の開発用 CLI 引数を直ちに削除するかは実装時に判断するが、新規設定項目は CLI flag として追加しない。最終的には `defaults -> config file -> process environment for secrets only` 程度に寄せ、CLI override を通常運用経路にしない。
### config file path
workspace-local な local config として、固定 path を使う。
```text
<workspace_root>/.yoi/workspace-backend.local.toml
```
理由:
- workspace ごとに Backend 設定が分かれる。
- `.local` を含むため current `.gitignore``*.local*` で git 管理外になる。
- `.yoi/tickets``.yoi/objectives` など project record とは別物として扱える。
- `--config` なしで discovery できる。
config file が無い場合は defaults で起動できること。
### data root の扱い
config file は data の保存先 override を持ってよいが、data 本体は config file に入れない。
既定の data root は user data 配下を使い、workspace_id で分離する。
```text
<data_dir>/workspace-server/<workspace_id>/
```
この配下に少なくとも次を置く。
```text
workspace.db
embedded-runtime/
logs/ # 必要なら。現状の detached dev log は別扱いでもよい。
```
workspace-local `.yoi/workspace-backend.local.toml` に data path override を書けるが、DB や runtime store の中身は config ではない。
## schema 案
実装時には serde 用の `WorkspaceBackendConfigFile` 相当の型を定義する。
```toml
[server]
listen = "127.0.0.1:8787"
frontend_url = "http://127.0.0.1:5173"
static_assets_dir = null
[data]
root = null
workspace_database_path = null
embedded_runtime_store_root = null
[limits]
max_records = 200
[[runtimes.remote]]
id = "example"
endpoint = "http://127.0.0.1:8790"
token_ref = null
```
### `[server]`
Config:
- `listen`: Backend HTTP/WebSocket listen address。
- `frontend_url`: Browser-facing frontend URL を生成・表示するための URL。
- `static_assets_dir`: packaged frontend ではなく local assets を serve する場合の override。
Data ではないもの:
- active connection。
- WebSocket cursor。
- generated Runtime event。
### `[data]`
Config:
- `root`: Workspace Backend data root の override。
- `workspace_database_path`: control plane DB path の override。
- `embedded_runtime_store_root`: embedded Runtime fs-store root の override。
Data:
- SQLite DB 本体。
- Runtime fs-store の snapshot / transcript / event / ConfigBundle store。
- generated logs / pid files。
Data 本体を TOML に書かない。
### `[limits]`
Config:
- `max_records`: API response で返す project record 数の上限。
最初は `max_records` だけに留める。body truncation や git log limit まで一気に config 化しない。
### `[[runtimes.remote]]`
Config:
- `id`: remote Runtime source id。
- `endpoint`: remote Runtime endpoint。
- `token_ref`: secret store / env / local secret への参照。
禁止:
- bearer token 値を直接 TOML に保存しない。
- Runtime endpoint の credential / socket path を Browser-facing API に出さない。
## config に入れないもの
- `workspace_id` / `created_at` / workspace identity metadata。
- Ticket / Objective body。
- WorkerId / TranscriptId。
- Runtime event cursor。
- transcript entries。
- ConfigBundle 本体。
- SQLite DB の中身。
- embedded Runtime fs-store の中身。
- secret 値 / bearer token 値。
- socket path / session path / raw execution handle。
- live execution state。
- git HEAD / dirty state / log projection。
## data / record の authority
- Workspace identity は `.yoi/workspace.toml` の record として扱い、Backend config file に移さない。
- Ticket / Objective は `.yoi/tickets` / `.yoi/objectives` など project record を authority とする。
- Backend control plane DB は generated data とする。
- embedded Runtime fs-store は generated Runtime data とする。
- config file はこれらの保存先や接続先を指定するだけで、record 本体を持たない。
## 実装要件
- `WorkspaceBackendConfigFile` 相当の serde TOML schema を追加する。
- config file が無い場合は defaults で起動する。
- unknown field は拒否するか warning にするかを明示し、silent ignore にしない。
- path は workspace root からの relative path と absolute path の扱いを明確にする。
- `data.root` relative path は workspace root 基準にする。
- user data default は absolute path として resolve する。
- `ServerConfig` は file config と workspace identity から解決された runtime config として扱う。
- new Backend setting を CLI flag として追加しない。
- secrets は `token_ref` など ref として持ち、値を config file に保存しない。
## Non-goals
- full secret store UI の実装。
- remote Runtime supervisor の完成。
- frontend Vite dev server の config schema 化。
- Ticket / Objective schema の変更。
- Runtime fs-store の内部 schema 変更。
- 既存 CLI 引数の全面削除。ただし新規設定項目を CLI flag として増やさない。
## 受け入れ条件
- Workspace Backend config file の schema が code/docs/tests 上で明確になっている。
- fixed path `<workspace_root>/.yoi/workspace-backend.local.toml` から config を読む。
- config file が無い場合は defaults で起動できる。
- `ServerConfig` が config file + workspace identity + defaults から解決される。
- 新規 Backend 設定項目用の CLI flag が追加されていない。
- config と data の分類が code/docs/tests 上で明確になっている。
- secret 値を config file に保存しない。
- unknown field の扱いが test されている。
- relative path / absolute path の resolve が test されている。
- Backend data root / DB path / embedded Runtime store root の override が test されている。
- Browser-facing API に config file path、data root、secret、socket path、runtime store path が漏れない。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `git diff --check` が通る。
- `nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,20 @@
Implemented Workspace Backend config file schema and resolver.
Delivered:
- `WorkspaceBackendConfigFile` TOML schema with `server`, `data`, `limits`, and `runtimes.remote` sections.
- Fixed workspace-local config path `.yoi/workspace-backend.local.toml`.
- Defaults when config file is absent.
- Unknown-field rejection.
- Workspace-root relative path resolution and absolute path preservation.
- Resolved config boundary for `ServerConfig`, listen address, and database path.
- Data root derivation for `workspace.db` and `embedded-runtime`.
- Secret value exclusion from schema; `token_ref` fails closed until secret ref resolution is implemented.
- `yoi-workspace-server serve` loads config and applies only existing legacy dev CLI overrides.
- Focused tests for defaults, unknown fields, path handling, data overrides, and secret field behavior.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi --no-link`

View File

@ -0,0 +1,157 @@
<!-- event: create author: "yoi ticket" at: 2026-07-01T14:41:48Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: plan author: hare at: 2026-07-01T14:43:04Z -->
## Plan
Workspace Backend config file schema を定義する Ticket として詳細化した。
決定:
- 新規 Backend 設定項目は CLI flag として増やさない。
- config file は固定 path `<workspace_root>/.yoi/workspace-backend.local.toml` から読む。
- config file は workspace-local だが `.local` 名にして git 管理外にする。
- config は server/listen/static assets/data path override/limits/remote runtime source などの方針だけを持つ。
- data 本体、workspace identity、Ticket/Objective、Runtime fs-store、transcript、secret 値は config file に入れない。
---
<!-- event: intake_summary author: hare at: 2026-07-01T15:32:38Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:38Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:38Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:39Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-01T15:48:09Z -->
## Implementation report
Implemented Workspace Backend config file schema and resolver.
Changes:
- Added `workspace_server::config` with `WorkspaceBackendConfigFile` serde TOML schema.
- Fixed config path: `<workspace_root>/.yoi/workspace-backend.local.toml`.
- Config file absence resolves to defaults.
- Unknown TOML fields are rejected via `deny_unknown_fields`.
- Relative paths resolve from workspace root; absolute paths are preserved.
- Added resolved config boundary for `ServerConfig`, listen address, and database path.
- Added data root derivation for `workspace.db` and `embedded-runtime`.
- Added remote runtime config schema with `token_ref`, while failing closed until secret ref resolution exists; raw token values are not in schema.
- Updated `yoi-workspace-server serve` to load config before applying existing legacy dev CLI overrides.
- Added focused tests for defaults, unknown fields, path resolution, data root derivation, secret value rejection, and token_ref fail-closed behavior.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:48:17Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-01T15:48:28Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-01T15:48:28Z status: closed -->
## 完了
Implemented Workspace Backend config file schema and resolver.
Delivered:
- `WorkspaceBackendConfigFile` TOML schema with `server`, `data`, `limits`, and `runtimes.remote` sections.
- Fixed workspace-local config path `.yoi/workspace-backend.local.toml`.
- Defaults when config file is absent.
- Unknown-field rejection.
- Workspace-root relative path resolution and absolute path preservation.
- Resolved config boundary for `ServerConfig`, listen address, and database path.
- Data root derivation for `workspace.db` and `embedded-runtime`.
- Secret value exclusion from schema; `token_ref` fails closed until secret ref resolution is implemented.
- `yoi-workspace-server serve` loads config and applies only existing legacy dev CLI overrides.
- Focused tests for defaults, unknown fields, path handling, data overrides, and secret field behavior.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi --no-link`
---
<!-- event: implementation_report author: hare at: 2026-07-01T16:38:31Z -->
## Implementation report
Moved the Workspace Backend default config template into runtime resources and wired init-time copying.
Changes:
- Added `resources/workspace-backend.default.toml` as the packaged template source.
- Embedded the template with `include_str!` from `workspace-server::config`.
- Added `.yoi/workspace-backend.default.toml` as the workspace-local copied template path.
- `yoi-workspace-server serve` now ensures the default template exists after workspace identity initialization and before reading `.local` config.
- Copying uses create-new semantics and does not overwrite an existing workspace-local default template.
- Removed the previously untracked `.yoi/workspace-backend.default.toml` workspace copy from the repository root.
Validation:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- TOML parse check with Deno
- `git diff --check`
- `nix build .#yoi --no-link`
---

View File

@ -0,0 +1,221 @@
---
title: 'Workspace初期化をinitコマンドに切り出しserveの副作用をなくす'
state: 'closed'
created_at: '2026-07-02T07:02:02Z'
updated_at: '2026-07-02T09:20:26Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-02T09:03:56Z'
---
## 背景
現状の Workspace Backend は `yoi-workspace-server serve` 起動時に workspace root を決め、その中で次の初期化を暗黙に実行している。
- `WorkspaceIdentity::load_or_init(workspace_root)`
- `.yoi/workspace.toml` が無ければ作る。
- `WorkspaceBackendConfigFile::ensure_default_template_for_workspace(workspace_root)`
- `.yoi/workspace-backend.default.toml` が無ければ `resources/workspace-backend.default.toml` からコピーする。
1Workspace=1Backend の現状では、Backend の実行 directory / `--workspace` が workspace root になっている。この前提自体はよいが、`serve` が初期化副作用を持つと、間違った cwd で起動しただけで `.yoi/workspace.toml` や default config template が作られる。
Workspace の一回だけ実行されるべき初期化は、明示的な `init` コマンドへ切り出し、`serve` は既に初期化済みの workspace を起動するだけにする。
## 目的
- Workspace 初期化を明示コマンドに切り出す。
- `serve` 起動時に workspace identity / default config template を作らない。
- `serve` は未初期化 workspace に対して typed diagnostic で失敗する。
- 1Workspace=1Backend の前提を保ち、workspace root は Backend 起動対象として明示する。
- 初期化で作る record / template と、serve/runtime が生成する data を分ける。
- 現状の local filesystem 保存を採用しつつ、Workspace / Project record の将来的な provider 可換性を妨げない。
## コマンド設計
### Product CLI
`yoi` 側に workspace init subcommand を追加する。
```text
yoi workspace init [--workspace <PATH>]
yoi workspace serve [OPTIONS]
```
`yoi workspace init``yoi-workspace-server init` を起動する thin wrapper にする。`yoi` binary は workspace-server crate を直接 link しない現在の境界を保つ。
### workspace-server CLI
`yoi-workspace-server` 側にも init command を追加する。
```text
yoi-workspace-server init [--workspace <PATH>]
yoi-workspace-server serve [OPTIONS]
```
`--workspace` は既存 `serve` と同様に、未指定なら current directory を workspace root とする。workspace root は canonicalize する。
## 初期化で作るもの
`init` は次を作る。
```text
.yoi/workspace.toml
.yoi/workspace-backend.default.toml
```
### `.yoi/workspace.toml`
- Workspace identity record。
- `workspace_id`, `created_at`, `display_name` を持つ。
- 既存ファイルがある場合は parse/validate し、上書きしない。
- 作成は `create_new` semantics を維持し、race 時は既存 record を読み直す。
### `.yoi/workspace-backend.default.toml`
- `resources/workspace-backend.default.toml` からコピーする workspace-local template。
- 既存ファイルがある場合は上書きしない。
- `.local` config ではないため、ユーザーが実設定として使う場合は `.yoi/workspace-backend.local.toml` にコピーして編集する。
## 初期化で作らないもの
`init` は Backend / Runtime data を作らない。
- `.yoi/workspace-backend.local.toml`
- control-plane SQLite DB
- embedded Runtime fs-store
- logs / pid files
- Worker / transcript / ConfigBundle data
- Ticket / Objective body
これらは config ではなく data / project record / runtime state なので、必要になった時点で各 subsystem が作る。
## Workspace storage / 正本境界
現状の Workspace 初期化は local filesystem marker を作る実装でよい。ただし、その filesystem layout を Workspace の public API contract として固定しない。
### 現状の local filesystem record
`init` が作る `.yoi/workspace.toml` は local workspace identity marker として扱う。
- 1Workspace=1Backend の現状では、Backend の実行 directory / `--workspace` が workspace root。
- `.yoi/workspace.toml` はその directory を Yoi workspace として識別する local record。
- `workspace_id` は Backend data root の key として使ってよい。
- `.yoi/workspace.toml` の raw path を Browser-facing API / Runtime create API / Worker conversation context の正本識別子として出さない。
### Backend / Runtime data
Backend DB と embedded Runtime fs-store は generated local data であり、Workspace / Project record の正本ではない。
- control-plane SQLite DB は Backend projection / control-plane data。
- embedded Runtime fs-store は Runtime snapshot / Worker snapshot / transcript / ConfigBundle store。
- これらの data path は config で override できても、data 本体は config や workspace identity ではない。
- Browser-facing API に data root、DB path、Runtime store path、raw execution handle を出さない。
### Project record provider 可換性
Ticket / Objective などの project record は、現状では `.yoi/tickets` / `.yoi/objectives` などの local filesystem backend を正本として扱ってよい。ただし、将来的に provider を差し替えられるよう、init / serve は以下を守る。
- `init` は Ticket / Objective body や provider-specific project record layout を生成しない。
- Backend DB を Ticket / Objective の正本として昇格しない。
- Runtime / Worker identity に `.yoi/tickets/...` などの filesystem path を持たせない。
- Browser-facing API は typed backend / record id 経由で扱い、filesystem path を正本識別子として公開しない。
- 将来 `ProjectRecordBackend` / `TicketBackend` / `ObjectiveBackend` 相当の provider を差し替える余地を残す。
この Ticket の成果物は local filesystem init を整えることだが、正本可換性のために上位 API へ local path を漏らさないことも受け入れ条件に含める。
## `serve` の挙動変更
`serve` は初期化済み workspace だけを起動する。
変更後:
1. workspace root を決める。
2. `.yoi/workspace.toml` を load する。
3. `.yoi/workspace-backend.default.toml` は作らない。
4. `.yoi/workspace-backend.local.toml` があれば読む。無ければ defaults。
5. resolved `ServerConfig` で Backend を起動する。
`.yoi/workspace.toml` が無い場合は失敗する。
Diagnostic 例:
```text
workspace is not initialized at <path>; run `yoi workspace init --workspace <path>` first
```
`serve``.yoi/workspace-backend.default.toml` が無いだけでは失敗しない方針とする。これは template であり、runtime config の authority ではないため。ただし init 済み workspace で default template が欠けている場合に warning を出すかは実装時に判断する。
## 内部 API 整理
Workspace 初期化は reusable な内部関数へ切り出す。
候補:
```rust
WorkspaceInitialization::ensure(workspace_root) -> WorkspaceInitializationReport
WorkspaceInitialization::load_required(workspace_root) -> WorkspaceIdentity
```
または既存型に近くするなら:
```rust
WorkspaceIdentity::init_if_missing(...)
WorkspaceIdentity::load_required(...)
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)
```
重要なのは、`serve` が `load_or_init` を呼ばないこと。
## 既存 CLI flag との関係
この Ticket は Workspace Backend config schema の方針を維持し、新規 Backend 設定項目を CLI flag として増やさない。
- `init` に必要なのは `--workspace` だけ。
- `serve` の既存 legacy dev flags をこの Ticket で全面削除するかは別判断にする。
- ただし `serve` の初期化副作用は必ずなくす。
## 実装要件
- `yoi workspace init [--workspace <PATH>]` を追加する。
- `yoi-workspace-server init [--workspace <PATH>]` を追加する。
- workspace-server 側の help に init を追加する。
- `WorkspaceIdentity` に load-only path を追加する。
- 既存 `load_or_init` は init command 内部用に残してよいが、serve からは呼ばない。
- `serve` から `WorkspaceIdentity::load_or_init(...)` を外す。
- `serve` から `WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)` を外す。
- 未初期化 workspace の `serve` は typed diagnostic で失敗する。
- `init` は existing workspace identity / default template を上書きしない。
- `init` は data root / DB / embedded Runtime store を作らない。
- `init` は Ticket / Objective など provider-specific project record layout を作らない。
- `serve` / Browser-facing API / Runtime create path に workspace-local filesystem path を正本識別子として漏らさない。
## 受け入れ条件
- `yoi workspace init [--workspace <PATH>]` が使える。
- `yoi-workspace-server init [--workspace <PATH>]` が使える。
- `init``.yoi/workspace.toml``.yoi/workspace-backend.default.toml` を作る。
- `init` が既存 `.yoi/workspace.toml` / `.yoi/workspace-backend.default.toml` を上書きしない。
- `init``.yoi/workspace-backend.local.toml`、DB、embedded Runtime fs-store、logs を作らない。
- `init` が Ticket / Objective body や provider-specific project record layout を作らない。
- `serve``.yoi/workspace.toml` を新規作成しない。
- `serve``.yoi/workspace-backend.default.toml` を新規作成しない。
- 未初期化 workspace で `serve` すると、`workspace init` を促す diagnostic で失敗する。
- 初期化済み workspace では config file が無くても defaults で `serve` が起動できる。
- Browser-facing API / Runtime create path / Worker conversation context に `.yoi/workspace.toml`、`.yoi/tickets/...`、data root、DB path、Runtime store path が正本識別子として漏れない。
- Project record provider 可換性を妨げないことが code/docs/tests 上で明確になっている。
- Help text が `workspace init``workspace serve` の責務差を説明している。
- Focused tests が init 作成、init idempotency、serve 未初期化拒否、serve 初期化済み起動、data 非作成を確認する。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo test -p yoi` が通る、または CLI parser tests が通る。
- `cargo check -p yoi` が通る。
- `git diff --check` が通る。
- `nix build .#yoi --no-link` が通る。
## 対象外
- Backend config schema の追加変更。
- frontend Vite config の管理。
- remote Runtime supervisor。
- secret store 実装。
- existing legacy `serve` flags の全面削除。
- Ticket / Objective init の統合。

View File

@ -0,0 +1,16 @@
Implemented explicit Workspace initialization command and removed implicit initialization from serve.
Delivered:
- `yoi workspace init [--workspace <PATH>]`.
- `yoi-workspace-server init [--workspace <PATH>]`.
- `init` creates `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml` only.
- `serve` requires `.yoi/workspace.toml` to already exist and fails with an init diagnostic otherwise.
- `serve` no longer creates workspace identity or default config template.
- Help text and focused tests updated.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo test -p yoi`
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`

View File

@ -0,0 +1,147 @@
<!-- event: create author: "yoi ticket" at: 2026-07-02T07:02:02Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: plan author: hare at: 2026-07-02T07:03:11Z -->
## Plan
Workspace 初期化を明示 init command に切り出し、serve の初期化副作用をなくす Ticket として詳細化した。
決定:
- `yoi workspace init [--workspace <PATH>]``yoi-workspace-server init [--workspace <PATH>]` を追加する。
- init は `.yoi/workspace.toml``.yoi/workspace-backend.default.toml` だけを作る。
- init は `.local` config、DB、embedded Runtime fs-store、logs、Worker data を作らない。
- serve は `.yoi/workspace.toml` を load-only し、無ければ `workspace init` を促す typed diagnostic で失敗する。
- serve は default config template を作らない。
- existing legacy serve flags の全面削除は対象外だが、新規 Backend 設定項目を CLI flag として増やさない方針は維持する。
---
<!-- event: decision author: hare at: 2026-07-02T08:31:22Z -->
## Decision
Workspace init Ticket に storage / 正本境界を追記した。
決定:
- 現状の Workspace 初期化は local filesystem marker を作る実装でよい。
- ただし `.yoi` filesystem layout を Workspace の public API contract として固定しない。
- `.yoi/workspace.toml` は local workspace identity marker であり、raw path を Browser-facing API / Runtime create API / Worker conversation context の正本識別子として出さない。
- Backend DB と embedded Runtime fs-store は generated local data であり、Workspace / Project record の正本ではない。
- Ticket / Objective など project record は現状 local filesystem backend を正本としてよいが、init は provider-specific project record layout を生成しない。
- 将来 ProjectRecordBackend / TicketBackend / ObjectiveBackend 相当の provider を差し替える余地を残す。
---
<!-- event: intake_summary author: hare at: 2026-07-02T09:03:56Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-02T09:03:56Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-02T09:03:56Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-02T09:03:56Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-02T09:20:10Z -->
## Implementation report
Implemented explicit Workspace init and removed serve-time initialization side effects.
Changes:
- Added `yoi workspace init [--workspace <PATH>]` passthrough to `yoi-workspace-server init`.
- Added `yoi-workspace-server init [--workspace <PATH>]`.
- `init` creates only `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml`.
- `init` does not create `.local` config, SQLite DB, embedded Runtime store, logs, Worker data, or project record layout.
- Added `WorkspaceIdentity::load_required(...)` for serve-time load-only behavior.
- `serve` now requires an initialized workspace and no longer calls `load_or_init` or copies the default config template.
- Uninitialized `serve` fails with a diagnostic telling the user to run `yoi workspace init --workspace <path>`.
- Updated help text for `yoi workspace` and `yoi-workspace-server`.
- Added focused tests for init parsing, init output files, load_required no-create behavior, and yoi CLI passthrough.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo test -p yoi`
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`
- manual smoke: uninitialized `yoi-workspace-server serve` fails without creating identity.
- manual smoke: `yoi-workspace-server init` creates only workspace identity and default template.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-02T09:20:10Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-02T09:20:26Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-02T09:20:26Z status: closed -->
## 完了
Implemented explicit Workspace initialization command and removed implicit initialization from serve.
Delivered:
- `yoi workspace init [--workspace <PATH>]`.
- `yoi-workspace-server init [--workspace <PATH>]`.
- `init` creates `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml` only.
- `serve` requires `.yoi/workspace.toml` to already exist and fails with an init diagnostic otherwise.
- `serve` no longer creates workspace identity or default config template.
- Help text and focused tests updated.
Validation:
- `cargo test -p yoi-workspace-server`
- `cargo test -p yoi`
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`
---

32
Cargo.lock generated
View File

@ -2880,31 +2880,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "pod-registry"
version = "0.1.0"
dependencies = [
"fs4",
"libc",
"manifest",
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "pod-store"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "pom"
version = "1.1.0"
@ -4688,11 +4663,10 @@ dependencies = [
"base64",
"client",
"crossterm 0.28.1",
"fs4",
"llm-engine",
"manifest",
"minijinja",
"pod-registry",
"pod-store",
"protocol",
"provider",
"pulldown-cmark",
@ -5910,8 +5884,6 @@ dependencies = [
"mcp",
"memory",
"minijinja",
"pod-registry",
"pod-store",
"protocol",
"provider",
"reqwest",
@ -5994,7 +5966,6 @@ dependencies = [
"client",
"manifest",
"memory",
"pod-store",
"project-record",
"serde",
"serde_json",
@ -6038,6 +6009,7 @@ dependencies = [
"axum",
"chrono",
"futures",
"manifest",
"project-record",
"protocol",
"reqwest",

View File

@ -12,10 +12,8 @@ members = [
"crates/worker-runtime",
"crates/plugin-pdk",
"crates/yoi",
"crates/pod-store",
"crates/protocol",
"crates/provider",
"crates/pod-registry",
"crates/session-metrics",
"crates/session-analytics",
"crates/lint-common",
@ -40,10 +38,8 @@ default-members = [
"crates/worker-runtime",
"crates/plugin-pdk",
"crates/yoi",
"crates/pod-store",
"crates/protocol",
"crates/provider",
"crates/pod-registry",
"crates/session-metrics",
"crates/session-analytics",
"crates/lint-common",
@ -75,8 +71,6 @@ worker = { path = "crates/worker" }
worker-runtime = { path = "crates/worker-runtime" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" }
pod-registry = { path = "crates/pod-registry" }
pod-store = { path = "crates/pod-store" }
protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" }

View File

@ -16,7 +16,7 @@ Owns:
Does not own:
- product command names (`yoi`)
- Worker state authority (`worker`, `pod-store`, `session-store`)
- Worker state authority (`worker`, `session-store` worker metadata)
- UI rendering (`tui`)
- Engine turn semantics (`llm-engine`)

View File

@ -19,7 +19,7 @@ Does not own:
- Worker names, sockets, process lifecycle, or scope delegation (`worker`)
- product CLI shape (`yoi`)
- provider catalog and secret resolution (`provider`, `secrets`)
- durable Worker current state (`pod-store`)
- durable Worker current state (`session-store` worker metadata)
## Design notes

View File

@ -88,9 +88,9 @@ pub fn sessions_dir() -> Option<PathBuf> {
sessions_dir_from_data_dir(data_dir())
}
/// `<runtime_dir>/workers.json` — machine-wide Worker allocation registry
pub fn pod_registry_path() -> Option<PathBuf> {
pod_registry_path_from_runtime_dir(runtime_dir())
/// `<runtime_dir>/workers.json` — machine-wide Worker allocation table
pub fn worker_allocation_path() -> Option<PathBuf> {
worker_allocation_path_from_runtime_dir(runtime_dir())
}
/// `<runtime_dir>/<worker_name>/` — Worker ごとのランタイムディレクトリ。
@ -104,8 +104,8 @@ pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
/// `RuntimeDir::socket_path()` で、Worker 名が分かっている外部 (TUI の
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
/// を返すことが期待される。
pub fn pod_socket_path(worker_name: &str) -> Option<PathBuf> {
pod_socket_path_from_runtime_dir(runtime_dir(), worker_name)
pub fn worker_socket_path(worker_name: &str) -> Option<PathBuf> {
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
}
// ---- internals --------------------------------------------------------------
@ -183,7 +183,7 @@ fn sessions_dir_from_data_dir(data_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(data_dir?.join("sessions"))
}
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(runtime_dir?.join("workers.json"))
}
@ -194,7 +194,7 @@ fn worker_runtime_dir_from_runtime_dir(
Some(runtime_dir?.join(worker_name))
}
fn pod_socket_path_from_runtime_dir(
fn worker_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
@ -396,7 +396,7 @@ mod tests {
PathBuf::from("/sand/sessions")
);
assert_eq!(
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
PathBuf::from("/sand/run/workers.json")
);
assert_eq!(
@ -404,7 +404,7 @@ mod tests {
PathBuf::from("/sand/run/foo")
);
assert_eq!(
pod_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
PathBuf::from("/sand/run/foo/sock")
);
}

View File

@ -266,7 +266,7 @@ impl Scope {
/// Allow rules with their targets resolved to absolute paths.
///
/// Used by the pod-registry, where every Worker's allocation
/// Used by the worker-allocation, where every Worker's allocation
/// must be expressed in absolute terms so prefix comparisons are
/// meaningful across processes.
pub fn allow_rules(&self) -> Vec<ScopeRule> {

View File

@ -1,17 +0,0 @@
[package]
name = "pod-registry"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
fs4 = { workspace = true, features = ["sync"] }
libc = { workspace = true }
manifest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
session-store = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@ -1,30 +0,0 @@
# pod-registry
## Role
`pod-registry` is the legacy-named crate that tracks live Worker process ownership and delegated scope locks at runtime.
## Boundaries
Owns:
- machine-local live Worker registration
- collision detection for running Worker names
- delegated scope lock bookkeeping
- registry cleanup hooks for stopped or unreachable children
Does not own:
- durable Worker metadata (`pod-store`)
- replayable session logs (`session-store`)
- socket protocol definitions (`protocol`)
- project work item state
## Design notes
The registry is a runtime coordination mechanism. It can help decide whether a Worker is live or colliding, but durable visibility/restoration should be backed by Worker metadata when possible.
## See also
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)

View File

@ -1,37 +0,0 @@
//! Machine-wide Worker allocation registry.
//!
//! A single JSON file at `<runtime_dir>/workers.json` records every live
//! Worker's allocation (see [`manifest::paths::pod_registry_path`] for
//! how the path is resolved). File-level `flock(2)` serialises access
//! across processes so spawn sequences from unrelated Workers can't race.
//!
//! Each Worker, when starting, acquires the lock, reclaims stale entries
//! (Workers whose PID has died), checks that its requested write scope
//! does not overlap any other allocation's effective write scope, and
//! registers itself. When it exits normally, it removes its entry and
//! returns delegated scope to its `delegated_from` parent. Crash
//! recovery rides on the next Worker that opens the file — no background
//! reaper.
mod conflict;
mod error;
mod lifecycle;
mod mutate;
mod table;
#[cfg(test)]
mod test_util;
pub use conflict::{
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
};
pub use error::ScopeLockError;
pub use lifecycle::{
ScopeAllocationGuard, SegmentLockInfo, adopt_allocation, install_top_level,
install_top_level_with_deny, lookup_segment, update_segment,
};
pub use mutate::{
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
register_worker_with_deny, release_worker,
};
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};

View File

@ -1,15 +0,0 @@
[package]
name = "pod-store"
description = "Legacy-named durable Worker metadata/state persistence"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
session-store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@ -1,30 +0,0 @@
# pod-store
## Role
`pod-store` is the legacy-named crate that owns current Worker metadata keyed by Worker name.
## Boundaries
Owns:
- persisted Worker metadata files
- current active/pending session pointers
- resolved manifest snapshots for restoration
- parent-visible spawned-child metadata
- restoration labels and diagnostics derived from metadata
Does not own:
- replayable conversation logs (`session-store`)
- live process locks or socket reachability (`pod-registry`, `client`)
- product CLI behavior (`yoi`)
- model turn execution (`llm-engine`)
## Design notes
Worker metadata is intentionally thin. It should answer current-state questions without duplicating transcripts or becoming a second session log.
## See also
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)

View File

@ -17,7 +17,7 @@ Does not own:
- Unix socket implementation details (`client`, `worker`)
- TUI rendering (`tui`)
- Engine history semantics (`llm-engine`)
- durable storage (`session-store`, `pod-store`)
- durable storage (`session-store`, `session-store` worker metadata)
## Design notes

View File

@ -99,7 +99,7 @@ pub enum Method {
///
/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The
/// parent Controller always applies variant-specific side effects
/// (registry / pod-registry updates). Agent-visible variants are also
/// (registry / worker-allocation updates). Agent-visible variants are also
/// queued into the notification buffer; control-plane-only variants are
/// not injected into the parent's LLM context.
///

View File

@ -15,8 +15,8 @@ Owns:
Does not own:
- current Worker-name metadata (`pod-store`)
- live process/socket discovery (`pod-registry`, `client`)
- current Worker-name metadata (`session-store` worker metadata)
- live process/socket discovery (`worker-allocation`, `client`)
- UI state (`tui`)
- generated memory summaries (`memory`)

View File

@ -37,6 +37,7 @@ pub mod segment;
pub mod segment_log;
pub mod store;
pub mod system_item;
pub mod worker_metadata;
pub use event_trace::{TraceEntry, TracePayload};
pub use fs_store::FsStore;
@ -52,6 +53,11 @@ pub use segment::{
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
pub use store::{Store, StoreError};
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
pub use worker_metadata::{
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore,
WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
validate_worker_name,
};
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
///

View File

@ -12,8 +12,8 @@
//! model remain session JSONL history. Socket and callback paths are last-known
//! runtime hints, not proof of liveness.
use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId};
use std::fs;
use std::path::PathBuf;
@ -26,8 +26,8 @@ pub enum WorkerStoreError {
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("invalid pod name: {0}")]
InvalidPodName(String),
#[error("invalid worker name: {0}")]
InvalidWorkerName(String),
}
/// Active Session/Segment pointer for a Worker.
@ -284,13 +284,13 @@ impl FsWorkerStore {
Ok(Self { root })
}
fn pod_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
validate_worker_name(worker_name)?;
Ok(self.root.join(worker_name))
}
fn metadata_path(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
Ok(self.pod_dir(worker_name)?.join("metadata.json"))
Ok(self.worker_dir(worker_name)?.join("metadata.json"))
}
}
@ -364,7 +364,7 @@ pub fn validate_worker_name(worker_name: &str) -> Result<(), WorkerStoreError> {
|| worker_name.contains('/')
|| worker_name.contains('\0')
{
return Err(WorkerStoreError::InvalidPodName(worker_name.to_string()));
return Err(WorkerStoreError::InvalidWorkerName(worker_name.to_string()));
}
Ok(())
}
@ -372,61 +372,58 @@ pub fn validate_worker_name(worker_name: &str) -> Result<(), WorkerStoreError> {
/// Convenience composition for callers that want one handle carrying separate
/// session-log and Worker-state roots.
#[derive(Clone)]
pub struct CombinedStore<S, P> {
pub struct CombinedStore<S, W> {
pub session_store: S,
pub pod_store: P,
pub worker_metadata_store: W,
}
impl<S, P> CombinedStore<S, P> {
pub fn new(session_store: S, pod_store: P) -> Self {
impl<S, W> CombinedStore<S, W> {
pub fn new(session_store: S, worker_metadata_store: W) -> Self {
Self {
session_store,
pod_store,
worker_metadata_store,
}
}
}
impl<S, P> session_store::Store for CombinedStore<S, P>
impl<S, W> crate::Store for CombinedStore<S, W>
where
S: session_store::Store,
P: Send + Sync,
S: crate::Store,
W: Send + Sync,
{
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
entry: &crate::LogEntry,
) -> Result<(), crate::StoreError> {
self.session_store.append(session_id, segment_id, entry)
}
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<session_store::LogEntry>, session_store::StoreError> {
) -> Result<Vec<crate::LogEntry>, crate::StoreError> {
self.session_store.read_all(session_id, segment_id)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, session_store::StoreError> {
fn list_sessions(&self) -> Result<Vec<SessionId>, crate::StoreError> {
self.session_store.list_sessions()
}
fn list_segments(
&self,
session_id: SessionId,
) -> Result<Vec<SegmentId>, session_store::StoreError> {
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, crate::StoreError> {
self.session_store.list_segments(session_id)
}
fn lookup_session_of(
&self,
segment_id: SegmentId,
) -> Result<Option<SessionId>, session_store::StoreError> {
) -> Result<Option<SessionId>, crate::StoreError> {
self.session_store.lookup_session_of(segment_id)
}
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[session_store::LogEntry],
) -> Result<(), session_store::StoreError> {
entries: &[crate::LogEntry],
) -> Result<(), crate::StoreError> {
self.session_store
.create_segment(session_id, segment_id, entries)
}
@ -434,7 +431,7 @@ where
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<bool, session_store::StoreError> {
) -> Result<bool, crate::StoreError> {
self.session_store.exists(session_id, segment_id)
}
fn truncate(
@ -442,7 +439,7 @@ where
session_id: SessionId,
segment_id: SegmentId,
entries_len: usize,
) -> Result<(), session_store::StoreError> {
) -> Result<(), crate::StoreError> {
self.session_store
.truncate(session_id, segment_id, entries_len)
}
@ -450,39 +447,39 @@ where
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, session_store::StoreError> {
) -> Result<usize, crate::StoreError> {
self.session_store.read_entry_count(session_id, segment_id)
}
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &session_store::TraceEntry,
) -> Result<(), session_store::StoreError> {
entry: &crate::TraceEntry,
) -> Result<(), crate::StoreError> {
self.session_store
.append_trace(session_id, segment_id, entry)
}
}
impl<S, P> WorkerMetadataStore for CombinedStore<S, P>
impl<S, W> WorkerMetadataStore for CombinedStore<S, W>
where
S: Send + Sync,
P: WorkerMetadataStore,
W: WorkerMetadataStore,
{
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
self.pod_store.write(metadata)
self.worker_metadata_store.write(metadata)
}
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.pod_store.read_by_name(worker_name)
self.worker_metadata_store.read_by_name(worker_name)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.pod_store.list_names()
self.worker_metadata_store.list_names()
}
fn root_dir(&self) -> Option<PathBuf> {
self.pod_store.root_dir()
self.worker_metadata_store.root_dir()
}
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
self.pod_store.delete_by_name(worker_name)
self.worker_metadata_store.delete_by_name(worker_name)
}
}
@ -491,15 +488,15 @@ mod tests {
use super::*;
#[test]
fn pod_metadata_manifest_snapshot_roundtrips() {
fn worker_metadata_manifest_snapshot_roundtrips() {
let mut metadata = WorkerMetadata::new(
"profile-pod",
"profile-worker",
Some(WorkerActiveSegmentRef::pending_segment(
session_store::new_session_id(),
crate::new_session_id(),
)),
);
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
"pod": { "name": "profile-pod" },
"worker": { "name": "profile-worker" },
"profile": { "source": { "kind": "path", "path": "/profiles/coder.lua" } }
}));
@ -510,22 +507,22 @@ mod tests {
}
#[test]
fn fs_store_writes_under_pod_state_root_only() {
fn fs_store_writes_under_worker_state_root_only() {
let tmp = tempfile::TempDir::new().unwrap();
let session_root = tmp.path().join("sessions");
let pod_root = tmp.path().join("workers");
let worker_root = tmp.path().join("workers");
fs::create_dir_all(&session_root).unwrap();
let store = FsWorkerStore::new(&pod_root).unwrap();
let store = FsWorkerStore::new(&worker_root).unwrap();
store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::pending_segment(
session_store::new_session_id(),
crate::new_session_id(),
)),
))
.unwrap();
assert!(pod_root.join("agent/metadata.json").exists());
assert!(worker_root.join("agent/metadata.json").exists());
assert!(!session_root.join("workers/agent/metadata.json").exists());
}
@ -540,16 +537,16 @@ mod tests {
scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
});
metadata.resolved_manifest_snapshot = Some(serde_json::json!({"pod":{"name":"agent"}}));
metadata.resolved_manifest_snapshot = Some(serde_json::json!({"worker":{"name":"agent"}}));
store.write(&metadata).unwrap();
let snapshot = serde_json::json!({"pod":{"name":"updated"}});
let snapshot = serde_json::json!({"worker":{"name":"updated"}});
store
.set_active(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(),
session_store::new_segment_id(),
crate::new_session_id(),
crate::new_segment_id(),
)),
Some(snapshot.clone()),
)
@ -564,10 +561,10 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsWorkerStore::new(tmp.path()).unwrap();
let active = WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(),
session_store::new_segment_id(),
crate::new_session_id(),
crate::new_segment_id(),
);
let snapshot = serde_json::json!({"pod":{"name":"agent"}});
let snapshot = serde_json::json!({"worker":{"name":"agent"}});
store
.set_active("agent", Some(active.clone()), Some(snapshot.clone()))
.unwrap();
@ -592,10 +589,10 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsWorkerStore::new(tmp.path()).unwrap();
let active = WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(),
session_store::new_segment_id(),
crate::new_session_id(),
crate::new_segment_id(),
);
let snapshot = serde_json::json!({"pod":{"name":"agent"}});
let snapshot = serde_json::json!({"worker":{"name":"agent"}});
store
.set_active("agent", Some(active.clone()), Some(snapshot.clone()))
.unwrap();

View File

@ -22,8 +22,7 @@ toml = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
pod-registry = { workspace = true }
fs4 = { workspace = true }
provider = { workspace = true }
ticket = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@ -16,7 +16,7 @@ Owns:
Does not own:
- durable transcript authority (`session-store`)
- Worker current state (`pod-store`)
- Worker current state (`session-store` worker metadata)
- Worker lifecycle policy (`worker`)
- product CLI ownership (`yoi`)

View File

@ -132,7 +132,7 @@ fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf
if let Some(p) = override_path {
return p;
}
manifest::paths::pod_socket_path(worker_name).unwrap_or_else(|| {
manifest::paths::worker_socket_path(worker_name).unwrap_or_else(|| {
PathBuf::from("/tmp")
.join("yoi")
.join(worker_name)
@ -317,7 +317,7 @@ async fn connect_live_pod(
if !allow_registry_fallback {
return None;
}
let registry_socket = picker::live_socket_for_pod(worker_name)?;
let registry_socket = picker::live_socket_for_worker(worker_name)?;
if registry_socket == preferred_socket {
return None;
}

View File

@ -19,7 +19,6 @@ use crossterm::event::{
Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
poll, read,
};
use pod_store::FsWorkerStore;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{ErrorCode, Event, Method, Segment, WorkerStatus};
use ratatui::Frame;
@ -31,6 +30,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore;
use session_store::FsWorkerStore;
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
use ticket::{
LocalTicketBackend, MarkdownText, TicketBackend, TicketIdOrSlug, TicketStateChange,
@ -46,7 +46,7 @@ use crate::role_session_registry::{
};
use crate::worker_list::{
StoredMetadataState, WorkerList, WorkerListEntry, WorkerVisibilitySource,
read_reachable_live_pod_infos, read_stored_worker_infos,
read_reachable_live_worker_infos, read_stored_worker_infos,
};
#[cfg(not(feature = "e2e-test"))]
use crate::workspace_panel::build_workspace_panel;
@ -522,9 +522,9 @@ fn default_store_dir() -> Result<PathBuf, DashboardError> {
})
}
fn default_pod_store_dir() -> Result<PathBuf, DashboardError> {
fn default_worker_metadata_dir() -> Result<PathBuf, DashboardError> {
manifest::paths::data_dir()
.map(|dir| dir.join("pods"))
.map(|dir| dir.join("workers"))
.ok_or_else(|| {
DashboardError::Io(io::Error::new(
io::ErrorKind::NotFound,
@ -3423,9 +3423,10 @@ async fn load_worker_list(
) -> Result<WorkerList, DashboardError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let pod_store = FsWorkerStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored = read_stored_worker_infos(&store, &pod_store)?;
let live = read_reachable_live_pod_infos(&store)
let worker_metadata_store =
FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?;
let stored = read_stored_worker_infos(&store, &worker_metadata_store)?;
let live = read_reachable_live_worker_infos(&store)
.await
.unwrap_or_default();
Ok(WorkerList::from_workspace_sources(

View File

@ -1,7 +1,7 @@
//! Inline-viewport "pick a Worker to attach or restore" UX.
//!
//! Reads live Worker allocations from the runtime registry and stopped Worker state
//! from the pod-store name-keyed metadata. Picking a live row attaches to
//! from the session-store worker metadata name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via the Worker runtime command.
use std::io;
@ -9,7 +9,6 @@ use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use pod_store::FsWorkerStore;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -18,11 +17,12 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::FsStore;
use session_store::FsWorkerStore;
use crate::worker_list::{
LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
WorkerVisibilitySource, live_socket_for_pod as worker_list_live_socket_for_pod,
read_reachable_live_pod_infos, read_stored_worker_infos,
WorkerVisibilitySource, live_socket_for_worker as worker_list_live_socket_for_worker,
read_reachable_live_worker_infos, read_stored_worker_infos,
};
const MAX_ROWS: usize = 10;
@ -156,9 +156,10 @@ fn list_for_options(
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let pod_store = FsWorkerStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored_workers = read_stored_worker_infos(&store, &pod_store)?;
let live_workers = read_reachable_live_pod_infos(&store)
let worker_metadata_store =
FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?;
let stored_workers = read_stored_worker_infos(&store, &worker_metadata_store)?;
let live_workers = read_reachable_live_worker_infos(&store)
.await
.unwrap_or_default();
let mut list = list_for_options(&options, stored_workers, live_workers);
@ -223,9 +224,9 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
})
}
fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
fn default_worker_metadata_dir() -> Result<PathBuf, PickerError> {
manifest::paths::data_dir()
.map(|dir| dir.join("pods"))
.map(|dir| dir.join("workers"))
.ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
@ -235,8 +236,8 @@ fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
})
}
pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
worker_list_live_socket_for_pod(worker_name)
pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option<PathBuf> {
worker_list_live_socket_for_worker(worker_name)
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {

View File

@ -1,13 +1,15 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::WorkerClient;
use pod_registry::{LockFileGuard, default_registry_path};
use pod_store::{WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore};
use manifest::paths;
use protocol::{Event, WorkerStatus};
use serde::Deserialize;
use session_store::{FsStore, SegmentId, SessionId};
use session_store::{WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore};
#[derive(Debug, Clone)]
pub(crate) struct WorkerList {
@ -314,11 +316,14 @@ pub(crate) enum WorkerEntryDiagnosticKind {
pub(crate) fn read_stored_worker_infos(
store: &FsStore,
pod_store: &impl WorkerMetadataStore,
worker_metadata_store: &impl WorkerMetadataStore,
) -> Result<Vec<StoredWorkerInfo>, io::Error> {
let mut records = Vec::new();
for worker_name in pod_store.list_names().map_err(io::Error::other)? {
let info = match pod_store.read_by_name(&worker_name) {
for worker_name in worker_metadata_store
.list_names()
.map_err(io::Error::other)?
{
let info = match worker_metadata_store.read_by_name(&worker_name) {
Ok(Some(metadata)) => stored_info_from_metadata(store, worker_name, metadata),
Ok(None) => corrupt_stored_info(
worker_name,
@ -331,16 +336,24 @@ pub(crate) fn read_stored_worker_infos(
Ok(records)
}
pub(crate) fn read_live_pod_infos() -> Result<Vec<LiveWorkerInfo>, io::Error> {
let path = default_registry_path()?;
let guard = LockFileGuard::open(&path)?;
Ok(guard
.data()
pub(crate) fn read_live_worker_infos() -> Result<Vec<LiveWorkerInfo>, io::Error> {
let path = paths::worker_allocation_path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not resolve worker allocation path",
)
})?;
let table = match read_worker_allocation_table(&path) {
Ok(table) => table,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
Ok(table
.allocations
.iter()
.into_iter()
.map(|allocation| LiveWorkerInfo {
worker_name: allocation.worker_name.clone(),
socket_path: allocation.socket.clone(),
worker_name: allocation.worker_name,
socket_path: allocation.socket,
status: None,
reachable: false,
segment_id: allocation.segment_id,
@ -349,20 +362,49 @@ pub(crate) fn read_live_pod_infos() -> Result<Vec<LiveWorkerInfo>, io::Error> {
.collect())
}
pub(crate) async fn read_reachable_live_pod_infos(
store: &FsStore,
) -> Result<Vec<LiveWorkerInfo>, io::Error> {
let records = read_live_pod_infos()?;
probe_reachable_live_pod_infos(store, records).await
fn read_worker_allocation_table(path: &Path) -> Result<WorkerAllocationTable, io::Error> {
let mut file = File::open(path)?;
fs4::fs_std::FileExt::lock_shared(&file)?;
let mut contents = String::new();
let read_result = file.read_to_string(&mut contents);
let unlock_result = fs4::fs_std::FileExt::unlock(&file);
read_result?;
unlock_result?;
if contents.trim().is_empty() {
return Ok(WorkerAllocationTable::default());
}
serde_json::from_str(&contents).map_err(io::Error::other)
}
async fn probe_reachable_live_pod_infos(
#[derive(Debug, Default, Deserialize)]
struct WorkerAllocationTable {
#[serde(default)]
allocations: Vec<WorkerAllocationRecord>,
}
#[derive(Debug, Deserialize)]
struct WorkerAllocationRecord {
worker_name: String,
socket: PathBuf,
#[serde(default)]
segment_id: Option<SegmentId>,
}
pub(crate) async fn read_reachable_live_worker_infos(
store: &FsStore,
) -> Result<Vec<LiveWorkerInfo>, io::Error> {
let records = read_live_worker_infos()?;
probe_reachable_live_worker_infos(store, records).await
}
async fn probe_reachable_live_worker_infos(
_store: &FsStore,
records: Vec<LiveWorkerInfo>,
) -> Result<Vec<LiveWorkerInfo>, io::Error> {
let mut handles = Vec::with_capacity(records.len());
for record in records {
handles.push(tokio::spawn(probe_live_pod_info(record)));
handles.push(tokio::spawn(probe_live_worker_info(record)));
}
let mut reachable = Vec::with_capacity(handles.len());
@ -378,15 +420,15 @@ async fn probe_reachable_live_pod_infos(
Ok(reachable)
}
async fn probe_live_pod_info(mut record: LiveWorkerInfo) -> Result<LiveWorkerInfo, io::Error> {
async fn probe_live_worker_info(mut record: LiveWorkerInfo) -> Result<LiveWorkerInfo, io::Error> {
let status = probe_live_status(&record.socket_path).await?;
record.reachable = true;
record.status = status;
Ok(record)
}
pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
read_live_pod_infos()
pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option<PathBuf> {
read_live_worker_infos()
.ok()?
.into_iter()
.find(|worker| worker.worker_name == worker_name)
@ -560,10 +602,10 @@ mod tests {
use std::sync::Arc;
use llm_engine::llm_client::types::RequestConfig;
use pod_store::FsWorkerStore;
use pod_store::{WorkerActiveSegmentRef, WorkerMetadataStore};
use protocol::stream::JsonLineWriter;
use session_store::FsWorkerStore;
use session_store::{LogEntry, Store, new_segment_id, new_session_id};
use session_store::{WorkerActiveSegmentRef, WorkerMetadataStore};
use tempfile::tempdir;
use tokio::net::UnixListener;
use tokio::sync::Barrier;
@ -877,7 +919,7 @@ mod tests {
let records = tokio::time::timeout(
LIVE_STATUS_PROBE_TIMEOUT * 3,
probe_reachable_live_pod_infos(&store, records),
probe_reachable_live_worker_infos(&store, records),
)
.await
.expect("status probes should complete")
@ -907,7 +949,7 @@ mod tests {
std::future::pending::<()>().await;
});
let records = probe_reachable_live_pod_infos(
let records = probe_reachable_live_worker_infos(
&store,
vec![live_probe_record("silent", socket_path.clone())],
)
@ -985,12 +1027,12 @@ mod tests {
fn read_stored_worker_infos_reports_corrupt_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
let pod_dir = dir.path().join("pods").join("broken");
std::fs::create_dir_all(&pod_dir).unwrap();
std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
let worker_metadata_store = FsWorkerStore::new(dir.path().join("workers")).unwrap();
let worker_metadata_dir = dir.path().join("workers").join("broken");
std::fs::create_dir_all(&worker_metadata_dir).unwrap();
std::fs::write(worker_metadata_dir.join("metadata.json"), "{not-json").unwrap();
let records = read_stored_worker_infos(&store, &pod_store).unwrap();
let records = read_stored_worker_infos(&store, &worker_metadata_store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].worker_name, "broken");
assert!(matches!(
@ -1003,10 +1045,10 @@ mod tests {
fn read_stored_worker_infos_reads_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
let worker_metadata_store = FsWorkerStore::new(dir.path().join("workers")).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
pod_store
worker_metadata_store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
@ -1015,7 +1057,7 @@ mod tests {
))
.unwrap();
let records = read_stored_worker_infos(&store, &pod_store).unwrap();
let records = read_stored_worker_infos(&store, &worker_metadata_store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].worker_name, "agent");
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);

View File

@ -1,31 +1,8 @@
use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::interaction::WorkerInput;
use serde::{Deserialize, Serialize};
/// Intent supplied when a Worker is created.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkerIntent {
Assistant {
#[serde(default, skip_serializing_if = "Option::is_none")]
purpose: Option<String>,
},
Task {
objective: String,
},
Role {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
purpose: Option<String>,
},
}
impl Default for WorkerIntent {
fn default() -> Self {
Self::Assistant { purpose: None }
}
}
/// Profile selector boundary. This is a selector, not a resolved config bundle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
@ -48,77 +25,21 @@ pub struct ConfigBundleRef {
pub digest: String,
}
/// Requested capability name plus optional human-readable reason.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityRequest {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl CapabilityRequest {
pub fn named(name: impl Into<String>) -> Self {
Self {
name: name.into(),
reason: None,
}
}
}
/// Opaque workspace reference supplied by a caller.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceRef {
pub name: String,
pub reference: String,
}
/// Opaque mount reference supplied by a caller.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MountRef {
pub name: String,
pub reference: String,
}
/// Worker creation request for the catalog/lifecycle API.
/// Canonical Runtime Worker creation request.
///
/// Browser/product launch semantics are resolved by a backend before this
/// request is built. The request contains only durable Runtime identity inputs:
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
/// and optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence. It carries no cwd/workspace path, tool
/// scope, credential, socket/session path, raw config body, or execution binding
/// internals.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateWorkerRequest {
pub intent: WorkerIntent,
pub profile: ProfileSelector,
pub config_bundle: ConfigBundleRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_bundle: Option<ConfigBundleRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requested_capabilities: Vec<CapabilityRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_refs: Vec<WorkspaceRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mount_refs: Vec<MountRef>,
}
impl Default for CreateWorkerRequest {
fn default() -> Self {
Self {
intent: WorkerIntent::default(),
profile: ProfileSelector::default(),
config_bundle: None,
requested_capabilities: Vec::new(),
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
}
}
}
impl CreateWorkerRequest {
/// Create a tools-less Worker using runtime-local default resources.
pub fn tools_less(intent: WorkerIntent, profile: ProfileSelector) -> Self {
Self {
intent,
profile,
config_bundle: None,
requested_capabilities: Vec::new(),
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
}
}
pub initial_input: Option<WorkerInput>,
}
/// Worker lifecycle status for the in-memory embedded runtime.
@ -144,10 +65,8 @@ pub struct WorkerSummary {
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,
pub intent: WorkerIntent,
pub profile: ProfileSelector,
pub requested_capability_count: usize,
pub has_config_bundle: bool,
pub config_bundle: ConfigBundleRef,
pub transcript_len: usize,
pub last_event_id: u64,
}
@ -160,16 +79,8 @@ pub struct WorkerDetail {
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,
pub intent: WorkerIntent,
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_bundle: Option<ConfigBundleRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requested_capabilities: Vec<CapabilityRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_refs: Vec<WorkspaceRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mount_refs: Vec<MountRef>,
pub config_bundle: ConfigBundleRef,
pub transcript_len: usize,
pub last_event_id: u64,
}

View File

@ -23,6 +23,9 @@ pub enum RuntimeError {
actual_runtime_id: RuntimeId,
},
#[error("initial worker input must be user input, got {kind}")]
InvalidInitialInputKind { kind: String },
#[error("worker {worker_id} was not found in runtime {runtime_id}")]
WorkerNotFound {
runtime_id: RuntimeId,
@ -35,6 +38,9 @@ pub enum RuntimeError {
message: String,
},
#[error("worker creation has no execution backend: {message}")]
ExecutionBackendUnavailable { message: String },
#[error("worker {worker_id} execution {operation:?} returned {outcome:?}: {message}")]
WorkerExecutionRejected {
worker_id: WorkerId,

View File

@ -18,9 +18,29 @@ use std::sync::Arc;
pub enum WorkerExecutionBackendKind {
#[default]
Unconnected,
/// A durable execution binding was restored, but no live handle was recovered.
Stale,
Connected,
}
/// Durable, non-authority execution binding projection.
///
/// This records only enough identity to diagnose stale mappings after restore.
/// It is not a live handle and must not contain sockets, paths, credentials, or
/// provider-private authority.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionBindingIdentity {
pub backend_id: String,
}
impl WorkerExecutionBindingIdentity {
pub fn from_handle(handle: &WorkerExecutionHandle) -> Self {
Self {
backend_id: handle.backend_id.clone(),
}
}
}
/// Current execution-side run state for a Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -131,6 +151,8 @@ pub struct WorkerExecutionStatus {
pub backend: WorkerExecutionBackendKind,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkerExecutionBindingIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
}
@ -143,10 +165,22 @@ impl WorkerExecutionStatus {
Self {
backend: WorkerExecutionBackendKind::Connected,
run_state,
binding: None,
last_result: None,
}
}
pub fn stale(mut previous: Self) -> Self {
previous.backend = WorkerExecutionBackendKind::Stale;
previous.run_state = WorkerExecutionRunState::Unconnected;
previous
}
pub fn with_binding(mut self, binding: WorkerExecutionBindingIdentity) -> Self {
self.binding = Some(binding);
self
}
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
self.run_state = result.run_state;
self.last_result = Some(result);

View File

@ -2,6 +2,7 @@ use crate::catalog::{CreateWorkerRequest, WorkerStatus};
use crate::config_bundle::ConfigBundle;
use crate::diagnostics::RuntimeDiagnostic;
use crate::error::RuntimeError;
use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
use crate::observation::{
@ -376,6 +377,7 @@ pub(crate) struct PersistedWorkerRecord {
pub(crate) worker_id: WorkerId,
pub(crate) status: WorkerStatus,
pub(crate) request: CreateWorkerRequest,
pub(crate) execution: WorkerExecutionStatus,
pub(crate) transcript: Vec<TranscriptEntry>,
pub(crate) next_transcript_sequence: u64,
pub(crate) last_event_id: u64,
@ -473,6 +475,8 @@ struct WorkerSnapshot {
worker_id: WorkerId,
status: WorkerStatus,
request: CreateWorkerRequest,
#[serde(default = "WorkerExecutionStatus::unconnected")]
execution: WorkerExecutionStatus,
next_transcript_sequence: u64,
last_event_id: u64,
}
@ -485,6 +489,7 @@ impl WorkerSnapshot {
worker_id: worker.worker_id.clone(),
status: worker.status,
request: worker.request.clone(),
execution: worker.execution.clone(),
next_transcript_sequence: worker.next_transcript_sequence,
last_event_id: worker.last_event_id,
}
@ -530,6 +535,7 @@ impl WorkerSnapshot {
worker_id: self.worker_id,
status: self.status,
request: self.request,
execution: self.execution,
transcript,
next_transcript_sequence: self.next_transcript_sequence,
last_event_id: self.last_event_id,

View File

@ -824,9 +824,11 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
}
RuntimeError::RuntimeStopped { .. }
| RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. }
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
RuntimeError::LimitTooLarge { .. }
| RuntimeError::InvalidRequest(_)
| RuntimeError::InvalidInitialInputKind { .. }
| RuntimeError::ConfigBundleDigestMismatch { .. }
| RuntimeError::InvalidProfileSelector { .. }
| RuntimeError::UnsupportedConfigDeclaration { .. }
@ -846,9 +848,11 @@ fn code_for_runtime_error(error: &RuntimeError) -> &'static str {
RuntimeError::WrongRuntimeCursor { .. } => "wrong_runtime_cursor",
RuntimeError::WorkerNotFound { .. } => "worker_not_found",
RuntimeError::WorkerExecutionUnavailable { .. } => "worker_execution_unavailable",
RuntimeError::ExecutionBackendUnavailable { .. } => "execution_backend_unavailable",
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected",
RuntimeError::LimitTooLarge { .. } => "limit_too_large",
RuntimeError::InvalidRequest(_) => "invalid_request",
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind",
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing",
RuntimeError::ConfigBundleDigestMismatch { .. } => "config_bundle_digest_mismatch",
RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector",
@ -872,7 +876,10 @@ pub enum RuntimeHttpServerError {
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CapabilityRequest, ProfileSelector, WorkerIntent};
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
@ -883,16 +890,38 @@ mod tests {
use axum::http::Method;
use tower::ServiceExt;
fn task_request(objective: &str) -> CreateWorkerRequest {
CreateWorkerRequest {
intent: WorkerIntent::Task {
objective: objective.to_string(),
fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "http-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "test-workspace".to_string(),
created_at: "test".to_string(),
provenance: ConfigBundleProvenance {
source: "test".to_string(),
detail: None,
},
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
config_bundle: None,
requested_capabilities: vec![CapabilityRequest::named("read")],
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
},
profiles: vec![ConfigProfileDescriptor {
selector: profile,
label: Some("test".to_string()),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone());
CreateWorkerRequest {
profile,
config_bundle: ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
initial_input: None,
}
}
@ -969,6 +998,11 @@ mod tests {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
let app = runtime_http_router(runtime.clone(), None);
let response = json_request(
@ -1091,15 +1125,89 @@ mod tests {
#[cfg(all(test, feature = "ws-server"))]
mod ws_tests {
use super::*;
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult,
};
use crate::interaction::WorkerInput;
use futures::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
struct WsBackend;
impl WorkerExecutionBackend for WsBackend {
fn backend_id(&self) -> &str {
"ws-test"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
}
fn ws_test_bundle(profile: ProfileSelector) -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "ws-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "test".to_string(),
created_at: "test".to_string(),
provenance: ConfigBundleProvenance {
source: "test".to_string(),
detail: None,
},
},
profiles: vec![ConfigProfileDescriptor {
selector: profile,
label: Some("ws".to_string()),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn ws_create_request() -> CreateWorkerRequest {
let bundle = ws_test_bundle(ProfileSelector::RuntimeDefault);
CreateWorkerRequest {
profile: ProfileSelector::RuntimeDefault,
config_bundle: ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
initial_input: None,
}
}
async fn spawn_runtime_server() -> (Runtime, WorkerRef, String) {
let runtime = Runtime::new_memory();
let worker = runtime
.create_worker(CreateWorkerRequest::default())
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(WsBackend))
.unwrap();
runtime
.store_config_bundle(ws_test_bundle(ProfileSelector::RuntimeDefault))
.unwrap();
let worker = runtime.create_worker(ws_create_request()).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn({
@ -1169,9 +1277,7 @@ mod ws_tests {
#[tokio::test]
async fn runtime_ws_cursor_resume_is_duplicate_safe_and_filters_workers() {
let (runtime, worker_ref, url) = spawn_runtime_server().await;
let other = runtime
.create_worker(CreateWorkerRequest::default())
.unwrap();
let other = runtime.create_worker(ws_create_request()).unwrap();
let first = runtime
.observe_worker_event(
&worker_ref,

View File

@ -24,4 +24,5 @@ mod runtime;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
pub use management::{RuntimeLimits, RuntimeOptions};
pub use runtime::Runtime;

View File

@ -4,15 +4,17 @@ use crate::catalog::{
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
validate_config_bundle_ref, validate_profile_selector,
validate_config_bundle_ref,
};
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
#[cfg(feature = "fs-store")]
use crate::diagnostics::DiagnosticSeverity;
use crate::diagnostics::RuntimeDiagnostic;
use crate::error::RuntimeError;
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionBackendKind, WorkerExecutionBackendRef,
WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
WorkerExecutionStatus,
WorkerExecutionBindingIdentity, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult, WorkerExecutionStatus,
};
#[cfg(feature = "fs-store")]
use crate::fs_store::{
@ -231,55 +233,112 @@ impl Runtime {
Ok(event_id)
}
/// Create a Worker in the embedded catalog.
/// Create a Worker through the canonical ConfigBundle + execution backend path.
pub fn create_worker(
&self,
request: CreateWorkerRequest,
) -> Result<WorkerDetail, RuntimeError> {
let (backend, worker_ref, spawn_request) = {
let mut state = self.lock()?;
state.ensure_running()?;
validate_create_worker_request(&request)?;
state.validate_worker_config_boundary(&request)?;
let backend = state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "worker creation requires an execution backend".to_string(),
}
})?;
let worker_id = WorkerId::generated(state.next_worker_sequence);
state.next_worker_sequence += 1;
let worker_ref = WorkerRef::new(state.runtime_id.clone(), worker_id.clone());
let backend = state.execution_backend.clone();
let spawn_request = backend.as_ref().map(|_| WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
request: request.clone(),
context: self.execution_context(worker_ref.clone()),
});
let event_id = state.push_event(
Some(worker_ref.clone()),
RuntimeEventKind::WorkerCreated,
format!("worker {worker_id} created"),
);
let mut transcript = Vec::new();
let mut next_transcript_sequence = 1;
if let Some(input) = request.initial_input.clone() {
transcript.push(TranscriptEntry {
sequence: next_transcript_sequence,
worker_ref: worker_ref.clone(),
role: TranscriptRole::User,
content: input.content,
event_id,
});
next_transcript_sequence += 1;
}
let record = WorkerRecord {
worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(),
status: WorkerStatus::Running,
request,
request: request.clone(),
execution: WorkerExecutionStatus::unconnected(),
execution_handle: None,
transcript: Vec::new(),
next_transcript_sequence: 1,
transcript,
next_transcript_sequence,
last_event_id: event_id,
};
let detail = record.detail(&state.runtime_id);
state.emit_create_diagnostics(&detail);
state.workers.insert(worker_id.clone(), record);
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_id)?;
state.persist_event_by_id(event_id)?;
drop(state);
state.workers.insert(worker_id, record);
let spawn_request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
request,
context: self.execution_context(worker_ref.clone()),
};
(backend, worker_ref, spawn_request)
};
if let (Some(backend), Some(spawn_request)) = (backend, spawn_request) {
let result = backend.spawn_worker(spawn_request);
self.apply_spawn_result(&worker_ref, result)
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, run_state) = match spawn_result {
WorkerExecutionSpawnResult::Connected { handle, run_state } => (handle, run_state),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?;
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: result.operation,
outcome: result.outcome,
message: result.message_or_default(),
result,
});
}
};
if let Some(initial_input) = {
let state = self.lock()?;
state.worker(&worker_ref)?.request.initial_input.clone()
} {
let dispatch_result = backend.dispatch_input(&handle, initial_input);
if !dispatch_result.is_accepted() {
let _ = backend.stop_worker(&handle);
self.rollback_failed_create(&worker_ref)?;
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: dispatch_result.operation,
outcome: dispatch_result.outcome,
message: dispatch_result.message_or_default(),
result: dispatch_result,
});
}
self.commit_created_worker(
&worker_ref,
handle,
WorkerExecutionRunState::Busy,
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
),
)
} else {
Ok(detail)
self.commit_created_worker(
&worker_ref,
handle,
run_state,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
)
}
}
@ -328,7 +387,9 @@ impl Runtime {
"worker has no execution backend",
);
let worker = state.worker_mut(worker_ref)?;
worker.execution = WorkerExecutionStatus::unconnected().with_result(result);
let mut execution = WorkerExecutionStatus::unconnected().with_result(result);
execution.binding = worker.execution.binding.clone();
worker.execution = execution;
state.persist_worker(&worker_ref.worker_id)?;
return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id.clone(),
@ -370,6 +431,7 @@ impl Runtime {
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: dispatch_result.run_state,
binding: worker.execution.binding.clone(),
last_result: Some(dispatch_result),
};
worker.transcript.push(TranscriptEntry {
@ -414,38 +476,44 @@ impl Runtime {
})
}
fn apply_spawn_result(
fn commit_created_worker(
&self,
worker_ref: &WorkerRef,
result: WorkerExecutionSpawnResult,
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
let runtime_id = state.runtime_id.clone();
let detail = {
let binding = WorkerExecutionBindingIdentity::from_handle(&handle);
let worker = state.worker_mut(worker_ref)?;
match result {
WorkerExecutionSpawnResult::Connected { handle, run_state } => {
worker.execution_handle = Some(handle);
worker.execution = WorkerExecutionStatus::connected(run_state).with_result(
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
);
}
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
worker.execution_handle = None;
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
last_result: Some(result),
};
}
}
worker.execution = WorkerExecutionStatus::connected(run_state)
.with_binding(binding)
.with_result(result);
worker.detail(&runtime_id)
};
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
let worker = state.worker(worker_ref)?;
for entry in &worker.transcript {
state.persist_transcript_entry(&worker_ref.worker_id, entry.sequence)?;
}
state.persist_event_by_id(detail.last_event_id)?;
Ok(detail)
}
fn rollback_failed_create(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
if let Some(record) = state.workers.remove(&worker_ref.worker_id) {
state.events.retain(|event| {
event.id != record.last_event_id || event.worker_ref.as_ref() != Some(worker_ref)
});
}
Ok(())
}
fn record_execution_result(
&self,
worker_ref: &WorkerRef,
@ -456,6 +524,7 @@ impl Runtime {
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
binding: worker.execution.binding.clone(),
last_result: Some(result),
};
state.persist_worker(&worker_ref.worker_id)?;
@ -827,6 +896,7 @@ struct RuntimeState {
execution_backend: Option<WorkerExecutionBackendRef>,
next_worker_sequence: u64,
next_event_id: u64,
#[cfg(feature = "fs-store")]
next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>,
config_bundles: BTreeMap<String, ConfigBundle>,
@ -852,6 +922,7 @@ impl RuntimeState {
execution_backend: None,
next_worker_sequence: 1,
next_event_id: 1,
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
config_bundles: BTreeMap::new(),
@ -883,6 +954,7 @@ impl RuntimeState {
execution_backend: None,
next_worker_sequence: 1,
next_event_id: 1,
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
config_bundles: BTreeMap::new(),
@ -915,7 +987,28 @@ impl RuntimeState {
}
let mut workers = BTreeMap::new();
let mut diagnostics = persisted.diagnostics;
let mut next_diagnostic_id = persisted.next_diagnostic_id;
for (worker_id, worker) in persisted.workers {
let execution = if worker.execution.binding.is_some()
&& worker.execution.backend == WorkerExecutionBackendKind::Connected
{
let stale = WorkerExecutionStatus::stale(worker.execution);
diagnostics.push(RuntimeDiagnostic {
id: next_diagnostic_id,
severity: DiagnosticSeverity::Warning,
code: "worker_execution_mapping_stale".to_string(),
message: format!(
"worker {} has persisted execution binding identity but no live execution handle was restored",
worker.worker_id
),
worker_ref: Some(worker.worker_ref.clone()),
});
next_diagnostic_id += 1;
stale
} else {
worker.execution
};
workers.insert(
worker_id,
WorkerRecord {
@ -923,7 +1016,7 @@ impl RuntimeState {
worker_id: worker.worker_id,
status: worker.status,
request: worker.request,
execution: WorkerExecutionStatus::unconnected(),
execution,
execution_handle: None,
transcript: worker.transcript,
next_transcript_sequence: worker.next_transcript_sequence,
@ -942,11 +1035,11 @@ impl RuntimeState {
execution_backend: None,
next_worker_sequence: persisted.next_worker_sequence,
next_event_id: persisted.next_event_id,
next_diagnostic_id: persisted.next_diagnostic_id,
next_diagnostic_id,
workers,
config_bundles: persisted.config_bundles,
events: persisted.events,
diagnostics: persisted.diagnostics,
diagnostics,
#[cfg(feature = "ws-server")]
next_observation_sequence: 1,
#[cfg(feature = "ws-server")]
@ -1131,8 +1224,7 @@ impl RuntimeState {
&self,
request: &CreateWorkerRequest,
) -> Result<(), RuntimeError> {
match &request.config_bundle {
Some(reference) => {
let reference = &request.config_bundle;
let availability = self.check_config_bundle_ref(reference)?;
let bundle = self
.config_bundles
@ -1144,24 +1236,11 @@ impl RuntimeState {
return Err(RuntimeError::InvalidProfileSelector {
profile: profile_label(&request.profile),
bundle_id: Some(reference.id.clone()),
message: "profile selector is not declared by synced config bundle"
.to_string(),
message: "profile selector is not declared by synced config bundle".to_string(),
});
}
Ok(())
}
None => match &request.profile {
ProfileSelector::RuntimeDefault | ProfileSelector::Builtin(_) => {
validate_profile_selector(request.profile.clone(), None)
}
ProfileSelector::Named(_) => Err(RuntimeError::InvalidProfileSelector {
profile: profile_label(&request.profile),
bundle_id: None,
message: "named profiles require a synced config bundle reference".to_string(),
}),
},
}
}
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
if worker_ref.runtime_id != self.runtime_id {
@ -1373,43 +1452,6 @@ impl RuntimeState {
status.run_state = next_run_state;
true
}
fn push_diagnostic(
&mut self,
severity: DiagnosticSeverity,
code: impl Into<String>,
message: impl Into<String>,
worker_ref: Option<WorkerRef>,
) {
let id = self.next_diagnostic_id;
self.next_diagnostic_id += 1;
self.diagnostics.push(RuntimeDiagnostic {
id,
severity,
code: code.into(),
message: message.into(),
worker_ref,
});
}
fn emit_create_diagnostics(&mut self, detail: &WorkerDetail) {
if detail.config_bundle.is_none() {
self.push_diagnostic(
DiagnosticSeverity::Info,
"runtime.local_default_resources",
"worker created without ConfigBundleRef; runtime-local defaults are assumed",
Some(detail.worker_ref.clone()),
);
}
if detail.requested_capabilities.is_empty() {
self.push_diagnostic(
DiagnosticSeverity::Info,
"worker.tools_less",
"worker created without requested tool capabilities",
Some(detail.worker_ref.clone()),
);
}
}
}
#[derive(Debug)]
@ -1433,10 +1475,8 @@ impl WorkerRecord {
worker_id: self.worker_id.clone(),
status: self.status,
execution: self.execution.clone(),
intent: self.request.intent.clone(),
profile: self.request.profile.clone(),
requested_capability_count: self.request.requested_capabilities.len(),
has_config_bundle: self.request.config_bundle.is_some(),
config_bundle: self.request.config_bundle.clone(),
transcript_len: self.transcript.len(),
last_event_id: self.last_event_id,
}
@ -1449,12 +1489,8 @@ impl WorkerRecord {
worker_id: self.worker_id.clone(),
status: self.status,
execution: self.execution.clone(),
intent: self.request.intent.clone(),
profile: self.request.profile.clone(),
config_bundle: self.request.config_bundle.clone(),
requested_capabilities: self.request.requested_capabilities.clone(),
workspace_refs: self.request.workspace_refs.clone(),
mount_refs: self.request.mount_refs.clone(),
transcript_len: self.transcript.len(),
last_event_id: self.last_event_id,
}
@ -1467,6 +1503,7 @@ impl WorkerRecord {
worker_id: self.worker_id.clone(),
status: self.status,
request: self.request.clone(),
execution: self.execution.clone(),
transcript: self.transcript.clone(),
next_transcript_sequence: self.next_transcript_sequence,
last_event_id: self.last_event_id,
@ -1483,17 +1520,25 @@ fn profile_label(selector: &ProfileSelector) -> String {
}
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
if let crate::catalog::WorkerIntent::Task { objective } = &request.intent {
if objective.trim().is_empty() {
if request.config_bundle.id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"task objective must not be empty".to_string(),
"config_bundle.id must not be empty".to_string(),
));
}
}
for capability in &request.requested_capabilities {
if capability.name.trim().is_empty() {
if request.config_bundle.digest.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"capability name must not be empty".to_string(),
"config_bundle.digest must not be empty".to_string(),
));
}
if let Some(input) = &request.initial_input {
if input.kind != WorkerInputKind::User {
return Err(RuntimeError::InvalidInitialInputKind {
kind: format!("{:?}", input.kind),
});
}
if input.content.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"initial_input.content must not be empty".to_string(),
));
}
}
@ -1512,7 +1557,7 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector, WorkerIntent};
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
ConfigDeclarationKind, ConfigProfileDescriptor,
@ -1524,19 +1569,45 @@ mod tests {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
fn task_request(objective: &str) -> CreateWorkerRequest {
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle_for_profile(profile.clone());
CreateWorkerRequest {
intent: WorkerIntent::Task {
objective: objective.to_string(),
profile,
config_bundle: ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
config_bundle: None,
requested_capabilities: vec![CapabilityRequest::named("read")],
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
initial_input: None,
}
}
fn test_bundle_for_profile(profile: ProfileSelector) -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
source: "workspace-backend".to_string(),
detail: Some("profile-sync".to_string()),
},
},
profiles: vec![ConfigProfileDescriptor {
selector: profile,
label: Some("Coder".to_string()),
}],
declarations: vec![ConfigDeclaration {
kind: ConfigDeclarationKind::CapabilityGrant,
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
}
.with_computed_digest()
}
#[derive(Default)]
struct TestExecutionBackend {
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
@ -1609,77 +1680,58 @@ mod tests {
}
fn runtime_with_backend() -> Runtime {
Runtime::with_execution_backend(
let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(),
Arc::new(TestExecutionBackend::default()),
)
.unwrap()
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
runtime
}
fn runtime_and_backend() -> (Runtime, Arc<TestExecutionBackend>) {
let backend = Arc::new(TestExecutionBackend::default());
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), backend.clone()).unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
(runtime, backend)
}
fn test_bundle() -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
source: "workspace-backend".to_string(),
detail: Some("profile-sync".to_string()),
},
},
profiles: vec![ConfigProfileDescriptor {
selector: ProfileSelector::Builtin("builtin:coder".to_string()),
label: Some("Coder".to_string()),
}],
declarations: vec![ConfigDeclaration {
kind: ConfigDeclarationKind::CapabilityGrant,
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
}
.with_computed_digest()
test_bundle_for_profile(ProfileSelector::Builtin("builtin:coder".to_string()))
}
fn bundled_task_request(objective: &str, bundle: &ConfigBundle) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.config_bundle = Some(ConfigBundleRef {
request.config_bundle = ConfigBundleRef {
id: bundle.metadata.id.clone(),
digest: bundle.metadata.digest.clone(),
});
};
request
}
#[test]
fn create_list_and_detail_preserve_runtime_worker_authority() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let detail = runtime.create_worker(task_request("implement v0")).unwrap();
assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap());
assert_eq!(detail.status, WorkerStatus::Running);
assert!(detail.config_bundle.is_none());
assert_eq!(detail.config_bundle.id, "bundle-1");
let list = runtime.list_workers().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].worker_ref, detail.worker_ref);
assert_eq!(list[0].requested_capability_count, 1);
assert_eq!(list[0].config_bundle, detail.config_bundle);
let fetched = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(fetched.worker_id, detail.worker_id);
assert_eq!(fetched.intent, detail.intent);
assert_eq!(fetched.profile, detail.profile);
}
#[test]
fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let bundle = test_bundle();
let availability = runtime.store_config_bundle(bundle.clone()).unwrap();
assert_eq!(availability.reference.id, "bundle-1");
@ -1697,7 +1749,7 @@ mod tests {
let detail = runtime
.create_worker(bundled_task_request("synced", &bundle))
.unwrap();
assert_eq!(detail.config_bundle, Some(availability.reference));
assert_eq!(detail.config_bundle, availability.reference);
}
#[test]
@ -1746,8 +1798,8 @@ mod tests {
#[test]
fn rejects_worker_refs_from_another_runtime() {
let runtime_a = Runtime::new_memory();
let runtime_b = Runtime::new_memory();
let runtime_a = runtime_with_backend();
let runtime_b = runtime_with_backend();
let detail = runtime_a.create_worker(task_request("runtime a")).unwrap();
let err = runtime_b.worker_detail(&detail.worker_ref).unwrap_err();
@ -1755,52 +1807,50 @@ mod tests {
}
#[test]
fn tools_less_worker_without_config_bundle_uses_local_defaults_and_diagnostics() {
let runtime = Runtime::new_memory();
let detail = runtime
.create_worker(CreateWorkerRequest::tools_less(
WorkerIntent::default(),
ProfileSelector::RuntimeDefault,
))
.unwrap();
fn create_worker_rejects_system_initial_input_without_persisting_worker() {
let runtime = runtime_with_backend();
let mut request = task_request("system initial input");
request.initial_input = Some(WorkerInput::system("role/system belongs in config bundle"));
assert!(detail.config_bundle.is_none());
assert!(detail.requested_capabilities.is_empty());
let diagnostics = runtime.diagnostics().unwrap();
assert_eq!(diagnostics.len(), 2);
let error = runtime.create_worker(request).unwrap_err();
assert!(matches!(
error,
RuntimeError::InvalidInitialInputKind { .. }
));
assert!(runtime.list_workers().unwrap().is_empty());
let events = runtime
.read_events(&runtime.event_cursor_from_start().unwrap(), 16)
.unwrap();
assert!(
diagnostics
events
.events
.iter()
.any(|diagnostic| diagnostic.code == "runtime.local_default_resources")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "worker.tools_less")
.all(|event| event.kind != RuntimeEventKind::WorkerCreated)
);
}
#[test]
fn backend_unconnected_worker_input_is_rejected_and_not_transcribed() {
fn create_worker_without_execution_backend_is_rejected_and_not_persisted() {
let runtime = Runtime::new_memory();
let detail = runtime.create_worker(task_request("placeholder")).unwrap();
assert_eq!(
detail.execution.backend,
WorkerExecutionBackendKind::Unconnected
);
let err = runtime
.send_input(&detail.worker_ref, WorkerInput::user("must reject"))
runtime.store_config_bundle(test_bundle()).unwrap();
let error = runtime
.create_worker(task_request("no backend"))
.unwrap_err();
assert!(matches!(
err,
RuntimeError::WorkerExecutionUnavailable { .. }
error,
RuntimeError::ExecutionBackendUnavailable { .. }
));
assert!(runtime.list_workers().unwrap().is_empty());
}
let projection = runtime
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 1))
.unwrap();
assert_eq!(projection.total_items, 0);
#[test]
fn create_worker_missing_config_bundle_is_rejected_before_backend() {
let runtime = Runtime::new_memory();
let error = runtime
.create_worker(task_request("missing bundle"))
.unwrap_err();
assert!(matches!(error, RuntimeError::ConfigBundleMissing { .. }));
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
@ -1885,6 +1935,7 @@ mod tests {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(InputOnlyBackend))
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(task_request("no stop")).unwrap();
let err = runtime
@ -1916,6 +1967,7 @@ mod tests {
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(task_request("chat")).unwrap();
let first = runtime
@ -1946,7 +1998,7 @@ mod tests {
#[test]
fn stop_and_cancel_workers_update_summary() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let stopped = runtime.create_worker(task_request("stop me")).unwrap();
let cancelled = runtime.create_worker(task_request("cancel me")).unwrap();
@ -1969,7 +2021,7 @@ mod tests {
#[test]
fn stop_then_cancel_preserves_stopped_terminal_state() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let cursor = runtime.event_cursor_from_start().unwrap();
let worker = runtime
.create_worker(task_request("stable stopped"))
@ -2014,7 +2066,7 @@ mod tests {
#[test]
fn cancel_then_stop_preserves_cancelled_terminal_state() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let cursor = runtime.event_cursor_from_start().unwrap();
let worker = runtime
.create_worker(task_request("stable cancelled"))
@ -2126,6 +2178,7 @@ mod tests {
runtime.summary().unwrap().backend,
RuntimeBackendKind::FsStore
);
runtime.store_config_bundle(test_bundle()).unwrap();
let worker = runtime.create_worker(task_request("persist me")).unwrap();
runtime
@ -2149,6 +2202,28 @@ mod tests {
.unwrap();
let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap();
assert_eq!(restored_worker.status, WorkerStatus::Stopped);
assert_eq!(
restored_worker.execution.backend,
WorkerExecutionBackendKind::Stale
);
assert_eq!(
restored_worker
.execution
.binding
.as_ref()
.map(|binding| binding.backend_id.as_str()),
Some("test-execution-backend")
);
assert!(
restored
.diagnostics()
.unwrap()
.iter()
.any(
|diagnostic| diagnostic.code == "worker_execution_mapping_stale"
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
)
);
assert_eq!(restored_worker.transcript_len, 2);
let projection = restored
@ -2224,13 +2299,17 @@ mod tests {
let missing_root = fs_store_root("missing");
let missing_runtime_id = RuntimeId::new("runtime-missing").unwrap();
let missing_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
let missing_runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions {
root: missing_root.clone(),
runtime_id: Some(missing_runtime_id.clone()),
display_name: None,
limits: RuntimeLimits::default(),
})
},
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
missing_runtime.store_config_bundle(test_bundle()).unwrap();
missing_runtime
.create_worker(task_request("missing worker snapshot"))
.unwrap();

View File

@ -14,13 +14,11 @@ async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] }
llm-engine = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
manifest = { workspace = true }
mcp = { workspace = true }
protocol = { workspace = true }
provider = { workspace = true }
client = { workspace = true }
pod-registry = { workspace = true }
worker-runtime = { workspace = true, features = ["ws-server"], optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -10,7 +10,7 @@ Owns:
- Worker lifecycle and socket protocol serving
- Engine construction around a resolved Manifest
- session-store and pod-store coordination
- session-store and session-store worker metadata coordination
- built-in tool registration under scope/policy
- spawned-child orchestration hooks
@ -19,7 +19,7 @@ Does not own:
- provider-specific wire formats (`provider` / `llm-engine` clients)
- product CLI parsing (`yoi`)
- TUI display authority (`tui`)
- current-state storage schema outside Worker metadata (`pod-store`)
- current-state storage schema outside Worker metadata (`session-store` worker metadata)
## Design notes

View File

@ -11,8 +11,8 @@
//! cargo run -p worker --example worker_cli
//! ```
use pod_store::{CombinedStore, FsWorkerStore};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use worker::{Worker, WorkerManifest, WorkerRunResult};
fn manifest_toml(pwd: &std::path::Path) -> String {

View File

@ -5,8 +5,8 @@
//! cargo run -p worker --example worker_protocol
//! ```
use pod_store::{CombinedStore, FsWorkerStore};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use worker::{Event, Method, WorkerController};
fn manifest_toml(pwd: &std::path::Path) -> String {

View File

@ -5,8 +5,8 @@ use std::sync::atomic::Ordering;
use llm_engine::EngineError;
use llm_engine::llm_client::client::LlmClient;
use manifest::TicketFeatureAccessConfig;
use pod_store::WorkerMetadataStore;
use session_store::Store;
use session_store::WorkerMetadataStore;
use ticket::LocalTicketBackend;
use ticket::config::TicketConfig;
use tokio::sync::{broadcast, mpsc, oneshot};
@ -608,7 +608,7 @@ where
let spawner_name = worker.manifest().worker.name.clone();
let spawner_manifest = worker.manifest().clone();
let prompts = worker.prompts().clone();
let pod_store = worker.store().clone();
let worker_metadata_store = worker.store().clone();
let self_parent_socket = worker.callback_socket().cloned();
// The Worker's SharedScope (already augmented with the bash-output
@ -724,8 +724,13 @@ where
worker.register_tool(send_to_worker_tool(spawned_registry.clone()));
worker.register_tool(read_worker_output_tool(spawned_registry.clone()));
worker.register_tool(stop_worker_tool(spawned_registry.clone()));
let discovery =
WorkerDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
let discovery = WorkerDiscovery::new(
worker_metadata_store,
spawner_name,
runtime_base,
cwd,
spawned_registry,
);
worker.register_tool(list_workers_tool(discovery.clone()));
worker.register_tool(restore_worker_tool(discovery.clone()));
worker.register_tool(send_to_peer_worker_tool(discovery));

View File

@ -18,19 +18,19 @@ use async_trait::async_trait;
use client::WorkerRuntimeCommand;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{Permission, ScopeRule};
use pod_store::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, validate_worker_name,
};
use protocol::stream::JsonLineReader;
use protocol::{Event, Method, WorkerStatus};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId};
use session_store::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, validate_worker_name,
};
use tokio::net::UnixStream;
use tokio::process::Command;
use crate::runtime::dir::SpawnedWorkerRecord;
use crate::runtime::pod_registry;
use crate::runtime::worker_allocation;
use crate::spawn::comm_tools::connect_and_send;
use crate::spawn::registry::SpawnedWorkerRegistry;
@ -705,9 +705,9 @@ pub enum WorkerDiscoveryError {
#[error("session store error: {0}")]
Store(#[from] session_store::StoreError),
#[error("worker store error: {0}")]
WorkerStore(#[from] pod_store::WorkerStoreError),
WorkerStore(#[from] session_store::WorkerStoreError),
#[error("scope lock error: {0}")]
ScopeLock(#[from] pod_registry::ScopeLockError),
ScopeLock(#[from] worker_allocation::ScopeLockError),
#[error("failed to launch restore process: {0}")]
RestoreSpawn(io::Error),
#[error("failed to launch restore runtime command `{command}`: {source}")]
@ -748,7 +748,7 @@ impl VisibilitySet {
}
}
fn comm_info_from_spawned_child(child: &pod_store::WorkerSpawnedChild) -> CommRegistryInfo {
fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> CommRegistryInfo {
let scope_delegated = child
.scope_delegated
.iter()
@ -773,7 +773,7 @@ fn comm_info_from_spawned_child(child: &pod_store::WorkerSpawnedChild) -> CommRe
}
async fn summarize_spawned_children(
children: &[pod_store::WorkerSpawnedChild],
children: &[session_store::WorkerSpawnedChild],
) -> SpawnedChildrenSummary {
let mut summary = SpawnedChildrenSummary {
count: children.len(),
@ -832,8 +832,8 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
fn lookup_segment_lock(
segment_id: SegmentId,
) -> Result<Option<pod_registry::SegmentLockInfo>, pod_registry::ScopeLockError> {
pod_registry::lookup_segment(segment_id)
) -> Result<Option<worker_allocation::SegmentLockInfo>, worker_allocation::ScopeLockError> {
worker_allocation::lookup_segment(segment_id)
}
#[derive(Debug, Deserialize, JsonSchema)]
@ -1061,9 +1061,11 @@ mod tests {
use std::sync::Mutex;
use manifest::{Permission, ScopeRule};
use pod_store::{FsWorkerStore, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError};
use protocol::stream::JsonLineWriter;
use protocol::{Alert, AlertLevel, AlertSource};
use session_store::{
FsWorkerStore, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
};
use session_store::{new_segment_id, new_session_id};
use tempfile::TempDir;
use tokio::net::UnixListener;
@ -1143,7 +1145,7 @@ mod tests {
child("child-pending", &pending_socket),
],
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "peer".into(),
}],
resolved_manifest_snapshot: None,
@ -1209,7 +1211,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "parent".into(),
}],
resolved_manifest_snapshot: None,
@ -1317,7 +1319,7 @@ mod tests {
assert!(matches!(restore_plan, RestorePlan::Restore { .. }));
let lock_socket = runtime_base.join("lock-owner.sock");
let _guard = pod_registry::install_top_level(
let _guard = worker_allocation::install_top_level(
"lock-owner".into(),
std::process::id(),
lock_socket.clone(),
@ -1415,7 +1417,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "target".into(),
}],
resolved_manifest_snapshot: None,
@ -1455,7 +1457,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "target".into(),
}],
resolved_manifest_snapshot: None,
@ -1468,7 +1470,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "source".into(),
}],
resolved_manifest_snapshot: None,
@ -1573,7 +1575,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "target".into(),
}],
resolved_manifest_snapshot: None,
@ -1586,7 +1588,7 @@ mod tests {
workspace_root: None,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
peers: vec![pod_store::WorkerPeer {
peers: vec![session_store::WorkerPeer {
worker_name: "source".into(),
}],
resolved_manifest_snapshot: None,

View File

@ -9,7 +9,7 @@ use manifest::{
WorkerManifest, WorkerManifestConfig, paths,
plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup},
};
use pod_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use session_store::{FsStore, SegmentId, Store};
use ticket::config::TicketRole;
@ -85,7 +85,7 @@ struct Cli {
/// Restore a Worker from an existing session. The Worker re-uses the
/// given session id and appends new turns to the same jsonl;
/// concurrent writers are prevented by the pod-registry.
/// concurrent writers are prevented by the worker-allocation.
/// Mutually exclusive with `--adopt` (spawned children always start
/// fresh).
#[arg(long, value_name = "UUID", conflicts_with_all = ["adopt"])]
@ -484,21 +484,21 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
return ExitCode::FAILURE;
}
};
let pod_store_dir = match paths::data_dir() {
Some(data_dir) => data_dir.join("pods"),
let worker_metadata_dir = match paths::data_dir() {
Some(data_dir) => data_dir.join("workers"),
None => store_dir
.parent()
.map(|parent| parent.join("pods"))
.map(|parent| parent.join("workers"))
.unwrap_or_else(|| PathBuf::from("workers")),
};
let pod_store = match FsWorkerStore::new(&pod_store_dir) {
let worker_metadata_store = match FsWorkerStore::new(&worker_metadata_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to initialize worker store at {pod_store_dir:?}: {e}");
eprintln!("error: failed to initialize worker store at {worker_metadata_dir:?}: {e}");
return ExitCode::FAILURE;
}
};
let store = CombinedStore::new(session_store, pod_store);
let store = CombinedStore::new(session_store, worker_metadata_store);
let mut worker = if cli.adopt {
let callback = match cli.callback.clone() {

View File

@ -10,7 +10,7 @@
//! parent's notification buffer. Control-plane-only variants may still have
//! a renderer for diagnostics, but receive-side classification keeps them
//! out of LLM history/context.
//! - **Apply side effects** on the parent (registry / pod-registry
//! - **Apply side effects** on the parent (registry / worker-allocation
//! updates) so that the receive path is idempotent and tolerant of
//! out-of-order delivery.
//!

View File

@ -1,2 +1,2 @@
pub mod dir;
pub use ::pod_registry;
pub mod worker_allocation;

View File

@ -0,0 +1,29 @@
//! Process-local Worker allocation table used only for scope ownership checks.
//!
//! This module is intentionally not a runtime identity store. Runtime Worker
//! identity, creation and durable persistence remain owned by worker-runtime
//! fs-store plus its execution backend mapping; this table coordinates
//! in-process scope delegation while a Worker is running.
mod conflict;
mod error;
mod lifecycle;
mod mutate;
mod table;
#[cfg(test)]
mod test_util;
pub use conflict::{
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
};
pub use error::ScopeLockError;
pub use lifecycle::{
ScopeAllocationGuard, SegmentLockInfo, adopt_allocation, install_top_level,
install_top_level_with_deny, lookup_segment, update_segment,
};
pub use mutate::{
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
register_worker_with_deny, release_worker,
};
pub use table::{Allocation, LockFile, LockFileGuard, default_allocation_path};

View File

@ -6,7 +6,7 @@
use manifest::{Permission, ScopeRule};
use crate::table::{Allocation, LockFile};
use super::table::{Allocation, LockFile};
/// Whether `a` and `b` claim any overlapping concrete path.
///
@ -156,9 +156,11 @@ fn find_conflict_in_subtree(
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::super::{
ScopeLockError, delegate_scope, register_worker, register_worker_with_deny,
};
use super::*;
use crate::test_util::*;
use crate::{ScopeLockError, delegate_scope, register_pod, register_worker_with_deny};
use tempfile::TempDir;
#[test]

View File

@ -1,4 +1,4 @@
//! Error type for mutating pod-registry operations.
//! Error type for mutating Worker allocation operations.
use std::io;
use std::path::PathBuf;
@ -6,14 +6,14 @@ use std::path::PathBuf;
use manifest::{ScopeError, ScopeRule};
use session_store::SegmentId;
/// Errors raised by the mutating pod-registry operations.
/// Errors raised by the mutating Worker allocation operations.
#[derive(Debug, thiserror::Error)]
pub enum ScopeLockError {
#[error("I/O error on workers.json: {0}")]
Io(#[from] io::Error),
#[error("pod name `{0}` is already registered")]
#[error("worker `{0}` is already allocated")]
DuplicateWorkerName(String),
#[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
#[error("requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
WriteConflict {
competitor: String,
rule: ScopeRule,
@ -26,10 +26,10 @@ pub enum ScopeLockError {
NotSubset { spawner: String, rule: ScopeRule },
#[error("invalid delegation scope: {source}")]
InvalidScope { source: ScopeError },
#[error("pod `{0}` is not registered")]
#[error("worker `{0}` is not allocated")]
UnknownWorker(String),
#[error(
"session {segment_id} is already held by pod `{worker_name}` at {}",
"session {segment_id} is already allocated to worker `{worker_name}` at {}",
.socket.display()
)]
SegmentConflict {

View File

@ -1,5 +1,5 @@
//! Owned-allocation guards and the high-level entry points that open
//! the default registry path, mutate it, and return a guard that cleans
//! the default worker allocation path, mutate it, and return a guard that cleans
//! up on drop.
use std::path::{Path, PathBuf};
@ -7,9 +7,9 @@ use std::path::{Path, PathBuf};
use manifest::ScopeRule;
use session_store::SegmentId;
use crate::error::ScopeLockError;
use crate::mutate::release_worker;
use crate::table::{LockFileGuard, default_registry_path};
use super::error::ScopeLockError;
use super::mutate::release_worker;
use super::table::{LockFileGuard, default_allocation_path};
/// Owned allocation: on drop, opens the lock file and releases this
/// Worker's entry. The guard keeps only the name + lock-file path; it
@ -68,9 +68,9 @@ pub fn install_top_level_with_deny(
scope_deny: Vec<ScopeRule>,
segment_id: SegmentId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let lock_path = default_allocation_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
crate::mutate::register_worker_with_deny(
super::mutate::register_worker_with_deny(
&mut guard,
worker_name.clone(),
pid,
@ -99,7 +99,7 @@ pub fn adopt_allocation(
new_pid: u32,
segment_id: SegmentId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let lock_path = default_allocation_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
let alloc = guard
.data_mut()
@ -134,7 +134,7 @@ pub fn adopt_allocation(
/// guard, so the segment_id collision check is atomic with the
/// rewrite.
pub fn update_segment(worker_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
let lock_path = default_registry_path()?;
let lock_path = default_allocation_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
if let Some(other) = guard.data().find_by_segment(new_segment_id) {
if other.worker_name != worker_name {
@ -169,9 +169,9 @@ pub struct SegmentLockInfo {
/// Used by `Worker::restore_from_manifest` to refuse a resume that would
/// race a live writer on the same source session.
pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
let lock_path = default_registry_path()?;
let lock_path = default_allocation_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
crate::mutate::reclaim_stale(&mut guard);
super::mutate::reclaim_stale(&mut guard);
Ok(guard
.data()
.find_by_segment(segment_id)
@ -184,9 +184,9 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>,
#[cfg(test)]
mod tests {
use super::super::table::Allocation;
use super::super::test_util::*;
use super::*;
use crate::table::Allocation;
use crate::test_util::*;
use tempfile::TempDir;
/// Mimic what the spawner does before the child comes up: push an

View File

@ -1,5 +1,5 @@
//! Mutating operations over the allocation table. All of these expect
//! the caller to hold a [`LockFileGuard`] for the registry's lock file.
//! the caller to hold a [`LockFileGuard`] for the worker allocation's lock file.
use std::io;
use std::path::PathBuf;
@ -7,9 +7,9 @@ use std::path::PathBuf;
use manifest::{DelegationScope, Permission, ScopeRule};
use session_store::SegmentId;
use crate::conflict::{find_conflict_owner, find_conflict_owners};
use crate::error::ScopeLockError;
use crate::table::{Allocation, LockFileGuard};
use super::conflict::{find_conflict_owner, find_conflict_owners};
use super::error::ScopeLockError;
use super::table::{Allocation, LockFileGuard};
/// Register a top-level Worker (started directly by a human, no
/// delegation parent). Reclaims stale entries before checking
@ -46,7 +46,7 @@ pub fn register_worker(
/// competitor.rule), not relational — it does not verify that the
/// competitor actually descends from this Worker's prior delegations.
/// In practice this is safe because the canonical restore caller derives
/// `scope_deny` from outstanding `pod-store` child delegations, so any
/// `scope_deny` from outstanding child worker metadata delegations, so any
/// covered competitor is expected to be a descendant of the original
/// allocation. Direct callers must uphold the same invariant.
pub fn register_worker_with_deny(
@ -79,7 +79,7 @@ pub fn register_worker_with_deny(
scope_deny
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|deny| crate::conflict::covers_fully(deny, &owner.rule))
.any(|deny| super::conflict::covers_fully(deny, &owner.rule))
});
if all_denied {
continue;
@ -286,9 +286,9 @@ fn pid_alive(pid: u32) -> bool {
#[cfg(test)]
mod tests {
use super::super::is_within_effective_write;
use super::super::test_util::*;
use super::*;
use crate::is_within_effective_write;
use crate::test_util::*;
use tempfile::TempDir;
#[test]

View File

@ -49,7 +49,7 @@ pub struct Allocation {
/// a top-level Worker started directly by a human.
pub delegated_from: Option<String>,
/// Segment ID this Worker is currently writing to. `None` means this
/// is a pre-reservation made by a spawner via [`crate::delegate_scope`]
/// is a pre-reservation made by a spawner via [`super::super::delegate_scope`]
/// before the child has come up; the child fills it in at
/// [`crate::adopt_allocation`] time.
#[serde(default)]
@ -79,11 +79,11 @@ impl LockFile {
}
/// Default on-disk path: `<runtime_dir>/workers.json` resolved via
/// [`manifest::paths::pod_registry_path`]. Tests should point this
/// [`manifest::paths::worker_allocation_path`]. Tests should point this
/// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a
/// tempdir.
pub fn default_registry_path() -> io::Result<PathBuf> {
paths::pod_registry_path().ok_or_else(|| {
pub fn default_allocation_path() -> io::Result<PathBuf> {
paths::worker_allocation_path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not resolve workers.json path (no YOI_HOME / \
@ -137,7 +137,7 @@ impl LockFileGuard {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for pod registry lock `{}`",
"timed out waiting for worker allocation lock `{}`",
path.display()
),
));
@ -149,7 +149,7 @@ impl LockFileGuard {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for pod registry lock `{}`",
"timed out waiting for worker allocation lock `{}`",
path.display()
),
));
@ -211,9 +211,9 @@ impl Drop for LockFileGuard {
#[cfg(test)]
mod tests {
use super::super::register_worker;
use super::super::test_util::*;
use super::*;
use crate::register_pod;
use crate::test_util::*;
use tempfile::TempDir;
#[test]
@ -277,7 +277,7 @@ mod tests {
sid(),
)
.unwrap();
crate::delegate_scope(
super::super::delegate_scope(
&mut g,
"parent",
"child".into(),

View File

@ -1,6 +1,6 @@
//! Shared test helpers for the pod-registry crate.
//! Shared test helpers for the pod-worker allocation crate.
//!
//! Visible to all `#[cfg(test)]` modules under `crate::test_util::*`.
//! Visible to all `#[cfg(test)]` modules under `super::test_util::*`.
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, MutexGuard};
@ -8,7 +8,7 @@ use std::sync::{LazyLock, Mutex, MutexGuard};
use manifest::{DelegationScope, Permission, ScopeConfig, ScopeRule};
use session_store::SegmentId;
use crate::table::LockFileGuard;
use super::table::LockFileGuard;
pub(crate) fn sid() -> SegmentId {
session_store::new_segment_id()
@ -17,7 +17,7 @@ pub(crate) fn sid() -> SegmentId {
/// Serialises tests that mutate runtime-dir env vars. The test
/// harness runs tests on multiple threads inside a single process,
/// so env-var writes from one test would otherwise leak into a
/// parallel test's `default_registry_path()` lookup.
/// parallel test's `default_allocation_path()` lookup.
pub(crate) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// Sandbox `YOI_RUNTIME_DIR` to a tempdir for the duration of

View File

@ -16,9 +16,9 @@ use std::time::Duration;
use async_trait::async_trait;
use manifest::paths;
use pod_store::{CombinedStore, FsWorkerStore};
use protocol::{Method, Segment, WorkerStatus};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use tokio::runtime::Runtime;
use tokio::sync::broadcast;
use worker_runtime::execution::{
@ -49,7 +49,7 @@ pub struct ProfileRuntimeWorkerFactory {
workspace_root: PathBuf,
cwd: PathBuf,
store_dir: Option<PathBuf>,
pod_store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
profile: Option<String>,
runtime_base_dir: Option<PathBuf>,
}
@ -61,7 +61,7 @@ impl ProfileRuntimeWorkerFactory {
cwd: workspace_root.clone(),
workspace_root,
store_dir: None,
pod_store_dir: None,
worker_metadata_dir: None,
profile: None,
runtime_base_dir: None,
}
@ -77,8 +77,8 @@ impl ProfileRuntimeWorkerFactory {
self
}
pub fn with_pod_store_dir(mut self, pod_store_dir: impl Into<PathBuf>) -> Self {
self.pod_store_dir = Some(pod_store_dir.into());
pub fn with_worker_metadata_dir(mut self, worker_metadata_dir: impl Into<PathBuf>) -> Self {
self.worker_metadata_dir = Some(worker_metadata_dir.into());
self
}
@ -104,11 +104,11 @@ impl ProfileRuntimeWorkerFactory {
})
}
fn pod_store_dir(&self, store_dir: &std::path::Path) -> PathBuf {
self.pod_store_dir
fn worker_metadata_dir(&self, store_dir: &std::path::Path) -> PathBuf {
self.worker_metadata_dir
.clone()
.or_else(|| paths::data_dir().map(|data_dir| data_dir.join("pods")))
.or_else(|| store_dir.parent().map(|parent| parent.join("pods")))
.or_else(|| paths::data_dir().map(|data_dir| data_dir.join("workers")))
.or_else(|| store_dir.parent().map(|parent| parent.join("workers")))
.unwrap_or_else(|| PathBuf::from("workers"))
}
@ -174,14 +174,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
store_dir.display()
)
})?;
let pod_store_dir = self.pod_store_dir(&store_dir);
let pod_store = FsWorkerStore::new(&pod_store_dir).map_err(|err| {
let worker_metadata_dir = self.worker_metadata_dir(&store_dir);
let worker_metadata_store = FsWorkerStore::new(&worker_metadata_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
pod_store_dir.display()
worker_metadata_dir.display()
)
})?;
let store = CombinedStore::new(session_store, pod_store);
let store = CombinedStore::new(session_store, worker_metadata_store);
let worker = Worker::from_manifest_with_context(
manifest,
@ -558,7 +558,7 @@ mod tests {
runtime_base: PathBuf,
cwd: PathBuf,
store_dir: PathBuf,
pod_store_dir: PathBuf,
worker_metadata_dir: PathBuf,
}
#[async_trait]
@ -588,7 +588,7 @@ mod tests {
.map_err(|err| err.to_string())?;
let store = CombinedStore::new(
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.pod_store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
);
let scope = Scope::writable(&self.cwd).map_err(|err| err.to_string())?;
let worker = Worker::new(
@ -662,7 +662,7 @@ mod tests {
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
pod_store_dir: store.path().join("pods"),
worker_metadata_dir: store.path().join("workers"),
};
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend(

View File

@ -21,7 +21,7 @@ use std::sync::Arc;
use std::time::Duration;
use manifest::{Permission, ScopeRule, SharedScope};
use pod_store::{
use session_store::{
WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerSpawnedScopeRule,
WorkerStoreError,
};
@ -30,7 +30,7 @@ use tokio::sync::Mutex;
use tracing::warn;
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use crate::runtime::pod_registry;
use crate::runtime::worker_allocation;
type RegistryStateWriter = Arc<dyn Fn(&[SpawnedWorkerRecord]) -> io::Result<()> + Send + Sync>;
type RegistryReclaimWriter = Arc<dyn Fn(&SpawnedWorkerRecord) -> io::Result<()> + Send + Sync>;
@ -339,11 +339,11 @@ fn reclaim_record(
.cloned()
.collect::<Vec<_>>();
let lock_path = pod_registry::default_registry_path()
let lock_path = worker_allocation::default_allocation_path()
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
let mut guard = pod_registry::LockFileGuard::open(&lock_path)
let mut guard = worker_allocation::LockFileGuard::open(&lock_path)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
pod_registry::reclaim_delegated_scope(
worker_allocation::reclaim_delegated_scope(
&mut guard,
parent_name,
&record.worker_name,
@ -361,12 +361,12 @@ fn reclaim_record(
}
fn release_child_allocation(worker_name: &str) -> io::Result<()> {
let lock_path = pod_registry::default_registry_path()
let lock_path = worker_allocation::default_allocation_path()
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
let mut guard = pod_registry::LockFileGuard::open(&lock_path)
let mut guard = worker_allocation::LockFileGuard::open(&lock_path)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
match pod_registry::release_worker(&mut guard, worker_name) {
Ok(()) | Err(pod_registry::ScopeLockError::UnknownWorker(_)) => Ok(()),
match worker_allocation::release_worker(&mut guard, worker_name) {
Ok(()) | Err(worker_allocation::ScopeLockError::UnknownWorker(_)) => Ok(()),
Err(err) => Err(io::Error::new(io::ErrorKind::Other, err)),
}
}

View File

@ -1,9 +1,9 @@
//! `SpawnWorker` tool — launch a new Worker process as a child of this one.
//!
//! Wires pod-registry delegation, child manifest-config construction, subprocess
//! Wires worker-allocation delegation, child manifest-config construction, subprocess
//! launch, and socket handoff into a single `Tool` implementation. When
//! the LLM calls `SpawnWorker`, a fresh Worker runtime command is exec'd in its own
//! process group, the pod-registry is updated atomically, and the child's
//! process group, the worker-allocation is updated atomically, and the child's
//! first turn is kicked off by handing its socket a `Method::Run`.
use std::path::{Path, PathBuf};
@ -29,7 +29,7 @@ use tokio::time::sleep;
use crate::ipc::event;
use crate::prompt::catalog::PromptCatalog;
use crate::runtime::dir::SpawnedWorkerRecord;
use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError};
use crate::runtime::worker_allocation::{self, LockFileGuard, ScopeLockError};
use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm};
use crate::spawn::registry::SpawnedWorkerRegistry;
use protocol::WorkerEvent;
@ -216,7 +216,7 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
/// controller once per Worker lifetime.
pub struct SpawnWorkerTool {
/// Spawner's own worker name — becomes the spawned Worker's
/// `delegated_from` in the pod-registry.
/// `delegated_from` in the worker-allocation.
spawner_name: String,
/// Path to the spawner's Unix socket. Handed to the child via
/// `--callback` so its `WorkerEvent` callbacks have somewhere to land.
@ -254,7 +254,7 @@ pub struct SpawnWorkerTool {
/// `Permission::Write` rules in the delegated scope are revoked
/// from the spawner's in-memory view (a `deny(Write, target)` is
/// pushed on top, downgrading the spawner's effective access on
/// those paths to `Read`). Mirrors the pod-registry's
/// those paths to `Read`). Mirrors the worker-allocation's
/// `effective_write` semantics: Write is the only permission
/// tracked across Workers, so revocation only touches Write.
spawner_scope: SharedScope,
@ -337,15 +337,15 @@ impl Tool for SpawnWorkerTool {
.map_err(|e| ToolError::InvalidArgument(format!("{e}")))?;
let predicted_socket = self.runtime_base.join(&input.name).join("sock");
let lock_path = pod_registry::default_registry_path()
.map_err(|e| ToolError::ExecutionFailed(format!("pod-registry path: {e}")))?;
let lock_path = worker_allocation::default_allocation_path()
.map_err(|e| ToolError::ExecutionFailed(format!("worker-allocation path: {e}")))?;
// Reserve the allocation up front. Spawner's pid is a live
// placeholder; the child will rewrite it via `adopt_allocation`.
{
let mut guard = LockFileGuard::open(&lock_path)
.map_err(|e| ToolError::ExecutionFailed(format!("pod-registry open: {e}")))?;
pod_registry::delegate_scope(
.map_err(|e| ToolError::ExecutionFailed(format!("worker-allocation open: {e}")))?;
worker_allocation::delegate_scope(
&mut guard,
&self.spawner_name,
input.name.clone(),
@ -354,7 +354,7 @@ impl Tool for SpawnWorkerTool {
scope_allow.clone(),
&self.delegation_scope,
)
.map_err(pod_registry_err_to_tool)?;
.map_err(worker_allocation_err_to_tool)?;
}
// `start_outcome` covers steps that happen before the child is
@ -527,7 +527,7 @@ impl SpawnWorkerTool {
fn release_reservation(&self, lock_path: &Path, worker_name: &str) {
if let Ok(mut g) = LockFileGuard::open(lock_path) {
let _ = pod_registry::release_worker(&mut g, worker_name);
let _ = worker_allocation::release_worker(&mut g, worker_name);
}
}
}
@ -864,7 +864,7 @@ fn spawn_delivery_error(worker_name: &str, err: SendRunError) -> ToolError {
}
}
fn pod_registry_err_to_tool(e: ScopeLockError) -> ToolError {
fn worker_allocation_err_to_tool(e: ScopeLockError) -> ToolError {
match e {
ScopeLockError::NotSubset { .. }
| ScopeLockError::WriteConflict { .. }

View File

@ -10,7 +10,7 @@ use tracing::{debug, warn};
use crate::discovery::{WeakNotifyDelivery, WorkerDiscovery};
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
use pod_store::WorkerMetadataStore;
use session_store::WorkerMetadataStore;
const MAX_TITLE_CHARS: usize = 96;
const MAX_SUMMARY_CHARS: usize = 160;
@ -251,11 +251,11 @@ mod tests {
use crate::runtime::dir::RuntimeDir;
use crate::spawn::registry::SpawnedWorkerRegistry;
use llm_engine::tool::ToolOutput;
use pod_store::FsWorkerStore;
use pod_store::WorkerMetadata;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method};
use serde_json::json;
use session_store::FsWorkerStore;
use session_store::WorkerMetadata;
use std::sync::Arc;
use tempfile::tempdir;
use ticket::NewTicket;

View File

@ -10,13 +10,13 @@ use llm_engine::llm_client::client::LlmClient;
use llm_engine::llm_client::types::Role;
use llm_engine::state::Mutable;
use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord};
use pod_store::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
};
use session_store::{
LogEntry, SegmentId, SessionId, Store, StoreError, SystemItem, segment_log, to_logged,
};
use session_store::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError,
};
use tracing::{info, warn};
use crate::segment_log_sink::SegmentLogSink;
@ -44,7 +44,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::PromptLoader;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
use crate::workflow::WorkflowResolveError;
#[cfg(test)]
use async_trait::async_trait;
@ -795,7 +795,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// Strip `revoke` rules from the Worker's runtime scope by adding
/// matching deny rules. A `Permission::Write` revoke caps effective
/// access at `Read` (mirroring the pod-registry `effective_write`
/// access at `Read` (mirroring the worker-allocation `effective_write`
/// semantics — Write is the only permission tracked across Workers).
/// A `Permission::Read` revoke removes access entirely.
pub fn revoke_scope_rules(
@ -2086,7 +2086,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.segment_state.set_entries_written(1);
self.sink.reset_with_initial(entry);
if self.scope_allocation.is_some() {
pod_registry::update_segment(&self.manifest.worker.name, fork_segment_id)?;
worker_allocation::update_segment(&self.manifest.worker.name, fork_segment_id)?;
}
self.write_worker_metadata_active(SegmentLocation {
session_id: loc.session_id,
@ -2795,7 +2795,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
// when no allocation is installed (e.g. compact under
// `Worker::new` in tests).
if self.scope_allocation.is_some() {
pod_registry::update_segment(&self.manifest.worker.name, new_segment_id)?;
worker_allocation::update_segment(&self.manifest.worker.name, new_segment_id)?;
}
self.write_worker_metadata_active(SegmentLocation {
session_id: old_loc.session_id,
@ -3847,19 +3847,19 @@ where
// Segment creation is deferred to the first run (see
// `ensure_segment_head`) so the SegmentStart entry can capture
// the rendered system prompt, not the raw template source. The
// session_id + segment_id are allocated here so the pod-registry
// session_id + segment_id are allocated here so the worker-allocation
// registration can record them from the start.
let session_id = session_store::new_session_id();
let segment_id = session_store::new_segment_id();
// Register this Worker in the machine-wide pod-registry
// Register this Worker in the machine-wide worker-allocation
// before building anything else, so a spawn that conflicts on
// scope fails fast.
let socket_path = dir::default_base()
.map_err(ScopeLockError::from)?
.join(&manifest.worker.name)
.join("sock");
let scope_allocation = pod_registry::install_top_level(
let scope_allocation = worker_allocation::install_top_level(
manifest.worker.name.clone(),
std::process::id(),
socket_path,
@ -3927,7 +3927,7 @@ where
///
/// Behaves like [`Worker::from_manifest`] but claims the scope
/// allocation that the spawner pre-registered via
/// [`pod_registry::delegate_scope`], rather than installing a new
/// [`worker_allocation::delegate_scope`], rather than installing a new
/// top-level entry. `callback_socket` carries the spawner's
/// Unix-socket path so the spawned Worker can send `Method::Notify`
/// back to the spawner.
@ -3971,7 +3971,7 @@ where
// fresh Session rather than joining the spawner's.
let session_id = session_store::new_session_id();
let segment_id = session_store::new_segment_id();
let scope_allocation = pod_registry::adopt_allocation(
let scope_allocation = worker_allocation::adopt_allocation(
manifest.worker.name.clone(),
std::process::id(),
segment_id,
@ -4104,9 +4104,9 @@ where
/// reuses the same `segment_id` so subsequent turns append to the
/// source jsonl as a continuation of the same conversation.
///
/// Concurrent writers are prevented by the pod-registry:
/// Concurrent writers are prevented by the worker-allocation:
/// the registration carries `segment_id`, and this constructor
/// refuses to start when `pod_registry::lookup_segment` already finds
/// refuses to start when `worker_allocation::lookup_segment` already finds
/// a live Worker writing to `segment_id`. So there is no need to fork —
/// resume is "the same session, a different process owning it".
///
@ -4173,7 +4173,7 @@ where
.map_err(ScopeLockError::from)?
.join(&manifest.worker.name)
.join("sock");
let scope_allocation = pod_registry::install_top_level_with_deny(
let scope_allocation = worker_allocation::install_top_level_with_deny(
manifest.worker.name.clone(),
std::process::id(),
socket_path,
@ -4291,10 +4291,10 @@ where
let delegated_scope = spawned_child_scope_rules(&child);
if !delegated_scope.is_empty() {
let lock_path =
pod_registry::default_registry_path().map_err(ScopeLockError::from)?;
let mut guard =
pod_registry::LockFileGuard::open(&lock_path).map_err(ScopeLockError::from)?;
pod_registry::reclaim_delegated_scope(
worker_allocation::default_allocation_path().map_err(ScopeLockError::from)?;
let mut guard = worker_allocation::LockFileGuard::open(&lock_path)
.map_err(ScopeLockError::from)?;
worker_allocation::reclaim_delegated_scope(
&mut guard,
&worker_name,
&child.worker_name,
@ -5300,7 +5300,7 @@ mod worker_metadata_restore_manifest_tests {
#[test]
fn metadata_writer_persists_workspace_root_through_store_update() {
let temp = tempfile::tempdir().unwrap();
let store = pod_store::FsWorkerStore::new(temp.path().join("pods")).unwrap();
let store = session_store::FsWorkerStore::new(temp.path().join("workers")).unwrap();
let workspace_root = temp.path().join("workspace-root");
std::fs::create_dir_all(&workspace_root).unwrap();
let writer = worker_metadata_writer_for_store(&store);

View File

@ -16,8 +16,8 @@ use llm_engine::Engine;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::types::Item;
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use pod_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use protocol::{Event, Method, RunResult};
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use session_store::{FsStore, LogEntry, Store};
use tokio::sync::broadcast;

View File

@ -26,8 +26,8 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
use memory::WorkspaceLayout;
use memory::extract::{ExtractedPayload, write_staging};
use memory::schema::SourceRef;
use pod_store::{CombinedStore, FsWorkerStore};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
use tokio::sync::broadcast;

View File

@ -9,7 +9,7 @@ use llm_engine::llm_client::event::{ErrorEvent, Event as LlmEvent, ResponseStatu
use llm_engine::llm_client::types::Item;
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use pod_store::{CombinedStore, FsWorkerStore};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry};
use worker::{Event, Method, Worker, WorkerController, WorkerHandle, WorkerManifest, WorkerStatus};

View File

@ -2,12 +2,12 @@
//! validation paths.
//!
//! These cases all return before `prepare_worker_common` runs, so they
//! do not need a real LLM client or pod-registry environment — only the
//! do not need a real LLM client or worker-allocation environment — only the
//! session store needs to be present.
use std::sync::{LazyLock, Mutex};
use pod_store::{
use session_store::{
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore,
};
use session_store::{FsStore, StoreError};

View File

@ -25,8 +25,8 @@ use llm_engine::Engine;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use pod_store::{CombinedStore, FsWorkerStore};
use session_metrics::{DOMAIN, Metric, metrics_from_extensions};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
use worker::{Worker, WorkerManifest};

View File

@ -1,6 +1,6 @@
//! Integration tests for the `SpawnWorker` tool.
//!
//! These tests exercise the tool's pod-registry delegation, subprocess
//! These tests exercise the tool's worker-allocation delegation, subprocess
//! launch, socket handoff, and `spawned_workers.json` write through an injected
//! typed runtime command. The mock command exits immediately while a
//! test-owned Unix listener pre-binds the predicted socket path, so the tool
@ -22,7 +22,7 @@ use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::UnixListener;
use worker::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use worker::runtime::pod_registry::{self, LockFileGuard};
use worker::runtime::worker_allocation::{self, LockFileGuard};
use worker::spawn::registry::SpawnedWorkerRegistry;
use worker::spawn::tool::spawn_worker_tool_with_runtime_command;
@ -67,7 +67,7 @@ async fn setup_spawner(
.unwrap();
let spawner_socket = spawner_rd.socket_path();
let _guard = pod_registry::install_top_level(
let _guard = worker_allocation::install_top_level(
spawner_name.into(),
std::process::id(),
spawner_socket.clone(),
@ -450,8 +450,8 @@ async fn spawn_worker_delegates_scope_and_sends_run() {
other => panic!("expected Run, got {other:?}"),
}
// Verify pod_registry has the child allocation under `root`.
let lock_path = pod_registry::default_registry_path().unwrap();
// Verify worker_allocation has the child allocation under `root`.
let lock_path = worker_allocation::default_allocation_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
let child = guard
.data()
@ -651,7 +651,7 @@ async fn spawn_worker_rejects_scope_outside_spawner() {
}
// The spawner's allocation is unchanged; no "child" appeared.
let lock_path = pod_registry::default_registry_path().unwrap();
let lock_path = worker_allocation::default_allocation_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
assert!(guard.data().find("child").is_none());
@ -724,7 +724,7 @@ async fn spawn_worker_rolls_back_reservation_when_socket_never_appears() {
}
// Rollback assertion: the reserved "ghost" allocation is gone.
let lock_path = pod_registry::default_registry_path().unwrap();
let lock_path = worker_allocation::default_allocation_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
assert!(
guard.data().find("ghost").is_none(),

View File

@ -8,7 +8,7 @@ use futures::Stream;
use llm_engine::Engine;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use pod_store::{CombinedStore, FsWorkerStore};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry, Store};
use worker::{PromptLoader, SystemPromptTemplate, Worker, WorkerError};

View File

@ -14,17 +14,17 @@ use std::sync::{Arc, LazyLock, Mutex};
use llm_engine::llm_client::types::{ContentPart, Item, Role};
use llm_engine::tool::ToolOutput;
use manifest::{Permission, Scope, ScopeRule, SharedScope};
use pod_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{ErrorCode, Event, Greeting, Method};
use serde_json::json;
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use tempfile::TempDir;
use tokio::net::UnixListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use worker::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use worker::runtime::pod_registry::{self, LockFileGuard};
use worker::runtime::worker_allocation::{self, LockFileGuard};
use worker::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
use worker::spawn::registry::SpawnedWorkerRegistry;
@ -416,7 +416,7 @@ async fn stop_worker_sends_shutdown_and_releases_scope() {
permission: Permission::Write,
recursive: true,
};
pod_registry::register_worker_with_deny(
worker_allocation::register_worker_with_deny(
&mut g,
"spawner".into(),
std::process::id(),
@ -426,7 +426,7 @@ async fn stop_worker_sends_shutdown_and_releases_scope() {
session_store::new_segment_id(),
)
.unwrap();
pod_registry::register_worker(
worker_allocation::register_worker(
&mut g,
"child".into(),
std::process::id(),
@ -663,7 +663,7 @@ async fn load_from_worker_state_reclaims_missing_child_scope_and_records_history
{
let mut g = LockFileGuard::open(&runtime_tmp.path().join("workers.json")).unwrap();
pod_registry::register_worker_with_deny(
worker_allocation::register_worker_with_deny(
&mut g,
"spawner".into(),
std::process::id(),

View File

@ -15,7 +15,7 @@ use tempfile::TempDir;
use tokio::net::UnixListener;
use worker::ipc::event::{apply_event_side_effects, fire_and_forget, render_event};
use worker::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use worker::runtime::pod_registry::{self, LockFileGuard};
use worker::runtime::worker_allocation::{self, LockFileGuard};
use worker::spawn::registry::SpawnedWorkerRegistry;
/// Serialises tests that mutate `YOI_RUNTIME_DIR`.
@ -62,7 +62,7 @@ impl Drop for EnvGuard {
}
}
/// Point `YOI_RUNTIME_DIR` at `dir`. The pod-registry then lives at
/// Point `YOI_RUNTIME_DIR` at `dir`. The worker-allocation then lives at
/// `<dir>/workers.json` and Worker runtime sub-dirs at `<dir>/{worker_name}/`.
fn set_runtime_dir(dir: &std::path::Path) {
unsafe {
@ -380,7 +380,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
// Install a top-level allocation for "kid" so ShutDown has
// something to release.
let guard = pod_registry::install_top_level(
let guard = worker_allocation::install_top_level(
"kid".into(),
std::process::id(),
"/tmp/kid.sock".into(),
@ -412,7 +412,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
)
.await;
// Allocation is gone from the pod-registry.
// Allocation is gone from the worker-allocation.
let g = LockFileGuard::open(&lock_path).unwrap();
assert!(
g.data().find("kid").is_none(),

View File

@ -10,6 +10,7 @@ async-trait.workspace = true
axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
manifest.workspace = true
protocol = { workspace = true }
project-record.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
@ -23,7 +24,7 @@ ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true
worker = { workspace = true, features = ["runtime-adapter"] }
worker-runtime = { workspace = true, features = ["ws-server"] }
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
toml.workspace = true
tracing.workspace = true
uuid = { workspace = true, features = ["v7"] }

View File

@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use worker_runtime::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector};
use worker_runtime::catalog::ProfileSelector;
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
@ -367,10 +367,6 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
let selector = companion_profile_selector();
let mut diagnostics = Vec::new();
let config_bundle = companion_config_bundle();
let config_ref = ConfigBundleRef {
id: config_bundle.metadata.id.clone(),
digest: config_bundle.metadata.digest.clone(),
};
match runtime.sync_config_bundle(COMPANION_RUNTIME_ID, config_bundle) {
Ok(result) => diagnostics.extend(result.diagnostics),
@ -390,8 +386,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
expected_segments: 0,
},
profile: Some(selector),
config_bundle: Some(config_ref),
requested_capabilities: vec![CapabilityRequest::named("worker.input.user")],
initial_input: None,
},
);
@ -611,36 +606,37 @@ mod tests {
}
#[test]
fn companion_spawns_worker_with_companion_profile_and_diagnostic_when_not_input_capable() {
let registry =
RuntimeRegistry::for_workspace(EmbeddedWorkerRuntime::new_memory("local:test"));
fn companion_spawns_worker_with_companion_profile_through_runtime_backend() {
let registry = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(DeterministicExecutionBackend::default()),
)
.unwrap(),
);
let registry = Arc::new(registry);
let companion = CompanionConsole::new(registry.clone());
let status = companion.status();
let worker = status.worker.clone().expect("companion worker");
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
assert_eq!(worker.role.as_deref(), Some("workspace_companion"));
assert!(!worker.capabilities.can_accept_input);
assert_eq!(status.transport.completion, "not_input_capable");
assert!(
status
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable")
);
assert_eq!(worker.role.as_deref(), Some(COMPANION_PROFILE_ID));
assert!(worker.capabilities.can_accept_input);
assert_eq!(status.transport.completion, "connected");
assert!(status.diagnostics.is_empty());
let response = companion.send_message(CompanionMessageRequest {
content: "hello".to_string(),
});
assert_eq!(response.state, CompanionState::Rejected);
assert_eq!(response.state, CompanionState::Accepted);
assert!(response.diagnostics.is_empty());
assert!(
!response
.diagnostics
response
.transcript
.items
.iter()
.any(|diagnostic| diagnostic.code == "companion_llm_not_connected")
.any(|entry| entry.role == "user" && entry.content == "hello")
);
assert!(response.transcript.items.is_empty());
let worker_detail = registry
.worker(COMPANION_RUNTIME_ID, &worker.worker_id)

View File

@ -0,0 +1,425 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::{fs, io};
use serde::Deserialize;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH: &str =
".yoi/workspace-backend.default.toml";
pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_MAX_RECORDS: usize = 200;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
#[serde(default)]
pub server: WorkspaceBackendServerConfig,
#[serde(default)]
pub data: WorkspaceBackendDataConfig,
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
#[serde(default)]
pub remote: Vec<RemoteRuntimeConfigFile>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RemoteRuntimeConfigFile {
pub id: String,
pub endpoint: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub token_ref: Option<String>,
}
#[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig,
pub listen: SocketAddr,
pub database_path: PathBuf,
}
impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn default_template_path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH)
}
pub fn ensure_default_template_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::default_template_path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse workspace backend config `{}`: {error}",
path.as_ref().display()
))
})
}
pub fn resolve(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
let workspace_root = workspace_root.as_ref();
let data_root = self
.data
.root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| {
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
});
let database_path = self
.data
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("workspace.db"));
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("embedded-runtime"));
let listen = self
.server
.listen
.as_deref()
.unwrap_or(DEFAULT_LISTEN)
.parse::<SocketAddr>()
.map_err(|_| {
Error::Config(format!(
"invalid workspace backend server.listen `{}`",
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
))
})?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.frontend_url = self
.server
.frontend_url
.clone()
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
server.static_assets_dir = self
.server
.static_assets_dir
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.remote_runtime_sources = self
.runtimes
.remote
.iter()
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::LocalDevToken {
token_configured: false,
};
Ok(ResolvedWorkspaceBackendConfig {
server,
listen,
database_path,
})
}
}
impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
self.database_path = path.into();
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
self.server.static_assets_dir = path;
self
}
pub fn with_listen(mut self, listen: SocketAddr) -> Self {
self.listen = listen;
self
}
}
fn resolve_remote_runtime(config: &RemoteRuntimeConfigFile) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
config.id
)));
}
Ok(RemoteRuntimeConfig::new(
config.id.clone(),
config
.display_name
.clone()
.unwrap_or_else(|| config.id.clone()),
config.endpoint.clone(),
None,
))
}
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity() -> WorkspaceIdentity {
WorkspaceIdentity {
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
display_name: "Workspace".to_string(),
}
}
#[test]
fn missing_config_path_uses_defaults() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("workspace.db"));
assert!(
resolved
.server
.embedded_runtime_store_root
.ends_with("embedded-runtime")
);
}
#[test]
fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn resolves_relative_paths_against_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[server]
static_assets_dir = "web/build"
[data]
root = ".yoi/backend-data"
workspace_database_path = ".yoi/custom.db"
embedded_runtime_store_root = ".yoi/runtime-store"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.server.static_assets_dir,
Some(dir.path().join("web/build"))
);
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".yoi/runtime-store")
);
}
#[test]
fn absolute_paths_are_preserved() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
workspace_database_path = "/tmp/yoi-workspace.db"
embedded_runtime_store_root = "/tmp/yoi-runtime"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
PathBuf::from("/tmp/yoi-workspace.db")
);
assert_eq!(
resolved.server.embedded_runtime_store_root,
PathBuf::from("/tmp/yoi-runtime")
);
}
#[test]
fn data_root_derives_database_and_runtime_store_paths() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
root = ".local-data"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
dir.path().join(".local-data/workspace.db")
);
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
);
}
#[test]
fn copies_default_template_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::default_template_path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom template\n").unwrap();
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "# custom template\n");
}
#[test]
fn token_value_field_is_not_in_schema() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token = "secret"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token_ref = "local:remote-token"
"#,
"test",
)
.unwrap();
let error = match config.resolve(dir.path(), identity()) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("secret ref resolution is not implemented"),
"unexpected error: {error}"
);
}
}

View File

@ -6,14 +6,20 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{sync::Arc, time::Duration};
use std::{path::PathBuf, sync::Arc, time::Duration};
use worker_runtime::catalog::{
CapabilityRequest, ConfigBundleRef, CreateWorkerRequest, ProfileSelector,
WorkerDetail as EmbeddedWorkerDetail, WorkerIntent, WorkerStatus as EmbeddedWorkerStatus,
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus,
};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance,
ConfigBundleSummary, ConfigProfileDescriptor,
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use worker_runtime::error::RuntimeError as EmbeddedRuntimeError;
use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::execution::{
WorkerExecutionBackendKind, WorkerExecutionRunState, WorkerExecutionStatus,
};
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpTranscriptResponse,
@ -236,11 +242,12 @@ pub struct WorkerLookupResult {
/// Browser-safe worker spawn request shape.
///
/// The request intentionally carries only workspace policy intents, stable
/// worker identifiers, optional profile selectors, config bundle refs, and
/// requested capability names. Raw workspace roots, child cwd, executable path,
/// Runtime endpoints/credentials, raw bundle storage paths, and host-local
/// resolved WorkerSpec content are resolved by the runtime service and never
/// The request carries Browser-facing launch semantics only: workspace intent,
/// optional display identity, acceptance policy, optional profile selector, and
/// optional initial input. Runtime execution authority is resolved by the host
/// into a synced ConfigBundle before the canonical Runtime create request is
/// built. Raw workspace roots, child cwd, executable paths, tool scope,
/// credentials, raw config stores, sockets, sessions, and storage paths are not
/// accepted from Workspace API callers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerSpawnRequest {
@ -251,9 +258,7 @@ pub struct WorkerSpawnRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_bundle: Option<ConfigBundleRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requested_capabilities: Vec<CapabilityRequest>,
pub initial_input: Option<EmbeddedWorkerInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -907,29 +912,46 @@ pub struct EmbeddedWorkerRuntime {
execution_enabled: bool,
}
impl EmbeddedWorkerRuntime {
pub fn new_memory(workspace_id: impl AsRef<str>) -> Self {
fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
.expect("embedded runtime id is a non-empty literal");
let runtime = worker_runtime::Runtime::with_options(EmbeddedRuntimeOptions {
EmbeddedRuntimeOptions {
runtime_id: Some(runtime_id),
display_name: Some("Workspace backend embedded Runtime".to_string()),
..EmbeddedRuntimeOptions::default()
});
}
}
impl EmbeddedWorkerRuntime {
pub fn new_memory(workspace_id: impl AsRef<str>) -> Self {
let runtime = worker_runtime::Runtime::with_options(embedded_runtime_options());
Self::from_runtime(workspace_id, runtime)
}
pub fn new_memory_with_execution_backend(
workspace_id: impl AsRef<str>,
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self, worker_runtime::error::RuntimeError> {
let runtime =
worker_runtime::Runtime::with_execution_backend(embedded_runtime_options(), backend)?;
let mut embedded = Self::from_runtime(workspace_id, runtime);
embedded.execution_enabled = true;
Ok(embedded)
}
pub fn new_fs_store_with_execution_backend(
workspace_id: impl AsRef<str>,
store_root: impl Into<PathBuf>,
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self, worker_runtime::error::RuntimeError> {
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
.expect("embedded runtime id is a non-empty literal");
let runtime = worker_runtime::Runtime::with_execution_backend(
EmbeddedRuntimeOptions {
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
FsRuntimeStoreOptions {
root: store_root.into(),
runtime_id: Some(runtime_id),
display_name: Some("Workspace backend embedded Runtime".to_string()),
..EmbeddedRuntimeOptions::default()
limits: EmbeddedRuntimeOptions::default().limits,
},
backend,
)?;
@ -964,11 +986,7 @@ impl EmbeddedWorkerRuntime {
status: EmbeddedWorkerStatus,
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
self.execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& execution.run_state == WorkerExecutionRunState::Idle
&& !execution_last_result_blocks_control(execution)
runtime_worker_can_accept_input(self.execution_enabled, status, execution)
}
fn can_stop_embedded_worker(
@ -976,16 +994,7 @@ impl EmbeddedWorkerRuntime {
status: EmbeddedWorkerStatus,
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
self.execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& !matches!(
execution.run_state,
WorkerExecutionRunState::Rejected
| WorkerExecutionRunState::Errored
| WorkerExecutionRunState::Unconnected
)
&& !execution_last_result_blocks_control(execution)
runtime_worker_can_stop(self.execution_enabled, status, execution)
}
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
@ -994,14 +1003,14 @@ impl EmbeddedWorkerRuntime {
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
role: embedded_intent_label(&summary.intent),
role: embedded_profile_label(&summary.profile),
profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_execution_status_label(summary.status, summary.execution.run_state)
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
@ -1009,15 +1018,12 @@ impl EmbeddedWorkerRuntime {
display_hint: "backend-internal worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: self.can_accept_embedded_input(summary.status, &summary.execution),
can_accept_input: self
.can_accept_embedded_input(summary.status, &summary.execution),
can_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
"embedded_runtime_projection",
DiagnosticSeverity::Info,
"Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(),
)],
diagnostics: embedded_worker_projection_diagnostics(&summary.execution),
}
}
@ -1027,14 +1033,14 @@ impl EmbeddedWorkerRuntime {
worker_id: detail.worker_id.as_str().to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()),
role: embedded_intent_label(&detail.intent),
role: embedded_profile_label(&detail.profile),
profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_execution_status_label(detail.status, detail.execution.run_state)
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
@ -1046,11 +1052,7 @@ impl EmbeddedWorkerRuntime {
can_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
"embedded_runtime_projection",
DiagnosticSeverity::Info,
"Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(),
)],
diagnostics: embedded_worker_projection_diagnostics(&detail.execution),
}
}
}
@ -1195,26 +1197,35 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
if matches!(request.acceptance, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } if expected_segments > 0)
{
diagnostics.push(diagnostic(
"embedded_runtime_tools_less",
"embedded_runtime_acceptance_projection",
DiagnosticSeverity::Info,
"Embedded Runtime v0 creates a tools-less catalog Worker and does not spawn provider segments".to_string(),
"Embedded Runtime accepts creation through a runtime execution backend; provider segment counts are observed after execution, not faked at create time".to_string(),
));
}
let create_request = CreateWorkerRequest {
intent: embedded_create_intent(&request.intent),
profile: request
let profile = request
.profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent)),
config_bundle: request.config_bundle.clone(),
requested_capabilities: if request.requested_capabilities.is_empty() {
vec![CapabilityRequest::named("read")]
} else {
request.requested_capabilities.clone()
},
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let config_bundle = match self
.runtime
.store_config_bundle(default_embedded_config_bundle(&profile))
{
Ok(availability) => availability.reference,
Err(error) => {
diagnostics.push(embedded_runtime_diagnostic(&error));
return WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: None,
acceptance_evidence: Vec::new(),
diagnostics,
};
}
};
let create_request = CreateWorkerRequest {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
};
match self.runtime.create_worker(create_request) {
Ok(detail) => {
@ -1675,22 +1686,23 @@ impl RemoteWorkerRuntime {
worker_id: summary.worker_ref.worker_id.as_str().to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()),
role: embedded_intent_label(&summary.intent),
role: None,
profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_execution_status_label(summary.status, &summary.execution)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
kind: "remote_worker_runtime".to_string(),
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: true,
can_stop: true,
can_accept_input: runtime_worker_can_accept_input(true, summary.status, &summary.execution),
can_stop: runtime_worker_can_stop(true, summary.status, &summary.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
@ -1707,22 +1719,23 @@ impl RemoteWorkerRuntime {
worker_id: detail.worker_id.as_str().to_string(),
host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()),
role: embedded_intent_label(&detail.intent),
role: None,
profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_execution_status_label(detail.status, &detail.execution)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
kind: "remote_worker_runtime".to_string(),
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: true,
can_stop: true,
can_accept_input: runtime_worker_can_accept_input(true, detail.status, &detail.execution),
can_stop: runtime_worker_can_stop(true, detail.status, &detail.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
@ -1875,20 +1888,24 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
)],
};
}
let create = CreateWorkerRequest {
intent: embedded_create_intent(&request.intent),
profile: request
let profile = request
.profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent)),
config_bundle: request.config_bundle.clone(),
requested_capabilities: if request.requested_capabilities.is_empty() {
vec![CapabilityRequest::named("read")]
} else {
request.requested_capabilities.clone()
},
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let sync = self.sync_config_bundle(default_embedded_config_bundle(&profile));
let Some(config_bundle) = sync.availability.map(|availability| availability.reference)
else {
return WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: None,
acceptance_evidence: Vec::new(),
diagnostics: sync.diagnostics,
};
};
let create = CreateWorkerRequest {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
Ok(response) => WorkerSpawnResult {
@ -2099,9 +2116,37 @@ fn embedded_spawn_execution_failure_diagnostic(
))
}
fn execution_last_result_blocks_control(
execution: &worker_runtime::execution::WorkerExecutionStatus,
fn runtime_worker_can_accept_input(
execution_enabled: bool,
status: EmbeddedWorkerStatus,
execution: &WorkerExecutionStatus,
) -> bool {
execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& execution.run_state == WorkerExecutionRunState::Idle
&& !execution_last_result_blocks_control(execution)
}
fn runtime_worker_can_stop(
execution_enabled: bool,
status: EmbeddedWorkerStatus,
execution: &WorkerExecutionStatus,
) -> bool {
execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& !matches!(
execution.run_state,
WorkerExecutionRunState::Stopped
| WorkerExecutionRunState::Rejected
| WorkerExecutionRunState::Errored
| WorkerExecutionRunState::Unconnected
)
&& !execution_last_result_blocks_control(execution)
}
fn execution_last_result_blocks_control(execution: &WorkerExecutionStatus) -> bool {
execution.last_result.as_ref().is_some_and(|result| {
matches!(
result.outcome,
@ -2120,39 +2165,90 @@ fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
}
}
fn embedded_worker_projection_diagnostics(
execution: &WorkerExecutionStatus,
) -> Vec<RuntimeDiagnostic> {
let mut diagnostics = vec![diagnostic(
"embedded_runtime_projection",
DiagnosticSeverity::Info,
"Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(),
)];
if execution.backend == WorkerExecutionBackendKind::Stale {
diagnostics.push(diagnostic(
"embedded_worker_execution_stale",
DiagnosticSeverity::Warning,
"Worker execution handle is not connected in this server process; persisted execution binding was marked stale".to_string(),
));
} else if execution.backend == WorkerExecutionBackendKind::Unconnected
|| execution.run_state == WorkerExecutionRunState::Unconnected
{
diagnostics.push(diagnostic(
"embedded_worker_execution_unconnected",
DiagnosticSeverity::Warning,
"Worker execution handle is not connected in this server process".to_string(),
));
} else if execution.run_state == WorkerExecutionRunState::Rejected {
diagnostics.push(diagnostic(
"embedded_worker_execution_spawn_rejected",
DiagnosticSeverity::Error,
"Worker execution spawn was rejected; backend-private details are not exposed"
.to_string(),
));
}
diagnostics
}
fn embedded_worker_execution_status_label(
status: EmbeddedWorkerStatus,
run_state: WorkerExecutionRunState,
execution: &WorkerExecutionStatus,
) -> &'static str {
match status {
EmbeddedWorkerStatus::Stopped => "stopped",
EmbeddedWorkerStatus::Cancelled => "cancelled",
EmbeddedWorkerStatus::Running => match run_state {
EmbeddedWorkerStatus::Running => {
if execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Stale {
return "stale";
}
match execution.run_state {
WorkerExecutionRunState::Idle => "idle",
WorkerExecutionRunState::Busy => "running",
WorkerExecutionRunState::Stopped => "stopped",
WorkerExecutionRunState::Rejected => "rejected",
WorkerExecutionRunState::Errored => "errored",
WorkerExecutionRunState::Unconnected => "unconnected",
},
}
}
}
}
fn embedded_create_intent(intent: &WorkerSpawnIntent) -> WorkerIntent {
match intent {
WorkerSpawnIntent::WorkspaceCompanion => WorkerIntent::Role {
role: "workspace_companion".to_string(),
purpose: Some("workspace backend internal companion".to_string()),
fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
let id = format!(
"workspace-runtime-{}",
embedded_profile_label(profile)
.unwrap_or_else(|| "default".to_string())
.replace([':', '/', ' '], "-")
);
ConfigBundle {
metadata: ConfigBundleMetadata {
id,
digest: String::new(),
revision: "workspace-runtime-v0".to_string(),
workspace_id: "workspace-server".to_string(),
created_at: "runtime-generated".to_string(),
provenance: ConfigBundleProvenance {
source: "workspace-server".to_string(),
detail: Some("backend-resolved launch bundle".to_string()),
},
WorkerSpawnIntent::WorkspaceOrchestrator => WorkerIntent::Role {
role: "workspace_orchestrator".to_string(),
purpose: Some("workspace backend internal orchestration".to_string()),
},
WorkerSpawnIntent::TicketRole { ticket_id, role } => WorkerIntent::Role {
role: ticket_role_profile_slug(role).to_string(),
purpose: Some(format!("ticket {ticket_id}")),
},
profiles: vec![ConfigProfileDescriptor {
selector: profile.clone(),
label: embedded_profile_label(profile),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
@ -2176,16 +2272,6 @@ fn ticket_role_profile_slug(role: &TicketWorkerRole) -> &'static str {
}
}
fn embedded_intent_label(intent: &WorkerIntent) -> Option<String> {
match intent {
WorkerIntent::Assistant { purpose } => {
purpose.clone().or_else(|| Some("assistant".to_string()))
}
WorkerIntent::Task { objective } => Some(safe_display_hint(objective)),
WorkerIntent::Role { role, .. } => Some(safe_display_hint(role)),
}
}
fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
Some(match profile {
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
@ -2324,7 +2410,8 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning,
"Embedded Runtime worker was not found".to_string(),
),
EmbeddedRuntimeError::WorkerExecutionUnavailable { .. } => diagnostic(
EmbeddedRuntimeError::WorkerExecutionUnavailable { .. }
| EmbeddedRuntimeError::ExecutionBackendUnavailable { .. } => diagnostic(
"embedded_worker_execution_unavailable",
DiagnosticSeverity::Warning,
"Embedded Worker has no execution backend attached".to_string(),
@ -2339,6 +2426,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning,
format!("Requested limit {requested} exceeds embedded Runtime maximum {max}"),
),
EmbeddedRuntimeError::InvalidInitialInputKind { .. } => diagnostic(
"embedded_worker_initial_input_kind_invalid",
DiagnosticSeverity::Warning,
error.to_string(),
),
EmbeddedRuntimeError::InvalidRequest(_)
| EmbeddedRuntimeError::ConfigBundleMissing { .. }
| EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. }
@ -2962,8 +3054,7 @@ mod tests {
expected_segments: 0,
},
profile: None,
config_bundle: None,
requested_capabilities: Vec::new(),
initial_input: None,
}
}
@ -2978,13 +3069,35 @@ mod tests {
assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!(spawned.acceptance_evidence.is_empty());
assert!(spawned.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "embedded_worker_execution_spawn_errored"
diagnostic.code == "embedded_worker_execution_rejected"
&& !diagnostic.message.contains("/tmp/secret-provider-config")
}));
let worker = spawned.worker.expect("failed execution is still projected");
assert_eq!(worker.status, "errored");
assert!(!worker.capabilities.can_accept_input);
assert!(!worker.capabilities.can_stop);
assert!(spawned.worker.is_none());
}
#[test]
fn embedded_runtime_rejects_system_initial_input_without_worker_projection() {
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(AcceptingExecutionBackend::default()),
)
.expect("test backend should connect");
let mut request = embedded_spawn_request();
request.initial_input = Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::System,
content: "system/role instruction belongs in profile".to_string(),
});
let spawned = runtime.spawn_worker(request);
assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!(spawned.worker.is_none());
assert!(spawned.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "embedded_worker_initial_input_kind_invalid"
&& diagnostic
.message
.contains("initial worker input must be user input")
}));
assert!(runtime.list_workers(10).items.is_empty());
}
#[test]
@ -3035,8 +3148,13 @@ mod tests {
#[test]
fn embedded_runtime_registers_routes_input_and_transcript_without_internal_leaks() {
let registry =
RuntimeRegistry::for_workspace(EmbeddedWorkerRuntime::new_memory("local:test"));
let registry = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(AcceptingExecutionBackend::default()),
)
.expect("test backend should connect"),
);
let runtimes = registry.list_runtimes(10);
let embedded_summary = runtimes
@ -3050,7 +3168,7 @@ mod tests {
);
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker);
assert!(!embedded_summary.capabilities.can_accept_input);
assert!(embedded_summary.capabilities.can_accept_input);
let spawned = registry
.spawn_worker(
@ -3065,8 +3183,7 @@ mod tests {
expected_segments: 0,
},
profile: None,
config_bundle: None,
requested_capabilities: Vec::new(),
initial_input: None,
},
)
.unwrap();
@ -3083,7 +3200,7 @@ mod tests {
assert_eq!(worker.workspace.identity, "runtime_registry_worker");
assert_eq!(worker.implementation.kind, "embedded_worker_runtime");
assert_eq!(worker.profile.as_deref(), Some("builtin:coder"));
assert!(!worker.capabilities.can_accept_input);
assert!(worker.capabilities.can_accept_input);
let input = registry
.send_input(
@ -3095,21 +3212,20 @@ mod tests {
},
)
.unwrap();
assert_eq!(input.state, WorkerOperationState::Rejected);
assert_eq!(input.state, WorkerOperationState::Accepted);
assert_eq!(input.runtime_id, EMBEDDED_RUNTIME_ID);
assert_eq!(input.worker_id, worker.worker_id);
assert!(
input
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "embedded_worker_execution_unavailable")
);
let transcript = registry
.transcript(EMBEDDED_RUNTIME_ID, &worker.worker_id, 0, 10)
.unwrap();
assert_eq!(transcript.state, WorkerOperationState::Accepted);
assert!(transcript.items.is_empty());
assert!(
transcript
.items
.iter()
.any(|entry| entry.role == "user" && entry.content == "hello embedded runtime")
);
let json = serde_json::to_string(&(embedded_summary, worker, transcript)).unwrap();
for forbidden in [
@ -3132,9 +3248,13 @@ mod tests {
#[test]
fn embedded_backend_syncs_config_bundle_and_spawns_with_bundle_ref() {
let registry = RuntimeRegistry::new(vec![Arc::new(EmbeddedWorkerRuntime::new_memory(
let registry = RuntimeRegistry::new(vec![Arc::new(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
))]);
Arc::new(AcceptingExecutionBackend::default()),
)
.unwrap(),
)]);
let bundle = test_config_bundle();
let sync = registry
.sync_config_bundle(EMBEDDED_RUNTIME_ID, bundle.clone())
@ -3162,8 +3282,7 @@ mod tests {
expected_segments: 0,
},
profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
config_bundle: Some(reference),
requested_capabilities: vec![CapabilityRequest::named("read")],
initial_input: None,
},
)
.unwrap();
@ -3176,9 +3295,13 @@ mod tests {
#[test]
fn embedded_runtime_rejects_socket_ready_acceptance_without_socket_identity() {
let registry = RuntimeRegistry::new(vec![Arc::new(EmbeddedWorkerRuntime::new_memory(
let registry = RuntimeRegistry::new(vec![Arc::new(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
))]);
Arc::new(AcceptingExecutionBackend::default()),
)
.unwrap(),
)]);
let result = registry
.spawn_worker(
EMBEDDED_RUNTIME_ID,
@ -3187,8 +3310,7 @@ mod tests {
requested_worker_name: None,
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: None,
config_bundle: None,
requested_capabilities: Vec::new(),
initial_input: None,
},
)
.unwrap();
@ -3275,6 +3397,8 @@ mod tests {
workers.items[0].workspace.identity,
"runtime_registry_worker"
);
assert!(workers.items[0].capabilities.can_accept_input);
assert!(workers.items[0].capabilities.can_stop);
let input = registry
.send_input(
@ -3304,6 +3428,101 @@ mod tests {
assert!(browser_payload.contains("worker_id"));
}
#[test]
fn remote_runtime_projection_blocks_stale_and_unconnected_execution_input() {
let (base_url, server) = serve_mock_http(vec![
mock_response(
"GET",
"/v1/workers",
true,
200,
json!({
"workers": [
worker_json_with_execution(
"embedded-worker-runtime",
"worker-stale",
"stale",
"unconnected",
None,
),
worker_json_with_execution(
"embedded-worker-runtime",
"worker-unconnected",
"unconnected",
"unconnected",
None,
),
worker_json_with_execution(
"embedded-worker-runtime",
"worker-rejected",
"connected",
"rejected",
Some("rejected"),
),
worker_json_with_execution(
"embedded-worker-runtime",
"worker-errored",
"connected",
"errored",
Some("errored"),
)
]
})
.to_string(),
),
mock_response(
"GET",
"/v1/workers/worker-stale",
true,
200,
json!({
"worker": worker_json_with_execution(
"embedded-worker-runtime",
"worker-stale",
"stale",
"unconnected",
None,
)})
.to_string(),
),
]);
let registry = RuntimeRegistry::new(vec![Arc::new(
RemoteWorkerRuntime::new(RemoteRuntimeConfig::new(
"remote:primary",
"Remote Primary",
base_url,
Some("secret-token-do-not-leak".to_string()),
))
.unwrap(),
)]);
let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 4);
for worker in &workers.items {
assert!(
!worker.capabilities.can_accept_input,
"{} should not be input-capable",
worker.worker_id
);
assert!(
!worker.capabilities.can_stop,
"{} should not be stoppable",
worker.worker_id
);
}
assert_eq!(workers.items[0].status, "stale");
assert_eq!(workers.items[1].status, "unconnected");
assert_eq!(workers.items[2].status, "rejected");
assert_eq!(workers.items[3].status, "errored");
let stale_detail = registry.worker("remote:primary", "worker-stale").unwrap();
assert!(!stale_detail.capabilities.can_accept_input);
assert!(!stale_detail.capabilities.can_stop);
assert_eq!(stale_detail.status, "stale");
server.join().expect("mock remote server finished");
}
#[test]
fn remote_config_bundle_sync_and_check_diagnostics_are_sanitized_and_path_safe() {
let leaked_store_path = "/var/lib/yoi/runtime/bundles/bundle-1.json";
@ -3472,20 +3691,33 @@ mod tests {
}
fn worker_json(runtime_id: &str, worker_id: &str) -> serde_json::Value {
worker_json_with_execution(runtime_id, worker_id, "connected", "idle", None)
}
fn worker_json_with_execution(
runtime_id: &str,
worker_id: &str,
backend: &str,
run_state: &str,
last_outcome: Option<&str>,
) -> serde_json::Value {
let last_result = last_outcome.map(|outcome| {
json!({
"operation": "input",
"outcome": outcome,
"run_state": run_state,
"message": format!("{outcome} result")
})
});
json!({
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
"runtime_id": runtime_id,
"worker_id": worker_id,
"status": "running",
"execution": { "backend": "connected", "run_state": "idle" },
"execution": { "backend": backend, "run_state": run_state, "last_result": last_result },
"intent": { "kind": "role", "role": "coder", "purpose": "remote test" },
"profile": { "kind": "builtin", "value": "coder" },
"config_bundle": null,
"requested_capabilities": [],
"workspace_refs": [],
"mount_refs": [],
"requested_capability_count": 0,
"has_config_bundle": false,
"config_bundle": { "id": "remote-bundle", "digest": "remote-digest" },
"transcript_len": 0,
"last_event_id": 0
})

View File

@ -38,6 +38,21 @@ impl WorkspaceIdentity {
})
}
pub fn load_required(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path(workspace_root.as_ref());
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi workspace init --workspace {}` first",
workspace_root.as_ref().display(),
workspace_root.as_ref().display()
)))
}
Err(error) => Err(Error::Io(error)),
}
}
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
@ -192,6 +207,21 @@ mod tests {
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[test]
fn load_required_rejects_uninitialized_workspace_without_creating_identity() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("uninitialized-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace is not initialized"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
}
#[test]
fn missing_identity_file_is_created_with_safe_fields() {
let temp = tempfile::tempdir().unwrap();

View File

@ -5,6 +5,7 @@
//! remain the canonical project records and are read through bounded bridge APIs.
pub mod companion;
pub mod config;
pub mod hosts;
pub mod identity;
pub mod observation;
@ -13,6 +14,10 @@ pub mod repositories;
pub mod server;
pub mod store;
pub use config::{
ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH,
WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH, WorkspaceBackendConfigFile,
};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
@ -38,6 +43,8 @@ pub enum Error {
Yaml(#[from] serde_yaml::Error),
#[error("invalid project record id `{0}`")]
InvalidRecordId(String),
#[error("workspace backend config error: {0}")]
Config(String),
#[error("record `{0}` is missing frontmatter")]
MissingFrontmatter(String),
#[error("unknown local host `{0}`")]

View File

@ -4,14 +4,28 @@ use std::process::ExitCode;
use std::sync::Arc;
use tokio::net::TcpListener;
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
use yoi_workspace_server::{
SqliteWorkspaceStore, WorkspaceBackendConfigFile, WorkspaceIdentity, serve,
};
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Init(InitOptions),
Help,
}
#[derive(Debug)]
struct ServeOptions {
workspace: PathBuf,
db: Option<PathBuf>,
frontend: Option<PathBuf>,
listen: SocketAddr,
listen: Option<SocketAddr>,
}
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
@ -38,59 +52,115 @@ async fn main() -> ExitCode {
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options),
Command::Help => Ok(()),
}
}
fn parse_command(args: &[String]) -> Result<Command, CliError> {
let Some((command, rest)) = args.split_first() else {
print_help();
return Ok(());
return Ok(Command::Help);
};
match command.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_init_help();
return Ok(Command::Help);
}
Ok(Command::Init(parse_init_options(rest)?))
}
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_serve_help();
return Ok(());
return Ok(Command::Help);
}
let options = parse_serve_options(rest)?;
run_serve(options).await?;
Ok(())
Ok(Command::Serve(parse_serve_options(rest)?))
}
"--help" | "-h" => {
print_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `init` or `serve`"
))),
}
}
fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(&options.workspace)?;
eprintln!(
"yoi-workspace-server: initialized workspace `{}` ({})",
options.workspace.display(),
identity.workspace_id
);
Ok(())
}
other => Err(Box::new(CliError(format!(
"unknown command `{other}`; expected `serve`"
)))),
}
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
let db = options
.db
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
if let Some(parent) = db.parent() {
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
let mut resolved = config_file.resolve(&options.workspace, identity)?;
if let Some(db) = options.db {
resolved = resolved.with_database_path(db);
}
if let Some(frontend) = options.frontend {
resolved = resolved.with_static_assets_dir(Some(frontend));
}
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
if let Some(parent) = resolved.database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
let mut config = ServerConfig::local_dev(&options.workspace, identity);
config.static_assets_dir = options.frontend;
let listener = TcpListener::bind(options.listen).await?;
let store = Arc::new(SqliteWorkspaceStore::open(&resolved.database_path)?);
let listener = TcpListener::bind(resolved.listen).await?;
eprintln!(
"yoi-workspace-server: serving workspace `{}` on http://{}",
options.workspace.display(),
listener.local_addr()?
);
serve(config, store, listener).await?;
serve(resolved.server, store, listener).await?;
Ok(())
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown init option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(InitOptions { workspace })
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
let mut db = None;
let mut frontend = None;
let mut listen = "127.0.0.1:8787".parse::<SocketAddr>().unwrap();
let mut listen = None;
let mut index = 0;
while index < args.len() {
@ -122,7 +192,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let value = args
.get(index)
.ok_or_else(|| CliError("--listen requires a value".to_string()))?;
listen = parse_listen(value)?;
listen = Some(parse_listen(value)?);
}
_ if arg.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
@ -134,7 +204,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?));
}
_ if arg.starts_with("--listen=") => {
listen = parse_listen(value_after_equals(arg, "--listen")?)?;
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
}
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
@ -182,12 +252,58 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_init_help() {
println!(
"yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the default Backend config template. Does not create Backend data stores.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_serve_help() {
println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help"
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
);
}
#[cfg(test)]
mod tests {
use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH, WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() {
let temp = tempfile::tempdir().unwrap();
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let options = parse_init_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
}
#[test]
fn init_creates_identity_and_default_template_only() {
let temp = tempfile::tempdir().unwrap();
run_init(InitOptions {
workspace: temp.path().canonicalize().unwrap(),
})
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
assert!(
temp.path()
.join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH)
.exists()
);
assert!(
!temp
.path()
.join(".yoi/workspace-backend.local.toml")
.exists()
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
}
}

View File

@ -51,6 +51,8 @@ pub struct ServerConfig {
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf,
pub frontend_url: String,
pub embedded_runtime_store_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig,
pub max_records: usize,
@ -61,11 +63,15 @@ pub struct ServerConfig {
impl ServerConfig {
pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
let workspace_root = workspace_root.into();
let workspace_id = identity.workspace_id;
let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id);
Self {
workspace_id: identity.workspace_id,
workspace_id,
workspace_display_name: identity.display_name,
workspace_created_at: identity.created_at,
workspace_root,
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
auth: AuthConfig::LocalDevToken {
token_configured: false,
@ -75,6 +81,45 @@ impl ServerConfig {
remote_runtime_sources: Vec::new(),
}
}
pub fn workspace_backend_data_root_for_data_dir(
data_dir: impl Into<PathBuf>,
workspace_id: impl AsRef<str>,
) -> PathBuf {
data_dir
.into()
.join("workspace-server")
.join(workspace_id.as_ref())
}
pub fn default_workspace_backend_data_root(workspace_id: impl AsRef<str>) -> PathBuf {
match manifest::paths::data_dir() {
Some(data_dir) => {
Self::workspace_backend_data_root_for_data_dir(data_dir, workspace_id.as_ref())
}
None => std::env::temp_dir()
.join("yoi")
.join("workspace-server")
.join(workspace_id.as_ref()),
}
}
pub fn embedded_runtime_store_root_for_data_dir(
data_dir: impl Into<PathBuf>,
workspace_id: impl AsRef<str>,
) -> PathBuf {
Self::workspace_backend_data_root_for_data_dir(data_dir, workspace_id)
.join("embedded-runtime")
}
pub fn default_embedded_runtime_store_root(workspace_id: impl AsRef<str>) -> PathBuf {
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
}
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
self.embedded_runtime_store_root = root.into();
self
}
}
#[derive(Clone)]
@ -115,8 +160,9 @@ impl WorkspaceApi {
})
.await?;
let mut runtime = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
config.workspace_id.clone(),
config.embedded_runtime_store_root.clone(),
execution_backend,
)
.map_err(|err| {
@ -1063,10 +1109,15 @@ mod tests {
use axum::http::Request;
use futures::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::sync::Arc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tower::ServiceExt;
use crate::hosts::{
TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent,
};
use crate::observation::ClientWorkerEventWsDiagnostic;
use crate::store::SqliteWorkspaceStore;
@ -1140,6 +1191,58 @@ mod tests {
}
}
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
let workspace_root = workspace_root.into();
let store_root = workspace_root.join(".test-embedded-runtime-store");
ServerConfig::local_dev(workspace_root, test_identity())
.with_embedded_runtime_store_root(store_root)
}
fn runtime_test_bundle() -> worker_runtime::config_bundle::ConfigBundle {
worker_runtime::config_bundle::ConfigBundle {
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
id: "server-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "test".to_string(),
created_at: "test".to_string(),
provenance: worker_runtime::config_bundle::ConfigBundleProvenance {
source: "test".to_string(),
detail: None,
},
},
profiles: vec![worker_runtime::config_bundle::ConfigProfileDescriptor {
selector: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
label: Some("server-test".to_string()),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest {
let bundle = runtime_test_bundle();
worker_runtime::catalog::CreateWorkerRequest {
profile: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
config_bundle: worker_runtime::catalog::ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
initial_input: None,
}
}
fn runtime_with_worker() -> (worker_runtime::Runtime, worker_runtime::identity::WorkerRef) {
let runtime = worker_runtime::Runtime::with_execution_backend(
worker_runtime::RuntimeOptions::default(),
Arc::new(DeterministicExecutionBackend::default()),
)
.unwrap();
runtime.store_config_bundle(runtime_test_bundle()).unwrap();
let worker = runtime.create_worker(runtime_create_request()).unwrap();
(runtime, worker.worker_ref)
}
#[tokio::test]
async fn serves_bounded_read_apis_and_static_spa_separately() {
let dir = tempfile::tempdir().unwrap();
@ -1151,9 +1254,15 @@ mod tests {
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
let mut config = test_server_config(dir.path());
config.static_assets_dir = Some(static_dir);
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(store),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let workspace = get_json(app.clone(), "/api/workspace").await;
@ -1260,7 +1369,7 @@ mod tests {
let worker_items = workers["items"].as_array().unwrap();
let companion_worker = worker_items
.iter()
.find(|worker| worker["role"] == "workspace_companion")
.find(|worker| worker["role"] == "builtin:companion")
.expect("companion worker is visible through runtime worker API");
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
assert!(companion_worker["capabilities"]["can_stop"].is_boolean());
@ -1270,7 +1379,7 @@ mod tests {
companion_status["state"].as_str(),
Some("ready") | Some("error")
));
assert_eq!(companion_status["worker"]["role"], "workspace_companion");
assert_eq!(companion_status["worker"]["role"], "builtin:companion");
assert_eq!(
companion_status["transport"]["kind"],
"embedded_worker_runtime"
@ -1305,7 +1414,7 @@ mod tests {
.as_array()
.unwrap()
.iter()
.any(|worker| worker["role"] == "workspace_companion")
.any(|worker| worker["role"] == "builtin:companion")
);
let runs_response = app
@ -1389,7 +1498,7 @@ mod tests {
#[tokio::test]
async fn legacy_companion_messages_route_dispatches_through_worker_runtime() {
let temp = tempfile::tempdir().unwrap();
let config = ServerConfig::local_dev(temp.path().join("workspace"), test_identity());
let config = test_server_config(temp.path().join("workspace"));
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
@ -1472,12 +1581,202 @@ mod tests {
);
}
#[tokio::test]
async fn embedded_runtime_fs_store_restores_catalog_config_bundle_transcript_and_stale_execution()
{
let dir = tempfile::tempdir().unwrap();
let config = test_server_config(dir.path().join("workspace"));
let store_root = config.embedded_runtime_store_root.clone();
let bundle = runtime_test_bundle();
let bundle_id = bundle.metadata.id.clone();
let api = WorkspaceApi::new_with_execution_backend(
config.clone(),
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.expect("fs-backed api starts");
let synced = api
.runtime
.sync_config_bundle("embedded-worker-runtime", bundle)
.expect("sync config bundle");
assert_eq!(synced.state, WorkerOperationState::Accepted);
assert!(store_root.exists(), "fs-store root should be created");
let spawned = api
.runtime
.spawn_worker(
"embedded-worker-runtime",
WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(),
role: TicketWorkerRole::Coder,
},
requested_worker_name: None,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: None,
initial_input: None,
},
)
.expect("spawn worker");
assert_eq!(spawned.state, WorkerOperationState::Accepted);
let worker_id = spawned.worker.expect("created worker").worker_id;
let sent = api
.runtime
.send_input(
"embedded-worker-runtime",
&worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "persist me".to_string(),
},
)
.expect("send input");
assert_eq!(sent.state, WorkerOperationState::Accepted);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
let transcript = api
.runtime
.transcript("embedded-worker-runtime", &worker_id, 0, 10)
.expect("transcript");
if transcript.items.iter().any(|item| {
item.role == "assistant" && item.content == "server companion echoed: persist me"
}) {
assert!(
transcript
.items
.iter()
.any(|item| item.role == "user" && item.content == "persist me")
);
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for deterministic transcript"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
drop(api);
let restored = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.expect("restored fs-backed api starts");
let restored_worker = restored
.runtime
.worker("embedded-worker-runtime", &worker_id)
.expect("restored worker");
assert_eq!(restored_worker.status, "stale");
assert!(!restored_worker.capabilities.can_accept_input);
assert!(!restored_worker.capabilities.can_stop);
assert!(
restored_worker
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "embedded_worker_execution_stale")
);
let bundles = restored
.runtime
.list_config_bundles("embedded-worker-runtime")
.expect("config bundle list");
assert!(
bundles
.bundles
.iter()
.any(|summary| summary.id == bundle_id)
);
let restored_transcript = restored
.runtime
.transcript("embedded-worker-runtime", &worker_id, 0, 10)
.expect("restored transcript");
assert!(
restored_transcript
.items
.iter()
.any(|item| item.role == "user" && item.content == "persist me")
);
assert!(restored_transcript.items.iter().any(|item| {
item.role == "assistant" && item.content == "server companion echoed: persist me"
}));
let rejected_input = restored
.runtime
.send_input(
"embedded-worker-runtime",
&worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "should not be routed to stale handle".to_string(),
},
)
.expect("stale worker input is projected as an operation result");
assert_eq!(rejected_input.state, WorkerOperationState::Rejected);
}
#[tokio::test]
async fn embedded_runtime_store_root_is_isolated_and_not_exposed_by_browser_api() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("user-data");
let workspace_root = dir.path().join("workspace");
let default_root =
ServerConfig::embedded_runtime_store_root_for_data_dir(&data_dir, TEST_WORKSPACE_ID);
assert_eq!(
default_root,
data_dir
.join("workspace-server")
.join(TEST_WORKSPACE_ID)
.join("embedded-runtime")
);
assert!(!default_root.starts_with(workspace_root.join(".yoi")));
let config = ServerConfig::local_dev(workspace_root, test_identity())
.with_embedded_runtime_store_root(default_root.clone());
let app = build_router(
WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap(),
);
let raw_store_root = default_root.to_string_lossy().to_string();
for uri in [
"/api/workspace",
"/api/hosts",
"/api/runtimes",
"/api/workers",
] {
let body = get_json(app.clone(), uri).await;
let serialized = serde_json::to_string(&body).unwrap();
assert!(
!serialized.contains(&raw_store_root),
"{uri} leaked embedded runtime store root: {serialized}"
);
}
}
#[tokio::test]
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let config = ServerConfig::local_dev(dir.path(), test_identity());
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let config = test_server_config(dir.path());
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(store),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let runtimes = get_json(app.clone(), "/api/runtimes").await;
@ -1515,20 +1814,14 @@ mod tests {
}),
)
.await;
assert_eq!(spawned["state"], "rejected");
assert!(
spawned["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| {
diagnostic["code"] == "embedded_worker_execution_spawn_errored"
&& !diagnostic["message"]
assert_eq!(spawned["state"], "accepted");
let diagnostics = spawned["diagnostics"].as_array().unwrap();
assert!(diagnostics.iter().all(|diagnostic| {
!diagnostic["message"]
.as_str()
.unwrap()
.unwrap_or_default()
.contains("/workspace/demo")
})
);
}));
let worker_id = spawned["worker"]["worker_id"].as_str().unwrap().to_string();
assert_eq!(spawned["worker"]["runtime_id"], "embedded-worker-runtime");
assert_eq!(
@ -1557,16 +1850,10 @@ mod tests {
}),
)
.await;
assert_eq!(accepted["state"], "rejected");
assert_eq!(accepted["state"], "accepted");
assert_eq!(accepted["runtime_id"], "embedded-worker-runtime");
assert_eq!(accepted["worker_id"], worker_id);
assert!(
accepted["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "embedded_worker_execution_unavailable")
);
assert!(accepted["diagnostics"].as_array().unwrap().is_empty());
let transcript = get_json(
app.clone(),
@ -1574,7 +1861,9 @@ mod tests {
)
.await;
assert_eq!(transcript["state"], "accepted");
assert!(transcript["items"].as_array().unwrap().is_empty());
assert!(transcript["items"].as_array().unwrap().iter().any(
|item| item["role"] == "user" && item["content"] == "hello from browser-facing api"
));
let wrong_runtime = app
.clone()
@ -1620,10 +1909,7 @@ mod tests {
#[tokio::test]
async fn proxies_worker_observation_ws_with_backend_cursors_and_diagnostics() {
let runtime = worker_runtime::Runtime::new_memory();
let worker = runtime
.create_worker(worker_runtime::catalog::CreateWorkerRequest::default())
.unwrap();
let (runtime, worker_ref) = runtime_with_worker();
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let runtime_addr = runtime_listener.local_addr().unwrap();
tokio::spawn({
@ -1637,7 +1923,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
let mut config = test_server_config(dir.path());
config
.runtime_event_sources
.push(RuntimeObservationSourceConfig {
@ -1645,11 +1931,17 @@ mod tests {
worker_id: "worker-a".into(),
endpoint: format!(
"ws://{runtime_addr}/v1/workers/{}/events/ws",
worker.worker_ref.worker_id
worker_ref.worker_id
),
bearer_token: None,
});
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(store),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let app_addr = app_listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(app_listener, build_router(api)).await.unwrap() });
@ -1666,7 +1958,7 @@ mod tests {
runtime
.observe_worker_event(
&worker.worker_ref,
&worker_ref,
protocol::Event::TextDelta {
text: "live".into(),
},
@ -1686,7 +1978,7 @@ mod tests {
let _snapshot = next_client_frame(&mut resumed).await;
runtime
.observe_worker_event(
&worker.worker_ref,
&worker_ref,
protocol::Event::TextDone {
text: "done".into(),
},
@ -1822,10 +2114,7 @@ mod tests {
worker_runtime::identity::WorkerRef,
String,
) {
let runtime = worker_runtime::Runtime::new_memory();
let worker = runtime
.create_worker(worker_runtime::catalog::CreateWorkerRequest::default())
.unwrap();
let (runtime, worker_ref) = runtime_with_worker();
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let runtime_addr = runtime_listener.local_addr().unwrap();
tokio::spawn({
@ -1838,9 +2127,9 @@ mod tests {
});
let endpoint = format!(
"ws://{runtime_addr}/v1/workers/{}/events/ws",
worker.worker_ref.worker_id
worker_ref.worker_id
);
(runtime, worker.worker_ref, endpoint)
(runtime, worker_ref, endpoint)
}
async fn spawn_workspace_proxy(
@ -1848,11 +2137,17 @@ mod tests {
) -> (String, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
let mut config = test_server_config(dir.path());
let runtime_id = source.runtime_id.clone();
let worker_id = source.worker_id.clone();
config.runtime_event_sources.push(source);
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(store),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let app_addr = app_listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(app_listener, build_router(api)).await.unwrap() });

View File

@ -15,7 +15,6 @@ client = { workspace = true }
memory = { workspace = true }
manifest = { workspace = true }
worker = { workspace = true }
pod-store = { workspace = true }
session-store = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }

View File

@ -29,7 +29,10 @@ enum Mode {
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket(ticket_cli::TicketCli),
WorkspaceHelp,
WorkspaceServe(Vec<String>),
WorkspaceServer {
subcommand: String,
args: Vec<String>,
},
WorkerRuntime(Vec<String>),
Keys,
SetupModel,
@ -78,7 +81,7 @@ async fn main() -> ExitCode {
print_workspace_help();
ExitCode::SUCCESS
}
Mode::WorkspaceServe(args) => run_workspace_server(args),
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE,
@ -607,24 +610,36 @@ fn current_dir() -> Result<PathBuf, ParseError> {
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi workspace requires `serve` (try `yoi workspace --help`)".to_string(),
"yoi workspace requires `init` or `serve` (try `yoi workspace --help`)".to_string(),
));
};
match subcommand.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: "init".to_string(),
args: rest.to_vec(),
})
}
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServe(rest.to_vec()))
Ok(Mode::WorkspaceServer {
subcommand: "serve".to_string(),
args: rest.to_vec(),
})
}
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
other => Err(ParseError(format!(
"unknown yoi workspace subcommand `{other}`"
"unknown yoi workspace subcommand `{other}`; expected `init` or `serve`"
))),
}
}
fn run_workspace_server(args: Vec<String>) -> ExitCode {
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> ExitCode {
let command = match resolve_workspace_server_command() {
Ok(command) => command,
Err(error) => {
@ -634,7 +649,7 @@ fn run_workspace_server(args: Vec<String>) -> ExitCode {
};
let mut child = Command::new(&command);
child.arg("serve");
child.arg(subcommand);
child.args(args);
match child.status() {
Ok(status) if status.success() => ExitCode::SUCCESS,
@ -999,13 +1014,14 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
fn print_workspace_help() {
println!(
"yoi workspace\n\nUsage:\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nOptions forwarded to yoi-workspace-server serve:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and the default Backend config template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/serve:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
);
}
@ -1209,7 +1225,21 @@ mod tests {
#[test]
fn parse_workspace_serve_passthrough() {
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
Mode::WorkspaceServe(args) => assert_eq!(args, vec!["--listen", "127.0.0.1:0"]),
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "serve");
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_init_passthrough() {
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "init");
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}

View File

@ -4,8 +4,8 @@ use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use manifest::paths;
use pod_store::{FsWorkerStore, WorkerMetadataStore};
use session_store::{FsStore, SessionId, Store};
use session_store::{FsWorkerStore, WorkerMetadataStore};
use crate::worker_cleanup_cli::parse_duration;
@ -203,8 +203,8 @@ pub fn run_prune_with_roots(
));
}
let session_store = FsStore::new(data_dir.join("sessions")).map_err(to_error)?;
let pod_store = FsWorkerStore::new(data_dir.join("pods")).map_err(to_error)?;
let referenced_sessions = referenced_sessions(&pod_store)?;
let worker_metadata_store = FsWorkerStore::new(data_dir.join("workers")).map_err(to_error)?;
let referenced_sessions = referenced_sessions(&worker_metadata_store)?;
let cutoff = options
.older_than
.map(|older_than| {
@ -315,10 +315,12 @@ pub fn run_prune_with_roots(
})
}
fn referenced_sessions(pod_store: &FsWorkerStore) -> Result<BTreeSet<SessionId>, SessionCliError> {
fn referenced_sessions(
worker_metadata_store: &FsWorkerStore,
) -> Result<BTreeSet<SessionId>, SessionCliError> {
let mut sessions = BTreeSet::new();
for name in pod_store.list_names().map_err(to_error)? {
let metadata = pod_store
for name in worker_metadata_store.list_names().map_err(to_error)? {
let metadata = worker_metadata_store
.read_by_name(&name)
.map_err(to_error)?
.ok_or_else(|| {
@ -358,8 +360,8 @@ pub fn help_text() -> &'static str {
#[cfg(test)]
mod tests {
use super::*;
use pod_store::{WorkerActiveSegmentRef, WorkerMetadata};
use session_store::{Store, new_segment_id, new_session_id};
use session_store::{WorkerActiveSegmentRef, WorkerMetadata};
use std::io::Write;
#[test]
@ -441,7 +443,7 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let session_store = FsStore::new(data_dir.join("sessions")).unwrap();
let pod_store = FsWorkerStore::new(data_dir.join("pods")).unwrap();
let worker_metadata_store = FsWorkerStore::new(data_dir.join("workers")).unwrap();
let referenced_session = new_session_id();
let referenced_segment = new_segment_id();
let orphan_session = new_session_id();
@ -452,7 +454,7 @@ mod tests {
session_store
.create_segment(orphan_session, orphan_segment, &[])
.unwrap();
pod_store
worker_metadata_store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(

View File

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use manifest::paths;
use pod_store::{FsWorkerStore, WorkerMetadata, WorkerMetadataStore, validate_worker_name};
use session_store::{FsWorkerStore, WorkerMetadata, WorkerMetadataStore, validate_worker_name};
const MAX_REPORT_ITEMS: usize = 50;
@ -234,12 +234,12 @@ async fn run_delete(
data_dir: PathBuf,
runtime_dir: PathBuf,
) -> Result<WorkerCleanupCliOutput, WorkerCleanupCliError> {
let store = FsWorkerStore::new(data_dir.join("pods")).map_err(to_error)?;
let store = FsWorkerStore::new(data_dir.join("workers")).map_err(to_error)?;
let metadata = store.read_by_name(&options.name).map_err(to_error)?;
let Some(metadata) = metadata else {
return Ok(WorkerCleanupCliOutput {
stdout: format!(
"yoi worker delete\nstatus: refused\npod: {}\nreason: worker metadata is missing\n",
"yoi worker delete\nstatus: refused\nworker: {}\nreason: worker metadata is missing\n",
options.name
),
status: WorkerCleanupCliStatus::Failure,
@ -250,7 +250,7 @@ async fn run_delete(
if let Some(reason) = probe.refusal_reason() {
return Ok(WorkerCleanupCliOutput {
stdout: format!(
"yoi worker delete\nstatus: refused\npod: {}\nreason: {}\nsocket: {}\n",
"yoi worker delete\nstatus: refused\nworker: {}\nreason: {}\nsocket: {}\n",
options.name,
reason,
probe.socket_path.display()
@ -290,7 +290,7 @@ async fn run_prune(
data_dir: PathBuf,
runtime_dir: PathBuf,
) -> Result<WorkerCleanupCliOutput, WorkerCleanupCliError> {
let store = FsWorkerStore::new(data_dir.join("pods")).map_err(to_error)?;
let store = FsWorkerStore::new(data_dir.join("workers")).map_err(to_error)?;
let names = store.list_names().map_err(to_error)?;
let cutoff = SystemTime::now()
.checked_sub(options.older_than)
@ -498,7 +498,7 @@ fn prune_help_text() -> &'static str {
#[cfg(test)]
mod tests {
use super::*;
use pod_store::WorkerActiveSegmentRef;
use session_store::WorkerActiveSegmentRef;
use session_store::{Store, new_segment_id, new_session_id};
fn string_args(args: &[&str]) -> Vec<String> {
@ -543,14 +543,14 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let runtime_dir = tmp.path().join("run");
let pod_store = FsWorkerStore::new(data_dir.join("pods")).unwrap();
let worker_metadata_store = FsWorkerStore::new(data_dir.join("workers")).unwrap();
let session_store = session_store::FsStore::new(data_dir.join("sessions")).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
session_store
.create_segment(session_id, segment_id, &[])
.unwrap();
pod_store
worker_metadata_store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
@ -573,7 +573,12 @@ mod tests {
assert_eq!(output.status, WorkerCleanupCliStatus::Success);
assert!(output.stdout.contains("deleted: worker metadata"));
assert!(pod_store.read_by_name("agent").unwrap().is_none());
assert!(
worker_metadata_store
.read_by_name("agent")
.unwrap()
.is_none()
);
assert!(session_store.exists(session_id, segment_id).unwrap());
}
@ -582,8 +587,8 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let runtime_dir = tmp.path().join("run");
let pod_store = FsWorkerStore::new(data_dir.join("pods")).unwrap();
pod_store
let worker_metadata_store = FsWorkerStore::new(data_dir.join("workers")).unwrap();
worker_metadata_store
.write(&WorkerMetadata::new("agent", None))
.unwrap();
@ -601,7 +606,12 @@ mod tests {
assert_eq!(output.status, WorkerCleanupCliStatus::Success);
assert!(output.stdout.contains("mode: dry-run"));
assert!(pod_store.read_by_name("agent").unwrap().is_some());
assert!(
worker_metadata_store
.read_by_name("agent")
.unwrap()
.is_some()
);
}
#[cfg(unix)]
@ -612,8 +622,8 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let runtime_dir = tmp.path().join("run");
let pod_store = FsWorkerStore::new(data_dir.join("pods")).unwrap();
pod_store
let worker_metadata_store = FsWorkerStore::new(data_dir.join("workers")).unwrap();
worker_metadata_store
.write(&WorkerMetadata::new("agent", None))
.unwrap();
std::fs::create_dir_all(runtime_dir.join("agent")).unwrap();
@ -634,6 +644,11 @@ mod tests {
drop(listener);
assert_eq!(output.status, WorkerCleanupCliStatus::Failure);
assert!(output.stdout.contains("status: refused"));
assert!(pod_store.read_by_name("agent").unwrap().is_some());
assert!(
worker_metadata_store
.read_by_name("agent")
.unwrap()
.is_some()
);
}
}

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-1jSDcivotZ0/v5AURQaetn9xjH5JyQNDeNlJ4AcwEUc=";
cargoHash = "sha256-9e99NfbErWlmyZqXd7d5UaJ88gx6ENbHOubqYtnjXVg=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,

Some files were not shown because too many files have changed in this diff Show More