fix: preserve uncertain config effects and exercise owner adoption fixtures
Select identifier-free startup summaries from T-840 1d8c205b, without adopting that whole branch or its review status. Keep physical identity guards, migration rollback assertions and old-writer fencing intact.
This commit is contained in:
@@ -2347,8 +2347,8 @@ impl RuntimeHttpRestError {
|
||||
RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result),
|
||||
_ => None,
|
||||
};
|
||||
let message = if startup.is_some() {
|
||||
"Worker startup failed".to_owned()
|
||||
let message = if let Some(startup) = &startup {
|
||||
worker::startup_failure::internal_startup_failure_summary(&startup.primary).to_owned()
|
||||
} else {
|
||||
error.to_string()
|
||||
};
|
||||
|
||||
@@ -120,6 +120,20 @@ pub fn reproject_internal_startup_failure(
|
||||
StartupFailureAttachment::from_projected(&projected)
|
||||
}
|
||||
|
||||
/// Fixed, identifier-free summary for an already authenticated internal caller.
|
||||
/// Keep the required operation actionable without exposing the source's Display.
|
||||
/// This neither projects a Browser cause nor chooses an HTTP status.
|
||||
pub fn internal_startup_failure_summary(failure: &StartupFailureAttachment) -> &'static str {
|
||||
if failure
|
||||
.project_for::<InternalTransfer, protocol::session_failure::SessionRegistrationCause>(&())
|
||||
.is_ok()
|
||||
{
|
||||
"Worker startup failed while registering Session (session.record)"
|
||||
} else {
|
||||
"Worker startup failed"
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch by the domain attested in process, not by parsing attachment fields.
|
||||
pub(crate) fn persist_binding_startup_failure<S: session_store::WorkerMetadataStore>(
|
||||
store: &S,
|
||||
@@ -249,6 +263,36 @@ mod tests {
|
||||
WorkerStoreError,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn internal_summary_retains_only_attested_required_operation() {
|
||||
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
|
||||
let session = protocol::session_failure::session_registration_attachment(
|
||||
SessionRegistrationCause::denied(SessionPrincipal::Subject),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
internal_startup_failure_summary(&session),
|
||||
"Worker startup failed while registering Session (session.record)"
|
||||
);
|
||||
let runtime = runtime_startup_attachment(RuntimeStartupCause {
|
||||
stage: StartupStage::Feature,
|
||||
reason: RuntimeStartupReason::FeatureFailed,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
internal_startup_failure_summary(&runtime),
|
||||
"Worker startup failed"
|
||||
);
|
||||
let corrupt = serde_json::from_value(serde_json::json!(
|
||||
"session.record denied TEST_ONLY_CREDENTIAL /private/path raw_http_body"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
internal_startup_failure_summary(&corrupt),
|
||||
"Worker startup failed"
|
||||
);
|
||||
}
|
||||
|
||||
struct LostSaveResponse(FsWorkerStore);
|
||||
impl WorkerMetadataStore for LostSaveResponse {
|
||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||
|
||||
@@ -6772,8 +6772,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
|
||||
),
|
||||
};
|
||||
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
|
||||
if result.startup_failure.is_some() {
|
||||
projection.message = "Embedded Runtime Worker startup failed".into();
|
||||
if let Some(primary) = &result.startup_failure {
|
||||
projection.message = format!(
|
||||
"Embedded Runtime: {}",
|
||||
worker::startup_failure::internal_startup_failure_summary(primary)
|
||||
);
|
||||
}
|
||||
projection.startup = result.startup_failure.as_ref().and_then(|primary| {
|
||||
internal_startup_transfer(runtime_api::WorkerStartupFailure {
|
||||
|
||||
@@ -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<_>>(),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user