From 50c80e7747b6d6274c8d868f9d9757387fe1e2f2 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 03:30:04 +0900 Subject: [PATCH 1/9] ticket: accept execution workspace materializer --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KWW9DYH4/item.md | 4 +- .yoi/tickets/00001KWW9DYH4/thread.md | 106 ++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KWW9DYH4/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KWW9DYH4/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KWW9DYH4/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..c2b8ff66 --- /dev/null +++ b/.yoi/tickets/00001KWW9DYH4/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 605662c4..53b51155 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -1,8 +1,8 @@ --- title: 'Add local Git worktree Execution Workspace materializer' -state: 'queued' +state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T18:28:26Z' +updated_at: '2026-07-06T18:30:00Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index 612a2d77..d887d1b2 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -30,4 +30,110 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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` body(Runtime 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//root/` に `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。 + +--- + + + +## 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 を作成してから開始する。 + --- From 9a064117bd7d83db1d29cc27b95bde48f9eee3ed Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 03:31:17 +0900 Subject: [PATCH 2/9] ticket: route execution workspace coder --- .yoi/tickets/00001KWW9DYH4/item.md | 2 +- .yoi/tickets/00001KWW9DYH4/thread.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 53b51155..94a7a6ad 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -2,7 +2,7 @@ title: 'Add local Git worktree Execution Workspace materializer' state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T18:30:00Z' +updated_at: '2026-07-06T18:31:11Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index d887d1b2..f3aa9353 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -137,3 +137,20 @@ Acceptance basis: - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 --- + + + +## 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` は既知の不具合があるため使用しない。 + +--- From 8b7a5da03130f4453b2c5857504b3c7839401e07 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 03:56:32 +0900 Subject: [PATCH 3/9] feat: materialize execution workspaces --- crates/worker-runtime/src/catalog.rs | 112 +++- crates/worker-runtime/src/execution.rs | 13 +- .../worker-runtime/src/execution_workspace.rs | 595 ++++++++++++++++++ crates/worker-runtime/src/http_server.rs | 10 + crates/worker-runtime/src/lib.rs | 1 + crates/worker-runtime/src/main.rs | 21 +- crates/worker-runtime/src/runtime.rs | 36 +- crates/worker-runtime/src/worker_backend.rs | 226 ++++++- crates/workspace-server/src/companion.rs | 5 + crates/workspace-server/src/hosts.rs | 16 +- crates/workspace-server/src/server.rs | 76 ++- 11 files changed, 1063 insertions(+), 48 deletions(-) create mode 100644 crates/worker-runtime/src/execution_workspace.rs diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 14f41649..4fd1a7e1 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -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 for RepositorySelector { + fn from(value: String) -> Self { + Self(value) + } +} + +impl AsRef 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_policy: Option, + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_workspace: Option, } /// Worker lifecycle status for the in-memory embedded runtime. diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 40db94a6..ac1fa6c8 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub last_result: Option, } @@ -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, } /// Result of backend Worker spawn/initialization. @@ -285,6 +295,7 @@ pub enum WorkerExecutionSpawnResult { Connected { handle: WorkerExecutionHandle, run_state: WorkerExecutionRunState, + execution_workspace: Option, }, Rejected(WorkerExecutionResult), Errored(WorkerExecutionResult), diff --git a/crates/worker-runtime/src/execution_workspace.rs b/crates/worker-runtime/src/execution_workspace.rs new file mode 100644 index 00000000..37b616e1 --- /dev/null +++ b/crates/worker-runtime/src/execution_workspace.rs @@ -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, + 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, message: impl Into) -> 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; + + 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) -> 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 { + 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 +where + I: IntoIterator, +{ + 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, +{ + git_stdout(repository_path, args).map(|_| ()) +} + +fn path_str(path: &Path) -> Result { + 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::(); + 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")); + } +} diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 48b2a185..9183a36f 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -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, } } diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index d300d499..70bd09c7 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -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")] diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index b589768f..44062c83 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -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 { 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 { } } +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(), diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 6d6373a1..8aa5d88f 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -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, result: WorkerExecutionResult, ) -> Result { 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()), } } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 2aef5af9..4067aa78 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -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 { 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, + execution_workspace: Option, } /// `worker-runtime` execution backend backed by real `worker` crate Workers. pub struct WorkerRuntimeExecutionBackend { backend_id: String, factory: Arc, + execution_workspace_materializer: Option>, runtime: Mutex>, workers: Mutex>, } @@ -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(receiver: mpsc::Receiver>) -> Result { 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>>, } #[async_trait] impl RuntimeWorkerFactory for MockFactory { async fn spawn_controller( &self, - _request: WorkerExecutionSpawnRequest, + request: WorkerExecutionSpawnRequest, ) -> Result { 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()); + } } diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 70443e27..a5be9a14 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -387,6 +387,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { }, profile: Some(selector), initial_input: None, + execution_workspace: None, }, ); @@ -575,6 +576,10 @@ mod tests { "deterministic-companion-test", ), run_state: WorkerExecutionRunState::Idle, + execution_workspace: request + .execution_workspace + .as_ref() + .map(|binding| binding.status()), } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 001cfc2a..fc5d006f 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -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, @@ -279,6 +279,8 @@ pub struct WorkerSpawnRequest { pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1321,6 +1323,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), + execution_workspace: request.execution_workspace.clone(), }; match self.runtime.create_worker(create_request) { Ok(detail) => { @@ -1993,6 +1996,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), + execution_workspace: request.execution_workspace.clone(), }; match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) { Ok(response) => WorkerSpawnResult { @@ -2916,6 +2920,10 @@ mod tests { self.backend_id(), ), run_state: WorkerExecutionRunState::Idle, + execution_workspace: request + .execution_workspace + .as_ref() + .map(|binding| binding.status()), } } @@ -3144,6 +3152,7 @@ mod tests { }, profile: None, initial_input: None, + execution_workspace: None, } } @@ -3273,6 +3282,7 @@ mod tests { }, profile: None, initial_input: None, + execution_workspace: None, }, ) .unwrap(); @@ -3372,6 +3382,7 @@ mod tests { }, profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())), initial_input: None, + execution_workspace: None, }, ) .unwrap(); @@ -3400,6 +3411,7 @@ mod tests { acceptance: WorkerSpawnAcceptanceRequirement::SocketReady, profile: None, initial_input: None, + execution_workspace: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f76f7578..ed10ca71 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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::{ @@ -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) -> Result { - 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, } #[derive(Debug, Serialize, Deserialize)] @@ -909,6 +917,47 @@ async fn get_worker_launch_options( Ok(Json(worker_launch_options_response(&api))) } +fn default_execution_workspace_request( + config: &ServerConfig, + repository_id: Option<&str>, +) -> Result> { + 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(ExecutionWorkspaceRequest { + repository: ExecutionWorkspaceRepository { + id: repository.id.clone(), + provider: repository.provider.clone(), + uri: repository.uri.clone(), + local_path: Some(repository.path.clone()), + selector: repository + .default_selector + .clone() + .map(RuntimeRepositorySelector) + .or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))), + }, + materializer: MaterializerKind::LocalGitWorktree, + dirty_state_policy: DirtyStatePolicy::CleanPointOnly, + })) +} + async fn create_workspace_worker( State(api): State, Json(request): Json, @@ -934,6 +983,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 +997,7 @@ async fn create_workspace_worker( }, profile: Some(profile_selector), initial_input, + execution_workspace, }, ) .map_err(|err| err.into_error())?; @@ -2415,6 +2467,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 +2567,7 @@ mod tests { digest: bundle.metadata.digest, }, initial_input: None, + execution_workspace: None, } } @@ -3250,6 +3307,7 @@ mod tests { }, profile: None, initial_input: None, + execution_workspace: None, }, ) .expect("spawn worker"); From 504090ac99f2b7723c386d7010ccbdc607c7657f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 03:57:46 +0900 Subject: [PATCH 4/9] ticket: record execution workspace implementation evidence --- .yoi/tickets/00001KWW9DYH4/item.md | 2 +- .yoi/tickets/00001KWW9DYH4/thread.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 94a7a6ad..51a89967 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -2,7 +2,7 @@ title: 'Add local Git worktree Execution Workspace materializer' state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T18:31:11Z' +updated_at: '2026-07-06T18:57:42Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index f3aa9353..3a3026da 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -154,3 +154,38 @@ Implementation routing update: - 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 --- + + + +## 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//root/`。 + - 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`: pass(34 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(15 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` は使わない。 + +--- From 6c4df18da1789a8fa632beb232d18bb9b7c2a0c7 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:06:30 +0900 Subject: [PATCH 5/9] ticket: record execution workspace review blocker --- .yoi/tickets/00001KWW9DYH4/item.md | 2 +- .yoi/tickets/00001KWW9DYH4/thread.md | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 51a89967..42b94303 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -2,7 +2,7 @@ title: 'Add local Git worktree Execution Workspace materializer' state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T18:57:42Z' +updated_at: '2026-07-06T19:06:24Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index 3a3026da..9d81d6e7 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -189,3 +189,35 @@ Implementation progress report: - External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 --- + + + +## Review: request changes + +External review result: request_changes + +Blocker: +- 実装が Browser/API-facing spawn boundary を広げ、API caller から full `ExecutionWorkspaceRequest`(`ExecutionWorkspaceRepository.local_path: Option` を含む)を受け取れる形になっている。 + - `crates/workspace-server/src/hosts.rs:263-283` は `WorkerSpawnRequest` を browser-safe として raw workspace roots/storage paths を受け取らない契約にしているが、現在は `execution_workspace: Option` を含む。 + - `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//root/` 配下に 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 に保つ余地がある。 + +--- From c90ebf2468232d5373c98e87be9a174104b9b1d1 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:16:04 +0900 Subject: [PATCH 6/9] fix: keep execution workspace paths internal --- crates/workspace-server/src/companion.rs | 1 + crates/workspace-server/src/hosts.rs | 32 ++++- crates/workspace-server/src/server.rs | 145 ++++++++++++++++++++--- 3 files changed, 154 insertions(+), 24 deletions(-) diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index a5be9a14..8240c80e 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -388,6 +388,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { profile: Some(selector), initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index fc5d006f..69ded6a4 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -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, +} + +#[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,8 +292,13 @@ pub struct WorkerSpawnRequest { pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_input: Option, + /// 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, + pub execution_workspace: Option, + #[serde(skip, default)] + pub resolved_execution_workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1323,7 +1341,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), - execution_workspace: request.execution_workspace.clone(), + execution_workspace: request.resolved_execution_workspace.clone(), }; match self.runtime.create_worker(create_request) { Ok(detail) => { @@ -1996,7 +2014,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), - execution_workspace: request.execution_workspace.clone(), + execution_workspace: request.resolved_execution_workspace.clone(), }; match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) { Ok(response) => WorkerSpawnResult { @@ -3153,6 +3171,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, } } @@ -3283,6 +3302,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); @@ -3383,6 +3403,7 @@ mod tests { profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())), initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); @@ -3412,6 +3433,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ed10ca71..7e266f3a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -25,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::{ @@ -917,6 +917,51 @@ 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 { + 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>, @@ -941,21 +986,9 @@ fn default_execution_workspace_request( }, }; - Ok(Some(ExecutionWorkspaceRequest { - repository: ExecutionWorkspaceRepository { - id: repository.id.clone(), - provider: repository.provider.clone(), - uri: repository.uri.clone(), - local_path: Some(repository.path.clone()), - selector: repository - .default_selector - .clone() - .map(RuntimeRepositorySelector) - .or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))), - }, - materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, - })) + Ok(Some(execution_workspace_request_from_repository( + repository, None, + ))) } async fn create_workspace_worker( @@ -997,7 +1030,8 @@ async fn create_workspace_worker( }, profile: Some(profile_selector), initial_input, - execution_workspace, + execution_workspace: None, + resolved_execution_workspace: execution_workspace, }, ) .map_err(|err| err.into_error())?; @@ -1082,8 +1116,15 @@ struct RuntimeConfigBundleAvailabilityQuery { async fn create_runtime_worker( State(api): State, AxumPath(runtime_id): AxumPath, - Json(request): Json, + Json(mut request): Json, ) -> ApiResult> { + 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) @@ -2895,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(); @@ -3308,6 +3414,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .expect("spawn worker"); From 673fbeed099abe11021b8f8c2c17e6b01efe0e9f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:17:23 +0900 Subject: [PATCH 7/9] ticket: record execution workspace review fixes --- .yoi/tickets/00001KWW9DYH4/item.md | 2 +- .yoi/tickets/00001KWW9DYH4/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 42b94303..451c257b 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -2,7 +2,7 @@ title: 'Add local Git worktree Execution Workspace materializer' state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T19:06:24Z' +updated_at: '2026-07-06T19:17:17Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index 9d81d6e7..5d475b82 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -221,3 +221,31 @@ Non-blocking follow-ups: - external HTTP/API error body で materializer diagnostic code を message string だけでなく structured に保つ余地がある。 --- + + + +## 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`: pass(34 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(15 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。 + +--- From ab19882b8eda6b1e783da40432dff2d99bf760d8 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:23:08 +0900 Subject: [PATCH 8/9] ticket: approve execution workspace materializer --- .yoi/tickets/00001KWW9DYH4/item.md | 2 +- .yoi/tickets/00001KWW9DYH4/thread.md | 39 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index 451c257b..d40a7bc8 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -2,7 +2,7 @@ title: 'Add local Git worktree Execution Workspace materializer' state: 'inprogress' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T19:17:17Z' +updated_at: '2026-07-06T19:23:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index 5d475b82..de257ded 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -249,3 +249,42 @@ Review-fix implementation report: - Requesting follow-up external review against `8b7a5da0..c90ebf24` and full combined implementation。 --- + + + +## 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//root/`。 + - 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. + +--- From 376a43b8ae08c0d6ff19f0c80657a43598e549b2 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:24:31 +0900 Subject: [PATCH 9/9] ticket: close execution workspace materializer --- .yoi/tickets/00001KWW9DYH4/item.md | 4 +- .yoi/tickets/00001KWW9DYH4/resolution.md | 32 ++++++++++ .yoi/tickets/00001KWW9DYH4/thread.md | 74 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KWW9DYH4/resolution.md diff --git a/.yoi/tickets/00001KWW9DYH4/item.md b/.yoi/tickets/00001KWW9DYH4/item.md index d40a7bc8..cb2ba290 100644 --- a/.yoi/tickets/00001KWW9DYH4/item.md +++ b/.yoi/tickets/00001KWW9DYH4/item.md @@ -1,8 +1,8 @@ --- title: 'Add local Git worktree Execution Workspace materializer' -state: 'inprogress' +state: 'closed' created_at: '2026-07-06T18:00:46Z' -updated_at: '2026-07-06T19:23:01Z' +updated_at: '2026-07-06T19:24:25Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T18:28:26Z' diff --git a/.yoi/tickets/00001KWW9DYH4/resolution.md b/.yoi/tickets/00001KWW9DYH4/resolution.md new file mode 100644 index 00000000..158108c8 --- /dev/null +++ b/.yoi/tickets/00001KWW9DYH4/resolution.md @@ -0,0 +1,32 @@ +完了。 + +実装内容: +- Runtime-side Execution Workspace materialization boundary を追加した。 +- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `execution-workspaces//root/` に 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`: pass(34 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(15 tests) +- `yoi ticket doctor`: ok + +未実行: +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 \ No newline at end of file diff --git a/.yoi/tickets/00001KWW9DYH4/thread.md b/.yoi/tickets/00001KWW9DYH4/thread.md index de257ded..90542c72 100644 --- a/.yoi/tickets/00001KWW9DYH4/thread.md +++ b/.yoi/tickets/00001KWW9DYH4/thread.md @@ -288,3 +288,77 @@ Non-blocking note: No merge-blocking issues found. --- + + + +## 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`: pass(34 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(15 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. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Runtime-side Execution Workspace materialization boundary を追加した。 +- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `execution-workspaces//root/` に 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`: pass(34 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(15 tests) +- `yoi ticket doctor`: ok + +未実行: +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 + +---