merge: sync orchestration before queue 00001KWWE8E04

This commit is contained in:
Keisuke Hirata 2026-07-07 04:53:14 +09:00
commit 76153b64c7
No known key found for this signature in database
15 changed files with 1564 additions and 55 deletions

View File

@ -0,0 +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"}

View File

@ -1,8 +1,8 @@
---
title: 'Add local Git worktree Execution Workspace materializer'
state: 'queued'
state: 'closed'
created_at: '2026-07-06T18:00:46Z'
updated_at: '2026-07-06T18:28:26Z'
updated_at: '2026-07-06T19:24:25Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-06T18:28:26Z'

View File

@ -0,0 +1,32 @@
完了。
実装内容:
- 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 を作成するようにした。
- 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 を構築するようにした。
- 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`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。

View File

@ -30,4 +30,335 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-06T18:29:42Z -->
## Decision
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と整合している。
- `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
- `TicketRelationQuery(00001KWW9DYH4)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWW9DYH4)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: prior repository-registry child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/worker-runtime/src/{runtime.rs,worker_backend.rs,execution.rs,fs_store.rs,http_server.rs,management.rs,config_bundle.rs}`, `crates/workspace-server/src/{repositories.rs,server.rs,config.rs}`, `crates/protocol/src/lib.rs`
IntentPacket:
Intent:
- Runtime Worker spawn の直前に Execution Workspace 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 配下に作る。
- 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 で明示する。
- created allocation には repository id、resolved commit/tree など evidence、materializer kind、cleanup target、status/cleanup policy に相当する record を持たせる。
- Worker stop / cleanup 時に worktree cleanup できる方針または最低限の cleanup record を持つ。
- Browser/API-facing diagnostics は raw host path を不必要に漏らさない。
- `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` できる。
- 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。
- dirty local workspace を暗黙に含めず clean-point-only policy であることが tests で分かる。
Implementation latitude:
- Runtime-side 型名、module 分割、allocation record の永続化形式、cleanup status 名は既存 `worker-runtime` style に合わせてよい。
- v0 で Runtime repository cache/bare mirror を作らず、configured local repository 自体を source cache 相当として扱ってよい。
- cleanup は full lifecycle 完成ではなく、最低限の record と stop/cleanup hook を優先してよい。ただし orphan worktree cleanup の方向が明示されていること。
- Browser/API response は detailed host paths を避け、debug/internal logs/tests では必要に応じて raw path を扱ってよい。
Escalate if:
- remote clone/fetch/cache, credentials, dirty snapshot/apply, synthetic branch/export policy, strong sandbox/container/network policy, multi-repository mount, or Backend store migration が必要になる場合。
- Runtime / Worker launch protocol の public boundary を広げ、raw source path/secret/internal runtime path を Browser-facing API に出す必要がある場合。
- RepositoryPoint / RepositorySelector の semantic boundary を変更する必要がある場合。
Validation:
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。
Current code map:
- Runtime worker spawn/control: `crates/worker-runtime/src/runtime.rs`, `worker_backend.rs`, `execution.rs`, `http_server.rs`, `management.rs`
- Runtime persistence/root: `crates/worker-runtime/src/fs_store.rs`, `catalog.rs`, `config_bundle.rs`
- Repository registry / point context: `crates/workspace-server/src/repositories.rs`, `config.rs`, `server.rs`
- Protocol/request shapes: `crates/protocol/src/lib.rs` and worker-runtime request/response structs。
Critical risks / reviewer focus:
- No source repository root scope/cwd is passed to Worker as the normal path after materialization。
- detached worktree is created at resolved commit, not branch checkout。
- dirty source repository state is rejected or clearly excluded by clean_point_only tests。
- allocation record/evidence/cleanup target is present and not only an ephemeral path local variable。
- unsupported providers/remotes fail closed with typed sanitized diagnostics。
- materializer boundary remains strategy-extensible and does not hard-code Git worktree as the only future model。
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T18:30:00Z 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。
- Objective `00001KWW44EXK` の Execution Workspace 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`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は local Git detached worktree materializer v0 に閉じており、remote clone/fetch/cache, credentials, dirty snapshot/apply, strong sandbox 等は非目標として明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T18:31:11Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWW9DYH4-execution-workspace-materializer`
- Branch: `work/00001KWW9DYH4-execution-workspace-materializer`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWW9DYH4-exec-workspace` を起動した。
- 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-06T18:57:42Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `8b7a5da0 feat: materialize execution workspaces` 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/{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>`
- 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。
- 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
- `cargo test -p yoi-workspace-server`: pass63 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
---
<!-- event: review author: reviewer at: 2026-07-06T19:06:24Z status: request_changes -->
## Review: request changes
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>` を含む。
- `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 内部境界に閉じる必要がある。
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 はしない。
- 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 更新を行う。
- tests は detached materialization、distinct paths、dirty rejection、unsupported diagnostics、cleanup、spawn cwd substitution を cover している。
Validation reviewed:
- Ticket / Objective と implementation diff/files を implementation worktree 内で read-only review。
- Orchestrator reported validation pass`git diff --check`, worker-runtime/workspace-server tests, `cargo check -p yoi`, web checks/tests, `yoi ticket doctor`)を確認。
Non-blocking follow-ups:
- `Runtime::stop_runtime` は currently workers を stopped に mark するだけなので、per-worker stop 以外の runtime-stop/orphan-sweeper cleanup を後続で検討するとよい。
- external HTTP/API error body で materializer diagnostic code を message string だけでなく structured に保つ余地がある。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T19:17:17Z -->
## Implementation report
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`.
- 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。
- 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。
- 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
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- Requesting follow-up external review against `8b7a5da0..c90ebf24` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-06T19:23:01Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed:
- Ticket / Objective context, combined diff, fix diff, and relevant runtime/workspace-server files in the implementation worktree。
- 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`
- `/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。
- 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>`
- 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。
- tests cover detached worktree, distinct paths, dirty rejection, unsupported diagnostics, cleanup, and materialized cwd。
Validation reviewed:
- Reviewer inspected requested fix diff and combined implementation diff。
- Reviewer ran read-only `git diff --check 50c80e77..HEAD` in the implementation worktree; no whitespace errors。
- Reviewer accepted Orchestrator-reported full validation suite as passing。
Non-blocking note:
- Runtime HTTP `/v1/workers` remains an internal/trusted backend-to-runtime API carrying `CreateWorkerRequest`; this matches the current trusted-runtime boundary and is not browser-facing。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T19:24:12Z 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/00001KWW9DYH4-execution-workspace-materializer` was merged into the orchestration branch with merge commit `c4cdf1c3 merge: execution workspace 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
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `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`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-06T19:24:25Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-06T19:24:25Z status: 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 を作成するようにした。
- 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 を構築するようにした。
- 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`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
---

View File

@ -2,6 +2,7 @@ use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::interaction::WorkerInput;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Profile selector boundary. This is a selector, not a resolved config bundle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -25,21 +26,124 @@ pub struct ConfigBundleRef {
pub digest: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySelector(pub String);
impl From<&str> for RepositorySelector {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl From<String> for RepositorySelector {
fn from(value: String) -> Self {
Self(value)
}
}
impl AsRef<str> for RepositorySelector {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for RepositorySelector {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceRepository {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
LocalGitWorktree,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DirtyStatePolicy {
#[default]
CleanPointOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceRequest {
pub repository: ExecutionWorkspaceRepository,
#[serde(default)]
pub materializer: MaterializerKind,
#[serde(default)]
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionWorkspaceStatusKind {
Active,
Removed,
CleanupPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceCleanupTarget {
pub kind: String,
pub allocation_id: String,
pub repository_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceSummary {
pub allocation_id: String,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<ExecutionWorkspaceCleanupTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_policy: Option<String>,
pub status: ExecutionWorkspaceStatusKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionWorkspaceStatus {
pub summary: ExecutionWorkspaceSummary,
}
/// Canonical Runtime Worker creation request.
///
/// Browser/product launch semantics are resolved by a backend before this
/// request is built. The request contains only durable Runtime identity inputs:
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
/// and optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence. It carries no cwd/workspace path, tool
/// scope, credential, socket/session path, raw config body, or execution binding
/// internals.
/// optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence, and an optional execution workspace
/// request that preserves RepositoryPoint-style semantics for runtime-side
/// materialization. Browser-facing status for materialized workspaces is
/// summarized without exposing raw host paths.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateWorkerRequest {
pub profile: ProfileSelector,
pub config_bundle: ConfigBundleRef,
#[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>,
}
/// Worker lifecycle status for the in-memory embedded runtime.

View File

@ -1,5 +1,6 @@
use crate::catalog::CreateWorkerRequest;
use crate::catalog::{CreateWorkerRequest, ExecutionWorkspaceStatus};
use crate::error::RuntimeError;
use crate::execution_workspace::ExecutionWorkspaceBinding;
use crate::identity::WorkerRef;
use crate::interaction::WorkerInput;
#[cfg(feature = "ws-server")]
@ -153,6 +154,8 @@ 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
}
@ -166,6 +169,7 @@ impl WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state,
binding: None,
execution_workspace: None,
last_result: None,
}
}
@ -181,6 +185,11 @@ impl WorkerExecutionStatus {
self
}
pub fn with_execution_workspace(mut self, status: ExecutionWorkspaceStatus) -> Self {
self.execution_workspace = Some(status);
self
}
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
self.run_state = result.run_state;
self.last_result = Some(result);
@ -277,6 +286,7 @@ pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext,
pub execution_workspace: Option<ExecutionWorkspaceBinding>,
}
/// Result of backend Worker spawn/initialization.
@ -285,6 +295,7 @@ pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
execution_workspace: Option<ExecutionWorkspaceStatus>,
},
Rejected(WorkerExecutionResult),
Errored(WorkerExecutionResult),

View File

@ -0,0 +1,595 @@
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,6 +863,7 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
}
}
@ -877,6 +878,10 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
.as_ref()
.map(|binding| binding.status()),
}
}
@ -1092,6 +1097,10 @@ mod ws_tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
.as_ref()
.map(|binding| binding.status()),
}
}
@ -1138,6 +1147,7 @@ mod ws_tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
}
}

View File

@ -11,6 +11,7 @@ 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")]

View File

@ -14,6 +14,7 @@ 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,
@ -80,8 +81,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let backend =
Arc::new(WorkerRuntimeExecutionBackend::new(factory).map_err(ProcessError::WorkerAdapter)?);
let execution_workspace_root = execution_workspace_runtime_root(config);
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)?
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
execution_workspace_root,
)),
);
match &config.http.store {
RuntimeHttpStoreSelection::Memory => {
@ -102,6 +109,16 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
}
fn execution_workspace_runtime_root(config: &ProcessConfig) -> PathBuf {
match &config.http.store {
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
_ => config
.worker_runtime_base_dir
.clone()
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime")),
}
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
RuntimeOptions {
runtime_id: config.runtime_id.clone(),

View File

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

View File

@ -18,6 +18,7 @@ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::execution_workspace::{ExecutionWorkspaceBinding, ExecutionWorkspaceMaterializer};
use crate::interaction::{WorkerInput, WorkerInputKind};
use async_trait::async_trait;
use manifest::paths;
@ -160,9 +161,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name(&request);
let profile = self.runtime_profile(&request);
let workspace_root = request
.execution_workspace
.as_ref()
.map(|binding| binding.workspace_root().to_path_buf())
.unwrap_or_else(|| self.workspace_root.clone());
let cwd = request
.execution_workspace
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
profile.as_deref(),
&self.workspace_root,
&workspace_root,
&worker_name,
)?;
manifest.worker.name = worker_name;
@ -183,15 +194,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
})?;
let store = CombinedStore::new(session_store, worker_metadata_store);
let worker = Worker::from_manifest_with_context(
manifest,
store,
loader,
self.workspace_root.clone(),
self.cwd.clone(),
)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let worker =
Worker::from_manifest_with_context(manifest, store, loader, workspace_root, cwd)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
@ -204,12 +210,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
struct RuntimeWorkerExecution {
handle: WorkerHandle,
busy: Arc<AtomicBool>,
execution_workspace: Option<ExecutionWorkspaceBinding>,
}
/// `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>>,
runtime: Mutex<Option<Runtime>>,
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
}
@ -233,6 +241,7 @@ where
Ok(Self {
backend_id: DEFAULT_BACKEND_ID.to_string(),
factory: Arc::new(factory),
execution_workspace_materializer: None,
runtime: Mutex::new(Some(runtime)),
workers: Mutex::new(HashMap::new()),
})
@ -243,6 +252,14 @@ where
self
}
pub fn with_execution_workspace_materializer(
mut self,
materializer: impl ExecutionWorkspaceMaterializer,
) -> Self {
self.execution_workspace_materializer = Some(Arc::new(materializer));
self
}
fn wait_for_runtime_task<T>(receiver: mpsc::Receiver<Result<T, String>>) -> Result<T, String> {
receiver
.recv_timeout(RUNTIME_TASK_TIMEOUT)
@ -356,6 +373,33 @@ 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 {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"execution workspace 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());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
None => None,
};
let factory = self.factory.clone();
let bridge_context = request.context.clone();
let worker_ref = request.worker_ref.clone();
@ -365,6 +409,12 @@ where
let handle = match spawn_result {
Ok(handle) => handle,
Err(message) => {
if let (Some(materializer), Some(binding)) = (
self.execution_workspace_materializer.as_ref(),
execution_workspace.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
message,
@ -405,11 +455,19 @@ where
));
}
};
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
busy,
execution_workspace: execution_workspace.clone(),
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: execution_workspace.map(|binding| binding.status()),
}
}
@ -468,19 +526,44 @@ where
}
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
let (worker, _busy) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Stop;
return result;
if handle.backend_id() != self.backend_id() {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Stop,
format!(
"execution handle belongs to backend {}, not {}",
handle.backend_id(),
self.backend_id()
),
);
}
let execution = match self.workers.lock() {
Ok(mut workers) => workers.remove(handle.worker_ref()),
Err(_) => {
return WorkerExecutionResult::errored(
WorkerExecutionOperation::Stop,
"worker adapter registry lock is poisoned",
);
}
};
self.send_method(
let Some(execution) = execution else {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Stop,
"execution handle does not reference a live Worker",
);
};
let result = self.send_method(
WorkerExecutionOperation::Stop,
worker,
execution.handle,
Method::Shutdown,
WorkerExecutionRunState::Stopped,
)
);
if let (Some(materializer), Some(binding)) = (
self.execution_workspace_materializer.as_ref(),
execution.execution_workspace.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
result
}
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@ -503,11 +586,17 @@ where
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::pin::Pin;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{ConfigBundleRef, CreateWorkerRequest, ProfileSelector};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, ExecutionWorkspaceRepository,
ExecutionWorkspaceRequest, MaterializerKind, ProfileSelector, RepositorySelector,
};
use crate::execution_workspace::LocalGitWorktreeMaterializer;
use crate::identity::RuntimeId;
use crate::management::RuntimeOptions;
use crate::observation::{TranscriptQuery, TranscriptRole};
@ -559,13 +648,14 @@ mod tests {
cwd: PathBuf,
store_dir: PathBuf,
worker_metadata_dir: PathBuf,
observed_cwds: Arc<Mutex<Vec<PathBuf>>>,
}
#[async_trait]
impl RuntimeWorkerFactory for MockFactory {
async fn spawn_controller(
&self,
_request: WorkerExecutionSpawnRequest,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
let manifest = WorkerManifest::from_toml(
r#"
@ -590,12 +680,18 @@ mod tests {
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
);
let scope = Scope::writable(&self.cwd).map_err(|err| err.to_string())?;
let cwd = request
.execution_workspace
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
self.observed_cwds.lock().unwrap().push(cwd.clone());
let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?;
let worker = Worker::new(
manifest,
Engine::new(self.client.clone()),
store,
self.cwd.clone(),
cwd,
scope,
)
.await
@ -650,6 +746,45 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
}
}
fn git(path: &std::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 execution_workspace_request(repo: &std::path::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,
}
}
@ -677,12 +812,14 @@ mod tests {
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory {
client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds,
};
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend(
@ -730,4 +867,47 @@ mod tests {
.any(|event| matches!(event.payload, protocol::Event::TextDone { .. }))
);
}
#[test]
fn worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo() {
let client = MockClient::new(simple_text_events());
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: repo.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: observed_cwds.clone(),
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
runtime_base.path(),
));
let runtime = EmbeddedRuntime::with_execution_backend(
RuntimeOptions {
runtime_id: RuntimeId::new("embedded-materialized"),
..RuntimeOptions::default()
},
Arc::new(backend),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.execution_workspace = Some(execution_workspace_request(repo.path()));
let detail = runtime.create_worker(request).unwrap();
assert!(detail.execution.execution_workspace.is_some());
let cwds = observed_cwds.lock().unwrap();
assert_eq!(cwds.len(), 1);
let cwd = &cwds[0];
assert!(cwd.starts_with(runtime_base.path()));
assert!(!cwd.starts_with(repo.path()));
assert!(cwd.join("README.md").exists());
}
}

