merge: browser working directory spawn

This commit is contained in:
Keisuke Hirata 2026-07-08 02:27:53 +09:00
commit fa219e40b3
No known key found for this signature in database
28 changed files with 2325 additions and 923 deletions

View File

@ -43,7 +43,7 @@ queued_at: '2026-07-04T19:34:04Z'
## 非目標
- Runtime materialization / Execution Workspace 作成をこのチケットで実装すること。
- Runtime materialization / working directory 作成をこのチケットで実装すること。
- multi-workspace Backend store を `~/.yoi/` に完全移行すること。
- Repository credential / clone / fetch / write operation を実装すること。
- Ticket target から RepositoryPoint を解決して Worker launch に渡すこと。

View File

@ -85,7 +85,7 @@ Implementation latitude:
- focused tests の構成は backend helper/route-level と web model/component-levelの現実的な範囲で選んでよい。
Escalate if:
- credential/clone/fetch/write operation、Runtime materialization、Execution Workspace 作成、multi-workspace store 移行、Ticket target からの RepositoryPoint 解決が必要になる場合。
- credential/clone/fetch/write operation、Runtime materialization、working directory 作成、multi-workspace store 移行、Ticket target からの RepositoryPoint 解決が必要になる場合。
- Browser-facing API に secret/auth ref/internal absolute paths を出さないと実装できない場合。
- `RepositorySelector` / `RepositoryPoint` の概念境界を設計変更する必要が出た場合。

View File

@ -1 +1 @@
{"id":"orch-plan-20260706-182951-1","ticket_id":"00001KWW9DYH4","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWW9DYH4 は独立 queued Ticket で blocker なし。Repository registry の次段として、local Git configured repository から Runtime root 配下に Worker ごとの detached Execution Workspace を materialize する v0 を実装する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWW9DYH4-execution-workspace-materializer","worktree":"/home/hare/Projects/yoi/.worktree/00001KWW9DYH4-execution-workspace-materializer","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、worker-runtime の Execution Workspace materialization boundary / local Git detached worktree allocation / cleanup evidence / tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / Objective boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-06T18:29:51Z"}
{"id":"orch-plan-20260706-182951-1","ticket_id":"00001KWW9DYH4","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWW9DYH4 は独立 queued Ticket で blocker なし。Repository registry の次段として、local Git configured repository から Runtime root 配下に Worker ごとの detached working directory を materialize する v0 を実装する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWW9DYH4-execution-workspace-materializer","worktree":"/home/hare/Projects/yoi/.worktree/00001KWW9DYH4-execution-workspace-materializer","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、worker-runtime の working directory materialization boundary / local Git detached worktree allocation / cleanup evidence / tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / Objective boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-06T18:29:51Z"}

View File

@ -1,5 +1,5 @@
---
title: 'Add local Git worktree Execution Workspace materializer'
title: 'Add local Git worktree working directory materializer'
state: 'closed'
created_at: '2026-07-06T18:00:46Z'
updated_at: '2026-07-06T19:24:25Z'
@ -10,29 +10,29 @@ queued_at: '2026-07-06T18:28:26Z'
## 背景
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使っている。そのため、同じ repository root を使う Worker を複数 spawn すると worker allocation conflict が起きる。また、Runtime が source repository root を直接 Worker に渡しており、Execution Workspace materialization / cleanup / evidence / sandbox の境界がまだない。
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使っている。そのため、同じ repository root を使う Worker を複数 spawn すると worker allocation conflict が起きる。また、Runtime が source repository root を直接 Worker に渡しており、working directory materialization / cleanup / evidence / sandbox の境界がまだない。
Objective `00001KWW44EXK` では、Runtime が RepositoryPoint から Worker ごとの Execution Workspace を materialize し、Worker は source repository root ではなく materialized workspace を scope として起動する方針を定めた。この Ticket ではその v0 として、remote/cache-backed Git clone は扱わず、Repository `uri` が local Git repository を指し、そこから `git worktree add --detach` できる前提で最小の materializer を実装する。
Objective `00001KWW44EXK` では、Runtime が RepositoryPoint から Worker ごとの working directory を materialize し、Worker は source repository root ではなく materialized workspace を scope として起動する方針を定めた。この Ticket ではその v0 として、remote/cache-backed Git clone は扱わず、Repository `uri` が local Git repository を指し、そこから `git worktree add --detach` できる前提で最小の materializer を実装する。
ローカル origin の Git clone / Runtime repository-cache / bare mirror cache はこの Ticket では非目標とする。v0 は local source repository 自体を source cache 相当として扱い、Runtime root 配下に Worker ごとの detached worktree を作る。
## 要件
- Runtime は Worker spawn 前に Execution Workspace materializer を呼ぶ境界を持つ。
- Runtime は Worker spawn 前に working directory materializer を呼ぶ境界を持つ。
- v0 materializer は configured local Git repository から detached worktree を Runtime root 配下に作る。
- Worker は source repository root ではなく、materialized worktree path を workspace scope / cwd として起動する。
- `RepositoryId` / `RepositorySelector` / resolved `RepositoryPoint` の将来境界を壊さない型にする。
- この Ticket では local URI の Git repository のみ対応する。remote URI、bare mirror cache、clone/fetch、credential resolution は扱わない。
- worktree は branch 名ではなく resolved commit に対する detached worktree として作る。v0 では branch を作らず、Worker の変更は detached worktree 上の diff / patch artifact として回収する。branch 作成は後続の merge/export policy として扱う。
- dirty local changes は暗黙に含めない。v0 は `clean_point_only` とし、必要なら dirty state diagnostic を返す。
- Execution Workspace`.yoi` や Backend workspace store ではなく Runtime root 配下に作る。
- working directory`.yoi` や Backend workspace store ではなく Runtime root 配下に作る。
- Worker stop / cleanup 時に worktree cleanup できる方針または最低限の cleanup record を持つ。
- Browser-facing/API-facing diagnostics は raw host path を不必要に漏らさない。
## 受け入れ条件
- `CreateWorkerRequest` または Runtime-side spawn path に Execution Workspace materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `execution-workspaces/<allocation-id>/root/<repository-id>` に `git worktree add --detach` できる。
- `CreateWorkerRequest` または Runtime-side spawn path に working directory materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `working-directories/<allocation-id>/root/<repository-id>` に `git worktree add --detach` できる。
- Worker 用の Git branch は v0 materialization では作成されず、変更回収は detached worktree の diff / patch artifact として扱われる。
- Worker spawn 時、Worker scope/cwd は source repository root ではなく materialized worktree を指す。
- 同じ local Git repository を対象に複数 Worker を spawn しても、source root scope の allocation conflict で失敗しない。

View File

@ -1,19 +1,19 @@
完了。
実装内容:
- Runtime-side Execution Workspace materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `execution-workspaces/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Runtime-side working directory materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `working-directories/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Worker spawn 時の workspace root / cwd / scope は source repository root ではなく materialized worktree path を使うようにした。
- 同一 source repository に対する複数 Worker が distinct materialized paths を使えるようにした。
- dirty source state は `clean_point_only` policy で拒否し、remote URI / non-Git provider / unsupported policy は typed diagnostics で fail closed するようにした。
- allocation evidence / cleanup target / cleanup policy / status を記録し、Worker stop cleanup hook で worktree cleanup と record update を行うようにした。
- Browser/API-facing spawn boundary は raw `ExecutionWorkspaceRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Browser/API-facing spawn boundary は raw `WorkingDirectoryRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `8b7a5da0 feat: materialize execution workspaces`
- review fix: `c90ebf24 fix: keep execution workspace paths internal`
- merge into orchestration: `c4cdf1c3 merge: execution workspace materializer`
- implementation: `8b7a5da0 feat: materialize working directories`
- review fix: `c90ebf24 fix: keep working directory paths internal`
- merge into orchestration: `c4cdf1c3 merge: working directory materializer`
Review:
- 初回 external review は request_changes。

View File

