fix: restore pending workspace prompt catalog

This commit is contained in:
2026-08-19 06:52:32 +09:00
parent 1e33b2945c
commit 5b5396599d
+104 -38
View File
@@ -412,6 +412,48 @@ impl ProfileRuntimeWorkerFactory {
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
Ok((manifest, PromptCatalogSource::builtins_only())) Ok((manifest, PromptCatalogSource::builtins_only()))
} }
fn observe_bundle_prompt_projection(
&self,
bundle: &crate::config_bundle::ConfigBundle,
expected_workspace_id: Option<&str>,
) -> Result<Option<Arc<worker::WorkspacePromptCatalogResolution>>, String> {
let Some(prompt_catalog) = bundle.prompt_catalog.clone() else {
return Ok(None);
};
if let Some(expected_workspace_id) = expected_workspace_id
&& bundle.metadata.workspace_id != expected_workspace_id
{
return Err(format!(
"Workspace Prompt projection scope mismatch: expected {expected_workspace_id}, got {}",
bundle.metadata.workspace_id
));
}
let source_digest = if prompt_catalog.source_digest.is_empty() {
bundle
.metadata
.provenance
.detail
.as_deref()
.and_then(|detail| {
detail
.split(';')
.find_map(|part| part.strip_prefix("source_tree_digest="))
})
.unwrap_or(&prompt_catalog.catalog_digest)
.to_string()
} else {
prompt_catalog.source_digest.clone()
};
let projection = worker::WorkspacePromptProjection::new(
bundle.metadata.workspace_id.clone(),
source_digest,
prompt_catalog.catalog_digest.clone(),
prompt_catalog,
)
.map_err(|error| error.to_string())?;
self.prompt_projection_cache.observe(projection).map(Some)
}
async fn resolve_profile_source_archive( async fn resolve_profile_source_archive(
&self, &self,
source: &ProfileSourceArchiveSource, source: &ProfileSourceArchiveSource,
@@ -672,34 +714,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)? )?
} }
}; };
if let Some(bundle) = request.config_bundle.as_ref() { if let Some(bundle) = request.config_bundle.as_ref()
if let Some(prompt_catalog) = bundle.prompt_catalog.clone() { && let Some(resolution) =
let source_digest = if prompt_catalog.source_digest.is_empty() { self.observe_bundle_prompt_projection(bundle, observation_workspace_id.as_deref())?
bundle {
.metadata loader = loader.with_effective_catalog(resolution.projection.catalog.clone());
.provenance
.detail
.as_deref()
.and_then(|detail| {
detail
.split(';')
.find_map(|part| part.strip_prefix("source_tree_digest="))
})
.unwrap_or(&prompt_catalog.catalog_digest)
.to_string()
} else {
prompt_catalog.source_digest.clone()
};
let projection = worker::WorkspacePromptProjection::new(
bundle.metadata.workspace_id.clone(),
source_digest,
prompt_catalog.catalog_digest.clone(),
prompt_catalog,
)
.map_err(|error| error.to_string())?;
let projection = self.prompt_projection_cache.observe(projection)?;
loader = loader.with_effective_catalog(projection.projection.catalog.clone());
}
} }
let flow_transition_enabled = manifest.feature.flow.enabled; let flow_transition_enabled = manifest.feature.flow.enabled;
@@ -869,23 +888,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.await .await
{ {
Ok(worker) => worker, Ok(worker) => worker,
Err(WorkerError::WorkerMetadataPending { .. })
if workspace_context.workspace_id().is_some()
&& request.config_bundle.is_none() =>
{
return Err(
"pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config"
.to_string(),
);
}
Err(WorkerError::WorkerMetadataPending { .. }) Err(WorkerError::WorkerMetadataPending { .. })
if request.request.initial_input.is_none() => if request.request.initial_input.is_none() =>
{ {
let pending_loader = if workspace_context.workspace_id().is_some() {
let bundle = request.config_bundle.as_ref().ok_or_else(|| {
"pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config"
.to_string()
})?;
let resolution = self
.observe_bundle_prompt_projection(
bundle,
observation_workspace_id.as_deref(),
)?
.ok_or_else(|| {
"pending Workspace Worker restore requires a saved Workspace Prompt projection"
.to_string()
})?;
loader
.clone()
.with_effective_catalog(resolution.projection.catalog.clone())
} else {
loader.clone()
};
Worker::restore_pending_from_worker_metadata_with_context( Worker::restore_pending_from_worker_metadata_with_context(
&worker_name, &worker_name,
manifest.clone(), manifest.clone(),
store, store,
loader, pending_loader,
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
) )
@@ -2694,6 +2724,42 @@ mod tests {
.expect("embedded archive should resolve without Backend resource client"); .expect("embedded archive should resolve without Backend resource client");
} }
#[test]
fn pending_restore_launch_material_preserves_workspace_prompt_catalog() {
let root = tempfile::tempdir().unwrap();
let factory = ProfileRuntimeWorkerFactory::new(root.path());
let builtins = worker::PromptCatalog::builtins_only().unwrap();
let projection = builtins.projection();
let mut templates = projection.templates.clone();
templates.insert(
"internal.notify_wrapper".to_string(),
"PENDING-LAUNCH {{ message }}".to_string(),
);
let mut effective = worker::EffectivePromptCatalog::new(
templates,
7,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
.unwrap();
effective.source_digest = "source-7".to_string();
let mut bundle = test_bundle();
bundle.metadata.workspace_id = "workspace-restore".to_string();
bundle.prompt_catalog = Some(effective);
bundle = bundle.with_computed_digest();
let resolution = factory
.observe_bundle_prompt_projection(&bundle, Some("workspace-restore"))
.unwrap()
.unwrap();
assert_eq!(resolution.projection.config_revision, 7);
assert_eq!(
resolution.catalog.notify_wrapper("restored").unwrap(),
"PENDING-LAUNCH restored"
);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial(worker_allocation)] #[serial_test::serial(worker_allocation)]
async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() { async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() {