diff --git a/crates/feature-storage/src/identity_tests.rs b/crates/feature-storage/src/identity_tests.rs index 31ed5f32c..b178a0d28 100644 --- a/crates/feature-storage/src/identity_tests.rs +++ b/crates/feature-storage/src/identity_tests.rs @@ -107,6 +107,32 @@ fn has_identity(connection: &Connection) -> bool { .unwrap() } +#[test] +fn lifecycle_verification_never_provisions_or_adopts_optional_storage() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let (manager, registration) = registered(&root); + let scoped = manager.scope(SCOPE).unwrap(); + scoped.verify_owned_if_present(®istration).unwrap(); + assert!(!root.exists()); + let path = legacy(&root, SCOPE); + assert_identity_error(scoped.verify_owned_if_present(®istration)); + assert!(!has_identity(&Connection::open(&path).unwrap())); + let database = scoped + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + let identity = database.identity().clone(); + scoped.verify_owned_if_present(®istration).unwrap(); + assert_eq!(values(&database), vec!["preserved"]); + drop(database); + fs::remove_file(&path).unwrap(); + assert!(matches!(scoped.verify_owned_if_present(®istration), + Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)); + assert!(!path.exists()); + assert!(matches!(scoped.open_existing(®istration, &identity), + Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)); +} + #[test] fn provision_then_reopen_retains_physical_identity_and_committed_data() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/feature-storage/src/lib.rs b/crates/feature-storage/src/lib.rs index 88b4e7176..64d513fdd 100644 --- a/crates/feature-storage/src/lib.rs +++ b/crates/feature-storage/src/lib.rs @@ -1006,6 +1006,31 @@ impl ScopedFeatureStorage { ) } + /// Validate optional storage for a lifecycle consumer without authorizing + /// adoption or provisioning. Existing files require completed physical + /// ownership; missing bound files fail closed. This is not an open permit: + /// subsequent database access must still validate stored identity and fences. + pub fn verify_owned_if_present(&self, registration: &RegisteredFeature) -> Result<()> { + self.manager.check_registration(registration)?; + if !self.manager.inner.root.try_exists()? { + return Ok(()); + } + let lifecycle = self.manager.lifecycle(&self.scope_id); + let _lease = lifecycle.lock(false)?; + let path = self + .manager + .feature_dir(&self.scope_id) + .join(format!("{}.sqlite", registration.feature_id)); + if let Some(binding) = lifecycle.lookup(&self.scope_id, ®istration.feature_id)? { + binding.check_file(&path)?; + } else if path.try_exists()? { + return Err(FeatureStorageError::Identity( + "lifecycle consumer cannot adopt unowned Feature storage".into(), + )); + } + Ok(()) + } + /// Host startup compatibility for a registered optional Feature. Does not /// provision missing optional storage. An existing legacy DB still requires /// explicit adoption authority and persisted attribution validation. diff --git a/crates/workspace-server/src/config_adoption.rs b/crates/workspace-server/src/config_adoption.rs index 018bec136..ee003bb50 100644 --- a/crates/workspace-server/src/config_adoption.rs +++ b/crates/workspace-server/src/config_adoption.rs @@ -529,6 +529,7 @@ fn prepare( schema, )?; crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?; + crate::repository_access::validate_repository_access_in_transaction(tx, workspace, &candidate)?; let source = policy_config::source(&candidate.evaluation)?; policy_config::validate_source(host, &source, require_subject)?; let state = WorkspaceConfigState { diff --git a/crates/workspace-server/src/content_history_tests.rs b/crates/workspace-server/src/content_history_tests.rs index ebdf1ffdb..68edbc186 100644 --- a/crates/workspace-server/src/content_history_tests.rs +++ b/crates/workspace-server/src/content_history_tests.rs @@ -1,6 +1,7 @@ //! Contract fixture for the actual Server database/transaction owner and the content adapter. //! Existing authorization is represented by an exact-target Host decision; this is not a route -//! or production policy cutover. Domain schema is installed only in this isolated fixture. +//! or production policy cutover. The Server lifecycle installs the content schema; +//! this fixture installs only its business data and exact-target Host decision. use config_source::{ ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, VirtualPath, @@ -88,7 +89,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai let path = dir.path().join("server.sqlite"); let db = SqliteWorkspaceStore::open_for_test(&path).unwrap(); db.with_immediate_transaction(|tx| { - tx.execute_batch(content_history_store::SCHEMA)?; tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('history-owner','user','history-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('history-ws','history-owner','History fixture','active','1','1');")?; Ok(()) }).unwrap(); @@ -127,7 +127,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai fn ticket_snapshot_and_history_restoration_do_not_rewind_separate_business_events() { let db = SqliteWorkspaceStore::in_memory_for_test().unwrap(); db.with_immediate_transaction(|tx| { - tx.execute_batch(content_history_store::SCHEMA)?; // Business state is deliberately not a field accepted by the content adapter. tx.execute_batch("CREATE TEMP TABLE business_events(kind TEXT,value TEXT); INSERT INTO business_events VALUES('state','closed'),('assignee','current-worker'),('relation','depends-on-current');")?; let s=scoped(tx); diff --git a/crates/workspace-server/src/plugin_catalog/migration_tests.rs b/crates/workspace-server/src/plugin_catalog/migration_tests.rs index 8b3493178..204034160 100644 --- a/crates/workspace-server/src/plugin_catalog/migration_tests.rs +++ b/crates/workspace-server/src/plugin_catalog/migration_tests.rs @@ -11,7 +11,9 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap(); let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap(); for version in [50, 105] { - let path = dir.path().join(format!("v{version}.db")); + let root = dir.path().join(format!("v{version}")); + std::fs::create_dir(&root).unwrap(); + let path = root.join("workspace.db"); if version == 50 { tests::prepare_schema_v50(&path, Some("workspace-a")); } else { @@ -30,7 +32,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() // Plugin catalog remains migration 106 even when startup applies later steps. assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::>(), vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); - assert_eq!(after.last().unwrap(), &(113, "Ticket work-decomposition parent links".into())); + assert_eq!(after.last().unwrap(), &(122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); @@ -83,6 +85,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang )); expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((113, "Ticket work-decomposition parent links".into())); + expected.push((122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(workspace_schema_migration_history(c)?, expected); crate::plugin_catalog::verify_with_data(c)?; Ok(()) @@ -101,7 +104,13 @@ fn plugin_catalog_startup_rejects_drifted_reference_fence() { }) .unwrap(); drop(store); - assert!( - matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted")) - ); + match SqliteWorkspaceStore::open_for_test(path) { + Err(Error::Store(message)) => assert!( + message.contains("Server storage format: physical Base mismatch") + && message.contains("plugin_reference_live"), + "{message}" + ), + Err(error) => panic!("unexpected rejection: {error}"), + Ok(_) => panic!("startup accepted a missing reference fence"), + } } diff --git a/crates/workspace-server/src/plugin_plan/migration_tests.rs b/crates/workspace-server/src/plugin_plan/migration_tests.rs index d98ed273d..8a55dbc27 100644 --- a/crates/workspace-server/src/plugin_plan/migration_tests.rs +++ b/crates/workspace-server/src/plugin_plan/migration_tests.rs @@ -41,6 +41,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match )); expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((113, "Ticket work-decomposition parent links".into())); + expected.push((122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(workspace_schema_migration_history(conn)?, expected); subject_assignment_migration::verify(conn)?; crate::plugin_plan::verify_plan_schema(conn)?; diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index c8a13c3f4..460f61ff5 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -189,24 +189,65 @@ fn project_repository_access_evaluation( projection_digest: &str, evaluation: &config_source::EvaluationResult, ) -> Result { - let projection = evaluation.projections.first().ok_or_else(|| { - Error::InvalidInput("Workspace config produced no active projection".to_string()) - })?; - let config: VirtualWorkspaceConfig = serde_json::from_value(projection.data_json.clone()) - .map_err(|error| { - Error::InvalidInput(format!("invalid Repository access config: {error}")) - })?; + let config = repository_access_source(evaluation)?; if !config.repository_access.is_empty() { secrets.ensure_workspace_default_credential(workspace_id)?; } + project_repository_access_bindings( + workspace_id, + projection_digest, + config, + |key| store.get_repository_by_key(workspace_id, key), + |id| secrets.get_credential(workspace_id, id, &[]), + |id| secrets.get_host_trust(workspace_id, id, &[]), + ) +} + +/// Validate against the same catalog transaction that will adopt the source. +/// This reads only public credential/trust metadata: no default key generation, +/// unsealing, filesystem writes or second connection under the writer lock. +/// Both preview and adoption use the owning projection's semantic checks. +pub(crate) fn validate_repository_access_in_transaction( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + candidate: &EvaluatedConfigCandidate, +) -> Result<()> { + project_repository_access_bindings( + workspace_id, + &candidate.evaluation.projection_digest, + repository_access_source(&candidate.evaluation)?, + |key| crate::store::read_repository_by_key(tx, workspace_id, key), + |id| read_credential(tx, workspace_id, id), + |id| read_host_trust(tx, workspace_id, id), + ) + .map(|_| ()) +} + +fn repository_access_source( + evaluation: &config_source::EvaluationResult, +) -> Result { + let projection = evaluation.projections.first().ok_or_else(|| { + Error::InvalidInput("Workspace config produced no active projection".to_string()) + })?; + serde_json::from_value(projection.data_json.clone()) + .map_err(|error| Error::InvalidInput(format!("invalid Repository access config: {error}"))) +} + +fn project_repository_access_bindings( + workspace_id: &str, + projection_digest: &str, + config: VirtualWorkspaceConfig, + repository: impl Fn(&str) -> Result>, + credential: impl Fn(&str) -> Result>, + host_trust: impl Fn(&str) -> Result>, +) -> Result { let mut bindings = Vec::with_capacity(config.repository_access.len()); for (repository_key, access) in config.repository_access { server_api::validate_repository_key(&repository_key) .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; validate_identifier("credential_id", &access.ssh.credential)?; validate_identifier("host_trust_id", &access.ssh.host_trust)?; - let repository = store - .get_repository_by_key(workspace_id, &repository_key)? + let repository = repository(&repository_key)? .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; validate_repository_access_source(&repository_key, &repository.source)?; if repository.source.kind != server_api::RepositorySourceKind::Ssh { @@ -214,28 +255,24 @@ fn project_repository_access_evaluation( "Repository `{repository_key}` is not an ssh:// Repository" ))); } - let credential = secrets - .get_credential(workspace_id, &access.ssh.credential, &[])? - .ok_or_else(|| { - Error::InvalidInput(format!( - "unknown Repository SSH credential `{}`", - access.ssh.credential - )) - })?; + let credential = credential(&access.ssh.credential)?.ok_or_else(|| { + Error::InvalidInput(format!( + "unknown Repository SSH credential `{}`", + access.ssh.credential + )) + })?; if credential.status != "active" { return Err(Error::InvalidInput(format!( "Repository SSH credential `{}` is not active", access.ssh.credential ))); } - let host_trust = secrets - .get_host_trust(workspace_id, &access.ssh.host_trust, &[])? - .ok_or_else(|| { - Error::InvalidInput(format!( - "unknown Repository SSH host trust `{}`", - access.ssh.host_trust - )) - })?; + let host_trust = host_trust(&access.ssh.host_trust)?.ok_or_else(|| { + Error::InvalidInput(format!( + "unknown Repository SSH host trust `{}`", + access.ssh.host_trust + )) + })?; let (hostname, port) = repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else( || { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a913b11f0..8e6588480 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -13242,7 +13242,9 @@ impl server_api::ServerApi for ServerApiContractService { &worker, &source.authentication_binding_id, )?; - recover_ticket_notifications(&api); + // The receipt certifies result/source commit, not notification acceptance. + // Do not make it wait for unrelated pending deliveries or their Runtime ACKs. + drop(enqueue_ticket_notification_recovery(&api)); Ok(receipt) })(); result @@ -17332,7 +17334,7 @@ async fn apply_set_ticket_assignment( // same best-effort notification completion as before offload. Ok::<_, Error>(( assignment, - enqueue_orchestrator_assignment_attention(¬ification_api), + enqueue_ticket_notification_recovery(¬ification_api), )) }) .await? @@ -23264,7 +23266,7 @@ fn maybe_dispatch_orchestrator_turn_end( }); } -fn enqueue_orchestrator_assignment_attention( +fn enqueue_ticket_notification_recovery( api: &WorkspaceApi, ) -> Option> { let api = api.clone(); @@ -23277,7 +23279,7 @@ fn enqueue_orchestrator_assignment_attention( }) { Ok(()) => Some(completion), Err(error) => { - tracing::warn!(%error, "Assign notification was not admitted; input remains durable"); + tracing::warn!(%error, "Ticket notification recovery was not admitted; source remains durable"); None } } @@ -70794,33 +70796,58 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); #[tokio::test] async fn generated_config_source_routes_preserve_nested_entry_paths() { let dir = tempfile::tempdir().unwrap(); - let app = test_app(dir.path()).await; + let api = test_api(dir.path()).await; + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "nested-config-owner").await; + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api)); + let app = workspace_server_router(catalog); let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); - let initial = get_json(app.clone(), &source_tree_path).await; - let digest = initial["snapshot"]["digest"].as_str().unwrap(); - - let committed = request_json( + let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let initial = get_json_authenticated(app.clone(), &adoption_path, &owner).await; + let request = json!({ + "operation_id": "nested-config-adoption", "expected_head": initial["history_head"], + "expected_catalog_digest": initial["catalog_digest"], "expected_legacy_export": initial["expected_legacy_export"], + "source": { + "base_digest": initial["state"]["snapshot"]["digest"], + "changes": [{ "kind": "create", "path": "notes/readme.txt", "content_type": "text", "content": "nested entry" }], + "entrypoints": ["main.dcdl"] + } + }); + let preview = request_json_authenticated( app.clone(), "POST", - &format!("{source_tree_path}/commit"), - Some(json!({ - "base_digest": digest, - "changes": [{ - "kind": "create", - "path": "notes/readme.txt", - "content_type": "text", - "content": "nested entry" - }], - "entrypoints": ["main.dcdl"] - })), - StatusCode::CREATED, + &format!("{adoption_path}/preview"), + Some(request.clone()), + &owner, + StatusCode::OK, ) .await; - let committed_digest = committed["snapshot"]["digest"].as_str().unwrap(); + let committed = request_json_authenticated( + app.clone(), + "POST", + &format!("{adoption_path}/commit"), + Some(request), + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(committed["state"], preview["state"]); + let committed_digest = committed["state"]["snapshot"]["digest"].as_str().unwrap(); - let config_snapshot = get_json( + let config_snapshot = get_json_authenticated( app.clone(), &format!("{source_tree_path}/history/{committed_digest}"), + &owner, ) .await; assert_eq!(config_snapshot["digest"], committed_digest); @@ -70829,9 +70856,10 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); "nested entry" ); - let entry = get_json( + let entry = get_json_authenticated( app, &format!("{source_tree_path}/entries/notes%2Freadme.txt"), + &owner, ) .await; assert_eq!(entry["path"], "notes/readme.txt"); diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs index d6414a8f1..b876c8e7d 100644 --- a/crates/workspace-server/src/server/tests/value_profiles_tests.rs +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -47,33 +47,63 @@ fn source(value: &str) -> String { async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; - let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); - let app = build_router(api.clone()); + let token = seed_test_workspace_owner_token(api.store.as_ref(), "builtin-profile-owner").await; + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); - let commit = format!("{tree}/commit"); - let initial = get_json_authenticated(app.clone(), &tree, &token).await; - let saved = request_json_authenticated( + let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let commit = format!("{adoption_path}/commit"); + let current = get_json_authenticated(app.clone(), &adoption_path, &token).await; + let initial = current["state"].clone(); + let adoption = |current: &Value, id: &str, source: Value| { + json!({ + "operation_id": id, "expected_head": current["history_head"], + "expected_catalog_digest": current["catalog_digest"], "expected_legacy_export": current["expected_legacy_export"], + "source": source, + }) + }; + let source_request = json!({ + "base_digest": current["state"]["snapshot"]["digest"], + "entrypoints": current["state"]["contract"]["entrypoints"], + "changes": [ + { "kind": "update", "path": "main.dcdl", + "expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"], + "content": source(r#"import "$builtin/profiles/companion.dcdl""#) }, + // A Workspace file at the matching suffix must not rescue an unknown builtin. + { "kind": "create", "path": "profiles/private.dcdl", "content_type": "decodal", "content": PROFILE } + ], + }); + let save = adoption(¤t, "builtin-profile-adopt", source_request); + let preview = request_json_authenticated( + app.clone(), + "POST", + &format!("{adoption_path}/preview"), + Some(save.clone()), + &token, + StatusCode::OK, + ) + .await; + let receipt = request_json_authenticated( app.clone(), "POST", &commit, - Some( - serde_json::to_value(request( - &api, - &source(r#"import "$builtin/profiles/companion.dcdl""#), - vec![config_source::ConfigTreeChange::Create { - // An ordinary Workspace file at the matching suffix must not rescue - // an unknown builtin import. - path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(), - content_type: config_source::ConfigContentType::Decodal, - content: PROFILE.into(), - }], - )) - .unwrap(), - ), + Some(save), &token, - StatusCode::CREATED, + StatusCode::OK, ) .await; + assert_eq!(receipt["state"], preview["state"]); + let saved = receipt["state"].clone(); assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]); let listed = get_json_authenticated(app.clone(), &tree, &token).await; assert_eq!(listed, saved); @@ -107,22 +137,23 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba "base_digest": listed["snapshot"]["digest"], "entrypoints": listed["contract"]["entrypoints"], }); + let current = get_json_authenticated(app.clone(), &adoption_path, &token).await; let reserved = "$builtin/profiles/companion.dcdl"; - for change in [ + for (index, change) in [ json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }), json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }), json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }), json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }), json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }), json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }), - ] { + ].into_iter().enumerate() { let mut body = base.clone(); body["changes"] = json!([change]); let error = request_json_authenticated( app.clone(), "POST", &commit, - Some(body), + Some(adoption(¤t, &format!("reserved-builtin-{index}"), body)), &token, StatusCode::BAD_REQUEST, ) @@ -148,7 +179,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba app.clone(), "POST", &commit, - Some(body), + Some(adoption(¤t, "unknown-builtin-import", body)), &token, StatusCode::BAD_REQUEST, ) diff --git a/crates/workspace-server/src/server_configuration_adoption.rs b/crates/workspace-server/src/server_configuration_adoption.rs index b64baf2d7..d4996056c 100644 --- a/crates/workspace-server/src/server_configuration_adoption.rs +++ b/crates/workspace-server/src/server_configuration_adoption.rs @@ -472,6 +472,24 @@ impl ConfigurationAdoptionApi for WorkspaceServerApi { )) }) .map_err(transfer_error)??; + // Core and Feature guards are released before best-effort Runtime + // publication. Dispatch transfers the admitted lifetime without + // keeping the SQL lane occupied by an external Runtime response. + // Exact historical receipt replay above must not publish old state. + let publication_api = api.clone(); + let state = result.state.clone(); + let _ = api.host_executors.host().and_then(|executor| { + executor.dispatch(move || { + if let Ok(projection) = publication_api + .prompt_projection_cache + .resolve(&publication_api.config.workspace_id, &state) + { + let _ = publication_api + .runtime + .observe_workspace_prompt_projection((*projection).clone()); + } + }) + }); Ok(receipt_to_api(result)) }) .await diff --git a/crates/workspace-server/src/server_configuration_adoption_tests.rs b/crates/workspace-server/src/server_configuration_adoption_tests.rs index 12ab1c5dc..96c0cc58f 100644 --- a/crates/workspace-server/src/server_configuration_adoption_tests.rs +++ b/crates/workspace-server/src/server_configuration_adoption_tests.rs @@ -355,6 +355,270 @@ mod configuration_adoption_http_integration { ) } + #[tokio::test] + async fn owner_http_repository_validation_is_read_only_and_precedes_seal() { + let f = Fixture::new().await; + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, + uri: "ssh://git@repository.example.test:2222/team/repo.git".into(), + }; + f.api + .store + .upsert_repository(&crate::store::RepositoryRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + repository_id: "ssh-repository".into(), + repository_key: "remote".into(), + kind: "git".into(), + provider: None, + source_fingerprint: crate::repository_source::repository_source_fingerprint( + &source, + ), + source, + default_ref: None, + observed_status: server_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + let mut non_ssh = f + .api + .store + .get_repository_by_key(TEST_WORKSPACE_ID, "remote") + .unwrap() + .unwrap(); + non_ssh.repository_id = "https-repository".into(); + non_ssh.repository_key = "nonssh".into(); + non_ssh.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Https, + uri: "https://repository.example.test/team/repo.git".into(), + }; + non_ssh.source_fingerprint = + crate::repository_source::repository_source_fingerprint(&non_ssh.source); + f.api.store.upsert_repository(&non_ssh).unwrap(); + for id in ["deploy", "revoked"] { + f.api + .repository_secrets + .generate_credential( + TEST_WORKSPACE_ID, + GenerateRepositorySshCredentialRequest { + operation_id: format!("generate-{id}"), + credential_id: id.into(), + name: id.into(), + }, + &f.owner_account, + ) + .unwrap(); + } + f.api.config_store.with_conn(|conn| { + conn.execute("UPDATE repository_ssh_credentials SET status='revoked' WHERE workspace_id=?1 AND credential_id='revoked'", [TEST_WORKSPACE_ID])?; + Ok(()) + }).unwrap(); + let public_key = + ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[37; 32])) + .public_key() + .to_openssh() + .unwrap(); + for (id, hostname, port) in [ + ("matching", "repository.example.test", 2222), + ("wrong-host", "other.example.test", 2222), + ("wrong-port", "repository.example.test", 22), + ] { + f.api + .repository_secrets + .put_host_trust( + TEST_WORKSPACE_ID, + PutRepositorySshHostTrustRequest { + operation_id: format!("trust-{id}"), + host_trust_id: id.into(), + hostname: hostname.into(), + port, + host_key: public_key.clone(), + expected_fingerprint: None, + }, + &f.owner_account, + ) + .unwrap(); + } + let secret_counts = || { + f.api + .config_store + .with_conn(|conn| { + [ + "repository_ssh_credentials", + "repository_ssh_credential_keys", + "repository_ssh_host_trusts", + "repository_ssh_host_trust_keys", + "repository_secret_operations", + "repository_secret_audit_events", + "server_secret_objects", + ] + .into_iter() + .map(|table| { + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE workspace_id=?1"), + [TEST_WORKSPACE_ID], + |r| r.get::<_, i64>(0), + ) + .map_err(Error::from) + }) + .collect::>>() + }) + .unwrap() + }; + let secrets_before = secret_counts(); + let content = |repo: &str, credential: &str, trust: &str, access: &str| { + format!( + "{{ repository_access = {{ {repo} = {{ ssh = {{ credential = {credential:?}; host_trust = {trust:?}; access = {access:?}; }}; }}; }}; }}" + ) + }; + for canonical in [false, true] { + let current = f.source().await; + assert_eq!(current.source_stored, canonical); + let before = f.durable(); + for (case, repo, credential, trust, access, diagnostic) in [ + ( + "mode", + "remote", + "deploy", + "matching", + "not-a-mode", + "invalid Repository access config", + ), + ( + "repository", + "absent", + "deploy", + "matching", + "read_only", + "unknown Repository", + ), + ( + "non-ssh", + "nonssh", + "deploy", + "matching", + "read_only", + "not an ssh", + ), + ( + "credential", + "remote", + "absent", + "matching", + "read_only", + "unknown Repository SSH credential", + ), + ( + "inactive", + "remote", + "revoked", + "matching", + "read_only", + "is not active", + ), + ( + "trust", + "remote", + "deploy", + "absent", + "read_only", + "unknown Repository SSH host trust", + ), + ( + "host", + "remote", + "deploy", + "wrong-host", + "read_only", + "does not match host trust", + ), + ( + "port", + "remote", + "deploy", + "wrong-port", + "read_only", + "does not match host trust", + ), + ] { + let adoption = request( + ¤t, + &format!("repository-{canonical}-{case}"), + vec![update( + ¤t, + "main.dcdl", + &content(repo, credential, trust, access), + )], + ); + for endpoint in ["/preview", "/commit"] { + let rejected = f + .owner_call(endpoint, &adoption, StatusCode::BAD_REQUEST) + .await; + assert!( + rejected.to_string().contains(diagnostic), + "{case} {endpoint}: {rejected}" + ); + assert_eq!( + f.durable(), + before, + "{case} {endpoint}: all old authorities must remain unchanged" + ); + assert_eq!( + secret_counts(), + secrets_before, + "validation must not generate keys or secret receipts" + ); + if canonical { + f.assert_legacy_sealed(); + } else { + f.assert_legacy_writable(); + } + } + } + let valid = request( + ¤t, + &format!("valid-repository-{canonical}"), + vec![update( + ¤t, + "main.dcdl", + &content( + "remote", + "deploy", + "matching", + if canonical { "read_write" } else { "read_only" }, + ), + )], + ); + let preview = f.owner_call("/preview", &valid, StatusCode::OK).await; + assert_eq!( + f.durable(), + before, + "valid preview must remain a read, before and after cutover" + ); + assert_eq!(secret_counts(), secrets_before); + let saved = f.commit(&valid).await; + assert_eq!( + serde_json::to_value(&saved.state).unwrap(), + preview["state"] + ); + assert_eq!(f.source().await.state, saved.state); + f.assert_legacy_sealed(); + assert_eq!( + secret_counts(), + secrets_before, + "adoption validates metadata without secret side effects" + ); + } + assert!( + f.api + .repository_secrets + .get_credential(TEST_WORKSPACE_ID, "workspace-default", &[]) + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() { let fixture = Fixture::new().await; diff --git a/crates/workspace-server/src/server_implementation_result_tests.rs b/crates/workspace-server/src/server_implementation_result_tests.rs index dc58c3691..a17c589b1 100644 --- a/crates/workspace-server/src/server_implementation_result_tests.rs +++ b/crates/workspace-server/src/server_implementation_result_tests.rs @@ -46,6 +46,111 @@ async fn assert_result_diagnostics( assert_eq!(checks[0]["captured"], true); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn implementation_result_receipt_and_replay_do_not_wait_for_notification_acceptance() { + use std::time::Duration; + + struct ReleaseOnDrop(Option>); + impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + if let Some(release) = self.0.take() { + let _ = release.send(()); + } + } + } + + let dir = tempfile::tempdir().unwrap(); + let (api, execution, orchestrator, identity) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Receipt is independent of notification acceptance"); + delivery_assign(&api, &ticket.id, "blocked-result-delegation").await; + let coder = spawn_ticket_check_source(&api, "blocked-result-coder"); + seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id); + let input = json!({"operation_key":"blocked-result", "kind":"result", "body":"Committed facts survive a blocked notification"}); + + let (entered, entered_rx) = tokio::sync::oneshot::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let release = ReleaseOnDrop(Some(release)); + let recipient = orchestrator.worker_id.clone(); + execution.before_input_returns(move |worker| { + assert_eq!(worker.worker_id.to_string(), recipient); + let _ = entered.send(()); + release_rx.recv_timeout(Duration::from_secs(15)).unwrap(); + }); + let mut report = tokio::spawn({ + let api = api.clone(); + let identity = identity.clone(); + let coder = coder.clone(); + let ticket = ticket.id.clone(); + let input = input.clone(); + async move { result_signed_http(&api, &identity, &coder, &ticket, &input, StatusCode::OK).await } + }); + tokio::time::timeout(Duration::from_secs(5), entered_rx) + .await + .expect("notification reached the gated Runtime acceptance boundary") + .unwrap(); + let receipt = tokio::time::timeout(Duration::from_secs(2), &mut report).await; + let replay = if receipt.is_ok() { + Some( + tokio::time::timeout( + Duration::from_secs(2), + result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK), + ) + .await, + ) + } else { + None + }; + api.config_store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM ticket_implementation_results", + [], + |row| row.get::<_, i64>(0) + )?, + 1 + ); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", + [], + |row| row.get::<_, i64>(0) + )?, + 1 + ); + Ok(()) + }) + .unwrap(); + + // Release before asserting the response result, including when testing the old coupled handler. + drop(release); + let cleanup = if receipt.is_err() { + Some(tokio::time::timeout(Duration::from_secs(5), report).await) + } else { + None + }; + tokio::time::timeout( + Duration::from_secs(5), + api.host_executors.host().unwrap().drain(), + ) + .await + .expect("owned recovery drained after release") + .unwrap(); + let receipt = receipt + .expect("committed receipt must not wait for Runtime acceptance") + .unwrap(); + assert_eq!( + replay + .unwrap() + .expect("replay must not wait for Runtime acceptance"), + receipt + ); + assert!(cleanup.is_none()); + assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await; + delivery_stop(&api, &coder); + delivery_stop(&api, &orchestrator); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr() { diff --git a/crates/workspace-server/src/server_plugin_controller_tests.rs b/crates/workspace-server/src/server_plugin_controller_tests.rs index 834ec5994..22dab8682 100644 --- a/crates/workspace-server/src/server_plugin_controller_tests.rs +++ b/crates/workspace-server/src/server_plugin_controller_tests.rs @@ -252,32 +252,7 @@ mod plugin_controller_tests { crate::plugin_settings::render_value(&plugins).unwrap(), crate::plugin_settings::render_value(&selection).unwrap() ); - let state = self - .api - .config_store - .load_workspace_config(TEST_WORKSPACE_ID) - .unwrap() - .unwrap(); - let path = config_source::VirtualPath::parse("main.dcdl").unwrap(); - let commit = ConfigCommitRequest { - base_digest: state.snapshot.digest.clone(), - entrypoints: state.contract.entrypoints.clone(), - changes: vec![config_source::ConfigTreeChange::Update { - path: path.clone(), - expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(), - content, - }], - }; - request( - build_router(self.api.clone()), - Some(&self.owner), - "POST", - &format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit"), - serde_json::to_vec(&commit).unwrap(), - false, - StatusCode::CREATED, - ) - .await; + super::plugin_tests::adopt_plugin_config(self.api.clone(), &self.owner, content).await; } async fn revoke(&self, installed: &server_api::PluginOperationResponse) { diff --git a/crates/workspace-server/src/server_plugin_host_tests.rs b/crates/workspace-server/src/server_plugin_host_tests.rs index 5a1e175f3..acbbe6db3 100644 --- a/crates/workspace-server/src/server_plugin_host_tests.rs +++ b/crates/workspace-server/src/server_plugin_host_tests.rs @@ -1373,25 +1373,24 @@ fn assert_check_physical_initializations( .subjektiv_registration .open_observation() .since(before); - let directory = f - .api - .config - .database_path - .parent() - .unwrap() - .join("feature-storage") - .join(TEST_WORKSPACE_ID) - .join("features"); - let resolved = fs::canonicalize(&directory).unwrap(); - let ancestors: std::collections::HashSet<_> = - directory.ancestors().chain(resolved.ancestors()).collect(); + // Subject creation has already durably provisioned this physical DB. Cold + // connection opens must still initialize/read SQLite, but must not provision + // or sync new metadata ancestors. Initial publication sync is owned below + // FeatureStorage's identity boundary, not repeated per authorization check. + assert!( + before.directory_sync_attempts > 0, + "fixture must exercise initial publication I/O" + ); + assert_eq!( + before.directory_sync_successes, + before.directory_sync_attempts + ); assert_eq!(observed.completed_initializations, expected, "{observed:?}"); assert_eq!(observed.physical_open_attempts, expected, "{observed:?}"); assert_eq!(observed.physical_opens, expected, "{observed:?}"); assert_eq!( - observed.directory_sync_attempts, - expected * ancestors.len() as u64, - "{observed:?}" + observed.directory_sync_attempts, 0, + "owned DB reopen must not publish directories: {observed:?}" ); assert_eq!( observed.directory_sync_successes, diff --git a/crates/workspace-server/src/server_plugin_remote_controller_tests.rs b/crates/workspace-server/src/server_plugin_remote_controller_tests.rs index 6729c67cf..e16b3b968 100644 --- a/crates/workspace-server/src/server_plugin_remote_controller_tests.rs +++ b/crates/workspace-server/src/server_plugin_remote_controller_tests.rs @@ -126,30 +126,7 @@ mod plugin_remote_controller_tests { crate::plugin_settings::render_value(&plugins).unwrap(), crate::plugin_settings::render_value(&selection).unwrap() ); - let state = f - .api - .config_store - .load_workspace_config(TEST_WORKSPACE_ID) - .unwrap() - .unwrap(); - let path = config_source::VirtualPath::parse("main.dcdl").unwrap(); - let commit = ConfigCommitRequest { - base_digest: state.snapshot.digest.clone(), - entrypoints: state.contract.entrypoints.clone(), - changes: vec![config_source::ConfigTreeChange::Update { - path: path.clone(), - expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(), - content, - }], - }; - let (status, result) = f - .human( - reqwest::Method::POST, - &format!("{prefix}/config/source-tree/commit"), - serde_json::to_value(commit).unwrap(), - ) - .await; - assert_eq!(status, StatusCode::CREATED, "{result}"); + super::plugin_tests::adopt_plugin_config(f.api.clone(), &f.owner, content).await; (installed, package.bytes) } diff --git a/crates/workspace-server/src/server_plugin_tests.rs b/crates/workspace-server/src/server_plugin_tests.rs index c5eee0607..b7ee5fea3 100644 --- a/crates/workspace-server/src/server_plugin_tests.rs +++ b/crates/workspace-server/src/server_plugin_tests.rs @@ -77,6 +77,53 @@ async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode) request(app, token, "GET", path, vec![], false, expected).await } +pub(super) async fn adopt_plugin_config(api: WorkspaceApi, owner: &str, content: String) { + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api)); + let app = workspace_server_router(catalog); + let prefix = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let current = get(app.clone(), Some(owner), &prefix, StatusCode::OK).await; + let adoption = serde_json::json!({ + "operation_id": uuid::Uuid::now_v7().to_string(), + "expected_head": current["history_head"], + "expected_catalog_digest": current["catalog_digest"], + "expected_legacy_export": current["expected_legacy_export"], + "source": { + "base_digest": current["state"]["snapshot"]["digest"], + "entrypoints": ["main.dcdl"], + "changes": [{"kind":"update", "path":"main.dcdl", + "expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"], + "content": content}] + } + }); + let preview = json( + app.clone(), + owner, + &format!("{prefix}/preview"), + adoption.clone(), + StatusCode::OK, + ) + .await; + let saved = json( + app, + owner, + &format!("{prefix}/commit"), + adoption, + StatusCode::OK, + ) + .await; + assert_eq!(saved["state"], preview["state"]); +} + #[tokio::test] async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() { let dir = tempfile::tempdir().unwrap(); @@ -414,12 +461,23 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog } #[tokio::test] -async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() { +async fn workspace_owner_without_hosting_admin_configures_plugin_through_owner_adoption() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-config-owner").await; let reader = seed_test_api_token(api.store.as_ref(), "plugin-config-reader"); - let app = build_router(api.clone()); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); let prefix = format!("/api/w/{TEST_WORKSPACE_ID}"); let upload = request( app.clone(), @@ -435,7 +493,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin let state = get( app.clone(), Some(&owner), - &format!("{prefix}/config/source-tree"), + &format!("{prefix}/config/adoption"), StatusCode::OK, ) .await; @@ -447,11 +505,15 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin a["package_digest"], a["artifact_digest"] ); - let change = serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]}); + let change = serde_json::json!({ + "operation_id": "config-plugin-adopt", "expected_head":state["history_head"], + "expected_catalog_digest":state["catalog_digest"], "expected_legacy_export":state["expected_legacy_export"], + "source": {"base_digest":state["state"]["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]} + }); json( app.clone(), &reader, - &format!("{prefix}/config/source-tree/commit"), + &format!("{prefix}/config/adoption/commit"), change.clone(), StatusCode::FORBIDDEN, ) @@ -459,7 +521,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin json( app.clone(), &reader, - &format!("{prefix}/config/source-tree/preview"), + &format!("{prefix}/config/adoption/preview"), change.clone(), StatusCode::FORBIDDEN, ) @@ -467,7 +529,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin let preview = json( app.clone(), &owner, - &format!("{prefix}/config/source-tree/preview"), + &format!("{prefix}/config/adoption/preview"), change.clone(), StatusCode::OK, ) @@ -475,26 +537,42 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin let unchanged = get( app.clone(), Some(&owner), - &format!("{prefix}/config/source-tree"), + &format!("{prefix}/config/adoption"), StatusCode::OK, ) .await; - assert_eq!(unchanged["snapshot"]["digest"], state["snapshot"]["digest"]); + assert_eq!(unchanged, state); let saved = json( app.clone(), &owner, - &format!("{prefix}/config/source-tree/commit"), + &format!("{prefix}/config/adoption/commit"), change.clone(), - StatusCode::CREATED, + StatusCode::OK, ) .await; - assert_eq!(saved, preview); - assert_ne!(saved["snapshot"]["digest"], state["snapshot"]["digest"]); + assert_eq!(saved["state"], preview["state"]); + assert_ne!( + saved["state"]["snapshot"]["digest"], + state["state"]["snapshot"]["digest"] + ); + assert_eq!( + json( + app.clone(), + &owner, + &format!("{prefix}/config/adoption/commit"), + change.clone(), + StatusCode::OK + ) + .await, + saved + ); + let mut stale = change; + stale["operation_id"] = "stale-plugin-adoption".into(); json( app.clone(), &owner, - &format!("{prefix}/config/source-tree/commit"), - change, + &format!("{prefix}/config/adoption/commit"), + stale, StatusCode::CONFLICT, ) .await; diff --git a/crates/workspace-server/src/server_residual_execution_tests.rs b/crates/workspace-server/src/server_residual_execution_tests.rs index ff81318b2..3ca8b2be0 100644 --- a/crates/workspace-server/src/server_residual_execution_tests.rs +++ b/crates/workspace-server/src/server_residual_execution_tests.rs @@ -1316,13 +1316,28 @@ async fn drive_and_config_preflight_keep_live_authority_owned_and_report_overloa } #[tokio::test(flavor = "current_thread")] -async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() { +async fn owner_adoption_publishes_after_commit_without_holding_the_sql_lane() { + use server_api::ConfigurationAdoptionApi; let fixture = fixture().await; + // This executor fixture supplies an authenticated owner context; full cookie + // admission belongs to configuration_adoption_http_integration. + let context = server_api::ServerRequestContext { + actor: Some(RequestActor { + user_id: "fixture-user".into(), + account_id: format!("account-{WORKSPACE}"), + handle: "fixture-user".into(), + display_name: "Fixture user".into(), + auth_method: ActorAuthMethod::BrowserSession, + }), + worker_source: None, + runtime_source: None, + origin: Some("http://localhost:5173".into()), + transport_headers: Vec::new(), + }; let current = fixture - .api - .config_store - .load_workspace_config(WORKSPACE) - .unwrap() + .server + .configuration_source(context.clone(), WORKSPACE.into()) + .await .unwrap(); let mut reply = HeldRuntimeReply::new(); install_remote_fixture( @@ -1331,31 +1346,29 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() &format!("http://{}", reply.address), false, ); - let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut commit = Box::pin(crate::db_executor::with_operation_lifetime( - Arc::new(CountJobLifetime(leases.clone())), - scoped_commit_workspace_config_tree( - State(fixture.api.clone()), - AxumPath(ScopedWorkspacePath { - workspace_id: WORKSPACE.into(), - }), - Json(server_api::ConfigCommitRequest { - base_digest: current.snapshot.digest.clone(), - entrypoints: current - .contract - .entrypoints - .iter() - .map(ToString::to_string) - .collect(), - changes: vec![server_api::ConfigTreeChange::Create { - path: "notes/owned-publication.txt".into(), - content_type: server_api::ConfigContentType::Text, - content: "committed before prompt publication".into(), - }], - }), - ), - )); - assert!(futures::poll!(&mut commit).is_pending()); + let server = fixture.server.clone(); + let request = server_api::ConfigurationAdoptionRequest { + operation_id: "owned-publication".into(), + expected_head: current.history_head.clone(), + expected_catalog_digest: current.catalog_digest.clone(), + expected_legacy_export: current.expected_legacy_export.clone(), + source: server_api::ConfigCommitRequest { + base_digest: current.state.snapshot.digest.clone(), + entrypoints: current.state.contract.entrypoints.clone(), + changes: vec![server_api::ConfigTreeChange::Create { + path: "notes/owned-publication.txt".into(), + content_type: server_api::ConfigContentType::Text, + content: "committed before prompt publication".into(), + }], + }, + }; + let commit = tokio::spawn(async move { + server + .configuration_adopt(context, WORKSPACE.into(), request) + .await + }); + let receipt = observed(commit).await.unwrap().unwrap(); + assert!(receipt.source_stored && receipt.policy_committed); let request = observed(reply.entered.take().unwrap()).await.unwrap(); assert!( request.starts_with("POST "), @@ -1371,7 +1384,7 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() .unwrap() .unwrap(); assert_ne!( - state.snapshot.digest, current.snapshot.digest, + state.snapshot.digest, current.state.snapshot.digest, "publication must observe the committed tree" ); assert!( @@ -1387,12 +1400,16 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() "control" ); assert!(!reply.upstream_released.load(Ordering::SeqCst)); - drop(commit); - assert_eq!(leases.load(Ordering::SeqCst), 1); + let admission = fixture.server.admission_for_workspace(WORKSPACE).await; + let mut deletion = Box::pin(admission.lock_deletion()); + assert!( + futures::poll!(&mut deletion).is_pending(), + "publication must retain its admitted Workspace lifetime after the receipt returns" + ); reply.finish(); observed(fixture.host_executor.drain()).await.unwrap(); observed(fixture.executor.drain()).await.unwrap(); - assert_eq!(leases.load(Ordering::SeqCst), 0); + let _deletion = observed(deletion).await; assert_eq!( fixture .api diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 3c54da854..6c5e29a54 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -633,9 +633,12 @@ pub(super) async fn commit( &persistence_api.config.workspace_id, &candidate, ).map_err(|_| unknown())?; + // Repository projection may already have auxiliary secret + // effects. A subsequent save error cannot promise that the + // whole operation had no durable effect, even on SQL rollback. persistence_api.config_store.commit_evaluated_workspace_config( &persistence_api.config.workspace_id, &candidate, - ).map_err(pre_error) + ).map_err(|_| unknown()) })()) }).map_err(|error| match error { subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"), diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index 9bbc39641..9bb15b701 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -390,8 +390,10 @@ mod workspace_config_integration { "the granted WIP path remains available" ); - // Real API-token authentication still permits the normal UI editor. - let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + // Real owner API-token authentication still permits the normal editor, + // but only through the head/request-bound adoption boundary. + let token = + seed_test_workspace_owner_token(api.store.as_ref(), "logical-editor-owner").await; for (method, path) in &routes { if *method == "GET" { request_json_authenticated( @@ -409,11 +411,52 @@ mod workspace_config_integration { build_router(api.clone()), "POST", &format!("{tree}/commit"), - Some(serde_json::to_value(change).unwrap()), + Some(serde_json::to_value(&change).unwrap()), &token, - StatusCode::CREATED, + StatusCode::CONFLICT, ) .await; + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let editor = workspace_server_router(catalog); + let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let current = get_json_authenticated(editor.clone(), &adoption_path, &token).await; + let adoption = json!({ + "operation_id": "logical-editor-owner-adoption", + "expected_head": current["history_head"], + "expected_catalog_digest": current["catalog_digest"], + "expected_legacy_export": current["expected_legacy_export"], + "source": {"base_digest": current["state"]["snapshot"]["digest"], + "entrypoints": current["state"]["contract"]["entrypoints"], "changes": change.changes} + }); + let preview = request_json_authenticated( + editor.clone(), + "POST", + &format!("{adoption_path}/preview"), + Some(adoption.clone()), + &token, + StatusCode::OK, + ) + .await; + let receipt = request_json_authenticated( + editor, + "POST", + &format!("{adoption_path}/commit"), + Some(adoption), + &token, + StatusCode::OK, + ) + .await; + assert_eq!(receipt["state"], preview["state"]); let saved = api .config_store .load_workspace_config(TEST_WORKSPACE_ID) diff --git a/crates/workspace-server/src/storage_owner_tests.rs b/crates/workspace-server/src/storage_owner_tests.rs index 02e3a6498..44532c1f1 100644 --- a/crates/workspace-server/src/storage_owner_tests.rs +++ b/crates/workspace-server/src/storage_owner_tests.rs @@ -41,6 +41,113 @@ fn legacy_subject(source: &Path, destination: &Path, attribution: &str) -> Strin subject.id } +#[test] +fn reserved_deletion_reuses_owned_storage_but_never_adopts_legacy_or_recreates_missing() { + use crate::workspace_deletion::WorkspaceDeletionStore; + for owned in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let catalog = catalog(&path); + let scope = FeatureStorage::for_server_database(&path) + .unwrap() + .scope("storage-workspace") + .unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let feature_path = temp + .path() + .join("feature-storage/storage-workspace/features/subjektiv.sqlite"); + let identity = if owned { + Some(scope.open(®istration).unwrap().identity().clone()) + } else { + legacy_subject( + &temp.path().join("source"), + &feature_path, + "storage-workspace", + ); + None + }; + catalog.with_conn(|conn| { + conn.execute_batch("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('retained-workspace','storage-owner','Retain','active','1','1');")?; + Ok(()) + }).unwrap(); + let preflight = catalog + .workspace_deletion_preflight("storage-owner", "storage-workspace") + .unwrap(); + catalog + .reserve_workspace_deletion( + "storage-owner", + "storage-workspace", + &server_api::WorkspaceDeletionRequest { + operation_id: "delete-storage".into(), + expected_workspace_updated_at: preflight.expected_workspace_updated_at, + confirmation: preflight.display_name, + }, + ) + .unwrap(); + let result = catalog.adopt_workspace_feature_storage(&scope, ®istration, "store_scope"); + if let Some(identity) = identity { + result.unwrap(); + assert_eq!( + scope + .open_existing(®istration, &identity) + .unwrap() + .identity(), + &identity + ); + std::fs::remove_file(&feature_path).unwrap(); + assert!( + catalog + .adopt_workspace_feature_storage(&scope, ®istration, "store_scope") + .is_err() + ); + assert!(!feature_path.exists()); + } else { + assert!(result.is_err()); + let raw = Connection::open(&feature_path).unwrap(); + let identity_count: i64 = raw + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE name='__yoi_storage_identity'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(identity_count, 0, "deletion is not new adoption authority"); + } + } +} + +#[test] +fn deleting_without_reservation_cannot_reuse_storage() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let catalog = catalog(&path); + let scope = FeatureStorage::for_server_database(&path) + .unwrap() + .scope("storage-workspace") + .unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let identity = scope.open(®istration).unwrap().identity().clone(); + catalog + .with_conn(|conn| { + conn.execute("UPDATE workspaces SET state='deleting'", [])?; + Ok(()) + }) + .unwrap(); + assert!( + catalog + .adopt_workspace_feature_storage(&scope, ®istration, "store_scope") + .is_err() + ); + assert_eq!( + scope + .open_existing(®istration, &identity) + .unwrap() + .identity(), + &identity + ); +} + #[test] fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index e9e437f82..ccbbb0d00 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -3076,10 +3076,25 @@ impl SqliteWorkspaceStore { } }; self.with_immediate_transaction(|tx| { - let row: Option<(String,String)> = tx.query_row( - "SELECT owner_account_id,created_at FROM workspaces WHERE workspace_id=?1 AND state='active'",[storage.scope_id()], - |row| Ok((row.get(0)?,row.get(1)?))).optional()?; - let (owner,created) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?; + let row: Option<(String,String,String)> = tx.query_row( + "SELECT owner_account_id,created_at,state FROM workspaces WHERE workspace_id=?1",[storage.scope_id()], + |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).optional()?; + let (owner,created,state) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?; + if state == "deleting" { + // A retained deletion consumer can validate completed ownership, + // but cannot authorize new legacy adoption or replacement storage. + let reserved: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspace_deletion_operations WHERE workspace_id=?1 AND owner_account_id=?2 AND state!='succeeded')", + params![storage.scope_id(),owner], |row| row.get(0))?; + if !reserved { + return Err(Error::Store("Workspace storage requires a retained deletion reservation".into())); + } + storage.verify_owned_if_present(registration)?; + return Ok(()); + } + if state != "active" { + return Err(Error::Store("Workspace storage adoption requires an active catalog owner".into())); + } let evidence = format!("catalog:{}:{owner}:{created}",storage.scope_id()); let request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id()); storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| { @@ -6711,19 +6726,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, repository_key: &str, ) -> Result> { - self.with_conn(|conn| { - conn.query_row( - r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, - source_fingerprint, observed_status, observed_at, created_at, updated_at - FROM repositories - WHERE workspace_id = ?1 AND repository_key = ?2"#, - params![workspace_id, repository_key], - read_repository_record, - ) - .optional() - .map_err(Error::from) - }) + self.with_conn(|conn| read_repository_by_key(conn, workspace_id, repository_key)) } fn list_repositories(&self, workspace_id: &str) -> Result> { @@ -12441,6 +12444,24 @@ fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()> Ok(()) } +pub(crate) fn read_repository_by_key( + conn: &Connection, + workspace_id: &str, + repository_key: &str, +) -> Result> { + conn.query_row( + r#"SELECT workspace_id, repository_id, repository_key, kind, provider, + source_kind, source_uri, default_ref, + source_fingerprint, observed_status, observed_at, created_at, updated_at + FROM repositories + WHERE workspace_id = ?1 AND repository_key = ?2"#, + params![workspace_id, repository_key], + read_repository_record, + ) + .optional() + .map_err(Error::from) +} + fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { let source_kind_value = row.get::<_, String>(5)?; let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value) diff --git a/tools/web-ux/browser-tests/configuration_adoption_review.ts b/tools/web-ux/browser-tests/configuration_adoption_review.ts new file mode 100644 index 000000000..7c2d19dca --- /dev/null +++ b/tools/web-ux/browser-tests/configuration_adoption_review.ts @@ -0,0 +1,319 @@ +#!/usr/bin/env -S deno run -A +/** 実Web/実WASMを合成HTTPで操作する。認証・保存の証拠はServer suiteが所有する。 */ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join, resolve } from "@std/path"; +import { createHash } from "node:crypto"; +import { chromium, type Page } from "playwright"; +import { sha256File } from "../src/artifacts.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts"; +import init, { + apply_changes, + compose_schema_bundle, + set_snapshot, +} from "../../../web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js"; +import type { + ConfigTreeChange, + ConfigTreeSnapshot, + WorkspaceConfigSchemaBundle, + WorkspaceConfigTreeResponse, +} from "../../../web/workspace/src/lib/workspace/config-source/types.ts"; + +const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build")); +const runId = Deno.args[1]; +const before = Deno.args[2] === "before"; +if (!runId || !/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("supply unique safe run id"); +const output = join(root, "target/web-ux/t838", runId); +await Deno.mkdir(dirname(output), { recursive: true }); +await Deno.mkdir(output); +await init({ + module_or_path: await Deno.readFile( + join( + root, + "web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", + ), + ), +}); +const hash = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`; +const schema = compose_schema_bundle([]) as WorkspaceConfigSchemaBundle; +const content = + "{\n permissions = { subjects = []; remove_subjects = []; };\n} as WorkspaceConfigSchema\n"; +const treeHash = createHash("sha256").update("yoi-config-tree-v1\0").update( + "main.dcdl\0text/x-decodal\0", +).update(content).update("\0"); +const initial: WorkspaceConfigTreeResponse = { + snapshot: { + digest: `sha256:${treeHash.digest("hex")}`, + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content, + content_digest: hash(content), + }, + }, + }, + contract: { + contract_version: 2, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: schema, + fingerprint: hash(JSON.stringify([2, "0.4.0", 1, ["main.dcdl"], 1, schema.fingerprint])), + }, + projection_digest: hash("synthetic-empty-policy-projection"), +}; +const port = await freePort(); +const base = `http://127.0.0.1:${port}`; +const owned = await startOwnedProcesses( + [{ + id: "configuration-synthetic-http", + command: Deno.execPath(), + cwd: root, + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"), + String(port), + buildRoot, + ], + readyUrl: `${base}/health`, + }], + import.meta.url, + join(output, "processes"), + [], +); +const source = await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root }).output(); +const manifest = { + evidence: "actual-Web-and-WASM-with-synthetic-HTTP-NOT-real-Backend", + before, + source: new TextDecoder().decode(source.stdout).trim(), + buildRoot, + buildIndexSha256: await sha256File(join(buildRoot, "index.html")), + visualVerdict: "UNREVIEWED", + captures: [] as unknown[], + failures: [] as string[], +}; +const browser = await chromium.launch({ headless: true }); +const documents = new WeakMap(); +async function capture(page: Page, id: string, errors: unknown[], expectedErrors: string[] = []) { + if (id.endsWith("-unknown")) { + await page.locator(".config-source-conflict").scrollIntoViewIfNeeded(); + } else if (id.endsWith("-late-receipt")) { + await page.locator(".config-source-status").scrollIntoViewIfNeeded(); + } + const image = join(output, `${id}.png`); + await page.screenshot({ path: image }); + const accessibilitySnapshot = `${id}.aria.txt`; + await Deno.writeTextFile( + join(output, accessibilitySnapshot), + await page.locator("body").ariaSnapshot(), + ); + manifest.captures.push({ + id, + route: new URL(page.url()).pathname, + documentStatus: documents.get(page) ?? null, + viewport: page.viewportSize(), + image, + imageSha256: await sha256File(image), + accessibilitySnapshot, + visibleAlerts: await page.getByRole("alert").allTextContents(), + errors: [...errors], + expectedErrors, + }); + await assertNoPageWideOverflow(page); +} +try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + await context.route("**/api/auth/whoami", (route) => + route.fulfill({ + json: { + authentication_reference: "fixture-authentication", + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + actor: { + user_id: "fixture-user", + account_id: "fixture-owner", + handle: "reviewer", + display_name: "Configuration review", + auth_method: "browser_session", + }, + }, + })); + await context.route( + "**/api/hosting/access", + (route) => route.fulfill({ json: { is_admin: false } }), + ); + await context.route("**/api/w/*/workspace", async (route) => { + const response = await route.fetch(); + const data = await response.json(); + data.permissions.delete_subjects = !route.request().url().includes("home-member"); + await route.fulfill({ json: data }); + }); + await context.route("**/*.wasm", async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-type": "application/wasm" }, + }); + }); + const page = await context.newPage(); + const errors: { kind: string; message: string }[] = []; + page.on("response", (r) => { + if (r.request().isNavigationRequest()) documents.set(page, r.status()); + if (r.status() >= 400) { + errors.push({ kind: "http", message: `${r.status()} ${new URL(r.url()).pathname}` }); + } + }); + page.on("pageerror", (e) => errors.push({ kind: "page", message: e.message })); + page.on("console", (e) => { + if (e.type() === "error" || e.type() === "warning") { + errors.push({ kind: "console", message: e.text() }); + } + }); + page.on( + "requestfailed", + (r) => + errors.push({ + kind: "request", + message: `${r.method()} ${new URL(r.url()).pathname}: ${r.failure()?.errorText}`, + }), + ); + let state = structuredClone(initial); + let request: unknown = null; + let savedReceipt: unknown = null; + let head = "head:observed"; + const prefix = `${colorScheme}-${width}`; + await page.route("**/api/w/*/config/**", async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.includes("/home-member/")) { + return await route.fulfill({ status: 403, body: "Workspace owner required" }); + } + if (path.endsWith("/source-tree")) return await route.fulfill({ json: state }); + if (path.endsWith("/adoption")) { + return await route.fulfill({ + json: { + state, + history_head: head, + source_stored: true, + catalog: { workspace: "home-owner", namespaces: [] }, + catalog_digest: hash("catalog"), + expected_legacy_export: null, + policies: [], + migration_diagnostics: [], + }, + }); + } + const input = route.request().postDataJSON(); + if (path.endsWith("/preview")) { + set_snapshot(state.snapshot); + const candidate = apply_changes( + input.source.changes as ConfigTreeChange[], + ) as ConfigTreeSnapshot; + return await route.fulfill({ + json: { + expected_head: head, + state: { ...state, snapshot: candidate }, + changed_paths: ["module.dcdl"], + changed_subjects: [], + }, + }); + } + if (path.endsWith("/commit")) { + request = input; + set_snapshot(state.snapshot); + state = { + ...state, + snapshot: apply_changes(input.source.changes) as ConfigTreeSnapshot, + }; + head = "head:adopted"; + savedReceipt = { + workspace_id: "home-owner", + operation_id: input.operation_id, + history_head: head, + state, + source_stored: true, + policy_committed: true, + runtime_application: "next_current_policy_check", + }; + return await route.abort("failed"); + } + if (path.endsWith("/reconcile")) { + assertEquals(input, request); + return await route.fulfill({ json: { status: "not_observed" } }); + } + throw new Error(`unexpected config request: ${path}`); + }); + await page.goto(`${base}/w/home-owner/settings/configuration`); + await page.locator(".cm-content[contenteditable=true]").waitFor(); + await page.locator(".config-source-shell").scrollIntoViewIfNeeded(); + await capture(page, `${prefix}-ready`, errors); + assertEquals(errors, []); + if (!before) { + await page.getByRole("button", { name: "Create local source" }).click(); + const commit = page.getByRole("button", { name: "Commit", exact: true }); + await commit.click(); + await page.getByRole("button", { name: "Reconcile adoption" }).waitFor(); + await page.getByText(/not observed does not mean failed/).first().waitFor(); + await capture(page, `${prefix}-unknown`, errors, [ + "Intentional failed POST commit response", + ]); + // 後続draftを編集してから古いreceiptを返し、実editorでも消えないことを確認する。 + const editor = page.locator(".cm-content"); + await editor.fill("{ later = true; }\n"); + await page.unroute("**/api/w/*/config/**"); + await page.route("**/api/w/*/config/adoption/reconcile", async (route) => { + assertEquals(route.request().postDataJSON(), request); + await route.fulfill({ json: { status: "adopted", receipt: savedReceipt } }); + }); + await page.getByRole("button", { name: "Reconcile adoption" }).click(); + await page.getByText(/Later local edits remain unsaved/).waitFor(); + assert((await editor.innerText()).includes("later")); + await capture(page, `${prefix}-late-receipt`, errors, [ + "Intentional failed POST commit response", + ]); + } + await page.route( + "**/api/w/home-member/config/**", + (route) => route.fulfill({ status: 403, body: "Workspace owner required" }), + ); + await page.goto(`${base}/w/home-member/settings/configuration`); + await page.getByRole("alert").filter({ hasText: "Unable to load workspace configuration" }) + .waitFor(); + assertEquals(await page.locator(".cm-content").count(), 0); + await capture(page, `${prefix}-non-owner`, errors, [ + "HTTP403 owner rejection", + ...(!before ? ["Intentional failed POST commit response"] : []), + ]); + } catch (error) { + const page = context.pages()[0]; + if (page) { + await page.screenshot({ path: join(output, `failure-${colorScheme}-${width}.png`) }); + await Deno.writeTextFile( + join(output, `failure-${colorScheme}-${width}.txt`), + await page.locator("body").innerText(), + ); + } + throw error; + } finally { + await context.close(); + } + } + } +} catch (e) { + manifest.failures.push(String(e)); + throw e; +} finally { + await browser.close(); + await stopOwnedProcesses(owned); + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index c9588effa..caade0d29 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts src/lib/workspace/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts index 30cb349b0..3ffdfe65a 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -374,7 +374,7 @@ async function prepareDraft() { const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body); -function commitReceiptResponse(state = candidateState, historyHead = "head:adopted") { +function commitReceiptResponse(state: WorkspaceConfigTreeResponse = candidateState, historyHead = "head:adopted") { return async (_input: string, init: RequestInit) => jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead)); } @@ -494,7 +494,10 @@ test("adopted reconciliation updates head and clears the legacy export for the n const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" }; toolchain.applyChanges.mockResolvedValue(tree.snapshot); toolchain.changesBetween.mockResolvedValue([deletion]); - await fireEvent.click(view.getByRole("button", { name: "Delete" })); + const remove = view.getByRole("button", { name: "Delete" }); + await waitFor(() => expect(remove).toHaveProperty("disabled", false)); + await fireEvent.click(remove); + await waitFor(() => expect(view.container.textContent).toContain("Delete staged locally")); fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted")); fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second")); await fireEvent.click(view.getByRole("button", { name: "Commit" })); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 588391e10..7f4af1699 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -40,6 +40,9 @@ let baseDigest = $state(""); let editingMetadata = $state.raw(null); let editingWorkspaceId = $state(""); + // 結果不明の依頼はeditor instanceのメモリ内だけに保持する。画面離脱や + // browser reloadを跨ぐ復元は保証しない。Serverのreceipt永続化とは別の限界で、 + // その場合も結果不明を失敗とせず、元の依頼全体を使った照合が必要になる。 let pendingAdoption = $state.raw<{ workspaceId: string; request: ConfigurationAdoptionRequest; diff --git a/web/workspace/src/lib/workspace/config-source/adoption-api.test.ts b/web/workspace/test/config-source/adoption-api.test.ts similarity index 94% rename from web/workspace/src/lib/workspace/config-source/adoption-api.test.ts rename to web/workspace/test/config-source/adoption-api.test.ts index 6641ef063..2131d90fb 100644 --- a/web/workspace/src/lib/workspace/config-source/adoption-api.test.ts +++ b/web/workspace/test/config-source/adoption-api.test.ts @@ -10,9 +10,9 @@ import { parseConfigurationSourceResponse, previewConfigTree, reconcileConfigTree, -} from "./api.ts"; -import type { ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest, ConfigurationReconciliation } from "#lib/generated/configuration-adoption.generated.ts"; -import type { WorkspaceConfigTreeResponse } from "./types.ts"; +} from "../../src/lib/workspace/config-source/api.ts"; +import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts"; +import type { WorkspaceConfigTreeResponse } from "../../src/lib/workspace/config-source/types.ts"; const state: WorkspaceConfigTreeResponse = { snapshot: { digest: "sha256:content-A", entries: {} }, @@ -36,7 +36,7 @@ const request: ConfigurationAdoptionRequest = { expected_legacy_export: source.expected_legacy_export, source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] }, }; -const receipt: ConfigurationAdoptionReceipt = { +const receipt = { workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted", state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check", }; @@ -117,7 +117,7 @@ Deno.test("not_observed and pending_transfer remain distinct unknown evidence, n { status: "not_observed" }, { status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" }, { status: "adopted", receipt }, - ] satisfies ConfigurationReconciliation[]) assertEquals(parseConfigurationReconciliation(evidence), evidence); + ]) assertEquals(parseConfigurationReconciliation(evidence), evidence); for (const invalid of [ { status: "failed" }, { status: "not_observed", receipt }, { status: "pending_transfer", transfer_identity: "transfer:one" },