@ -39,15 +39,15 @@ Ticket を `workspace-panel` が queued にしました。
Routing decision: implementation_ready
Reason:
- Ticket は local Git worktree Execution Workspace materializer の v0 範囲、非目標、acceptance criteria が具体化されている。
- Objective `00001KWW44EXK` の境界Runtime root / Execution Workspace / RepositoryPoint / materialization strategy / cleanup registry / dirty state policyと整合している。
- Ticket は local Git worktree working directory materializer の v0 範囲、非目標、acceptance criteria が具体化されている。
- Objective `00001KWW44EXK` の境界Runtime root / working directory / RepositoryPoint / materialization strategy / cleanup registry / dirty state policyと整合している。
- `remote URI`, clone/fetch/cache, credential resolution, CoW/sandbox 強化、dirty snapshot/apply は非目標として明確。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- Objective `00001KWW44EXK` bodyRuntime execution workspace materialization direction, Runtime root separation, detached worktree strategy, clean point policy, cleanup registry
- Objective `00001KWW44EXK` bodyRuntime working directory materialization direction, Runtime root separation, detached worktree strategy, clean point policy, cleanup registry
- `TicketRelationQuery(00001KWW9DYH4)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWW9DYH4)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
@ -59,11 +59,11 @@ Evidence checked:
IntentPacket:
Intent:
- Runtime Worker spawn の直前に Execution Workspace materialization boundary を追加し、configured local Git repository から Worker ごとの detached worktree を Runtime root 配下に作る v0 materializer を実装する。
- Runtime Worker spawn の直前に working directory materialization boundary を追加し、configured local Git repository から Worker ごとの detached worktree を Runtime root 配下に作る v0 materializer を実装する。
- Worker の workspace scope / cwd を source repository root ではなく materialized worktree path にすることで、同じ local Git repository を対象に複数 Worker を spawn しても source root scope allocation conflict で失敗しないようにする。
Binding decisions / invariants:
- Execution Workspace`.yoi` / Backend workspace store ではなく Runtime root 配下に作る。
- working directory`.yoi` / Backend workspace store ではなく Runtime root 配下に作る。
- v0 は local Git repository URI のみ対応する。remote URI / non-Git provider / clone/fetch/cache / credentials は typed diagnostic で拒否または非対応扱い。
- worktree は branch 名ではなく resolved commit に対する detached worktree として作る。v0 materialization では Worker 用 branch を作らない。
- dirty local changes は暗黙に含めない。v0 は `clean_point_only` とし、dirty state は typed diagnostic / test evidence で明示する。
@ -73,8 +73,8 @@ Binding decisions / invariants:
- `RepositoryId` / `RepositorySelector` / `RepositoryPoint` の将来境界を壊さず、Git branch/tag/hash 固定の抽象にしない。
Requirements / acceptance criteria:
- `CreateWorkerRequest` または Runtime-side spawn path に Execution Workspace materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `execution-workspaces/<allocation-id>/root/<repository-id>` に `git worktree add --detach` できる。
- `CreateWorkerRequest` または Runtime-side spawn path に working directory materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `working-directories/<allocation-id>/root/<repository-id>` に `git worktree add --detach` できる。
- Worker spawn 時の scope/cwd は source repository root ではなく materialized worktree を指す。
- 同じ local Git repository を対象に複数 Worker を spawn しても source root scope conflict で失敗しない。
- unsupported non-local URI / non-Git provider は typed diagnostic。
@ -124,7 +124,7 @@ Queued acceptance recorded after Workspace Dashboard Queue notification authoriz
Checked context:
- Ticket body / thread / artifacts。
- Objective `00001KWW44EXK`Execution Workspace materialization strategy / dirty policy / Runtime root separation / cleanup registry context。
- Objective `00001KWW44EXK`working directory materialization strategy / dirty policy / Runtime root separation / cleanup registry context。
- `TicketRelationQuery(00001KWW9DYH4)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWW9DYH4)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
@ -161,22 +161,22 @@ Implementation routing update:
Implementation progress report:
- Coder Pod completed implementation and committed `8b7a5da0 feat: materialize execution workspaces` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Coder Pod completed implementation and committed `8b7a5da0 feat: materialize working directories` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Worktree status was clean after commit.
- Changed files include:
- `crates/worker-runtime/src/execution_workspace.rs` (new)
- `crates/worker-runtime/src/working_directory.rs` (new)
- `crates/worker-runtime/src/{catalog.rs,execution.rs,http_server.rs,lib.rs,main.rs,runtime.rs,worker_backend.rs}`
- `crates/workspace-server/src/{companion.rs,hosts.rs,server.rs}`
- Implementation summary:
- Runtime-side Execution Workspace request/status types and local Git worktree materializer。
- detached Git worktree creation under Runtime root: `execution-workspaces/<allocation-id>/root/<repository-id>`。
- Runtime-side working directory request/status types and local Git worktree materializer。
- detached Git worktree creation under Runtime root: `working-directories/<allocation-id>/root/<repository-id>`。
- clean HEAD/selector commit and tree evidence resolution。
- dirty source rejection under `clean_point_only`
- typed diagnostics for remote URI / non-Git provider / unsupported policy。
- materialization/cleanup evidence persistence and best-effort worktree cleanup on Worker stop。
- Worker spawn cwd/scope uses materialized worktree path, not source repository root。
- workspace-server embedded runtime startup and Worker spawn pass configured repositories into materialization request。
- API-facing execution workspace status summaries remain sanitized/path-free。
- API-facing working directory status summaries remain sanitized/path-free。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
@ -197,16 +197,16 @@ Implementation progress report:
External review result: request_changes
Blocker:
- 実装が Browser/API-facing spawn boundary を広げ、API caller から full `ExecutionWorkspaceRequest``ExecutionWorkspaceRepository.local_path: Option<PathBuf>` を含む)を受け取れる形になっている。
- `crates/workspace-server/src/hosts.rs:263-283``WorkerSpawnRequest` を browser-safe として raw workspace roots/storage paths を受け取らない契約にしているが、現在は `execution_workspace: Option<ExecutionWorkspaceRequest>` を含む。
- 実装が Browser/API-facing spawn boundary を広げ、API caller から full `WorkingDirectoryRequest``WorkingDirectoryRepository.local_path: Option<PathBuf>` を含む)を受け取れる形になっている。
- `crates/workspace-server/src/hosts.rs:263-283``WorkerSpawnRequest` を browser-safe として raw workspace roots/storage paths を受け取らない契約にしているが、現在は `working_directory: Option<WorkingDirectoryRequest>` を含む。
- `crates/worker-runtime/src/catalog.rs:58-66` の request は raw `local_path` を運べる。
- `/api/runtimes/{runtime_id}/workers` はこの request shape を受け取り、embedded/remote host spawn path が `CreateWorkerRequest` に pass-through している。
- これは Ticket / routing invariant の「raw source/internal paths を含む public launch boundary 拡張を避ける。必要なら escalate」に反し、既存コード上の browser-safe contract とも衝突している。
- safer `/api/workers` path は server-side repository config から `repository_id` により解決しているため、generic API も caller-supplied raw `local_path` authority を受け取らない形にするか、Execution Workspace materialization request の組み立てを host/runtime 内部境界に閉じる必要がある。
- safer `/api/workers` path は server-side repository config から `repository_id` により解決しているため、generic API も caller-supplied raw `local_path` authority を受け取らない形にするか、working directory materialization request の組み立てを host/runtime 内部境界に閉じる必要がある。
Evidence supporting the rest of the implementation:
- Runtime/backend materialization boundary は存在する。`Runtime::create_worker` が request を backend に渡し、`WorkerRuntimeExecutionBackend::spawn_worker` が spawn 前に materializer を呼ぶ。
- local materializer は `execution-workspaces/<allocation-id>/root/<repository-id>` 配下に resolved commit の detached worktree を作成し、branch checkout はしない。
- local materializer は `working-directories/<allocation-id>/root/<repository-id>` 配下に resolved commit の detached worktree を作成し、branch checkout はしない。
- Worker `workspace_root` / `cwd``Worker::from_manifest_with_context` 前に materialized binding に差し替えられている。
- dirty source は `clean_point_only` で拒否され、remote/non-Git は typed diagnostic code を返す。
- allocation evidence/status/cleanup target/policy は記録・永続化され、worker stop cleanup は worktree removal / cleanup-pending 更新を行う。
@ -228,15 +228,15 @@ Non-blocking follow-ups:
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `c90ebf24 fix: keep execution workspace paths internal` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Coder Pod completed requested changes and committed follow-up `c90ebf24 fix: keep working directory paths internal` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Worktree status was clean after commit.
- Fix summary:
- Browser/API-facing `WorkerSpawnRequest` no longer deserializes full `ExecutionWorkspaceRequest` or caller-supplied raw `local_path`.
- Added browser-safe execution workspace request shape with `repository_id` and optional `selector` only。
- Added `deny_unknown_fields` so raw fields such as `execution_workspace.local_path` and unexpected fields are rejected at the API boundary。
- Server-side host code resolves configured repository registry into internal `ExecutionWorkspaceRequest`; raw `local_path` construction stays behind trusted host/server boundary。
- Browser/API-facing `WorkerSpawnRequest` no longer deserializes full `WorkingDirectoryRequest` or caller-supplied raw `local_path`.
- Added browser-safe working directory request shape with `repository_id` and optional `selector` only。
- Added `deny_unknown_fields` so raw fields such as `working_directory.local_path` and unexpected fields are rejected at the API boundary。
- Server-side host code resolves configured repository registry into internal `WorkingDirectoryRequest`; raw `local_path` construction stays behind trusted host/server boundary。
- Existing `/api/workers` repository-id safe behavior remains preserved。
- Added focused API tests for rejecting raw `execution_workspace.local_path` and accepting safe repository-id/selector materialization without exposing raw workspace path。
- Added focused API tests for rejecting raw `working_directory.local_path` and accepting safe repository-id/selector materialization without exposing raw workspace path。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
@ -263,15 +263,15 @@ Evidence reviewed:
- Fix commit `c90ebf24` and combined implementation after `8b7a5da0`
Findings:
- Prior blocker is resolved. Browser/API `WorkerSpawnRequest` now exposes only `execution_workspace: { repository_id, selector? }` via `WorkerSpawnExecutionWorkspaceRequest` with `deny_unknown_fields`; internal `resolved_execution_workspace` is `#[serde(skip, default)]`
- `/api/runtimes/{runtime_id}/workers` resolves safe repository id/selector server-side from configured repositories into an internal `ExecutionWorkspaceRequest`。
- Prior blocker is resolved. Browser/API `WorkerSpawnRequest` now exposes only `working_directory: { repository_id, selector? }` via `WorkerSpawnWorkingDirectoryRequest` with `deny_unknown_fields`; internal `resolved_working_directory` is `#[serde(skip, default)]`
- `/api/runtimes/{runtime_id}/workers` resolves safe repository id/selector server-side from configured repositories into an internal `WorkingDirectoryRequest`。
- `/api/workers` remains safe: `BrowserCreateWorkerRequest` only accepts optional `repository_id`; server-side code builds the internal request。
- Fix tests explicitly reject caller-supplied `local_path` and accept safe `repository_id`/`selector`, with response path redaction asserted。
- Response/status projections remain browser-safe: execution workspace summaries carry ids/commit/tree/cleanup metadata, not raw workspace/source paths。
- Response/status projections remain browser-safe: working directory summaries carry ids/commit/tree/cleanup metadata, not raw workspace/source paths。
- Original materializer acceptance remains satisfied:
- local Git only, remote/non-Git/dirty source rejected with typed diagnostics。
- selector resolves to commit/tree。
- detached `git worktree add --detach` is created under Runtime root `execution-workspaces/<allocation-id>/root/<repository-id>`。
- detached `git worktree add --detach` is created under Runtime root `working-directories/<allocation-id>/root/<repository-id>`。
- worker factory uses materialized workspace root/cwd instead of source root。
- allocation evidence and cleanup target are recorded。
- cleanup hook removes worktree and updates record。
@ -296,7 +296,7 @@ No merge-blocking issues found.
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWW9DYH4-execution-workspace-materializer` was merged into the orchestration branch with merge commit `c4cdf1c3 merge: execution workspace materializer`。
- Implementation branch `work/00001KWW9DYH4-execution-workspace-materializer` was merged into the orchestration branch with merge commit `c4cdf1c3 merge: working directory materializer`。
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
@ -308,8 +308,8 @@ Merge/validation acceptance:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
Implementation commits:
- `8b7a5da0 feat: materialize execution workspaces`
- `c90ebf24 fix: keep execution workspace paths internal`
- `8b7a5da0 feat: materialize working directories`
- `c90ebf24 fix: keep working directory paths internal`
No merge-blocking issues remain.
@ -331,19 +331,19 @@ Ticket を closed にしました。
完了。
実装内容:
- Runtime-side Execution Workspace materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `execution-workspaces/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Runtime-side working directory materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `working-directories/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Worker spawn 時の workspace root / cwd / scope は source repository root ではなく materialized worktree path を使うようにした。
- 同一 source repository に対する複数 Worker が distinct materialized paths を使えるようにした。
- dirty source state は `clean_point_only` policy で拒否し、remote URI / non-Git provider / unsupported policy は typed diagnostics で fail closed するようにした。
- allocation evidence / cleanup target / cleanup policy / status を記録し、Worker stop cleanup hook で worktree cleanup と record update を行うようにした。
- Browser/API-facing spawn boundary は raw `ExecutionWorkspaceRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Browser/API-facing spawn boundary は raw `WorkingDirectoryRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `8b7a5da0 feat: materialize execution workspaces`
- review fix: `c90ebf24 fix: keep execution workspace paths internal`
- merge into orchestration: `c4cdf1c3 merge: execution workspace materializer`
- implementation: `8b7a5da0 feat: materialize working directories`
- review fix: `c90ebf24 fix: keep working directory paths internal`
- merge into orchestration: `c4cdf1c3 merge: working directory materializer`
Review:
- 初回 external review は request_changes。

View File

@ -51,7 +51,7 @@ Evidence checked:
- `TicketOrchestrationPlanQuery(00001KWWE8E04)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: previous execution-workspace child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- visible Pods: previous working-directory child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/workspace-server/src/{identity.rs,server.rs,hosts.rs,config.rs,records.rs,repositories.rs}`, `web/workspace/src/routes/**`, `web/workspace/src/lib/workspace-sidebar/**`, workspace settings/sidebar/console helpers。

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260707-134133-1","ticket_id":"00001KWY8EHEJ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWY8EHEJ は独立 queued Ticket で blocker なし。先行 Runtime materializer と workspace-id scoped API を前提に、Browser-managed pre-allocated working directory を Worker spawn に接続する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWY8EHEJ-browser-working-directories","worktree":"/home/hare/Projects/yoi/.worktree/00001KWY8EHEJ-browser-working-directories","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、workspace-scoped Browser-managed working directory API / Worker launch UI integration / allocation-id spawn connection / relative_cwd validation / cleanup/status tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-07T13:41:33Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Enable Browser-managed execution workspaces for Worker spawn'
state: 'queued'
title: 'Enable Browser-managed working directories for Worker spawn'
state: 'closed'
created_at: '2026-07-07T12:22:06Z'
updated_at: '2026-07-07T13:40:23Z'
updated_at: '2026-07-07T14:58:35Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-07T13:40:23Z'
@ -10,31 +10,31 @@ queued_at: '2026-07-07T13:40:23Z'
## 背景
Runtime 側には `CreateWorkerRequest.execution_workspace` と local Git worktree materializer があり、Worker 作成時に RepositoryPoint 相当の入力から detached worktree を materialize して Worker の workspace/cwd/scope にする境界が既にある。一方、Browser UI からは作業環境を事前に用意・確認し、それを Worker spawn 操作で選択する経路がまだ無い。
Runtime 側には `CreateWorkerRequest.working_directory` と local Git worktree materializer があり、Worker 作成時に RepositoryPoint 相当の入力から detached worktree を materialize して Worker の workspace/cwd/scope にする境界が既にある。一方、Browser UI からは作業環境を事前に用意・確認し、それを Worker spawn 操作で選択する経路がまだ無い。
Browser から raw host path や内部 materialization request を直接指定させるのではなく、Workspace Backend が repository / selector / policy を検証して Execution Workspace を作成し、Browser には allocation id と安全な summary だけを返す形にする。
Browser から raw host path や内部 materialization request を直接指定させるのではなく、Workspace Backend が repository / selector / policy を検証して working directory を作成し、Browser には allocation id と安全な summary だけを返す形にする。
## 要件
- Browser-facing API で Execution Workspace を作成・一覧・詳細確認できる。
- Browser-facing API で working directory を作成・一覧・詳細確認できる。
- v0 の materializer は既存方針どおり local Git repository からの detached worktree とし、remote clone/cache や dirty source inclusion は扱わない。
- Browser-facing payload に backend-private absolute path、raw materialization request、内部 runtime path を露出しない。
- Worker spawn form/API から、作成済み Execution Workspace を選択して Worker 作成に利用できる。
- Worker spawn form/API から、作成済み working directory を選択して Worker 作成に利用できる。
- Worker が利用する cwd 指定は host absolute path ではなく、materialized workspace root からの安全な `relative_cwd` として扱う。
- `relative_cwd` は absolute path、`..` escape、symlink escape、存在しない/不正な working directory を拒否する。
- Worker detail / list には execution workspace の safe summary を表示する。
- Worker detail / list には working directory の safe summary を表示する。
- cleanup policy と手動 cleanup 操作の v0 方針を明示する。
## 受け入れ条件
- `POST /api/w/<workspace-id>/execution-workspaces` 相当の API で configured repository と selector から Execution Workspace を作成できる。
- `GET /api/w/<workspace-id>/execution-workspaces` 相当の API で作成済み Execution Workspace の safe summary を一覧できる。
- Worker launch UI で Execution Workspace を選択でき、spawn request が raw path ではなく safe id/selector 情報を送る。
- Worker spawn 時に選択した Execution Workspace が Runtime の `CreateWorkerRequest.execution_workspace` または同等の backend-resolved request に接続される。
- `POST /api/w/<workspace-id>/working-directories` 相当の API で configured repository と selector から working directory を作成できる。
- `GET /api/w/<workspace-id>/working-directories` 相当の API で作成済み working directory の safe summary を一覧できる。
- Worker launch UI で working directory を選択でき、spawn request が raw path ではなく safe id/selector 情報を送る。
- Worker spawn 時に選択した working directory が Runtime の `CreateWorkerRequest.working_directory` または同等の backend-resolved request に接続される。
- 作成された Worker の workspace/cwd/scope が materialized worktree 配下になり、Browser/API には内部 path が漏れない。
- 不正な repository、dirty unsupported source、remote unsupported source、invalid `relative_cwd` は typed diagnostic として拒否される。
- frontend check/test、workspace-server tests、`yoi ticket doctor`、`nix build .#yoi` が通る。
## 設計メモ
初期実装は pre-allocate 方式を優先する。つまり Browser で Execution Workspace を明示的に作成し、Worker spawn では `allocation_id` と任意の `relative_cwd` を選択する。単発 spawn request だけで暗黙 materialize する経路は、UI/cleanup/diagnostics が固まるまで後回しにする。
初期実装は pre-allocate 方式を優先する。つまり Browser で working directory を明示的に作成し、Worker spawn では `allocation_id` と任意の `relative_cwd` を選択する。単発 spawn request だけで暗黙 materialize する経路は、UI/cleanup/diagnostics が固まるまで後回しにする。

View File

@ -0,0 +1,35 @@
完了。
実装内容:
- Browser-managed working directory API を scoped Workspace API に追加した。
- `POST /api/w/<workspace-id>/working-directories`
- `GET /api/w/<workspace-id>/working-directories`
- detail / cleanup endpoint
- Browser create payload は configured `repository_id` / selector / policy を使い、raw host path / raw internal `WorkingDirectoryRequest` / internal runtime path を受け取らないようにした。
- Browser response は allocation id、repository id、selector/commit evidence、status、cleanup policy など safe summary のみを返すようにした。
- Worker launch UI で working directory を create/select できるようにした。
- Worker spawn は selected allocation id + optional `relative_cwd` を使い、Runtime materialized workspace に接続するようにした。
- `relative_cwd` は materialized root 相対として検証し、absolute path、`..` escape、symlink escape、nonexistent / non-directory を拒否するようにした。
- invalid `relative_cwd` は Browser-facing API で typed `working_directory_relative_cwd_*` diagnostic として返るようにした。
- Worker list/detail に working directory safe summary を含めた。
- cleanup は allocation root に bounded され、typed/sanitized diagnostic を返すようにした。
- Focused backend/runtime/web tests を追加した。
主な commit / merge:
- implementation: `684b19e8 feat: add browser working directories`
- review fix: `9adb0fae fix: preserve working directory cwd diagnostics`
- merge into orchestration: `b52986e5 merge: browser working directories`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass

View File

@ -30,4 +30,325 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-07T13:41:22Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Browser-managed working directory の pre-allocate 方式、API/UI/spawn 接続、`relative_cwd` validation、path leak invariant、cleanup policy の v0 方針を具体化している。
- 先行 Runtime-side materializer / Repository registry / workspace-id scoped API が orchestration branch に統合済みで、Browser-facing API を `/api/w/<workspace-id>/...` に追加する実装基盤がある。
- raw host path / internal materialization request を Browser payload に露出しない binding invariant が明確で、Worker spawn は allocation id + safe `relative_cwd` で接続する要求になっている。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWY8EHEJ)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWY8EHEJ)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: previous child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/worker-runtime/src/{catalog.rs,working_directory.rs,runtime.rs,worker_backend.rs,http_server.rs}`, `crates/workspace-server/src/{server.rs,hosts.rs,repositories.rs,config.rs}`, `web/workspace/src/lib/workspace-sidebar/**`, scoped routes/API under `web/workspace/src/routes/w/[workspaceId]/**`
IntentPacket:
Intent:
- Browser-facing API から configured repository + selector + policy により working directory を事前作成し、Browser には allocation id と safe summary だけを返す。
- Worker launch UI/API で作成済み working directory を選択し、Worker spawn 時に allocation id と safe `relative_cwd` を Runtime materializer / Worker spawn boundary に接続する。
- Worker detail/list に working directory の safe summary を表示し、cleanup policy と手動 cleanup 操作の v0 方針を実装/明示する。
Binding decisions / invariants:
- Browser から raw host path、backend-private absolute path、raw `WorkingDirectoryRequest`、internal runtime path を送らせない/返さない。
- pre-allocate 方式を優先する。単発 spawn request だけで暗黙 materialize する経路は後回し。
- v0 materializer は existing local Git detached worktree 方針を使う。remote clone/cache、dirty source inclusion は扱わない。
- Worker spawn は作成済み allocation id と任意の `relative_cwd` を選ぶ。
- `relative_cwd` は materialized workspace root からの相対 path とし、absolute path、`..` escape、symlink escape、存在しない/不正 directory を拒否する。
- Browser-facing status/detail/list は safe summary のみ。内部 path / cleanup target raw path は露出しない。
- cleanup policy と manual cleanup operation は v0 の範囲で明示し、cleanup diagnostics は typed/sanitized にする。
- Workspace-scoped Browser API は `/api/w/<workspace-id>/...` を使い、Runtime `/v1/...` API を Browser contract として広げない。
Requirements / acceptance criteria:
- `POST /api/w/<workspace-id>/working-directories` 相当で configured repository + selector から working directory を作成できる。
- `GET /api/w/<workspace-id>/working-directories` 相当で safe summary list を取得できる。
- 必要なら detail / cleanup endpoint を scoped API に追加し、safe summary / typed diagnostic を返す。
- Worker launch UI で working directory を選択でき、raw path ではなく safe id/selector 情報を送る。
- Worker spawn 時に選択 allocation が Runtime の materialized workspace に接続され、Worker cwd/scope は materialized worktree 配下になる。
- invalid repository / dirty unsupported / remote unsupported / invalid `relative_cwd` は typed diagnostic。
- Worker detail/list に working directory safe summary を表示する。
- frontend check/test、workspace-server tests、`yoi ticket doctor`、`nix build .#yoi` を通す。
Implementation latitude:
- Allocation store の所在/形式、API endpoint 名、UI placement、cleanup status 名、test helper は既存 workspace-server / worker-runtime / web style に合わせてよい。
- v0 では Runtime materializer の既存 allocation evidence を再利用し、Browser-managed allocation registry は必要最小限でよい。
- Worker launch UI は既存 New Worker form に working directory select / create link / refresh を追加する形でよい。
- `relative_cwd` は empty/`.` を workspace root と扱ってよい。
Escalate if:
- raw host path や internal runtime path を Browser contract に出さないと満たせない場合。
- remote clone/cache, dirty snapshot/apply, credential resolution, multi-repository mount, strong sandbox policy, or cross-Runtime allocation migration が必要になる場合。
- pre-allocate 方式でなく implicit one-shot materialization を主経路にする必要が出た場合。
Validation:
- `cd web/workspace && deno task check && deno task test`
- `cargo test -p yoi-workspace-server`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo check -p yoi`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`Ticket acceptance に含まれるため実行する。実行不能なら理由を implementation report に明記する)
Current code map:
- Runtime working directory: `crates/worker-runtime/src/catalog.rs`, `working_directory.rs`, `runtime.rs`, `worker_backend.rs`, `http_server.rs`
- Workspace Backend host/API: `crates/workspace-server/src/server.rs`, `hosts.rs`, `repositories.rs`, `config.rs`
- Worker launch UI and API helpers: `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`, worker launch model/tests, workspace API helper, scoped route layout。
- Worker detail/console: scoped worker console route and runtime worker detail/list projections。
Critical risks / reviewer focus:
- Browser payload/response must not expose raw path or raw internal materialization request。
- `allocation_id` must not become ambient authority across mismatched workspace/runtime/repository contexts。
- `relative_cwd` validation must handle absolute paths, `..`, symlink escape, missing/non-directory paths。
- cleanup operation must not delete outside materialized allocation root。
- Worker spawn should use existing materialized allocation rather than creating hidden implicit workspaces as the primary flow。
- Runtime `/v1` remains internal/trusted; Browser uses scoped workspace API。
---
<!-- event: state_changed author: orchestrator at: 2026-07-07T13:41:43Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
## State changed
Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing.
Checked context:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWY8EHEJ)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWY8EHEJ)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は Browser-managed pre-allocated working directory と Worker spawn 接続に閉じており、remote clone/cache, dirty source inclusion, raw path Browser contract, implicit one-shot materialization は非目標/後回しとして明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T13:42:50Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWY8EHEJ-browser-working-directories`
- Branch: `work/00001KWY8EHEJ-browser-working-directories`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWY8EHEJ-browser-execws` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T14:23:27Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `684b19e8 feat: add browser working directories` on branch `work/00001KWY8EHEJ-browser-working-directories`.
- Worktree status was clean after commit.
- Changed files include:
- `crates/worker-runtime/src/{catalog.rs,working_directory.rs,http_server.rs,runtime.rs,worker_backend.rs}`
- `crates/workspace-server/src/{companion.rs,hosts.rs,server.rs}`
- `web/workspace/src/lib/workspace-sidebar/{WorkersNavSection.svelte,types.ts,worker-launch.ts,worker-launch.test.ts}`
- Implementation summary:
- Runtime-side preallocated working directory create/list/detail/cleanup and allocation binding at Worker spawn。
- `relative_cwd` validation rejects absolute paths, `..`, missing paths, files, and symlink escapes。
- scoped Browser API: `POST/GET /api/w/<workspace-id>/working-directories`, detail, and cleanup。
- Browser API uses configured `repository_id` + selector + policy, not raw paths/internal `WorkingDirectoryRequest`
- Browser responses expose safe summaries only。
- Worker spawn uses selected allocation id plus optional safe `relative_cwd`
- Worker list/detail projections include working directory safe summary。
- Workspace UI Worker launch flow can select existing allocations or create a new allocation before launch。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass66 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
---
<!-- event: review author: reviewer at: 2026-07-07T14:36:39Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blocker:
1. Invalid `relative_cwd` is rejected internally, but the Browser-facing Worker spawn API does not return the required typed working directory diagnostic。
- Ticket acceptance requires invalid `relative_cwd` to be rejected as a typed diagnostic。
- Materializer has specific typed codes:
- `working_directory_relative_cwd_invalid`
- `working_directory_relative_cwd_unavailable`
- `working_directory_relative_cwd_escape_rejected`
- `crates/worker-runtime/src/worker_backend.rs` converts `WorkingDirectoryDiagnostic` to a plain string in `WorkerExecutionResult::rejected(...)`
- Workspace host/API then maps this to generic diagnostics/error codes, including generic embedded execution rejection and generic `workspace_worker_create_failed`
- Result: Browser caller submitting invalid `relative_cwd` gets a generic worker-create/runtime rejection rather than a typed `working_directory_relative_cwd_*` diagnostic。
- Browser/scoped API-level tests cover materializer bind validation but not the API rejection shape。
Required fix:
- Preserve/propagate `WorkingDirectoryDiagnostic.code` through Worker spawn, or prevalidate allocation + `relative_cwd` in the Workspace API and return that typed diagnostic directly。
- Add a scoped Browser API test for at least one invalid `relative_cwd` that asserts the typed `working_directory_relative_cwd_*` diagnostic is returned。
Non-blocking follow-ups:
- The implementation otherwise appears aligned with the pre-allocation design: scoped `/api/w/<workspace-id>/working-directories` routes exist, Browser create payload uses configured `repository_id`/selector/policy rather than raw `WorkingDirectoryRequest`, summaries avoid raw paths, UI explicitly creates/selects allocations, and cleanup is bounded to the allocation root。
Validation reviewed:
- Reviewer read Ticket item/thread, inspected `git show --stat --check 684b19e8` / `git show --name-only 684b19e8`, and reviewed Runtime, Workspace API/host, and frontend launch files。
- Reviewer did not rerun full validation; Orchestrator-provided validation was used as passing evidence。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T14:51:28Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `9adb0fae fix: preserve working directory cwd diagnostics` on branch `work/00001KWY8EHEJ-browser-working-directories`.
- Worktree status was clean after commit.
- Fix summary:
- Browser Worker spawn now prevalidates selected working directory allocation + `relative_cwd` through the materializer before spawn。
- `WorkingDirectoryDiagnostic` is mapped directly into Browser API error diagnostics, preserving typed codes such as `working_directory_relative_cwd_invalid`, `working_directory_relative_cwd_unavailable`, and `working_directory_relative_cwd_escape_rejected`
- Added a scoped Browser API regression test asserting invalid `relative_cwd: "../escape"` returns typed `working_directory_relative_cwd_invalid` diagnostic。
- Existing scoped working directory routes, safe summaries, explicit UI allocation flow, and bounded cleanup behavior remain unchanged。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
- Requesting follow-up external review against `684b19e8..9adb0fae` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-07T14:56:52Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed in implementation worktree only:
- Ticket records: `.yoi/tickets/00001KWY8EHEJ/item.md`, `thread.md`, and orchestration artifact。
- Fix diff: `9adb0fae` stat/check and `684b19e8..9adb0fae` focused diff for `crates/workspace-server/src/server.rs`
- Relevant files: `workspace-server/src/server.rs`, `worker-runtime/src/working_directory.rs`, `worker_backend.rs`, scoped host/runtime API pieces, and worker launch UI/model/tests。
Findings:
- Prior blocker is resolved. Browser Worker spawn now pre-validates `working_directory.relative_cwd` through the working directory materializer before calling the runtime spawn path, and maps `WorkingDirectoryError` into API diagnostics via `working_directory_api_error`
- Invalid Browser spawn `relative_cwd` now returns typed diagnostics with the expected codes, covered by the added test asserting `working_directory_relative_cwd_invalid` appears both in diagnostics and message。
- Browser-facing spawn payload remains allocation-based (`allocation_id` + optional `relative_cwd`) rather than exposing raw `WorkingDirectoryRequest` or host paths. UI request construction sends allocation id and relative cwd only。
- Scoped create/list/detail/cleanup routes remain under `/api/w/:workspace_id/working-directories`; worker creation uses the scoped workspace API path from the Browser UI。
- Cleanup remains bounded to the allocation root: cleanup re-derives allocation root from the allocation id/root, canonicalizes workspace root, rejects escape, and removes only the recorded workspace root。
- Runtime/internal trusted path still performs canonical materialization/bind at Worker launch, while Browser gets a safer scoped allocation API。
- Reported validation was comprehensive and green; reviewer did not rerun tests as this was a read-only follow-up review。
Non-blocking follow-ups: none identified。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-07T14:58:18Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWY8EHEJ-browser-working-directories` was merged into the orchestration branch with merge commit `b52986e5 merge: browser working directories`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
Implementation commits:
- `684b19e8 feat: add browser working directories`
- `9adb0fae fix: preserve working directory cwd diagnostics`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-07T14:58:35Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-07T14:58:35Z status: closed -->
## 完了
完了。
実装内容:
- Browser-managed working directory API を scoped Workspace API に追加した。
- `POST /api/w/<workspace-id>/working-directories`
- `GET /api/w/<workspace-id>/working-directories`
- detail / cleanup endpoint
- Browser create payload は configured `repository_id` / selector / policy を使い、raw host path / raw internal `WorkingDirectoryRequest` / internal runtime path を受け取らないようにした。
- Browser response は allocation id、repository id、selector/commit evidence、status、cleanup policy など safe summary のみを返すようにした。
- Worker launch UI で working directory を create/select できるようにした。
- Worker spawn は selected allocation id + optional `relative_cwd` を使い、Runtime materialized workspace に接続するようにした。
- `relative_cwd` は materialized root 相対として検証し、absolute path、`..` escape、symlink escape、nonexistent / non-directory を拒否するようにした。
- invalid `relative_cwd` は Browser-facing API で typed `working_directory_relative_cwd_*` diagnostic として返るようにした。
- Worker list/detail に working directory safe summary を含めた。
- cleanup は allocation root に bounded され、typed/sanitized diagnostic を返すようにした。
- Focused backend/runtime/web tests を追加した。
主な commit / merge:
- implementation: `684b19e8 feat: add browser working directories`
- review fix: `9adb0fae fix: preserve working directory cwd diagnostics`
- merge into orchestration: `b52986e5 merge: browser working directories`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
---

View File

@ -56,7 +56,7 @@ impl std::ops::Deref for RepositorySelector {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceRepository {
pub struct WorkingDirectoryRepository {
pub id: String,
pub provider: String,
pub uri: String,
@ -81,33 +81,42 @@ pub enum DirtyStatePolicy {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceRequest {
pub repository: ExecutionWorkspaceRepository,
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
#[serde(default)]
pub materializer: MaterializerKind,
#[serde(default)]
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryAllocationClaim {
pub allocation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionWorkspaceStatusKind {
pub enum WorkingDirectoryStatusKind {
Active,
Removed,
CleanupPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceCleanupTarget {
pub struct WorkingDirectoryCleanupTarget {
pub kind: String,
pub allocation_id: String,
pub repository_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceSummary {
pub struct WorkingDirectorySummary {
pub allocation_id: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -115,15 +124,15 @@ pub struct ExecutionWorkspaceSummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<ExecutionWorkspaceCleanupTarget>,
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_policy: Option<String>,
pub status: ExecutionWorkspaceStatusKind,
pub status: WorkingDirectoryStatusKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceStatus {
pub summary: ExecutionWorkspaceSummary,
pub struct WorkingDirectoryStatus {
pub summary: WorkingDirectorySummary,
}
/// Canonical Runtime Worker creation request.
@ -132,9 +141,9 @@ pub struct ExecutionWorkspaceStatus {
/// request is built. The request contains only durable Runtime identity inputs:
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
/// optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence, and an optional execution workspace
/// Worker catalog/transcript persistence, and an optional working directory
/// request that preserves RepositoryPoint-style semantics for runtime-side
/// materialization. Browser-facing status for materialized workspaces is
/// materialization. Browser-facing status for materialized working directories is
/// summarized without exposing raw host paths.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateWorkerRequest {
@ -143,7 +152,9 @@ pub struct CreateWorkerRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<WorkerInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace: Option<ExecutionWorkspaceRequest>,
pub working_directory: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_allocation: Option<WorkingDirectoryAllocationClaim>,
}
/// Worker lifecycle status for the in-memory embedded runtime.

View File

@ -1,10 +1,10 @@
use crate::catalog::{CreateWorkerRequest, ExecutionWorkspaceStatus};
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
use crate::error::RuntimeError;
use crate::execution_workspace::ExecutionWorkspaceBinding;
use crate::identity::WorkerRef;
use crate::interaction::WorkerInput;
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationEvent;
use crate::working_directory::WorkingDirectoryBinding;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
@ -154,7 +154,7 @@ pub struct WorkerExecutionStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkerExecutionBindingIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace: Option<ExecutionWorkspaceStatus>,
pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
}
@ -169,7 +169,7 @@ impl WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state,
binding: None,
execution_workspace: None,
working_directory: None,
last_result: None,
}
}
@ -185,8 +185,8 @@ impl WorkerExecutionStatus {
self
}
pub fn with_execution_workspace(mut self, status: ExecutionWorkspaceStatus) -> Self {
self.execution_workspace = Some(status);
pub fn with_working_directory(mut self, status: WorkingDirectoryStatus) -> Self {
self.working_directory = Some(status);
self
}
@ -286,7 +286,7 @@ pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext,
pub execution_workspace: Option<ExecutionWorkspaceBinding>,
pub working_directory: Option<WorkingDirectoryBinding>,
}
/// Result of backend Worker spawn/initialization.
@ -295,7 +295,7 @@ pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
execution_workspace: Option<ExecutionWorkspaceStatus>,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
Errored(WorkerExecutionResult),

View File

@ -1,595 +0,0 @@
use crate::catalog::{
DirtyStatePolicy, ExecutionWorkspaceCleanupTarget, ExecutionWorkspaceRequest,
ExecutionWorkspaceStatus, ExecutionWorkspaceStatusKind, ExecutionWorkspaceSummary,
MaterializerKind,
};
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const EXECUTION_WORKSPACES_DIR: &str = "execution-workspaces";
const MATERIALIZATION_RECORD: &str = "materialization.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceEvidence {
pub repository_id: String,
pub resolved_commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceAllocation {
pub id: String,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
pub evidence: ExecutionWorkspaceEvidence,
pub cleanup_target: ExecutionWorkspaceCleanupTarget,
pub cleanup_policy: String,
pub status: ExecutionWorkspaceStatusKind,
}
impl ExecutionWorkspaceAllocation {
pub fn status_summary(&self) -> ExecutionWorkspaceSummary {
ExecutionWorkspaceSummary {
allocation_id: self.id.clone(),
repository_id: self.repository_id.clone(),
materializer_kind: self.materializer_kind.clone(),
dirty_state_policy: self.dirty_state_policy.clone(),
resolved_commit: Some(self.evidence.resolved_commit.clone()),
resolved_tree: self.evidence.resolved_tree.clone(),
cleanup_target: Some(self.cleanup_target.clone()),
cleanup_policy: Some(self.cleanup_policy.clone()),
status: self.status.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecutionWorkspaceBinding {
pub allocation: ExecutionWorkspaceAllocation,
pub workspace_root: PathBuf,
pub cwd: PathBuf,
allocation_root: PathBuf,
source_repository_path: PathBuf,
}
impl ExecutionWorkspaceBinding {
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn allocation_root(&self) -> &Path {
&self.allocation_root
}
pub fn source_repository_path(&self) -> &Path {
&self.source_repository_path
}
pub fn status(&self) -> ExecutionWorkspaceStatus {
ExecutionWorkspaceStatus {
summary: self.allocation.status_summary(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecutionWorkspaceDiagnostic {
pub code: String,
pub message: String,
}
impl ExecutionWorkspaceDiagnostic {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl std::fmt::Display for ExecutionWorkspaceDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ExecutionWorkspaceDiagnostic {}
pub trait ExecutionWorkspaceMaterializer: Send + Sync + 'static {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic>;
fn cleanup(
&self,
binding: &ExecutionWorkspaceBinding,
) -> Result<(), ExecutionWorkspaceDiagnostic>;
}
#[derive(Clone, Debug)]
pub struct LocalGitWorktreeMaterializer {
runtime_root: PathBuf,
}
impl LocalGitWorktreeMaterializer {
pub fn new(runtime_root: impl Into<PathBuf>) -> Self {
Self {
runtime_root: runtime_root.into(),
}
}
pub fn runtime_root(&self) -> &Path {
&self.runtime_root
}
fn allocation_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
format!(
"{}-{}-{}",
sanitize_path_component(worker_ref.runtime_id.as_str()),
sanitize_path_component(worker_ref.worker_id.as_str()),
sanitize_path_component(repository_id)
)
}
fn allocation_root(&self, allocation_id: &str) -> PathBuf {
self.runtime_root
.join(EXECUTION_WORKSPACES_DIR)
.join(allocation_id)
}
fn write_record(
&self,
binding: &ExecutionWorkspaceBinding,
) -> Result<(), ExecutionWorkspaceDiagnostic> {
let record = ExecutionWorkspaceMaterializationRecord {
allocation: binding.allocation.clone(),
workspace_root: binding.workspace_root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let path = binding.allocation_root.join(MATERIALIZATION_RECORD);
let raw = serde_json::to_vec_pretty(&record).map_err(|error| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_record_serialize_failed",
format!("failed to serialize execution workspace record: {error}"),
)
})?;
fs::write(&path, raw).map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_record_write_failed",
"failed to write execution workspace record; backend-private path details were omitted",
)
})
}
}
impl ExecutionWorkspaceMaterializer for LocalGitWorktreeMaterializer {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &ExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceBinding, ExecutionWorkspaceDiagnostic> {
if request.materializer != MaterializerKind::LocalGitWorktree {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_materializer_unsupported",
"only local_git_worktree execution workspace materialization is supported in v0",
));
}
if request.repository.provider != "git" {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_repository_provider_unsupported",
format!(
"repository provider `{}` is not supported by the v0 execution workspace materializer",
request.repository.provider
),
));
}
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_dirty_policy_unsupported",
"only clean_point_only dirty-state policy is supported in v0",
));
}
if is_remote_uri(&request.repository.uri) {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_remote_repository_unsupported",
"remote repository URI materialization is not implemented in v0",
));
}
let source_path = request
.repository
.local_path
.clone()
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
.map(|value| PathBuf::from(value.trim()))
.map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_git_repository_unavailable",
"configured local repository is not an available Git worktree; backend-private path details were omitted",
)
})?;
let status = git_stdout(&source_root, ["status", "--porcelain"])?;
if !status.trim().is_empty() {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_dirty_source_rejected",
"clean_point_only execution workspace materialization rejects dirty source repository state",
));
}
let selector = request
.repository
.selector
.as_deref()
.unwrap_or("HEAD")
.to_string();
let commit_spec = format!("{selector}^{{commit}}");
let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])?
.trim()
.to_string();
let tree_spec = format!("{resolved_commit}^{{tree}}");
let resolved_tree = git_stdout(&source_root, ["rev-parse", tree_spec.as_str()])
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let allocation_id = Self::allocation_id(worker_ref, &request.repository.id);
let allocation_root = self.allocation_root(&allocation_id);
let workspace_root = allocation_root
.join("root")
.join(sanitize_path_component(&request.repository.id));
if workspace_root.exists() {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_allocation_exists",
"execution workspace allocation target already exists; cleanup or choose a new Worker allocation",
));
}
fs::create_dir_all(workspace_root.parent().ok_or_else(|| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_invalid_target",
"execution workspace allocation target has no parent directory",
)
})?)
.map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_create_failed",
"failed to create execution workspace allocation directory; backend-private path details were omitted",
)
})?;
let workspace_root_arg = path_str(&workspace_root)?;
git_status(
&source_root,
[
"worktree",
"add",
"--detach",
workspace_root_arg.as_str(),
resolved_commit.as_str(),
],
)?;
let allocation = ExecutionWorkspaceAllocation {
id: allocation_id,
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
evidence: ExecutionWorkspaceEvidence {
repository_id: request.repository.id.clone(),
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
},
cleanup_target: ExecutionWorkspaceCleanupTarget {
kind: "git_worktree".to_string(),
allocation_id: Self::allocation_id(worker_ref, &request.repository.id),
repository_id: request.repository.id.clone(),
},
cleanup_policy: "remove_on_worker_stop".to_string(),
status: ExecutionWorkspaceStatusKind::Active,
};
let binding = ExecutionWorkspaceBinding {
allocation,
workspace_root: workspace_root.clone(),
cwd: workspace_root,
allocation_root,
source_repository_path: source_root,
};
self.write_record(&binding)?;
Ok(binding)
}
fn cleanup(
&self,
binding: &ExecutionWorkspaceBinding,
) -> Result<(), ExecutionWorkspaceDiagnostic> {
let mut allocation = binding.allocation.clone();
let workspace_root_arg = path_str(binding.workspace_root())?;
let remove_result = git_status(
binding.source_repository_path(),
["worktree", "remove", "--force", workspace_root_arg.as_str()],
)
.or_else(|_| {
if binding.workspace_root.exists() {
fs::remove_dir_all(binding.workspace_root()).map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_cleanup_failed",
"failed to remove execution workspace; backend-private path details were omitted",
)
})
} else {
Ok(())
}
});
allocation.status = if remove_result.is_ok() {
ExecutionWorkspaceStatusKind::Removed
} else {
ExecutionWorkspaceStatusKind::CleanupPending
};
let updated = ExecutionWorkspaceBinding {
allocation,
workspace_root: binding.workspace_root.clone(),
cwd: binding.cwd.clone(),
allocation_root: binding.allocation_root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let _ = self.write_record(&updated);
remove_result
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ExecutionWorkspaceMaterializationRecord {
allocation: ExecutionWorkspaceAllocation,
workspace_root: PathBuf,
source_repository_path: PathBuf,
}
fn git_stdout<'a, I>(
repository_path: &Path,
args: I,
) -> Result<String, ExecutionWorkspaceDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
let output = Command::new("git")
.arg("-C")
.arg(repository_path)
.args(args)
.output()
.map_err(|_| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_git_unavailable",
"Git command could not be executed; backend-private path details were omitted",
)
})?;
if !output.status.success() {
return Err(ExecutionWorkspaceDiagnostic::new(
"execution_workspace_git_failed",
"Git command failed; backend-private path details were omitted",
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_status<'a, I>(repository_path: &Path, args: I) -> Result<(), ExecutionWorkspaceDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
git_stdout(repository_path, args).map(|_| ())
}
fn path_str(path: &Path) -> Result<String, ExecutionWorkspaceDiagnostic> {
path.to_str().map(ToString::to_string).ok_or_else(|| {
ExecutionWorkspaceDiagnostic::new(
"execution_workspace_non_utf8_path",
"execution workspace path is not valid UTF-8; backend-private path details were omitted",
)
})
}
fn is_remote_uri(uri: &str) -> bool {
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
}
fn sanitize_path_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let trimmed = sanitized.trim_matches(['-', '.']).to_string();
if trimmed.is_empty() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("workspace-{now}")
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{ExecutionWorkspaceRepository, RepositorySelector};
use crate::identity::{RuntimeId, WorkerId};
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn create_clean_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init"]);
git(
dir.path(),
&["config", "user.email", "test@example.invalid"],
);
git(dir.path(), &["config", "user.name", "Yoi Test"]);
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
git(dir.path(), &["add", "README.md"]);
git(dir.path(), &["commit", "-m", "init"]);
dir
}
fn request(repo: &Path) -> ExecutionWorkspaceRequest {
ExecutionWorkspaceRequest {
repository: ExecutionWorkspaceRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
}
}
fn worker_ref(sequence: u64) -> WorkerRef {
WorkerRef::new(
RuntimeId::new("runtime-test").unwrap(),
WorkerId::generated(sequence),
)
}
#[test]
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
assert!(binding.workspace_root.starts_with(runtime_root.path()));
assert!(binding.workspace_root.join("README.md").exists());
let branch = git_stdout(binding.workspace_root(), ["branch", "--show-current"]).unwrap();
assert!(
branch.is_empty(),
"worktree should be detached, got {branch}"
);
assert_eq!(
binding.allocation.materializer_kind,
MaterializerKind::LocalGitWorktree
);
assert_eq!(
binding.allocation.dirty_state_policy,
DirtyStatePolicy::CleanPointOnly
);
assert!(
binding
.allocation_root()
.join(MATERIALIZATION_RECORD)
.exists()
);
}
#[test]
fn multiple_workers_materialize_distinct_paths_for_same_source_repo() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let first = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let second = materializer
.materialize(&worker_ref(2), &request(repo.path()))
.unwrap();
assert_ne!(first.workspace_root, second.workspace_root);
assert!(first.workspace_root.starts_with(runtime_root.path()));
assert!(second.workspace_root.starts_with(runtime_root.path()));
assert!(!first.workspace_root.starts_with(repo.path()));
assert!(!second.workspace_root.starts_with(repo.path()));
}
#[test]
fn dirty_source_is_rejected_by_clean_point_only_policy() {
let repo = create_clean_repo();
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let error = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap_err();
assert_eq!(error.code, "execution_workspace_dirty_source_rejected");
assert!(error.message.contains("clean_point_only"));
}
#[test]
fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() {
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let mut remote = request(Path::new("."));
remote.repository.local_path = None;
remote.repository.uri = "https://example.invalid/repo.git".to_string();
let error = materializer
.materialize(&worker_ref(1), &remote)
.unwrap_err();
assert_eq!(
error.code,
"execution_workspace_remote_repository_unsupported"
);
let mut non_git = remote;
non_git.repository.provider = "archive".to_string();
non_git.repository.uri = ".".to_string();
let error = materializer
.materialize(&worker_ref(2), &non_git)
.unwrap_err();
assert_eq!(
error.code,
"execution_workspace_repository_provider_unsupported"
);
}
#[test]
fn cleanup_removes_worktree_and_updates_record() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let workspace_root = binding.workspace_root.clone();
materializer.cleanup(&binding).unwrap();
assert!(!workspace_root.exists());
let raw =
fs::read_to_string(binding.allocation_root().join(MATERIALIZATION_RECORD)).unwrap();
assert!(raw.contains("removed"));
}
}

View File

@ -863,7 +863,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
working_directory: None,
working_directory_allocation: None,
}
}
@ -878,8 +879,8 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
@ -1097,8 +1098,8 @@ mod ws_tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
@ -1147,7 +1148,8 @@ mod ws_tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
working_directory: None,
working_directory_allocation: None,
}
}

View File

@ -11,7 +11,6 @@ pub mod config_bundle;
pub mod diagnostics;
pub mod error;
pub mod execution;
pub mod execution_workspace;
#[cfg(feature = "fs-store")]
pub mod fs_store;
#[cfg(feature = "http-server")]
@ -22,6 +21,7 @@ pub mod management;
pub mod observation;
mod runtime;
pub mod worker_backend;
pub mod working_directory;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};

View File

@ -14,13 +14,13 @@ use std::process::ExitCode;
use std::sync::Arc;
use worker_runtime::error::RuntimeError;
use worker_runtime::execution_workspace::LocalGitWorktreeMaterializer;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::identity::RuntimeId;
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
@ -81,12 +81,12 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let execution_workspace_root = execution_workspace_runtime_root(config);
let working_directory_root = working_directory_runtime_root(config);
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)?
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
execution_workspace_root,
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
working_directory_root,
)),
);
@ -109,7 +109,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
}
fn execution_workspace_runtime_root(config: &ProcessConfig) -> PathBuf {
fn working_directory_runtime_root(config: &ProcessConfig) -> PathBuf {
match &config.http.store {
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
_ => config

View File

@ -1,7 +1,6 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest,
ExecutionWorkspaceStatus as CatalogExecutionWorkspaceStatus, ProfileSelector, WorkerDetail,
WorkerLifecycleAck, WorkerStatus, WorkerSummary,
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
@ -289,18 +288,18 @@ impl Runtime {
worker_ref: worker_ref.clone(),
request,
context: self.execution_context(worker_ref.clone()),
execution_workspace: None,
working_directory: None,
};
(backend, worker_ref, spawn_request)
};
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, run_state, execution_workspace) = match spawn_result {
let (handle, run_state, working_directory) = match spawn_result {
WorkerExecutionSpawnResult::Connected {
handle,
run_state,
execution_workspace,
} => (handle, run_state, execution_workspace),
working_directory,
} => (handle, run_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?;
@ -334,7 +333,7 @@ impl Runtime {
&worker_ref,
handle,
WorkerExecutionRunState::Busy,
execution_workspace,
working_directory,
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
@ -345,7 +344,7 @@ impl Runtime {
&worker_ref,
handle,
run_state,
execution_workspace,
working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
)
}
@ -441,7 +440,7 @@ impl Runtime {
backend: WorkerExecutionBackendKind::Connected,
run_state: dispatch_result.run_state,
binding: worker.execution.binding.clone(),
execution_workspace: worker.execution.execution_workspace.clone(),
working_directory: worker.execution.working_directory.clone(),
last_result: Some(dispatch_result),
};
worker.transcript.push(TranscriptEntry {
@ -491,7 +490,7 @@ impl Runtime {
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
execution_workspace: Option<CatalogExecutionWorkspaceStatus>,
working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
@ -501,8 +500,8 @@ impl Runtime {
let worker = state.worker_mut(worker_ref)?;
worker.execution_handle = Some(handle);
let mut execution = WorkerExecutionStatus::connected(run_state).with_binding(binding);
if let Some(status) = execution_workspace {
execution = execution.with_execution_workspace(status);
if let Some(status) = working_directory {
execution = execution.with_working_directory(status);
}
worker.execution = execution.with_result(result);
worker.detail(&runtime_id)
@ -538,7 +537,7 @@ impl Runtime {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
binding: worker.execution.binding.clone(),
execution_workspace: worker.execution.execution_workspace.clone(),
working_directory: worker.execution.working_directory.clone(),
last_result: Some(result),
};
state.persist_worker(&worker_ref.worker_id)?;
@ -1593,7 +1592,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
working_directory: None,
working_directory_allocation: None,
}
}
@ -1659,8 +1659,8 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
@ -1934,8 +1934,8 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}

View File

@ -18,8 +18,8 @@ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::execution_workspace::{ExecutionWorkspaceBinding, ExecutionWorkspaceMaterializer};
use crate::interaction::{WorkerInput, WorkerInputKind};
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer};
use async_trait::async_trait;
use manifest::paths;
use protocol::{Method, Segment, WorkerStatus};
@ -162,12 +162,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let worker_name = Self::runtime_worker_name(&request);
let profile = self.runtime_profile(&request);
let workspace_root = request
.execution_workspace
.working_directory
.as_ref()
.map(|binding| binding.workspace_root().to_path_buf())
.unwrap_or_else(|| self.workspace_root.clone());
let cwd = request
.execution_workspace
.working_directory
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
@ -210,14 +210,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
struct RuntimeWorkerExecution {
handle: WorkerHandle,
busy: Arc<AtomicBool>,
execution_workspace: Option<ExecutionWorkspaceBinding>,
working_directory: Option<WorkingDirectoryBinding>,
}
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
backend_id: String,
factory: Arc<F>,
execution_workspace_materializer: Option<Arc<dyn ExecutionWorkspaceMaterializer>>,
working_directory_materializer: Option<Arc<dyn WorkingDirectoryMaterializer>>,
runtime: Mutex<Option<Runtime>>,
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
}
@ -241,7 +241,7 @@ where
Ok(Self {
backend_id: DEFAULT_BACKEND_ID.to_string(),
factory: Arc::new(factory),
execution_workspace_materializer: None,
working_directory_materializer: None,
runtime: Mutex::new(Some(runtime)),
workers: Mutex::new(HashMap::new()),
})
@ -252,11 +252,11 @@ where
self
}
pub fn with_execution_workspace_materializer(
pub fn with_working_directory_materializer(
mut self,
materializer: impl ExecutionWorkspaceMaterializer,
materializer: impl WorkingDirectoryMaterializer,
) -> Self {
self.execution_workspace_materializer = Some(Arc::new(materializer));
self.working_directory_materializer = Some(Arc::new(materializer));
self
}
@ -374,17 +374,26 @@ where
}
let mut request = request;
let execution_workspace = match request.request.execution_workspace.as_ref() {
Some(workspace_request) => {
let Some(materializer) = self.execution_workspace_materializer.as_ref() else {
let working_directory = match (
request.request.working_directory.as_ref(),
request.request.working_directory_allocation.as_ref(),
) {
(Some(_), Some(_)) => {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"worker spawn cannot specify both working_directory and working_directory_allocation",
));
}
(Some(workspace_request), None) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"execution workspace materialization requested, but no materializer is configured for this runtime backend",
"working directory materialization requested, but no materializer is configured for this runtime backend",
));
};
match materializer.materialize(&request.worker_ref, workspace_request) {
Ok(binding) => {
request.execution_workspace = Some(binding.clone());
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
@ -397,7 +406,32 @@ where
}
}
}
None => None,
(None, Some(allocation)) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"working directory allocation requested, but no materializer is configured for this runtime backend",
));
};
match materializer.bind_allocation(
&allocation.allocation_id,
allocation.relative_cwd.as_deref(),
) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
(None, None) => None,
};
let factory = self.factory.clone();
@ -410,8 +444,8 @@ where
Ok(handle) => handle,
Err(message) => {
if let (Some(materializer), Some(binding)) = (
self.execution_workspace_materializer.as_ref(),
execution_workspace.as_ref(),
self.working_directory_materializer.as_ref(),
working_directory.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
@ -460,14 +494,14 @@ where
RuntimeWorkerExecution {
handle,
busy,
execution_workspace: execution_workspace.clone(),
working_directory: working_directory.clone(),
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: execution_workspace.map(|binding| binding.status()),
working_directory: working_directory.map(|binding| binding.status()),
}
}
@ -558,8 +592,8 @@ where
WorkerExecutionRunState::Stopped,
);
if let (Some(materializer), Some(binding)) = (
self.execution_workspace_materializer.as_ref(),
execution.execution_workspace.as_ref(),
self.working_directory_materializer.as_ref(),
execution.working_directory.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
@ -593,13 +627,13 @@ mod tests {
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, ExecutionWorkspaceRepository,
ExecutionWorkspaceRequest, MaterializerKind, ProfileSelector, RepositorySelector,
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector,
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
};
use crate::execution_workspace::LocalGitWorktreeMaterializer;
use crate::identity::RuntimeId;
use crate::management::RuntimeOptions;
use crate::observation::{TranscriptQuery, TranscriptRole};
use crate::working_directory::LocalGitWorktreeMaterializer;
use async_trait::async_trait;
use futures::Stream;
use llm_engine::Engine;
@ -681,7 +715,7 @@ mod tests {
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
);
let cwd = request
.execution_workspace
.working_directory
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
@ -746,7 +780,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
working_directory: None,
working_directory_allocation: None,
}
}
@ -774,9 +809,9 @@ mod tests {
dir
}
fn execution_workspace_request(repo: &std::path::Path) -> ExecutionWorkspaceRequest {
ExecutionWorkspaceRequest {
repository: ExecutionWorkspaceRepository {
fn working_directory_request(repo: &std::path::Path) -> WorkingDirectoryRequest {
WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
@ -885,7 +920,7 @@ mod tests {
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
runtime_base.path(),
));
let runtime = EmbeddedRuntime::with_execution_backend(
@ -898,11 +933,11 @@ mod tests {
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.execution_workspace = Some(execution_workspace_request(repo.path()));
request.working_directory = Some(working_directory_request(repo.path()));
let detail = runtime.create_worker(request).unwrap();
assert!(detail.execution.execution_workspace.is_some());
assert!(detail.execution.working_directory.is_some());
let cwds = observed_cwds.lock().unwrap();
assert_eq!(cwds.len(), 1);
let cwd = &cwds[0];

View File

@ -0,0 +1,901 @@
use crate::catalog::{
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const WORKING_DIRECTORIES_DIR: &str = "working-directories";
const MATERIALIZATION_RECORD: &str = "materialization.json";
static NEXT_ALLOCATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryEvidence {
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub resolved_commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryAllocation {
pub id: String,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
pub evidence: WorkingDirectoryEvidence,
pub cleanup_target: WorkingDirectoryCleanupTarget,
pub cleanup_policy: String,
pub status: WorkingDirectoryStatusKind,
}
impl WorkingDirectoryAllocation {
pub fn status_summary(&self) -> WorkingDirectorySummary {
WorkingDirectorySummary {
allocation_id: self.id.clone(),
repository_id: self.repository_id.clone(),
requested_selector: self.evidence.requested_selector.clone(),
materializer_kind: self.materializer_kind.clone(),
dirty_state_policy: self.dirty_state_policy.clone(),
resolved_commit: Some(self.evidence.resolved_commit.clone()),
resolved_tree: self.evidence.resolved_tree.clone(),
cleanup_target: Some(self.cleanup_target.clone()),
cleanup_policy: Some(self.cleanup_policy.clone()),
status: self.status.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkingDirectoryBinding {
pub allocation: WorkingDirectoryAllocation,
pub workspace_root: PathBuf,
pub cwd: PathBuf,
allocation_root: PathBuf,
source_repository_path: PathBuf,
}
impl WorkingDirectoryBinding {
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn allocation_root(&self) -> &Path {
&self.allocation_root
}
pub fn source_repository_path(&self) -> &Path {
&self.source_repository_path
}
pub fn status(&self) -> WorkingDirectoryStatus {
WorkingDirectoryStatus {
summary: self.allocation.status_summary(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkingDirectoryDiagnostic {
pub code: String,
pub message: String,
}
impl WorkingDirectoryDiagnostic {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl std::fmt::Display for WorkingDirectoryDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for WorkingDirectoryDiagnostic {}
pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn preallocate(
&self,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn bind_allocation(
&self,
allocation_id: &str,
relative_cwd: Option<&str>,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn list_allocations(&self) -> Result<Vec<WorkingDirectoryStatus>, WorkingDirectoryDiagnostic>;
fn allocation_status(
&self,
allocation_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic>;
fn cleanup_allocation(
&self,
allocation_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic>;
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
}
#[derive(Clone, Debug)]
pub struct LocalGitWorktreeMaterializer {
runtime_root: PathBuf,
}
impl LocalGitWorktreeMaterializer {
pub fn new(runtime_root: impl Into<PathBuf>) -> Self {
Self {
runtime_root: runtime_root.into(),
}
}
pub fn runtime_root(&self) -> &Path {
&self.runtime_root
}
fn allocation_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
format!(
"{}-{}-{}",
sanitize_path_component(worker_ref.runtime_id.as_str()),
sanitize_path_component(worker_ref.worker_id.as_str()),
sanitize_path_component(repository_id)
)
}
fn allocation_root(&self, allocation_id: &str) -> PathBuf {
self.runtime_root
.join(WORKING_DIRECTORIES_DIR)
.join(allocation_id)
}
fn write_record(
&self,
binding: &WorkingDirectoryBinding,
) -> Result<(), WorkingDirectoryDiagnostic> {
let record = WorkingDirectoryMaterializationRecord {
allocation: binding.allocation.clone(),
workspace_root: binding.workspace_root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let path = binding.allocation_root.join(MATERIALIZATION_RECORD);
let raw = serde_json::to_vec_pretty(&record).map_err(|error| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_serialize_failed",
format!("failed to serialize working directory record: {error}"),
)
})?;
fs::write(&path, raw).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_write_failed",
"failed to write working directory record; backend-private path details were omitted",
)
})
}
fn read_binding(
&self,
allocation_id: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let allocation_root = self.allocation_root(allocation_id);
let path = allocation_root.join(MATERIALIZATION_RECORD);
let raw = fs::read(&path).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_allocation_not_found",
"working directory allocation was not found",
)
})?;
let record: WorkingDirectoryMaterializationRecord = serde_json::from_slice(&raw).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_invalid",
"working directory allocation record is invalid; backend-private path details were omitted",
)
})?;
Ok(WorkingDirectoryBinding {
allocation: record.allocation,
workspace_root: record.workspace_root.clone(),
cwd: record.workspace_root,
allocation_root,
source_repository_path: record.source_repository_path,
})
}
fn materialize_with_allocation_id(
&self,
allocation_id: String,
request: &WorkingDirectoryRequest,
cleanup_policy: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_allocation_id(&allocation_id)?;
if request.materializer != MaterializerKind::LocalGitWorktree {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_materializer_unsupported",
"only local_git_worktree working directory materialization is supported in v0",
));
}
if request.repository.provider != "git" {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_provider_unsupported",
format!(
"repository provider `{}` is not supported by the v0 working directory materializer",
request.repository.provider
),
));
}
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_policy_unsupported",
"only clean_point_only dirty-state policy is supported in v0",
));
}
if is_remote_uri(&request.repository.uri) {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_unsupported",
"remote repository URI materialization is not implemented in v0",
));
}
let source_path = request
.repository
.local_path
.clone()
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
.map(|value| PathBuf::from(value.trim()))
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_git_repository_unavailable",
"configured local repository is not an available Git worktree; backend-private path details were omitted",
)
})?;
let status = git_stdout(&source_root, ["status", "--porcelain"])?;
if !status.trim().is_empty() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_source_rejected",
"clean_point_only working directory materialization rejects dirty source repository state",
));
}
let selector = request
.repository
.selector
.as_deref()
.unwrap_or("HEAD")
.to_string();
let commit_spec = format!("{selector}^{{commit}}");
let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])?
.trim()
.to_string();
let tree_spec = format!("{resolved_commit}^{{tree}}");
let resolved_tree = git_stdout(&source_root, ["rev-parse", tree_spec.as_str()])
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let allocation_root = self.allocation_root(&allocation_id);
let workspace_root = allocation_root
.join("root")
.join(sanitize_path_component(&request.repository.id));
if workspace_root.exists() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_allocation_exists",
"working directory allocation target already exists; cleanup or choose a new allocation",
));
}
fs::create_dir_all(workspace_root.parent().ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_invalid_target",
"working directory allocation target has no parent directory",
)
})?)
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_create_failed",
"failed to create working directory allocation directory; backend-private path details were omitted",
)
})?;
let workspace_root_arg = path_str(&workspace_root)?;
git_status(
&source_root,
[
"worktree",
"add",
"--detach",
workspace_root_arg.as_str(),
resolved_commit.as_str(),
],
)?;
let allocation = WorkingDirectoryAllocation {
id: allocation_id.clone(),
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
evidence: WorkingDirectoryEvidence {
repository_id: request.repository.id.clone(),
requested_selector: request
.repository
.selector
.as_ref()
.map(|selector| selector.as_ref().to_string()),
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
},
cleanup_target: WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
allocation_id,
repository_id: request.repository.id.clone(),
},
cleanup_policy: cleanup_policy.to_string(),
status: WorkingDirectoryStatusKind::Active,
};
let binding = WorkingDirectoryBinding {
allocation,
workspace_root: workspace_root.clone(),
cwd: workspace_root,
allocation_root,
source_repository_path: source_root,
};
self.write_record(&binding)?;
Ok(binding)
}
}
impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let allocation_id = Self::allocation_id(worker_ref, &request.repository.id);
self.materialize_with_allocation_id(allocation_id, request, "remove_on_worker_stop")
}
fn preallocate(
&self,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let allocation_id = next_allocation_id(&request.repository.id);
self.materialize_with_allocation_id(allocation_id, request, "manual_or_worker_stop")
}
fn bind_allocation(
&self,
allocation_id: &str,
relative_cwd: Option<&str>,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_allocation_id(allocation_id)?;
let binding = self.read_binding(allocation_id)?;
if binding.allocation.status != WorkingDirectoryStatusKind::Active {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_not_active",
"working directory allocation is not active",
));
}
let cwd = validate_relative_cwd(binding.workspace_root(), relative_cwd)?;
Ok(WorkingDirectoryBinding { cwd, ..binding })
}
fn list_allocations(&self) -> Result<Vec<WorkingDirectoryStatus>, WorkingDirectoryDiagnostic> {
let root = self.runtime_root.join(WORKING_DIRECTORIES_DIR);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_list_failed",
"failed to list working directory allocations; backend-private path details were omitted",
));
}
};
let mut statuses = Vec::new();
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let allocation_id = entry.file_name().to_string_lossy().to_string();
if validate_allocation_id(&allocation_id).is_err() {
continue;
}
if let Ok(status) = self.allocation_status(&allocation_id) {
statuses.push(status);
}
}
statuses
.sort_by(|left, right| left.summary.allocation_id.cmp(&right.summary.allocation_id));
Ok(statuses)
}
fn allocation_status(
&self,
allocation_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_allocation_id(allocation_id)?;
Ok(self.read_binding(allocation_id)?.status())
}
fn cleanup_allocation(
&self,
allocation_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_allocation_id(allocation_id)?;
let binding = self.read_binding(allocation_id)?;
self.cleanup(&binding)?;
self.allocation_status(allocation_id)
}
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
let mut allocation = binding.allocation.clone();
let allocation_root = binding.allocation_root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_target_invalid",
"working directory allocation root is unavailable; backend-private path details were omitted",
)
})?;
let workspace_root = binding.workspace_root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_target_invalid",
"working directory root is unavailable; backend-private path details were omitted",
)
})?;
if !workspace_root.starts_with(&allocation_root) {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_escape_rejected",
"working directory cleanup target is outside the allocation root",
));
}
let workspace_root_arg = path_str(&workspace_root)?;
let remove_result = git_status(
binding.source_repository_path(),
["worktree", "remove", "--force", workspace_root_arg.as_str()],
)
.or_else(|_| {
if workspace_root.exists() {
fs::remove_dir_all(&workspace_root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_failed",
"failed to remove working directory; backend-private path details were omitted",
)
})
} else {
Ok(())
}
});
allocation.status = if remove_result.is_ok() {
WorkingDirectoryStatusKind::Removed
} else {
WorkingDirectoryStatusKind::CleanupPending
};
let updated = WorkingDirectoryBinding {
allocation,
workspace_root: binding.workspace_root.clone(),
cwd: binding.cwd.clone(),
allocation_root: binding.allocation_root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let _ = self.write_record(&updated);
remove_result
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct WorkingDirectoryMaterializationRecord {
allocation: WorkingDirectoryAllocation,
workspace_root: PathBuf,
source_repository_path: PathBuf,
}
fn git_stdout<'a, I>(repository_path: &Path, args: I) -> Result<String, WorkingDirectoryDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
let output = Command::new("git")
.arg("-C")
.arg(repository_path)
.args(args)
.output()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_git_unavailable",
"Git command could not be executed; backend-private path details were omitted",
)
})?;
if !output.status.success() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_git_failed",
"Git command failed; backend-private path details were omitted",
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_status<'a, I>(repository_path: &Path, args: I) -> Result<(), WorkingDirectoryDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
git_stdout(repository_path, args).map(|_| ())
}
fn path_str(path: &Path) -> Result<String, WorkingDirectoryDiagnostic> {
path.to_str().map(ToString::to_string).ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_non_utf8_path",
"working directory path is not valid UTF-8; backend-private path details were omitted",
)
})
}
fn is_remote_uri(uri: &str) -> bool {
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
}
fn sanitize_path_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let trimmed = sanitized.trim_matches(['-', '.']).to_string();
if trimmed.is_empty() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("workspace-{now}")
} else {
trimmed
}
}
fn next_allocation_id(repository_id: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = NEXT_ALLOCATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"alloc-{now}-{sequence}-{}",
sanitize_path_component(repository_id)
)
}
fn validate_allocation_id(allocation_id: &str) -> Result<(), WorkingDirectoryDiagnostic> {
let sanitized = sanitize_path_component(allocation_id);
if allocation_id.is_empty()
|| allocation_id != sanitized
|| allocation_id.contains(std::path::MAIN_SEPARATOR)
|| allocation_id.contains('/')
|| allocation_id.contains('\\')
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_allocation_id_invalid",
"working directory allocation id is invalid",
));
}
Ok(())
}
fn validate_relative_cwd(
workspace_root: &Path,
relative_cwd: Option<&str>,
) -> Result<PathBuf, WorkingDirectoryDiagnostic> {
let workspace_root = workspace_root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_root_unavailable",
"working directory root is unavailable; backend-private path details were omitted",
)
})?;
let relative = relative_cwd.unwrap_or(".").trim();
if relative.is_empty() || relative == "." {
return Ok(workspace_root);
}
let relative_path = Path::new(relative);
if relative_path.is_absolute()
|| relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_invalid",
"working directory relative_cwd must be a relative path inside the allocation root",
));
}
let target = workspace_root
.join(relative_path)
.canonicalize()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_unavailable",
"working directory relative_cwd does not identify an existing directory",
)
})?;
if !target.starts_with(&workspace_root) || !target.is_dir() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_escape_rejected",
"working directory relative_cwd must resolve to a directory inside the allocation root",
));
}
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
use crate::identity::{RuntimeId, WorkerId};
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn create_clean_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init"]);
git(
dir.path(),
&["config", "user.email", "test@example.invalid"],
);
git(dir.path(), &["config", "user.name", "Yoi Test"]);
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
git(dir.path(), &["add", "README.md"]);
git(dir.path(), &["commit", "-m", "init"]);
dir
}
fn request(repo: &Path) -> WorkingDirectoryRequest {
WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
}
}
fn worker_ref(sequence: u64) -> WorkerRef {
WorkerRef::new(
RuntimeId::new("runtime-test").unwrap(),
WorkerId::generated(sequence),
)
}
#[test]
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
assert!(binding.workspace_root.starts_with(runtime_root.path()));
assert!(binding.workspace_root.join("README.md").exists());
let branch = git_stdout(binding.workspace_root(), ["branch", "--show-current"]).unwrap();
assert!(
branch.is_empty(),
"worktree should be detached, got {branch}"
);
assert_eq!(
binding.allocation.materializer_kind,
MaterializerKind::LocalGitWorktree
);
assert_eq!(
binding.allocation.dirty_state_policy,
DirtyStatePolicy::CleanPointOnly
);
assert!(
binding
.allocation_root()
.join(MATERIALIZATION_RECORD)
.exists()
);
}
#[test]
fn multiple_workers_materialize_distinct_paths_for_same_source_repo() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let first = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let second = materializer
.materialize(&worker_ref(2), &request(repo.path()))
.unwrap();
assert_ne!(first.workspace_root, second.workspace_root);
assert!(first.workspace_root.starts_with(runtime_root.path()));
assert!(second.workspace_root.starts_with(runtime_root.path()));
assert!(!first.workspace_root.starts_with(repo.path()));
assert!(!second.workspace_root.starts_with(repo.path()));
}
#[test]
fn dirty_source_is_rejected_by_clean_point_only_policy() {
let repo = create_clean_repo();
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let error = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap_err();
assert_eq!(error.code, "working_directory_dirty_source_rejected");
assert!(error.message.contains("clean_point_only"));
}
#[test]
fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() {
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let mut remote = request(Path::new("."));
remote.repository.local_path = None;
remote.repository.uri = "https://example.invalid/repo.git".to_string();
let error = materializer
.materialize(&worker_ref(1), &remote)
.unwrap_err();
assert_eq!(
error.code,
"working_directory_remote_repository_unsupported"
);
let mut non_git = remote;
non_git.repository.provider = "archive".to_string();
non_git.repository.uri = ".".to_string();
let error = materializer
.materialize(&worker_ref(2), &non_git)
.unwrap_err();
assert_eq!(
error.code,
"working_directory_repository_provider_unsupported"
);
}
#[test]
fn preallocated_allocation_binds_safe_relative_cwd_and_lists_without_paths() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("crates/yoi")).unwrap();
fs::write(repo.path().join("crates/yoi/lib.rs"), "// ok\n").unwrap();
git(repo.path(), &["add", "crates/yoi/lib.rs"]);
git(repo.path(), &["commit", "-m", "add crate"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let allocation = materializer.preallocate(&request(repo.path())).unwrap();
let bound = materializer
.bind_allocation(&allocation.allocation.id, Some("crates/yoi"))
.unwrap();
assert_eq!(bound.cwd.file_name().unwrap(), "yoi");
let listed = materializer.list_allocations().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].summary.allocation_id, allocation.allocation.id);
assert_eq!(
listed[0].summary.requested_selector.as_deref(),
Some("HEAD")
);
}
#[test]
fn relative_cwd_rejects_absolute_parent_nonexistent_file_and_symlink_escape() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("inside")).unwrap();
fs::write(repo.path().join("inside/file.txt"), "file\n").unwrap();
git(repo.path(), &["add", "inside/file.txt"]);
git(repo.path(), &["commit", "-m", "add inside"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let allocation = materializer.preallocate(&request(repo.path())).unwrap();
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("/tmp"))
.unwrap_err()
.code,
"working_directory_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("../outside"))
.unwrap_err()
.code,
"working_directory_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("missing"))
.unwrap_err()
.code,
"working_directory_relative_cwd_unavailable"
);
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("inside/file.txt"))
.unwrap_err()
.code,
"working_directory_relative_cwd_escape_rejected"
);
#[cfg(unix)]
{
std::os::unix::fs::symlink("/tmp", allocation.workspace_root().join("escape")).unwrap();
assert_eq!(
materializer
.bind_allocation(&allocation.allocation.id, Some("escape"))
.unwrap_err()
.code,
"working_directory_relative_cwd_escape_rejected"
);
}
}
#[test]
fn cleanup_removes_worktree_and_updates_record() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let workspace_root = binding.workspace_root.clone();
materializer.cleanup(&binding).unwrap();
assert!(!workspace_root.exists());
let raw =
fs::read_to_string(binding.allocation_root().join(MATERIALIZATION_RECORD)).unwrap();
assert!(raw.contains("removed"));
}
}

View File

@ -387,8 +387,9 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
},
profile: Some(selector),
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
},
);
@ -577,8 +578,8 @@ mod tests {
"deterministic-companion-test",
),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}

View File

@ -12,8 +12,9 @@ use std::{
time::Duration,
};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ExecutionWorkspaceRequest, ProfileSelector,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryAllocationClaim, WorkingDirectoryRequest,
WorkingDirectorySummary,
};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance,
@ -238,6 +239,8 @@ pub struct WorkerSummary {
pub last_seen_at: Option<String>,
pub implementation: WorkerImplementationSummary,
pub capabilities: WorkerCapabilitySummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectorySummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@ -265,14 +268,14 @@ pub struct WorkerLookupResult {
/// The request carries Browser-facing launch semantics only: workspace intent,
/// optional display identity, acceptance policy, optional profile selector,
/// optional initial input, and optional configured Repository selector for
/// execution-workspace materialization. Runtime execution authority is resolved by the host
/// working-directory materialization. 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)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnExecutionWorkspaceRequest {
pub struct WorkerSpawnWorkingDirectoryRequest {
/// Safe configured Repository id. The host resolves this id to repository
/// authority from server-side config; browser callers cannot provide raw
/// source paths or runtime-internal storage paths.
@ -292,13 +295,15 @@ pub struct WorkerSpawnRequest {
pub profile: Option<ProfileSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<EmbeddedWorkerInput>,
/// Optional safe execution-workspace selector. The Workspace server resolves
/// this into a runtime-internal `ExecutionWorkspaceRequest` from configured
/// Optional safe working-directory selector. The Workspace server resolves
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
/// repositories before calling a host.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace: Option<WorkerSpawnExecutionWorkspaceRequest>,
pub working_directory: Option<WorkerSpawnWorkingDirectoryRequest>,
#[serde(skip, default)]
pub resolved_execution_workspace: Option<ExecutionWorkspaceRequest>,
pub resolved_working_directory: Option<WorkingDirectoryRequest>,
#[serde(skip, default)]
pub resolved_working_directory_allocation: Option<WorkingDirectoryAllocationClaim>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -1138,6 +1143,11 @@ impl EmbeddedWorkerRuntime {
can_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
can_spawn_followup: false,
},
working_directory: summary
.execution
.working_directory
.clone()
.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(&summary.execution),
}
}
@ -1167,6 +1177,11 @@ impl EmbeddedWorkerRuntime {
can_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
can_spawn_followup: false,
},
working_directory: detail
.execution
.working_directory
.clone()
.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(&detail.execution),
}
}
@ -1341,7 +1356,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
execution_workspace: request.resolved_execution_workspace.clone(),
working_directory: request.resolved_working_directory.clone(),
working_directory_allocation: request.resolved_working_directory_allocation.clone(),
};
match self.runtime.create_worker(create_request) {
Ok(detail) => {
@ -1817,6 +1833,7 @@ impl RemoteWorkerRuntime {
can_stop: runtime_worker_can_stop(true, summary.status, &summary.execution),
can_spawn_followup: false,
},
working_directory: summary.execution.working_directory.clone().map(|status| status.summary),
diagnostics: vec![diagnostic(
"remote_runtime_projection",
DiagnosticSeverity::Info,
@ -1850,6 +1867,7 @@ impl RemoteWorkerRuntime {
can_stop: runtime_worker_can_stop(true, detail.status, &detail.execution),
can_spawn_followup: false,
},
working_directory: detail.execution.working_directory.clone().map(|status| status.summary),
diagnostics: vec![diagnostic(
"remote_runtime_projection",
DiagnosticSeverity::Info,
@ -2014,7 +2032,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
execution_workspace: request.resolved_execution_workspace.clone(),
working_directory: request.resolved_working_directory.clone(),
working_directory_allocation: request.resolved_working_directory_allocation.clone(),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
Ok(response) => WorkerSpawnResult {
@ -2825,6 +2844,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
diagnostics: vec![diagnostic(
"runtime_capability_unsupported",
DiagnosticSeverity::Info,
@ -2938,8 +2958,8 @@ mod tests {
self.backend_id(),
),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
@ -3020,6 +3040,7 @@ mod tests {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
diagnostics: Vec::new(),
}],
}
@ -3170,8 +3191,9 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
}
}
@ -3301,8 +3323,9 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
},
)
.unwrap();
@ -3402,8 +3425,9 @@ mod tests {
},
profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
},
)
.unwrap();
@ -3432,8 +3456,9 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
},
)
.unwrap();

View File

@ -12,8 +12,10 @@ use chrono::{SecondsFormat, Utc};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::execution_workspace::LocalGitWorktreeMaterializer;
use worker_runtime::worker_backend::WorkerRuntimeExecutionBackend;
use worker_runtime::working_directory::{
LocalGitWorktreeMaterializer, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
};
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -25,8 +27,8 @@ use crate::hosts::{
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
RuntimeRegistryUnregisterResult, RuntimeSummary, WorkerInputRequest, WorkerInputResult,
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnExecutionWorkspaceRequest, WorkerSpawnIntent,
WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTranscriptProjection,
};
use crate::identity::WorkspaceIdentity;
use crate::observation::{
@ -43,8 +45,9 @@ use crate::repositories::{
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use crate::{Error, Result};
use worker_runtime::catalog::{
ConfigBundleRef, DirtyStatePolicy, ExecutionWorkspaceRepository, ExecutionWorkspaceRequest,
MaterializerKind, ProfileSelector, RepositorySelector as RuntimeRepositorySelector,
ConfigBundleRef, DirtyStatePolicy, MaterializerKind, ProfileSelector,
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryAllocationClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectorySummary,
};
use worker_runtime::config_bundle::ConfigBundle;
use worker_runtime::http_server::{
@ -151,10 +154,14 @@ pub struct WorkspaceApi {
runtime: Arc<RuntimeRegistry>,
companion: Arc<CompanionConsole>,
observation_proxy: BackendObservationProxy,
working_directory_materializer: Arc<LocalGitWorktreeMaterializer>,
}
impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let materializer = Arc::new(LocalGitWorktreeMaterializer::new(
config.embedded_runtime_store_root.clone(),
));
let execution_backend =
WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone())
.map_err(|err| {
@ -162,9 +169,7 @@ impl WorkspaceApi {
"failed to initialize embedded Worker backend: {err}"
))
})?
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
config.embedded_runtime_store_root.clone(),
));
.with_working_directory_materializer((*materializer).clone());
Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await
}
@ -199,6 +204,9 @@ impl WorkspaceApi {
let runtime = Arc::new(runtime);
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
let working_directory_materializer = Arc::new(LocalGitWorktreeMaterializer::new(
config.embedded_runtime_store_root.clone(),
));
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
config,
@ -206,6 +214,7 @@ impl WorkspaceApi {
runtime,
companion,
observation_proxy,
working_directory_materializer,
})
}
@ -261,6 +270,14 @@ pub fn build_router(api: WorkspaceApi) -> Router {
)
.route("/api/hosts", get(list_hosts))
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
.route(
"/api/w/{workspace_id}/working-directories",
get(scoped_list_working_directories).post(scoped_create_working_directory),
)
.route(
"/api/w/{workspace_id}/working-directories/{allocation_id}",
get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory),
)
.route("/api/runtimes", get(list_runtimes))
.route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes))
.route(
@ -530,6 +547,8 @@ pub struct WorkerLaunchOptionsResponse {
pub workspace_id: String,
pub runtimes: Vec<WorkerLaunchRuntimeOption>,
pub profiles: Vec<WorkerLaunchProfileCandidate>,
pub repositories: Vec<WorkingDirectoryRepositoryOption>,
pub working_directories: Vec<WorkingDirectorySummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@ -550,6 +569,68 @@ pub struct WorkerLaunchProfileCandidate {
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingDirectoryRepositoryOption {
pub id: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BrowserWorkingDirectoryCreatePolicy {
#[serde(default)]
pub dirty_state: BrowserWorkingDirectoryDirtyStatePolicy,
#[serde(default)]
pub cleanup: BrowserWorkingDirectoryCleanupPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BrowserWorkingDirectoryDirtyStatePolicy {
#[default]
CleanPointOnly,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BrowserWorkingDirectoryCleanupPolicy {
#[default]
ManualOrWorkerStop,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkingDirectoryCreateRequest {
pub repository_id: String,
#[serde(default)]
pub selector: Option<String>,
#[serde(default)]
pub policy: BrowserWorkingDirectoryCreatePolicy,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BrowserWorkingDirectoryListResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BrowserWorkingDirectoryDetailResponse {
pub workspace_id: String,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkerWorkingDirectorySelection {
pub allocation_id: String,
#[serde(default)]
pub relative_cwd: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserCreateWorkerRequest {
@ -558,7 +639,7 @@ pub struct BrowserCreateWorkerRequest {
pub profile: String,
pub initial_text: String,
#[serde(default)]
pub repository_id: Option<String>,
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -660,6 +741,12 @@ struct ScopedRuntimePath {
runtime_id: String,
}
#[derive(Debug, Deserialize)]
struct ScopedWorkingDirectoryPath {
workspace_id: String,
allocation_id: String,
}
#[derive(Debug, Deserialize)]
struct ScopedConfigBundlePath {
workspace_id: String,
@ -797,6 +884,87 @@ async fn scoped_get_worker_launch_options(
get_worker_launch_options(State(api)).await
}
async fn scoped_list_working_directories(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let items = working_directory_summaries(&api)?;
Ok(Json(BrowserWorkingDirectoryListResponse {
workspace_id: api.config.workspace_id.clone(),
items,
diagnostics: Vec::new(),
}))
}
async fn scoped_create_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let workspace_request = working_directory_request_for_browser(&api, request)?;
let binding = api
.working_directory_materializer
.preallocate(&workspace_request)
.map_err(|diagnostic| {
ApiError::from(Error::RuntimeOperationFailed {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
code: diagnostic.code,
message: diagnostic.message,
})
})?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item: binding.status().summary,
diagnostics: Vec::new(),
}))
}
async fn scoped_working_directory_detail(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let status = api
.working_directory_materializer
.allocation_status(&path.allocation_id)
.map_err(|diagnostic| {
ApiError::from(Error::RuntimeOperationFailed {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
code: diagnostic.code,
message: diagnostic.message,
})
})?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item: status.summary,
diagnostics: Vec::new(),
}))
}
async fn scoped_cleanup_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkingDirectoryPath>,
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let status = api
.working_directory_materializer
.cleanup_allocation(&path.allocation_id)
.map_err(|diagnostic| {
ApiError::from(Error::RuntimeOperationFailed {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
code: diagnostic.code,
message: diagnostic.message,
})
})?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item: status.summary,
diagnostics: Vec::new(),
}))
}
async fn scoped_get_runtime_connection_settings(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -1383,12 +1551,12 @@ async fn get_worker_launch_options(
Ok(Json(worker_launch_options_response(&api)))
}
fn execution_workspace_request_from_repository(
fn working_directory_request_from_repository(
repository: &ConfiguredRepository,
selector: Option<&str>,
) -> ExecutionWorkspaceRequest {
ExecutionWorkspaceRequest {
repository: ExecutionWorkspaceRepository {
) -> WorkingDirectoryRequest {
WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: repository.id.clone(),
provider: repository.provider.clone(),
uri: repository.uri.clone(),
@ -1408,55 +1576,26 @@ fn execution_workspace_request_from_repository(
}
}
fn configured_execution_workspace_request(
fn configured_working_directory_request(
config: &ServerConfig,
request: &WorkerSpawnExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceRequest> {
request: &WorkerSpawnWorkingDirectoryRequest,
) -> Result<WorkingDirectoryRequest> {
let repository = config
.repositories
.iter()
.find(|repository| repository.id == request.repository_id)
.ok_or_else(|| {
Error::Config(format!(
"unknown repository id `{}` for Worker execution workspace",
"unknown repository id `{}` for Worker working directory",
request.repository_id
))
})?;
Ok(execution_workspace_request_from_repository(
Ok(working_directory_request_from_repository(
repository,
request.selector.as_deref(),
))
}
fn default_execution_workspace_request(
config: &ServerConfig,
repository_id: Option<&str>,
) -> Result<Option<ExecutionWorkspaceRequest>> {
let repository = match repository_id {
Some(id) => config
.repositories
.iter()
.find(|repository| repository.id == id)
.ok_or_else(|| {
Error::Config(format!(
"unknown repository id `{id}` for Worker execution workspace"
))
})?,
None => match config.repositories.as_slice() {
[] => return Ok(None),
[repository] => repository,
repositories => repositories
.iter()
.find(|repository| repository.id == "main")
.unwrap_or(&repositories[0]),
},
};
Ok(Some(execution_workspace_request_from_repository(
repository, None,
)))
}
async fn create_workspace_worker(
State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>,
@ -1482,8 +1621,33 @@ async fn create_workspace_worker(
content: initial_text,
})
};
let execution_workspace =
default_execution_workspace_request(&api.config, request.repository_id.as_deref())?;
let resolved_working_directory_allocation =
request
.working_directory
.map(|selection| WorkingDirectoryAllocationClaim {
allocation_id: selection.allocation_id,
relative_cwd: selection.relative_cwd,
});
if resolved_working_directory_allocation.is_some()
&& request.runtime_id != EMBEDDED_WORKER_RUNTIME_ID
{
return Err(Error::RuntimeOperationFailed {
runtime_id: request.runtime_id.clone(),
code: "working_directory_runtime_unsupported".to_string(),
message: "preallocated working directories are only supported by the embedded local runtime in v0".to_string(),
}
.into());
}
if let Some(allocation) = resolved_working_directory_allocation.as_ref() {
api.working_directory_materializer
.bind_allocation(
&allocation.allocation_id,
allocation.relative_cwd.as_deref(),
)
.map_err(|diagnostic| {
working_directory_api_error(request.runtime_id.clone(), diagnostic)
})?;
}
let result = api
.runtime
.spawn_worker(
@ -1496,8 +1660,9 @@ async fn create_workspace_worker(
},
profile: Some(profile_selector),
initial_input,
execution_workspace: None,
resolved_execution_workspace: execution_workspace,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation,
},
)
.map_err(|err| err.into_error())?;
@ -1586,11 +1751,11 @@ async fn create_runtime_worker(
AxumPath(runtime_id): AxumPath<String>,
Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> {
request.resolved_execution_workspace = request
.execution_workspace
request.resolved_working_directory = request
.working_directory
.as_ref()
.map(|execution_workspace| {
configured_execution_workspace_request(&api.config, execution_workspace)
.map(|working_directory| {
configured_working_directory_request(&api.config, working_directory)
})
.transpose()?;
let result = api
@ -2523,10 +2688,75 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
workspace_id: api.config.workspace_id.clone(),
runtimes,
profiles: worker_profile_candidates(),
repositories: working_directory_repository_options(api),
working_directories: working_directory_summaries(api).unwrap_or_default(),
diagnostics: Vec::new(),
}
}
fn working_directory_repository_options(
api: &WorkspaceApi,
) -> Vec<WorkingDirectoryRepositoryOption> {
api.config
.repositories
.iter()
.map(|repository| WorkingDirectoryRepositoryOption {
id: repository.id.clone(),
display_name: repository
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone()),
default_selector: repository.default_selector.clone(),
})
.collect()
}
fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirectorySummary>> {
api.working_directory_materializer
.list_allocations()
.map(|items| items.into_iter().map(|status| status.summary).collect())
.map_err(|diagnostic| {
ApiError::from(Error::RuntimeOperationFailed {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
code: diagnostic.code,
message: diagnostic.message,
})
})
}
fn working_directory_request_for_browser(
api: &WorkspaceApi,
request: BrowserWorkingDirectoryCreateRequest,
) -> ApiResult<WorkingDirectoryRequest> {
let repository = api
.config
.repositories
.iter()
.find(|repository| repository.id == request.repository_id)
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
let selector = request
.selector
.or_else(|| repository.default_selector.clone())
.filter(|selector| !selector.trim().is_empty());
match request.policy.dirty_state {
BrowserWorkingDirectoryDirtyStatePolicy::CleanPointOnly => {}
}
match request.policy.cleanup {
BrowserWorkingDirectoryCleanupPolicy::ManualOrWorkerStop => {}
}
Ok(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: repository.id.clone(),
provider: "git".to_string(),
uri: repository.path.to_string_lossy().to_string(),
local_path: Some(repository.path.clone()),
selector: selector.map(RuntimeRepositorySelector),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
})
}
fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
vec![
WorkerLaunchProfileCandidate {
@ -2592,6 +2822,24 @@ fn worker_create_not_accepted_error(
)
}
fn working_directory_api_error(
runtime_id: String,
diagnostic: WorkingDirectoryDiagnostic,
) -> ApiError {
ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id,
code: diagnostic.code.clone(),
message: diagnostic.message.clone(),
},
vec![RuntimeDiagnostic {
code: diagnostic.code,
severity: DiagnosticSeverity::Error,
message: diagnostic.message,
}],
)
}
fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError {
Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
@ -2920,6 +3168,7 @@ impl IntoResponse for ApiError {
if code.starts_with("workspace_settings_")
|| code.starts_with("invalid_")
|| code.starts_with("unsupported_worker_profile")
|| code.starts_with("working_directory_")
|| code.ends_with("_already_exists")
|| code.ends_with("_not_config_managed")
|| code.ends_with("_unsupported") =>
@ -3035,8 +3284,8 @@ mod tests {
self.backend_id(),
),
run_state: worker_runtime::execution::WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
@ -3092,6 +3341,40 @@ mod tests {
config
}
fn init_clean_git_workspace(path: &std::path::Path) {
for args in [
vec!["init"],
vec!["config", "user.email", "test@example.invalid"],
vec!["config", "user.name", "Yoi Test"],
] {
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success());
}
std::fs::write(path.join("README.md"), "clean\n").unwrap();
std::fs::write(
path.join(".gitignore"),
".yoi/\n.test-embedded-runtime-store/\n",
)
.unwrap();
for args in [
vec!["add", "README.md", ".gitignore"],
vec!["commit", "-m", "init"],
] {
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success());
}
}
async fn test_app(workspace_root: impl Into<PathBuf>) -> Router {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let api = WorkspaceApi::new_with_execution_backend(
@ -3135,7 +3418,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
working_directory: None,
working_directory_allocation: None,
}
}
@ -3150,6 +3434,98 @@ mod tests {
(runtime, worker.worker_ref)
}
#[tokio::test]
async fn browser_working_directory_preallocate_list_detail_and_cleanup_are_path_safe() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let app = test_app(dir.path()).await;
let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
let created = post_json(
app.clone(),
&workspace_path,
serde_json::json!({
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD",
"policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" }
}),
)
.await;
let allocation_id = created["item"]["allocation_id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(created["item"]["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(created["item"]["requested_selector"], "HEAD");
assert_eq!(created["item"]["status"], "active");
let projected = serde_json::to_string(&created).unwrap();
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
let list = get_json(app.clone(), &workspace_path).await;
assert_eq!(list["items"].as_array().unwrap().len(), 1);
assert_eq!(list["items"][0]["allocation_id"], allocation_id);
let detail_path = format!("{workspace_path}/{allocation_id}");
let detail = get_json(app.clone(), &detail_path).await;
assert_eq!(detail["item"]["allocation_id"], allocation_id);
let removed = request_json(app, "DELETE", &detail_path, None, StatusCode::OK).await;
assert_eq!(removed["item"]["status"], "removed");
}
#[tokio::test]
async fn scoped_worker_create_invalid_relative_cwd_returns_typed_diagnostic() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let app = test_app(dir.path()).await;
let working_directories_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories");
let created = post_json(
app.clone(),
&working_directories_path,
serde_json::json!({
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD",
"policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" }
}),
)
.await;
let allocation_id = created["item"]["allocation_id"].as_str().unwrap();
let response = request_json(
app,
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
Some(serde_json::json!({
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID,
"display_name": "Coding Worker",
"profile": "builtin:coder",
"initial_text": "",
"working_directory": {
"allocation_id": allocation_id,
"relative_cwd": "../escape"
}
})),
StatusCode::BAD_REQUEST,
)
.await;
assert!(
response["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "working_directory_relative_cwd_invalid"),
"expected typed relative_cwd diagnostic, got {response}"
);
assert!(
response["message"]
.as_str()
.unwrap_or_default()
.contains("working_directory_relative_cwd_invalid")
);
let projected = serde_json::to_string(&response).unwrap();
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
}
#[tokio::test]
async fn runtime_connection_settings_add_delete_apply_live_registry() {
let dir = tempfile::tempdir().unwrap();
@ -3464,7 +3840,7 @@ mod tests {
}
#[tokio::test]
async fn runtime_worker_spawn_rejects_raw_execution_workspace_fields() {
async fn runtime_worker_spawn_rejects_raw_working_directory_fields() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let response = request_json(
@ -3481,7 +3857,7 @@ mod tests {
"kind": "run_accepted",
"expected_segments": 0
},
"execution_workspace": {
"working_directory": {
"repository_id": TEST_REPOSITORY_ID,
"local_path": dir.path().display().to_string()
}
@ -3494,7 +3870,7 @@ mod tests {
.as_str()
.unwrap_or_default()
.contains("unknown field"),
"raw execution workspace field should be rejected: {response}"
"raw working directory field should be rejected: {response}"
);
}
@ -3516,7 +3892,7 @@ mod tests {
"kind": "run_accepted",
"expected_segments": 0
},
"execution_workspace": {
"working_directory": {
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD"
}
@ -4003,8 +4379,9 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
working_directory: None,
resolved_working_directory: None,
resolved_working_directory_allocation: None,
},
)
.expect("spawn worker");

View File

@ -1411,3 +1411,38 @@
background: rgba(255, 255, 255, 0.04);
padding: 0.75rem;
}
@layer components {
.worker-submit-error {
display: grid;
gap: var(--space-2);
}
.worker-submit-error p {
margin: 0;
}
.worker-error-diagnostics {
display: grid;
gap: var(--space-1);
margin: 0;
padding-left: var(--space-4);
}
.worker-error-diagnostics li {
color: var(--text-muted);
}
.worker-error-diagnostics li.error {
color: var(--danger);
}
.worker-error-diagnostics strong {
color: inherit;
font-weight: 750;
}
.worker-error-diagnostics span {
display: block;
}
}

View File

@ -4,6 +4,8 @@
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
@ -16,6 +18,17 @@
workspaceId: string;
};
type DisplayError = {
message: string;
diagnostics: Diagnostic[];
};
type ErrorPayload = {
error?: { message?: string; code?: string } | string;
message?: string;
diagnostics?: Diagnostic[];
};
let { currentPath = '/', workspaceId }: Props = $props();
function workerApiPath(path: string): string {
@ -30,11 +43,16 @@
let optionsError = $state<string | null>(null);
let showNewWorker = $state(false);
let submitting = $state(false);
let submitError = $state<string | null>(null);
let submitError = $state<DisplayError | null>(null);
let displayName = $state('Coding Worker');
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
let workingDirectoryAllocationId = $state('');
let workingDirectoryRepositoryId = $state('');
let workingDirectorySelector = $state('HEAD');
let relativeCwd = $state('');
let creatingWorkingDirectory = $state(false);
$effect(() => {
if (!workspaceId) {
@ -96,10 +114,18 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_allocation_id: workingDirectoryAllocationId,
working_directory_repository_id: workingDirectoryRepositoryId,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
});
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
workingDirectoryAllocationId = form.working_directory_allocation_id;
workingDirectoryRepositoryId = form.working_directory_repository_id;
workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
@ -108,9 +134,43 @@
}
}
async function createWorkingDirectory() {
if (!workingDirectoryRepositoryId) {
submitError = { message: 'select a repository before creating a working directory', diagnostics: [] };
return;
}
creatingWorkingDirectory = true;
submitError = null;
try {
const response = await fetch(workerApiPath('/working-directories'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
}),
});
if (!response.ok) {
submitError = await responseDisplayError(response, 'working directory create failed');
return;
}
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
const items = options?.working_directories ?? [];
options = options
? { ...options, working_directories: [...items.filter((item) => item.allocation_id !== payload.item.allocation_id), payload.item] }
: options;
workingDirectoryAllocationId = payload.item.allocation_id;
} catch (err) {
submitError = exceptionDisplayError(err, 'working directory create failed');
} finally {
creatingWorkingDirectory = false;
}
}
async function createWorker() {
if (!workspaceId) {
submitError = 'workspace id is unavailable';
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return;
}
@ -125,35 +185,51 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_allocation_id: workingDirectoryAllocationId,
working_directory_repository_id: workingDirectoryRepositoryId,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
})),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'worker create failed'));
submitError = await responseDisplayError(response, 'worker create failed');
return;
}
const payload = (await response.json()) as BrowserCreateWorkerResponse;
await loadWorkers();
window.location.href = payload.console_href;
} catch (err) {
submitError = err instanceof Error ? err.message : 'worker create failed';
submitError = exceptionDisplayError(err, 'worker create failed');
} finally {
submitting = false;
}
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
try {
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
const payload = (await response.json()) as ErrorPayload;
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
if (typeof payload.error === 'object' && payload.error?.message) {
return `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`;
return {
message: `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`,
diagnostics,
};
}
if (payload.message) {
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
return `${code}: ${payload.message}`;
return { message: `${code}: ${payload.message}`, diagnostics };
}
} catch {
// fall through
}
return `${fallback} (${response.status})`;
return { message: `${fallback} (${response.status})`, diagnostics: [] };
}
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
return {
message: err instanceof Error ? err.message : fallback,
diagnostics: [],
};
}
</script>
@ -200,6 +276,43 @@
{/if}
</select>
</label>
<fieldset class="worker-working-directory">
<legend>Working directory</legend>
<label>
<span>Allocation</span>
<select bind:value={workingDirectoryAllocationId}>
<option value="">No allocation selected</option>
{#each options?.working_directories ?? [] as directory}
<option value={directory.allocation_id} disabled={directory.status !== 'active'}>
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'} · {directory.resolved_commit.slice(0, 12)} · {directory.status}
</option>
{/each}
</select>
</label>
<label>
<span>Repository for new allocation</span>
<select bind:value={workingDirectoryRepositoryId}>
{#if options?.repositories.length}
{#each options.repositories as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
{:else}
<option value="" disabled>No configured repositories</option>
{/if}
</select>
</label>
<label>
<span>Selector</span>
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
</label>
<button type="button" disabled={creatingWorkingDirectory || !workingDirectoryRepositoryId} onclick={() => void createWorkingDirectory()}>
{creatingWorkingDirectory ? 'Allocating…' : 'Create working directory'}
</button>
<label>
<span>Relative cwd</span>
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside allocation" />
</label>
</fieldset>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
@ -208,9 +321,21 @@
<p class="section-state error">{optionsError}</p>
{/if}
{#if submitError}
<p class="section-state error">{submitError}</p>
<div class="section-state error worker-submit-error">
<p>{submitError.message}</p>
{#if submitError.diagnostics.length > 0}
<ul class="worker-error-diagnostics">
{#each submitError.diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
<button type="submit" disabled={submitting || !runtimeId || !profile || !workingDirectoryAllocationId}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
@ -234,6 +359,7 @@
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
</span>
</a>
</li>

View File

@ -1,8 +1,8 @@
import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment
} from '$lib/generated/protocol';
Segment as PodProtocolSegment,
} from "$lib/generated/protocol";
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
@ -88,10 +88,11 @@ export type Worker = {
last_seen_at?: string | null;
implementation: { kind: string; display_hint: string };
capabilities: WorkerCapabilities;
working_directory?: WorkingDirectorySummary | null;
diagnostics: Diagnostic[];
};
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
@ -108,10 +109,61 @@ export type WorkerLaunchProfileCandidate = {
description: string;
};
export type WorkingDirectoryRepositoryOption = {
id: string;
display_name: string;
default_selector?: string | null;
};
export type WorkingDirectorySummary = {
allocation_id: string;
repository_id: string;
requested_selector?: string | null;
materializer_kind: string;
dirty_state_policy: string;
resolved_commit: string;
resolved_tree?: string | null;
status: string;
cleanup_policy: string;
cleanup_target: {
kind: string;
allocation_id: string;
repository_id: string;
};
};
export type BrowserWorkingDirectoryCreateResponse = {
workspace_id: string;
item: WorkingDirectorySummary;
diagnostics: Diagnostic[];
};
export type BrowserWorkingDirectoryListResponse = {
workspace_id: string;
items: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
export type BrowserWorkerWorkingDirectorySelection = {
allocation_id: string;
relative_cwd?: string | null;
};
export type BrowserWorkingDirectoryCreateRequest = {
repository_id: string;
selector?: string | null;
policy?: {
dirty_state?: "clean_point_only";
cleanup?: "manual_or_worker_stop";
};
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
repositories: WorkingDirectoryRepositoryOption[];
working_directories: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
@ -135,7 +187,7 @@ export type WorkerInputResult = {
export type WorkerTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
role: "user" | "assistant" | "system" | string;
content: string;
event_id: number;
};
@ -166,8 +218,8 @@ export type ClientWorkerEventWsDiagnostic = {
};
export type ClientWorkerEventWsFrame =
| { kind: 'event'; envelope: ClientWorkerEventWsEnvelope }
| { kind: 'diagnostic'; diagnostic: ClientWorkerEventWsDiagnostic };
| { kind: "event"; envelope: ClientWorkerEventWsEnvelope }
| { kind: "diagnostic"; diagnostic: ClientWorkerEventWsDiagnostic };
export type ListResponse<T> = {
workspace_id: string;
@ -297,13 +349,13 @@ export type ObjectiveListResponse = {
};
export type CompanionState =
| 'ready'
| 'busy'
| 'error'
| 'timeout'
| 'cancelled'
| 'accepted'
| 'rejected';
| "ready"
| "busy"
| "error"
| "timeout"
| "cancelled"
| "accepted"
| "rejected";
export type CompanionTransportSummary = {
kind: string;
@ -320,7 +372,7 @@ export type CompanionStatusResponse = {
export type CompanionTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
role: "user" | "assistant" | "system" | string;
content: string;
created_at: string;
source: string;

View File

@ -1,83 +1,109 @@
import {
buildBrowserCreateWorkerRequest,
defaultWorkerLaunchForm,
type WorkerLaunchFormState,
} from './worker-launch.ts';
import type { WorkerLaunchOptionsResponse } from './types.ts';
} from "./worker-launch.ts";
import type { WorkerLaunchOptionsResponse } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
test(name: string, fn: () => Promise<void> | void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
const options: WorkerLaunchOptionsResponse = {
workspace_id: 'workspace',
workspace_id: "workspace",
runtimes: [
{
runtime_id: 'remote-runtime',
display_name: 'Remote Runtime',
runtime_id: "remote",
display_name: "Remote",
status: "active",
can_spawn_worker: true,
built_in: false,
can_spawn_worker: false,
status: 'active',
diagnostics: [],
},
{
runtime_id: 'embedded-worker-runtime',
display_name: 'Embedded Runtime',
built_in: true,
runtime_id: "embedded",
display_name: "Embedded",
status: "active",
can_spawn_worker: true,
status: 'active',
built_in: false,
diagnostics: [],
},
],
profiles: [
{ id: "builtin:companion", label: "Companion", description: "chat" },
{ id: "builtin:coder", label: "Coder", description: "code" },
],
repositories: [
{ id: "repo", display_name: "Repo", default_selector: "HEAD" },
],
working_directories: [
{
id: 'runtime_default',
label: 'Runtime default',
description: 'Runtime default profile.',
},
{
id: 'builtin:coder',
label: 'Coding Worker',
description: 'Coding role.',
allocation_id: "alloc-1-repo",
repository_id: "repo",
requested_selector: "HEAD",
materializer_kind: "local_git_worktree",
dirty_state_policy: "clean_point_only",
resolved_commit: "0123456789abcdef",
status: "active",
cleanup_policy: "manual_or_worker_stop",
cleanup_target: {
kind: "git_worktree",
allocation_id: "alloc-1-repo",
repository_id: "repo",
},
},
],
diagnostics: [],
};
Deno.test('new worker form defaults to backend-published runtime and profile candidates', () => {
const current: WorkerLaunchFormState = {
runtime_id: '',
display_name: '',
profile: 'free-text-profile',
initial_text: 'start here',
};
const form = defaultWorkerLaunchForm(options, current);
assert(form.runtime_id === 'embedded-worker-runtime', 'should choose spawn-capable runtime');
assert(form.profile === 'builtin:coder', 'should choose backend-published coder profile');
assert(form.display_name === 'Coding Worker', 'should derive default display name');
assert(form.initial_text === 'start here', 'should preserve initial text');
});
Deno.test('new worker submit payload exposes only browser contract fields', () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: 'embedded-worker-runtime',
display_name: 'Coding Worker',
profile: 'builtin:coder',
initial_text: 'implement ticket',
Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, repository, and working directory", () => {
const form = defaultWorkerLaunchForm(options, {
runtime_id: "",
display_name: "",
profile: "",
initial_text: "hello",
working_directory_allocation_id: "",
working_directory_repository_id: "",
working_directory_selector: "",
relative_cwd: "",
});
assert(
JSON.stringify(Object.keys(request).sort()) ===
JSON.stringify(['display_name', 'initial_text', 'profile', 'runtime_id'].sort()),
'submit payload should contain only Browser-facing worker create fields',
);
assert(!('kind' in request), 'kind must not be exposed as a Browser request field');
assertEquals(form.runtime_id, "remote");
assertEquals(form.display_name, "Coding Worker");
assertEquals(form.profile, "builtin:coder");
assertEquals(form.initial_text, "hello");
assertEquals(form.working_directory_allocation_id, "alloc-1-repo");
assertEquals(form.working_directory_repository_id, "repo");
assertEquals(form.working_directory_selector, "HEAD");
});
Deno.test("buildBrowserCreateWorkerRequest sends allocation id and relative cwd only", () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:coder",
initial_text: "go",
working_directory_allocation_id: "alloc-1-repo",
working_directory_repository_id: "repo",
working_directory_selector: "main",
relative_cwd: "crates/yoi",
});
assertEquals(request, {
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:coder",
initial_text: "go",
working_directory: {
allocation_id: "alloc-1-repo",
relative_cwd: "crates/yoi",
},
});
});

View File

@ -1,41 +1,90 @@
import type { WorkerLaunchOptionsResponse } from './types';
import type {
BrowserWorkerWorkingDirectorySelection,
WorkerLaunchOptionsResponse,
} from "./types";
export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
working_directory_allocation_id: string;
working_directory_repository_id: string;
working_directory_selector: string;
relative_cwd: string;
};
export type BrowserCreateWorkerRequest = WorkerLaunchFormState;
export type BrowserCreateWorkerRequest = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
working_directory?: BrowserWorkerWorkingDirectorySelection;
};
export function defaultWorkerLaunchForm(
options: WorkerLaunchOptionsResponse | null,
current: WorkerLaunchFormState,
): WorkerLaunchFormState {
const preferredRuntime = options?.runtimes.find((runtime) => runtime.can_spawn_worker && runtime.status === 'active')
?? options?.runtimes.find((runtime) => runtime.can_spawn_worker)
?? options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) => candidate.id === 'builtin:coder')
?? options?.profiles[0];
const preferredRuntime =
options?.runtimes.find((runtime) =>
runtime.can_spawn_worker && runtime.status === "active"
) ??
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
options?.runtimes[0];
const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0];
const preferredWorkingDirectory =
options?.working_directories.find((directory) =>
directory.status === "active"
) ??
options?.working_directories[0];
const preferredRepository =
options?.repositories.find((repository) =>
repository.id === current.working_directory_repository_id
) ??
options?.repositories[0];
return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || '',
display_name: current.display_name || 'Coding Worker',
profile: options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || '',
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
display_name: current.display_name || "Coding Worker",
profile:
options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || "",
initial_text: current.initial_text,
working_directory_allocation_id: options?.working_directories.some(
(directory) =>
directory.allocation_id === current.working_directory_allocation_id,
)
? current.working_directory_allocation_id
: preferredWorkingDirectory?.allocation_id || "",
working_directory_repository_id: current.working_directory_repository_id ||
preferredRepository?.id || "",
working_directory_selector: current.working_directory_selector ||
preferredRepository?.default_selector || "HEAD",
relative_cwd: current.relative_cwd,
};
}
export function buildBrowserCreateWorkerRequest(
form: WorkerLaunchFormState,
): BrowserCreateWorkerRequest {
return {
const request: BrowserCreateWorkerRequest = {
runtime_id: form.runtime_id,
display_name: form.display_name,
profile: form.profile,
initial_text: form.initial_text,
};
if (form.working_directory_allocation_id) {
request.working_directory = {
allocation_id: form.working_directory_allocation_id,
};
const relativeCwd = form.relative_cwd.trim();
if (relativeCwd) {
request.working_directory.relative_cwd = relativeCwd;
}
}
return request;
}