View File

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

View File

@ -12,8 +12,8 @@ use std::{
time::Duration,
};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus,
ConfigBundleRef, CreateWorkerRequest, ExecutionWorkspaceRequest, ProfileSelector,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance,
@ -263,13 +263,26 @@ pub struct WorkerLookupResult {
/// Browser-safe worker spawn request shape.
///
/// The request carries Browser-facing launch semantics only: workspace intent,
/// optional display identity, acceptance policy, optional profile selector, and
/// optional initial input. Runtime execution authority is resolved by the host
/// 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
/// 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 {
/// 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.
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnRequest {
pub intent: WorkerSpawnIntent,
#[serde(skip_serializing_if = "Option::is_none")]
@ -279,6 +292,13 @@ 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
/// repositories before calling a host.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_workspace: Option<WorkerSpawnExecutionWorkspaceRequest>,
#[serde(skip, default)]
pub resolved_execution_workspace: Option<ExecutionWorkspaceRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -1321,6 +1341,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
execution_workspace: request.resolved_execution_workspace.clone(),
};
match self.runtime.create_worker(create_request) {
Ok(detail) => {
@ -1993,6 +2014,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
execution_workspace: request.resolved_execution_workspace.clone(),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
Ok(response) => WorkerSpawnResult {
@ -2916,6 +2938,10 @@ mod tests {
self.backend_id(),
),
run_state: WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
.as_ref()
.map(|binding| binding.status()),
}
}
@ -3144,6 +3170,8 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
}
}
@ -3273,6 +3301,8 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
},
)
.unwrap();
@ -3372,6 +3402,8 @@ mod tests {
},
profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
},
)
.unwrap();
@ -3400,6 +3432,8 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
},
)
.unwrap();

