From ec6c5beda090eedda4d7d3fd87a65797ee054b39 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 00:34:50 +0900 Subject: [PATCH 1/2] test: connect Drive publication to client and resource lifetimes --- .../tests/ticket_worker_launch_tests.rs | 56 ++- .../src/server/tests/worker_drive_tests.rs | 422 +++++++++++++++++- 2 files changed, 461 insertions(+), 17 deletions(-) diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index ee8dba11d..304e18160 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -132,6 +132,16 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { self.execution.create_working_directory(request) } + fn cleanup_working_directory( + &self, + id: &str, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + self.execution.cleanup_working_directory(id) + } + fn spawn_worker(&self, mut request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { self.creates.lock().unwrap().push(request.request.clone()); for claim in &request.request.workdir_attachments { @@ -164,9 +174,9 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { } } -struct LaunchFixture { +pub(super) struct LaunchFixture { _temp: tempfile::TempDir, - api: WorkspaceApi, + pub(super) api: WorkspaceApi, backend: Arc, runtime_id: String, runtime_server: Option>, @@ -182,11 +192,51 @@ impl Drop for LaunchFixture { } impl LaunchFixture { + // Reuse the authenticated Runtime HTTP harness for Drive/source lifetime + // assertions without duplicating its trust/bootstrap/listener lifecycle. + pub(super) async fn with_source_workdir() -> ( + Self, + worker_runtime::working_directory::WorkingDirectoryBinding, + ) { + let fixture = Self::new(true).await; + init_clean_git_workspace(fixture._temp.path()); + let (_, Json(created)) = create_workspace_working_directory( + &fixture.api, + TEST_WORKSPACE_ID, + Some(&fixture.runtime_id), + BrowserWorkingDirectoryCreateRequest { + runtime_id: Some(fixture.runtime_id.clone()), + display_name: None, + repository_key: "test-repository".into(), + selector: Some("HEAD".into()), + operation_id: Some("drive-source-workdir".into()), + }, + ) + .await + .unwrap(); + let binding = fixture + .backend + .execution + .materializer + .bind_working_directory(&created.item.working_directory_id, None) + .unwrap(); + (fixture, binding) + } + async fn new(remote: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); - let backend = Arc::new(LaunchExecutionBackend::default()); + let backend = Arc::new(LaunchExecutionBackend { + execution: DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path() + .join(".test-embedded-runtime-store/source-workdirs"), + ), + ..Default::default() + }, + ..Default::default() + }); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), backend.clone()) .await diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index e951a6308..609cbd4ab 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -1,6 +1,10 @@ -//! Worker tools -> signed WorkspaceClient -> real isolated Server authority. -//! No Git checkout, live DB, network service, or DTO-only assertions. Workdir -//! import tests attach an explicitly read-scoped temporary source session. +//! Tools/native WIP -> signed WorkspaceClient -> isolated Server DB and LocalFileSystem. +//! Default fixtures have no Repository/Workdir and substitute Runtime execution. +//! Lifetime cases use real embedded Runtime stop/removal or the shared authenticated +//! Runtime HTTP harness for actual Git Workdir removal; controller execution remains +//! deterministic. Import sessions have explicit read scope (and real materializer +//! leases in the removal case). No external model, live data, or browser is used. +//! Run with the normal Server lib target and the `worker_drive_tests` filter. use super::*; use agen::tool::{Attachment, ToolError, ToolExecutionContext, ToolOutput}; use std::result::Result; @@ -148,28 +152,46 @@ struct Fixture { } impl Fixture { async fn new(access: [&str; 2]) -> Self { + Self::with_runtime(access, false).await + } + async fn with_runtime(access: [&str; 2], embedded: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let mut config = test_server_config(temp.path()); config.repositories.clear(); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); - let mut api = WorkspaceApi::new_with_execution_backend( - config, - store, - Arc::new(DeterministicExecutionBackend::default()), - ) - .await - .unwrap(); + let execution = Arc::new(DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path().join("runtime-workdirs"), + ), + ..Default::default() + }); + let api = WorkspaceApi::new_with_execution_backend(config, store, execution) + .await + .unwrap(); + Self::on_api(api, temp, access, embedded).await + } + async fn on_api( + mut api: WorkspaceApi, + temp: tempfile::TempDir, + access: [&str; 2], + embedded: bool, + ) -> Self { let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - api.runtime - .register_or_replace(WorkdirlessFixtureRuntime::default()); + let runtime_id = if embedded { + EMBEDDED_RUNTIME_ID + } else { + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + api.runtime + .register_or_replace(WorkdirlessFixtureRuntime::default()); + WorkdirlessFixtureRuntime::RUNTIME_ID + }; let mut workers = Vec::new(); for name in ["Drive first", "Drive second"] { let Json(created) = create_workspace_worker( State(api.clone()), HeaderMap::new(), Json(CreateWorkspaceWorkerRequest { - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + runtime_id: runtime_id.into(), display_name: name.into(), singleton_key: None, profile: Some("builtin:coder".into()), @@ -248,6 +270,45 @@ impl Fixture { .unwrap(), ) } + async fn upload_text(&self, name: &str, text: &str) -> Value { + use sha2::{Digest, Sha256}; + let root = self.root(0).await; + let digest: String = Sha256::digest(text.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("operation", "create") + .append_pair("request_id", &format!("user-upload-{name}")) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("parent_id", root["node_id"].as_str().unwrap()) + .append_pair("name", name) + .append_pair("content_type", "text/markdown") + .append_pair("size", &text.len().to_string()) + .append_pair("sha256", &digest) + .finish(); + let response = build_router(self.api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("{}/upload?{query}", base())) + .header("authorization", format!("Bearer {}", self.owner)) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(text.to_owned())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() + } fn importer(&self, worker: usize) -> DriveFeature { let root = self._temp.path().join("import-source"); fs::create_dir_all(root.join("public")).unwrap(); @@ -415,6 +476,339 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant ); } +#[tokio::test] +async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity() + { + let f = Fixture::new(["read_write", "read_only"]).await; + let original = "# ユーザー資料\n未処理\n"; + let updated_text = "# ユーザー資料\n処理済み ✓\n"; + for native in [false, true] { + let uploaded = f + .upload_text(if native { "native.md" } else { "tools.md" }, original) + .await; + let id = uploaded["entry"]["entry"].clone(); + let updated = if native { + let tools = native_tools(&f.features[0]); + // Resolve the production path projection, not an invented node path. + let metadata = value( + tool(&f.features[0], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(), + ); + let path = metadata["path"].as_str().unwrap(); + let inspected = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + let reference = inspected["interfaces"][0]["reference"].clone(); + let read = value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":reference,"operation":"read","arguments":{}}), + ) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap()) + } else { + let read = value( + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value( + tool( + &f.features[0], + "DriveWrite", + json!({"entry":id,"content":updated_text}), + ) + .await + .unwrap(), + ) + }; + assert_eq!(entry(&updated), id); + let mutation = &updated["entry"]["metadata"]["last_mutation_id"]; + assert_ne!(*mutation, uploaded["entry"]["last_mutation_id"]); + assert_eq!(*mutation, updated["request_id"]); + let independent = value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(independent["text"], updated_text); + assert_eq!(independent["entry"], updated["entry"]); + let metadata = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={}", + base(), + id["node_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(metadata, updated["entry"]["metadata"]); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + updated["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], metadata); + let response = build_router(f.api.clone()).oneshot(Request::builder() + .uri(format!("{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={}&expected_mutation_id={}", base(), id["node_id"].as_str().unwrap(), mutation.as_str().unwrap())) + .header("authorization", format!("Bearer {}", f.owner)) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK, "native={native}"); + assert_eq!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .as_ref(), + updated_text.as_bytes() + ); + } +} + +#[tokio::test] +async fn source_workdir_removal_keeps_published_drive_bytes_and_receipt() { + let (source, binding) = + super::ticket_worker_launch_tests::LaunchFixture::with_source_workdir().await; + let workdir_id = binding.working_directory.id.clone(); + let f = Fixture::on_api( + source.api.clone(), + tempfile::tempdir().unwrap(), + ["read_write", "read_only"], + false, + ) + .await; + let bytes = b"clean\n"; + let root_path = binding.root().to_owned(); + let materialization_root = binding.working_directory_root().to_owned(); + let scope = manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root_path.clone(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + .unwrap(); + let sessions = Arc::new(WorkdirSessionRouter::default()); + sessions + .attach( + workdir::WorkdirAttachmentAlias::new("source").unwrap(), + Arc::new( + workdir::LocalWorkdirSession::materialized_bound_with_environment( + workdir::Workdir::new(workdir_id.clone()), + root_path.clone(), + root_path.clone(), + manifest::SharedScope::new(scope), + workdir::WorkdirSessionCapabilities::READ_ONLY, + binding.command_environment(), + binding.session_resources().unwrap(), + ), + ), + ) + .unwrap(); + let importer = DriveFeature::new(f.clients[0].clone(), sessions.clone()); + let root = f.root(0).await; + let saved = value(tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"README.md","parent":root,"name":"retained.md","content_type":"text/markdown"})).await.unwrap()); + sessions.close_all().await.unwrap(); + drop(importer); + drop(sessions); + drop(binding); + assert!(root_path.join("README.md").exists()); + let api = source.api.clone(); + let removal_id = workdir_id.clone(); + let removed = tokio::task::spawn_blocking(move || { + execute_workdir_removal( + &api, + &removal_id, + "account:drive-test", + "source no longer needed", + ) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + removed.disposition, + server_api::WorkingDirectoryRemovalDisposition::Removed, + "{removed:?}" + ); + assert!(!removed.retryable); + assert!( + !materialization_root.exists(), + "actual source materialization must be gone" + ); + assert!( + f.api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &workdir_id) + .unwrap() + .is_none() + ); + let fresh = DriveFeature::new( + f.clients[1].clone(), + Arc::new(WorkdirSessionRouter::default()), + ); + let read = value( + tool(&fresh, "DriveRead", json!({"entry":entry(&saved)})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"].as_str().unwrap().as_bytes(), bytes); + let receipt = value( + tool( + &fresh, + "DriveRequestStatus", + json!({"request_id":saved["request_id"]}), + ) + .await + .unwrap(), + ); + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); +} + +#[tokio::test] +async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replacement() { + // Real embedded Runtime lifecycle/retention; only controller execution is + // deterministic. No Workdir or external model is involved in this case. + let f = Fixture::with_runtime(["read_write", "read_only"], true).await; + let saved = f.create("retained.md", "# Beyond the Worker\n").await; + let id = entry(&saved); + let old = &f.clients[0].worker; + let worker = WorkspaceWorker::resolve_async(&f.api, &old.runtime_id, &old.worker_id) + .await + .unwrap(); + let stopped = worker + .stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("Drive lifetime test".into()), + ticket_assignment: None, + }, + ) + .await + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert_eq!(f.api.runtime.worker(old).unwrap().state, "stopped"); + seed_worker_session_for_cleanup(f._temp.path(), old); + let plan = build_runtime_cleanup_plan(&f.api, &old.runtime_id).unwrap(); + let candidate = plan + .workers + .iter() + .find(|c| c.runtime_worker_id == old.worker_id) + .unwrap(); + assert_eq!(candidate.blocking_reason, None); + worker + .delete_from_plan(&WorkerOperationContext::Backend, candidate) + .await + .unwrap(); + assert!(f.api.runtime.worker(old).is_err()); + assert!( + f.api + .store + .get_worker_registry(TEST_WORKSPACE_ID, old) + .unwrap() + .is_none() + ); + denied(tool(&f.features[0], "DriveRead", json!({"entry":id})).await); + let Json(created) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_RUNTIME_ID.into(), + display_name: "Replacement reader".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + assert_ne!(created.worker_id, old.worker_id); + let client = Arc::new(RouterClient { + api: f.api.clone(), + identity: f.clients[0].identity.clone(), + worker: RuntimeWorkerRef::new(created.runtime_id, created.worker_id), + handle: tokio::runtime::Handle::current(), + lose_next_mutation_response: AtomicBool::new(false), + lose_next_upload_response: AtomicBool::new(false), + requests: Mutex::new(vec![]), + }); + let replacement = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::default())); + denied(tool(&replacement, "DriveRead", json!({"entry":id})).await); + owner_call(&f.api, &f.owner, "POST", &format!("{}/grants", base()), Some(json!({"runtime_id":client.worker.runtime_id,"worker_id":client.worker.worker_id,"access":"read_only"}))).await; + let read = value( + tool(&replacement, "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"], "# Beyond the Worker\n"); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + saved["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); + // Stop remaining deterministic controllers before dropping isolated resources. + for target in [&f.clients[1].worker, &client.worker] { + let stopped = f + .api + .runtime + .stop_worker( + target, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + } +} + #[tokio::test] async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict() { From 387b5ff7df9e55cf46955544b155524caa193bfe Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 00:35:11 +0900 Subject: [PATCH 2/2] docs: record Drive connection coverage and fault evidence --- docs/development/regression-test-design.md | 2 + docs/report/t725-drive-connections.md | 75 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 docs/report/t725-drive-connections.md diff --git a/docs/development/regression-test-design.md b/docs/development/regression-test-design.md index 255d260f1..91164df56 100644 --- a/docs/development/regression-test-design.md +++ b/docs/development/regression-test-design.md @@ -95,6 +95,8 @@ T-744では既存Rust代表8件が成功(上表以外の公開reissue/case/Ans T-725現行本文・保留trackingを読んだ。`workspace-drive/tests/storage.rs::receipts_survive_response_loss_and_restart_without_double_mutation` は実DB/blob再open後のreceipt一致、同request再送一致、別actor競合拒否、list件数1をassertする。`workspace-server/src/server/tests/worker_drive_tests.rs::lost_mutation_response_is_resolved_by_receipt_without_resending_effect` は本番DriveFeature→署名router/storeを通し、receipt committed・POST mutate数1・別Worker listのlast mutation一致をassertするがRuntime起動はfixtureである。これらの保存/応答喪失規則を再設計しない。実Web adapter接続は `drive_web_roundtrip_tests.rs::repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` の既存ignore/明示実行であり、通常suiteの成功へ含めない。Worker寿命・Web画面までの不足と必要コストはT-725再開時の判断で、今回未確認とする。 +**2026-10-19、T-725再開後の候補実装**は[Drive接続の調査・検証記録](../report/t725-drive-connections.md)へ分離した。通常Server libにuser upload→WorkdirなしTools/WIP→owner/別Workerの同一node・mutation・bytes・receipt、実Runtime Worker停止/削除→明示grant付き交換Worker、実Runtime HTTP経由のsource Workdir削除→Drive資料保持の3件を追加した。保存/応答喪失/GC/backupは既存を維持し、Ticket一巡も複製しない。3件の単独libtest実測合計5.54秒(Cargo wall合計6.324秒、build温存時)であり、通常runner全体の増分・並列負荷は未測定。代表的な本文投影欠落とcleanupによる誤ったDrive blob削除のmutantを検出し、復元後Server全crateを成功確認した。既存ignored Web adapterも別途1件成功したがbrowser/実モデル/全Workspace runner/CIの成功には含めない。上の保留記述はT-761設計時の観測であり、この候補を対象developへ採用・統合したという記録ではない。 + ## 設計時の代表試行と残った検証 全て専用checkoutで逐次実行、Rust buildはjobs 2。表の本体はlibtest/Deno表示、外側は`time -p`の1回測定であり、通常suite全体の増分やCI中央値ではない。 diff --git a/docs/report/t725-drive-connections.md b/docs/report/t725-drive-connections.md new file mode 100644 index 000000000..7017c62c0 --- /dev/null +++ b/docs/report/t725-drive-connections.md @@ -0,0 +1,75 @@ +# Drive共有の接続不足と検証(T-725、2026-10-19) + +## 判断の基準 + +基準 `adfa42297352c5a64fe8100969aad6fda4df8a81` と着手HEAD `cea0225a6e2d9e23c59a7d4a453f99bc2e1eba97` を比較した。差分81 filesはSession保存、Journal、Ticket公開操作、復元親の子操作等であり、以下のDrive保存/API/Worker/Webテストに差分はない。Cargo.lockの `wip-{client,http,protocol,text-view}` はすべてcrates.ioの0.2.0で、ローカル差替えはしていない。 + +検証したテスト実装は専用branch `work/T-725-drive-connections` の `ec6c5bed`。以下の結果はこの実装に対する証拠であり、対象developへの統合判断とは分ける。 + +T-721〜724の保存・公開操作を再実装せず、**別入口の返却値が次の操作へ渡る接続と、cleanupの所有境界**だけを追加した。製品修正は不要だった。Ticket一巡は実装・採用済みT-744の `ticket-regression-test-design.md`、通常suiteの判断はT-761の `regression-test-design.md` と分担する。関連Ticketの変更・再開はしていない。 + +## 優先順位と追加した契約 + +配置は `crates/workspace-server/src/server/tests/worker_drive_tests.rs`。以下はすべて通常のServer lib targetであり、ignoreやテスト専用featureは追加しない。 + +| 優先 | 利用結果/具体故障・コード根拠 | 既存証拠の限界/採否 | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | ユーザーuploadの返却node/mutationをWorkdirなしTools/WIPが読み更新し、owner/別Workerが同一資料を取得する。`server_drive::upload/read_text/public_node`、Worker `drive/backend.rs::read/mutate` とWIPの投影がずれると、個別成功でも本文や観測が別物になる。 | `workdirless_workers_share_markdown_through_current_read_and_write_grants` はWorker作成から別Worker読取り。`image_attachment_retains_original_bytes_after_drive_update_and_revocation` はuser uploadから画像添付。`native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker` はWIP作成から更新。この間の**user Markdown binary upload→Tools/WIP更新**を新規接続。 | +| P2 | Workerを止め、保持規則に従い削除・交換してもDrive文書の参照と内容が残る。`server/worker_operations.rs::stop/delete_from_plan`、`WorkerRemovalService` と `store::commit_worker_catalog_removal` はWorker権限を終え、Drive資料を削除してはならない。 | `cleanup_service_deletes_stopped_singleton_worker` は実Runtime停止・削除・交換、storeの `drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart` はgrant取消監査を確認するが、Drive資料の再取得はない。**元参照→交換Workerの明示grant後読取り**を新規接続。停止だけでgrantが失効するという別契約は追加しない。 | +| P2 | source Workdirを本当に削除した後も保存した資料を使える。`drive.rs` のSaveWorkdirはbytesをAPIへ渡し、`execute_workdir_removal`→Runtime→`working_directory/cleanup.rs` はmaterializationだけを削除する。誤ったroot削除やsource依存を検出する。 | `scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_reads` は独立metadata、完全download bytes、画像添付を確認するがsourceが残る。**実provider削除の後に同node/mutation/bytes/receiptを読む**接続を追加。保存crateのbackup/GC行列は増やさない。 | + +### 入力・fixture・観測点 + +- `user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity`:既存Fixtureの実SQLite/FeatureStorage/LocalFileSystem、2 Workerの空Workdir links、明示read_write/read_only grantを使用。owner binary PUTはsize/SHA256と日本語Markdown bytesを渡す。Toolsとnative WIPの2経路で読取り・writeを行う。返却Workspace/node、uploadのlast_mutation_id、更新request ID=last_mutation_id、別Worker readのentry/本文、owner metadata、committed receiptのentry、mutation固定downloadの完全bytesを照合する。WIPは本番install/Inspect/Invoke、外部LLMは呼ぶとpanicする。Runtime起動とHTTP transportはfixtureだが、毎要求の署名・router・現grant・storeは本番。 +- `worker_removal_preserves_drive_reference_for_explicitly_granted_replacement`:real embedded Runtime+既存deterministic controller backendでRepository/WorkdirなしWorkerが文書を作る。本番WorkspaceWorker stop→Runtime stopped→cleanup plan→delete_from_planを通し、Runtime/catalog除去、旧Workerの拒否を確認。別IDの交換Workerはgrant前拒否、read_only付与後に**元参照**で完全entry(latest_url、last_mutation_idを含む)と本文が一致し、元receiptもcommitted。保持対象Sessionは既存fixtureで準備する。lifecycle admissionは内部Backend test contextであり、browser/Worker-control認証の新証拠ではない。実controllerのshutdown/モデル挙動は代替されている。 +- `source_workdir_removal_keeps_published_drive_bytes_and_receipt`:`ticket_worker_launch_tests::LaunchFixture` の既存認証付きloopback Runtimeを共有する。通常のServer Workdir createでlocal Git sourceからcleanなREADME.mdをmaterializeする。source sessionはLocalWorkdirSessionでread scopeと**実materializer lease**を渡す(Runtime session transportは代替)。DriveSaveWorkdirはalias `source`+論理path `README.md`、元bytes `clean\n`。session close/drop後、Server removal→認証付きRuntime HTTP→実materializer unlinkを通し、Removed/nonretryable・物理root不存在・registry不存在をassert。その後、新しいWorkdirなしDriveFeatureから元entry/bytes/receiptを照合する。`on_api`のcloneは同じstore/drive_host/Runtimeを共有し、別DBを作らない。 + +Workdir用に新しい起動基盤は作らず、既存Launch backendにcleanupの実materializerへの委譲を足し、materializer配置をfixtureのTempDir配下へ閉じた。listenerは既存Dropでabort、通信は既存有界timeoutを利用する。embedded Server adapterは仕様上Workdir create/status/cleanup非対応(`hosts.rs`)。最初のembedded試行はAttentionRequiredとなったため不採用とし、成功fakeへ変えず既存HTTP harnessへ切り替えた。 + +## 追加しなかった保証と既存assertion + +パスは、断りがなければServer `src/server/tests/` 配下。 + +| 保証/採否 | 主な実在テストと観測、代替境界 | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| stale先勝ち:維持 | `simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict` は成功1件・敗者StructuredConflict・勝者本文/metadata、`stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id` はwrite/edit/move/delete拒否と本文/名前保持。nativeは前掲create/write testでvalidator conflictと勝者bytes。Tools/WIP→実署名router、Runtimeのみfixture。 | +| 現grant取消:維持 | `revocation_invalidates_cached_metadata_reads_writes_and_receipt_queries` はread/write/list/search/status等のdeniedと別Worker本文保持。`native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_current_grants` はcached invoke/refreshed inspect拒否。`drive_api_tests::drive_verified_upload_revoked_during_reception_cannot_commit_or_replay` はbody frameで取消、403/denied、uncommitted、list空。最後の注入はverified-source Extensionを使用し、署名ingress自体の証拠とは分ける。 | +| commit後応答喪失:維持 | `lost_mutation_response_is_resolved_by_receipt_without_resending_effect` と `lost_binary_upload_reply_reconciles_same_request_without_duplicate_publication` は本番router成功後の返答だけをtransportで落とす。committed/元node、POSTまたはPUT数1、元requestのGET照会、別Worker一覧1件とmutation一致をassert。単なる失敗応答fakeではない。 | +| Web接続:維持/明示実行 | `drive_web_roundtrip_tests::repositoryless_drive_shares_worker_bytes_and_enforces_current_read_only_grants_over_http` はRepositoryなしbootstrap、実HTTP、Worker grant/revokeの403、一覧entryと実blob bytesを確認。`repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` は実HTTPと本番Deno adapter(parser/operation/grant)を接続する既存ignored test。Worker起動はfixture、browser DOM/Passkey loginは対象外。画面draft/取消等は別のsynthetic API browser suiteを重複しない。 | +| 保存/reopen/backup/GC:所有crateを維持 | `workspace-drive/tests/storage.rs::markdown_image_empty_and_folders_survive_restart_with_stable_identity` はDrive再open後のmetadata/本文/画像。`receipts_survive_response_loss_and_restart_without_double_mutation` はreceipt/replay一致・別actor拒否・list1件。`stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs` は一致するDB+blobsの復元、元metadata/bytes/receiptと新ID採番。`src/tests.rs::saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it` は未公開・uncommitted・再open回収、`commit_failure_rolls_back_metadata_and_receipt_together` はmetadata/receipt同時rollback。`collection_cannot_remove_in_flight_upload_or_selected_read` はin-flight保護。これらをServer全体再起動や実電源断保証に拡張せず、新たな保存障害行列は不採用。 | + +## 故障検出試行と復元 + +以下は意図的なmutantであり、製品不具合の発見ではない。patchとログは `target/t725/` に保持し、製品sourceは復元した。 + +| 試行 | 注入と検出 | 実行結果(外側wall/libtest) | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `mutant-read` | `server_drive::read_text` の返却本文を `String::new()` にする。P1 filterの元upload本文比較が空文字≠日本語Markdownで失敗。 | exit101、64.156秒/0.41秒(build込み) | +| `mutant-cleanup` | `execute_workdir_removal` に誤ったDrive blob rootのremove_dir_allを挿入。実source削除後のfresh DriveReadが公開error「Drive storage or transport unavailable」で失敗。 | exit101、88.103秒/0.59秒(build込み) | +| 復元 | `server.rs` と `server_drive.rs` に差分がないことを検査後、Server全crateと3つの個別filterを実行。 | 下表の全成功。故障をテストから避ける製品修正はない。 | + +開発中にはsource fixtureの未定義git helperによるcompile error、準備用Repository FK不足、embedded adapter非対応の試行失敗もあった。helperを増やさず既存clean READMEを使用し、最終fixtureは本番Server create+既存HTTP Runtimeを利用した。これらを製品regressionや故障検出成功には数えない。いずれも `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` の試行で、`source-removal-first` はcompile exit101/wall6.875秒、`source-removal` はFK失敗exit101/wall84.053秒/libtest1.64秒、`source-removal-fixed` は非対応判定exit101/wall56.519秒/libtest2.14秒、HTTP経路への修正後 `source-removal-http` は1 pass/wall83.709秒/libtest0.61秒。時間採取は `/usr/bin/time` がないためBash `TIMEFORMAT` を使用し、出力のなかった背景起動の試行はbuild成功に数えなかった。 + +## 実行区分・費用とT-761への接続 + +すべて専用checkoutで実行した単発実測であり、CIや全通常suiteの速度ではない。`wall` はCargo起動/build込み、libtest時間は実行のみ。既存dead_code warningsは継続。 + +| command(root) | 結果 | wall/実行秒 | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------- | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests --no-run` | 初回build成功 | 234.981/実行なし | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests -- --test-threads=4` | P1+Worker寿命追加時20 pass | 71.131/9.69 | +| `cargo test --locked -p yoi-workspace-server -- --test-threads=4` | 最終実装1210 lib+12 bin pass、既存3 ignored、doctest対象0 | 400.021/291.85(lib) | +| `cargo test --locked -p yoi-workspace-server --lib user_uploaded_markdown_roundtrips` | 復元後1 pass(Tools/WIP両方) | 0.994/0.72 | +| `cargo test --locked -p yoi-workspace-server --lib worker_removal_preserves_drive_reference` | 復元後1 pass | 4.458/4.20 | +| `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` | 復元後1 pass | 0.872/0.62 | +| `cargo test --locked -p workspace-drive` | 40 unit+13 storage pass、doctest対象0 | 4.934/0.21+0.09 | +| `cargo check --locked` | root通常compile closure成功 | 75.368 | +| `cargo test --locked -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored` | **明示**Web adapter1 pass | 1.051/0.75 | + +最終module境界コメントの補正後にも同じ `worker_drive_tests -- --test-threads=4` を実行し21 pass(wall71.296秒、libtest9.75秒)を確認した。`cargo doc --locked -p yoi-workspace-server --no-deps` は成功(wall70.312秒)。`cargo fmt --all -- --check`(3.824秒)、`git diff --check HEAD`(0.004秒)、`env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts`(0.189秒、0 unapproved/0 stale exceptions)、2文書の `deno fmt --check` も成功した。文書の相対参照先は実在する。 + +T-761の通常runnerはCargo通常lib targetを列挙するため、追加3件は同targetのままで選択される。3件の逐次libtest実測合計5.54秒(単独Cargo wall合計6.324秒)。既存runnerのshard/並列負荷やcold build増分は未測定であり、全体時間へ単純加算しない。最も重いWorker寿命4.20秒は実永続Runtime停止・保持・再作成を検出する費用で、既存DB単体や共有2Workerの成功では代替できない。通常suiteへの追加はこの3件だけとし、Web adapterの既存ignoreは変更しない。 + +全Workspace `deno task test`、browser、製品全体E2E、実モデル判断、物理電源断、Server/Runtime全体再起動、CI実行は未実施。新規CI jobは追加しておらず、CIへの自動配線・実行済みとは扱わない。実モデルの調査・待機・完了判断を固定LLM応答で証明せず、追加したのは機構の接続保証である。 + +証拠は `target/t725/*.log`・`*.time` と2つのmutant patch。報告書へ生ログ/秘密は転載しない。稼働環境・対象develop・Ticket caseは変更していない。