View File

@ -12,6 +12,7 @@ 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 crate::companion::{
@ -24,8 +25,8 @@ use crate::hosts::{
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
RuntimeRegistryUnregisterResult, RuntimeSummary, WorkerInputRequest, WorkerInputResult,
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerSummary, WorkerTranscriptProjection,
WorkerSpawnAcceptanceRequirement, WorkerSpawnExecutionWorkspaceRequest, WorkerSpawnIntent,
WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection,
};
use crate::identity::WorkspaceIdentity;
use crate::observation::{
@ -41,7 +42,10 @@ use crate::repositories::{
};
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use crate::{Error, Result};
use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
use worker_runtime::catalog::{
ConfigBundleRef, DirtyStatePolicy, ExecutionWorkspaceRepository, ExecutionWorkspaceRequest,
MaterializerKind, ProfileSelector, RepositorySelector as RuntimeRepositorySelector,
};
use worker_runtime::config_bundle::ConfigBundle;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse,
@ -151,14 +155,16 @@ pub struct WorkspaceApi {
impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let execution_backend = WorkerRuntimeExecutionBackend::from_workspace(
config.workspace_root.clone(),
)
.map_err(|err| {
crate::Error::Store(format!(
"failed to initialize embedded Worker backend: {err}"
))
})?;
let execution_backend =
WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone())
.map_err(|err| {
crate::Error::Store(format!(
"failed to initialize embedded Worker backend: {err}"
))
})?
.with_execution_workspace_materializer(LocalGitWorktreeMaterializer::new(
config.embedded_runtime_store_root.clone(),
));
Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await
}
@ -442,6 +448,8 @@ pub struct BrowserCreateWorkerRequest {
pub display_name: String,
pub profile: String,
pub initial_text: String,
#[serde(default)]
pub repository_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -909,6 +917,80 @@ async fn get_worker_launch_options(
Ok(Json(worker_launch_options_response(&api)))
}
fn execution_workspace_request_from_repository(
repository: &ConfiguredRepository,
selector: Option<&str>,
) -> ExecutionWorkspaceRequest {
ExecutionWorkspaceRequest {
repository: ExecutionWorkspaceRepository {
id: repository.id.clone(),
provider: repository.provider.clone(),
uri: repository.uri.clone(),
local_path: Some(repository.path.clone()),
selector: selector
.map(|selector| RuntimeRepositorySelector::from(selector.to_string()))
.or_else(|| {
repository
.default_selector
.clone()
.map(RuntimeRepositorySelector)
})
.or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
}
}
fn configured_execution_workspace_request(
config: &ServerConfig,
request: &WorkerSpawnExecutionWorkspaceRequest,
) -> Result<ExecutionWorkspaceRequest> {
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",
request.repository_id
))
})?;
Ok(execution_workspace_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>,
@ -934,6 +1016,8 @@ async fn create_workspace_worker(
content: initial_text,
})
};
let execution_workspace =
default_execution_workspace_request(&api.config, request.repository_id.as_deref())?;
let result = api
.runtime
.spawn_worker(
@ -946,6 +1030,8 @@ async fn create_workspace_worker(
},
profile: Some(profile_selector),
initial_input,
execution_workspace: None,
resolved_execution_workspace: execution_workspace,
},
)
.map_err(|err| err.into_error())?;
@ -1030,8 +1116,15 @@ struct RuntimeConfigBundleAvailabilityQuery {
async fn create_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
Json(request): Json<WorkerSpawnRequest>,
Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> {
request.resolved_execution_workspace = request
.execution_workspace
.as_ref()
.map(|execution_workspace| {
configured_execution_workspace_request(&api.config, execution_workspace)
})
.transpose()?;
let result = api
.runtime
.spawn_worker(&runtime_id, request)
@ -2415,6 +2508,10 @@ mod tests {
self.backend_id(),
),
run_state: worker_runtime::execution::WorkerExecutionRunState::Idle,
execution_workspace: request
.execution_workspace
.as_ref()
.map(|binding| binding.status()),
}
}
@ -2511,6 +2608,7 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
execution_workspace: None,
}
}
@ -2838,6 +2936,71 @@ mod tests {
assert!(!projected.contains("http://"));
}
#[tokio::test]
async fn runtime_worker_spawn_rejects_raw_execution_workspace_fields() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let response = request_json(
app,
"POST",
"/api/runtimes/embedded-worker-runtime/workers",
Some(serde_json::json!({
"intent": {
"kind": "ticket_role",
"ticket_id": "00001KVZSGT0Q",
"role": "coder"
},
"acceptance": {
"kind": "run_accepted",
"expected_segments": 0
},
"execution_workspace": {
"repository_id": TEST_REPOSITORY_ID,
"local_path": dir.path().display().to_string()
}
})),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
assert!(
response["message"]
.as_str()
.unwrap_or_default()
.contains("unknown field"),
"raw execution workspace field should be rejected: {response}"
);
}
#[tokio::test]
async fn runtime_worker_spawn_accepts_safe_repository_selector() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let response = request_json(
app,
"POST",
"/api/runtimes/embedded-worker-runtime/workers",
Some(serde_json::json!({
"intent": {
"kind": "ticket_role",
"ticket_id": "00001KVZSGT0Q",
"role": "coder"
},
"acceptance": {
"kind": "run_accepted",
"expected_segments": 0
},
"execution_workspace": {
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD"
}
})),
StatusCode::OK,
)
.await;
let projected = serde_json::to_string(&response).unwrap();
assert!(!projected.contains(dir.path().to_string_lossy().as_ref()));
}
#[tokio::test]
async fn browser_worker_create_rejects_extra_request_fields() {
let dir = tempfile::tempdir().unwrap();
@ -3250,6 +3413,8 @@ mod tests {
},
profile: None,
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
},
)
.expect("spawn worker");