diff --git a/apps/synth_desktop/package.json b/apps/synth_desktop/package.json index d4eec002b..e3eaa9f20 100644 --- a/apps/synth_desktop/package.json +++ b/apps/synth_desktop/package.json @@ -1,6 +1,6 @@ { "name": "@synth/synth-desktop", - "version": "0.10.0", + "version": "0.10.1", "private": true, "description": "Synth Workshop \u2014 local-first research engineering workbench", "type": "module", diff --git a/apps/synth_desktop/src-tauri/Cargo.lock b/apps/synth_desktop/src-tauri/Cargo.lock index 3ab237236..6ade8015e 100644 --- a/apps/synth_desktop/src-tauri/Cargo.lock +++ b/apps/synth_desktop/src-tauri/Cargo.lock @@ -3883,7 +3883,7 @@ dependencies = [ [[package]] name = "synth-desktop" -version = "0.10.0" +version = "0.10.1" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/apps/synth_desktop/src-tauri/Cargo.toml b/apps/synth_desktop/src-tauri/Cargo.toml index ab7ddc10b..6d8476b12 100644 --- a/apps/synth_desktop/src-tauri/Cargo.toml +++ b/apps/synth_desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "synth-desktop" -version = "0.10.0" +version = "0.10.1" description = "Synth Workshop Tauri host" edition = "2021" default-run = "synth-desktop" diff --git a/apps/synth_desktop/src-tauri/src/adapters/mcp/operations/optimizers.rs b/apps/synth_desktop/src-tauri/src/adapters/mcp/operations/optimizers.rs index 28e39420f..d2c1ea141 100644 --- a/apps/synth_desktop/src-tauri/src/adapters/mcp/operations/optimizers.rs +++ b/apps/synth_desktop/src-tauri/src/adapters/mcp/operations/optimizers.rs @@ -38,7 +38,7 @@ fn request(method: &str, path: &str, body: Option) -> Result Value { let mut manifest = json!({"tools":[ - {"name":"optimizer_manage","description":"Operate Synth optimizer runs and the checkpoint catalog. Inline evaluation is the default: draft and inspect the immutable spec, then start it with bounded approval. Catalog recipes are only for an explicit catalog request.","inputSchema":{"type":"object","x-workshop-caller-session-path":"/arguments/session_ref","properties":{"operation":{"type":"string","enum":["evaluation_spec_draft","evaluation_spec_validate","evaluation_spec_admit","evaluation_start","reconcile_evaluation_evidence","list_algorithms","list_recipes","start_workflow","prepare","open_visual","await_ready","start","start_recipe","stage_eval_candidates","launch_artifact_inference","inspect_local_mlx","inspect_training_runtime","install_training_runtime","plan_model_install","install_model_or_runtime","create_training_plan","list_training_artifacts","inspect_training_artifact","launch_artifact_eval","export_or_delete_artifact","list_runs","get_run","watch_run","get_state","get_result","reconcile_cloud","cancel_run","cancel","pause_run","resume_run","finalize","list_checkpoints","archive_checkpoint","import_checkpoint","infer_checkpoint","update_checkpoint","publish_checkpoint"]},"arguments":{"type":"object","additionalProperties":true}},"required":["operation","arguments"],"additionalProperties":false}}, + {"name":"optimizer_manage","description":"Operate Synth optimizer runs and the checkpoint catalog. Inline evaluation is the default: draft and inspect the immutable spec, then start it with bounded approval. Catalog recipes are only for an explicit catalog request.","inputSchema":{"type":"object","x-workshop-caller-session-path":"/arguments/session_ref","properties":{"operation":{"type":"string","enum":["evaluation_spec_draft","evaluation_spec_validate","evaluation_spec_admit","evaluation_start","reconcile_evaluation_evidence","list_algorithms","list_recipes","start_workflow","prepare","open_visual","await_ready","start","start_recipe","stage_eval_candidates","launch_artifact_inference","inspect_local_mlx","inspect_training_runtime","install_training_runtime","plan_model_install","install_model_or_runtime","create_training_plan","list_training_artifacts","inspect_training_artifact","launch_artifact_eval","export_or_delete_artifact","export_snapshot","import_snapshot","get_snapshot","list_runs","get_run","watch_run","get_state","get_result","reconcile_cloud","cancel_run","cancel","pause_run","resume_run","finalize","list_checkpoints","archive_checkpoint","import_checkpoint","infer_checkpoint","update_checkpoint","publish_checkpoint"]},"arguments":{"type":"object","additionalProperties":true}},"required":["operation","arguments"],"additionalProperties":false}}, {"name":"optimizer_list_algorithms","description":"List optimizer algorithms and availability","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}, {"name":"optimizer_list_recipes","description":"List workspace-declared recipes for this session plus remaining product training recipes. Task GEPA/eval ids come from workshop.recipe.toml, never a shipped catalog.","inputSchema":{"type":"object","properties":{"session_ref":{"type":"string"}},"additionalProperties":false}}, {"name":"optimizer_evaluation_spec_draft","description":"Default evaluation path. Construct, validate, pin, and hash an inline execution specification from the requested container, policy, model, seeds, and hard limits. Does not request approval or spend.","inputSchema":{"type":"object","properties":{"containerId":{"type":"string"},"family":{"type":"string"},"policyNamespace":{"type":"string"},"policyName":{"type":"string"},"policyOverrides":{"type":"object"},"provider":{"type":"string"},"modelId":{"type":"string"},"seeds":{"type":"array","items":{"type":"integer"},"minItems":1},"maximumRollouts":{"type":"integer","minimum":1},"maximumModelCallsPerRollout":{"type":"integer","minimum":1},"maximumStepsPerRollout":{"type":"integer","minimum":1},"hardTotalCostUsd":{"type":"number","exclusiveMinimum":0}},"required":["policyNamespace","policyName","provider","modelId","seeds","maximumRollouts","maximumModelCallsPerRollout","maximumStepsPerRollout","hardTotalCostUsd"],"additionalProperties":false}}, @@ -200,7 +200,7 @@ pub(crate) fn call_tool(name: &str, args: &Value) -> Result { let nested = args.get("arguments").cloned().unwrap_or_else(|| json!({})); let allow_path = matches!( operation, - "import_local" | "create_run" | "import_checkpoint" + "import_local" | "create_run" | "import_checkpoint" | "import_snapshot" ); reject_secret_keys(&nested, allow_path)?; if operation.starts_with("evaluation_") { @@ -213,6 +213,9 @@ pub(crate) fn call_tool(name: &str, args: &Value) -> Result { "evaluation_start" => "optimizer_evaluation_start", "reconcile_evaluation_evidence" => "optimizer_reconcile_evaluation_evidence", "list_algorithms" => "optimizer_list_algorithms", + "export_snapshot" => "optimizer_export_snapshot", + "import_snapshot" => "optimizer_import_snapshot", + "get_snapshot" => "optimizer_get_snapshot", "list_recipes" => "optimizer_list_recipes", "start_workflow" => "optimizer_start_workflow", "prepare" => "optimizer_prepare", @@ -661,6 +664,11 @@ mod tests { let catalog = tools(); let tools = catalog["tools"].as_array().unwrap(); assert!(tools.iter().any(|tool| tool["name"] == "optimizer_manage")); + let manager = tools.iter().find(|tool| tool["name"] == "optimizer_manage").unwrap(); + let operations = manager.pointer("/inputSchema/properties/operation/enum").unwrap().as_array().unwrap(); + for operation in ["export_snapshot", "import_snapshot", "get_snapshot"] { + assert!(operations.contains(&json!(operation)), "missing {operation}"); + } let encoded = catalog.to_string(); assert!(!encoded.contains("gepa.banking77.")); assert!(!encoded.contains("eval.banking77.baseline.v1")); diff --git a/apps/synth_desktop/src-tauri/src/contract/commands.rs b/apps/synth_desktop/src-tauri/src/contract/commands.rs index 0e19972fa..9120f89e3 100644 --- a/apps/synth_desktop/src-tauri/src/contract/commands.rs +++ b/apps/synth_desktop/src-tauri/src/contract/commands.rs @@ -31,6 +31,8 @@ impl Commands { pub const CODEX_THREAD_ITEMS_LIST: &'static str = "codex_thread_items_list"; pub const CODEX_TURN_STEER: &'static str = "codex_turn_steer"; pub const CODEX_APPROVAL_RESOLVE: &'static str = "codex_approval_resolve"; + pub const APPROVALS_PENDING: &'static str = "approvals_pending"; + pub const APPROVALS_APPROVE_DIGEST: &'static str = "approvals_approve_digest"; pub const CODEX_SESSION_CLOSE: &'static str = "codex_session_close"; pub const ACCOUNT_BEGIN_SIGN_IN: &'static str = "account_begin_sign_in"; pub const ACCOUNT_POLL_SIGN_IN: &'static str = "account_poll_sign_in"; @@ -52,6 +54,13 @@ impl Commands { pub const MODEL_MULTI_AGENT_LIST: &'static str = "model_multi_agent_list"; pub const MODEL_MULTI_AGENT_UPDATE: &'static str = "model_multi_agent_update"; pub const WORKSPACE_ACCESS_GET: &'static str = "workspace_access_get"; + pub const WORKSPACE_READ_FILE: &'static str = "workspace_read_file"; + pub const WORKSPACE_LIST_DIR: &'static str = "workspace_list_dir"; + pub const DOCUMENT_SHOW: &'static str = "document_show"; + pub const VISUALS_TEMPLATE_SHELL_SOURCE: &'static str = "visuals_template_shell_source"; + pub const VISUALS_TEMPLATE_SAVE: &'static str = "visuals_template_save"; + pub const VISUALS_TEMPLATE_CREATE: &'static str = "visuals_template_create"; + pub const VISUALS_TEMPLATE_VALIDATE: &'static str = "visuals_template_validate"; pub const WORKSPACE_ACCESS_UPDATE: &'static str = "workspace_access_update"; pub const WORKSPACE_SCOPE_GET: &'static str = "workspace_scope_get"; pub const WORKSPACE_SCOPE_CHOOSE_AND_ATTACH: &'static str = "workspace_scope_choose_and_attach"; diff --git a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs index f25857f1e..8a8c5d4ac 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -354,6 +354,23 @@ pub const NAMES: &[&str] = &[ "product_telemetry_set_consent", "product_telemetry_recent", "product_telemetry_flush_now", + "workspace_read_file", + "workspace_list_dir", + "document_show", + "visuals_template_shell_source", + "visuals_template_save", + "visuals_template_create", + "visuals_template_validate", + "approvals_pending", + "approvals_approve_digest", + "project_sources_get", + "project_sources_refresh", + "project_source_add", + "project_source_remove", + "project_source_request", + "project_source_requests_list", + "project_source_deny", + "project_source_approve", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -710,6 +727,23 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "product_telemetry_set_consent" => operation_348(app, args), "product_telemetry_recent" => operation_349(app, args), "product_telemetry_flush_now" => operation_350(app, args), + "workspace_read_file" => operation_351(app, args), + "workspace_list_dir" => operation_352(app, args), + "document_show" => operation_353(app, args), + "visuals_template_shell_source" => operation_354(app, args), + "visuals_template_save" => operation_355(app, args), + "visuals_template_create" => operation_356(app, args), + "visuals_template_validate" => operation_357(app, args), + "approvals_pending" => operation_358(app, args), + "approvals_approve_digest" => operation_359(app, args), + "project_sources_get" => operation_360(app, args), + "project_sources_refresh" => operation_361(app, args), + "project_source_add" => operation_362(app, args), + "project_source_remove" => operation_363(app, args), + "project_source_request" => operation_364(app, args), + "project_source_requests_list" => operation_365(app, args), + "project_source_deny" => operation_366(app, args), + "project_source_approve" => operation_367(app, args), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4574,3 +4608,190 @@ fn operation_350(app: &tauri::AppHandle, args: Value) -> Reply<'_> { Ok(json!({"result": result})) }) } + +fn operation_351(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/documents/commands.rs + let allowed: &[&str] = &["sessionId", "path"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::documents::commands::workspace_read_file(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?, serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_352(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/documents/commands.rs + let allowed: &[&str] = &["sessionId", "path"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::documents::commands::workspace_list_dir(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?, serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_353(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/documents/commands.rs + let allowed: &[&str] = &["sessionId", "path"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::documents::commands::document_show(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?, serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_354(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/visuals/user_templates.rs + let allowed: &[&str] = &["templateId"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::visuals::user_templates::visuals_template_shell_source(serde_json::from_value(args.get("templateId").cloned().unwrap_or(Value::Null)).context("invalid templateId")?).map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_355(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/visuals/user_templates.rs + let allowed: &[&str] = &["sessionId", "templateId", "manifest", "source"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::visuals::user_templates::visuals_template_save(app.clone(), serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?, serde_json::from_value(args.get("templateId").cloned().unwrap_or(Value::Null)).context("invalid templateId")?, serde_json::from_value(args.get("manifest").cloned().unwrap_or(Value::Null)).context("invalid manifest")?, serde_json::from_value(args.get("source").cloned().unwrap_or(Value::Null)).context("invalid source")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_356(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/visuals/user_templates.rs + let allowed: &[&str] = &["sessionId", "templateId", "fromTemplateId", "title"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::visuals::user_templates::visuals_template_create(app.clone(), serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?, serde_json::from_value(args.get("templateId").cloned().unwrap_or(Value::Null)).context("invalid templateId")?, serde_json::from_value(args.get("fromTemplateId").cloned().unwrap_or(Value::Null)).context("invalid fromTemplateId")?, serde_json::from_value(args.get("title").cloned().unwrap_or(Value::Null)).context("invalid title")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_357(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/visuals/user_templates.rs + let allowed: &[&str] = &["templateId"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::visuals::user_templates::visuals_template_validate(serde_json::from_value(args.get("templateId").cloned().unwrap_or(Value::Null)).context("invalid templateId")?).map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_358(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/session/approval_inspection.rs + let allowed: &[&str] = &[]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::session::approval::inspection::approvals_pending(app.try_state().context("runtime service is unavailable")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_359(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/session/approval_inspection.rs + let allowed: &[&str] = &["request"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::session::approval::inspection::approvals_approve_digest(app.clone(), app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("request").cloned().unwrap_or(Value::Null)).context("invalid request")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_360(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &[]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_sources_get().map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_361(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &[]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_sources_refresh().map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_362(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["containers", "recipes"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_add(app.clone(), app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("containers").cloned().unwrap_or(Value::Null)).context("invalid containers")?, serde_json::from_value(args.get("recipes").cloned().unwrap_or(Value::Null)).context("invalid recipes")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_363(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["path"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_remove(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_364(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["request"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_request(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("request").cloned().unwrap_or(Value::Null)).context("invalid request")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_365(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["sessionId"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_requests_list(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("sessionId").cloned().unwrap_or(Value::Null)).context("invalid sessionId")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_366(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["requestId"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_deny(app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("requestId").cloned().unwrap_or(Value::Null)).context("invalid requestId")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} + +fn operation_367(app: &tauri::AppHandle, args: Value) -> Reply<'_> { + Box::pin(async move { + anyhow::ensure!(args.is_object(), "operation arguments must be an object"); + // Handler: apps/synth_desktop/src-tauri/src/project_sources/commands.rs + let allowed: &[&str] = &["requestId"]; + anyhow::ensure!(args.as_object().unwrap().keys().all(|key| allowed.contains(&key.as_str())), "unknown operation argument"); + let result = crate::project_sources::commands::project_source_approve(app.clone(), app.try_state().context("runtime service is unavailable")?, app.try_state().context("runtime service is unavailable")?, serde_json::from_value(args.get("requestId").cloned().unwrap_or(Value::Null)).context("invalid requestId")?).await.map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + Ok(json!({"result": result})) + }) +} diff --git a/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs b/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs index b34da3693..c885fb4bf 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs @@ -21,6 +21,11 @@ pub fn human_surface(name: &str) -> Option<&'static str> { "context_mcp_group_update" | "desktop_state_commit" | "codex_approval_resolve" + | "approvals_approve_digest" + | "project_source_add" + | "project_source_remove" + | "project_source_deny" + | "project_source_approve" | "workspace_scope_approve_request" | "workspace_scope_deny_request" | "desktop_permissions_update" @@ -62,6 +67,14 @@ mod tests { fn evidence_and_consent_cannot_be_invoked_as_agent_actions() { assert!(super::internal("visuals_observation_report")); assert!(super::human_surface("codex_approval_resolve").is_some()); + assert!(super::human_surface("approvals_approve_digest").is_some()); + assert!(super::human_surface("approvals_pending").is_none()); + assert!(super::human_surface("project_source_add").is_some()); + assert!(super::human_surface("project_source_remove").is_some()); + assert!(super::human_surface("project_sources_get").is_none()); + assert!(super::human_surface("project_source_request").is_none()); + assert!(super::human_surface("project_source_deny").is_some()); + assert!(super::human_surface("project_source_approve").is_some()); assert!(super::human_surface("secrets_grant_use").is_some()); assert!(super::human_surface("human_annotation_submit").is_some()); assert!(!super::internal("visuals_render")); diff --git a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json index dd0583ae6..6787d1911 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -30010,12 +30010,422 @@ "outputSchema": { "type": "object", "properties": { - "result": {} + "result": { + "$ref": "#/$defs/T1" + } }, "required": [ "result" ], - "$defs": {} + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "events": {}, + "cursor": { + "$ref": "#/$defs/T2" + }, + "projection": {}, + "evidenceTruncated": { + "type": "boolean" + }, + "receipt": { + "$ref": "#/$defs/T3" + } + }, + "required": [ + "schemaVersion", + "events", + "cursor", + "projection", + "evidenceTruncated", + "receipt" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "next": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "high_water": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "has_more": { + "anyOf": [ + { + "type": "null" + }, + { + "const": false + }, + { + "const": true + } + ] + }, + "closed": { + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "visualId": { + "type": "string" + }, + "revision": { + "type": "number" + }, + "state": { + "anyOf": [ + { + "const": "terminal" + }, + { + "const": "declared" + }, + { + "const": "idle" + }, + { + "const": "replaying" + }, + { + "const": "live" + }, + { + "const": "error" + } + ] + }, + "timeInStateMs": { + "type": "number" + }, + "observed": { + "type": "boolean" + }, + "everLeftDeclared": { + "type": "boolean" + }, + "declaredStreamCount": { + "type": "number" + }, + "respondingStreamCount": { + "type": "number" + }, + "closedStreamCount": { + "type": "number" + }, + "streamsMissingTransport": { + "type": "array", + "items": { + "type": "string" + } + }, + "streams": { + "type": "array", + "items": { + "$ref": "#/$defs/T4" + } + }, + "gaps": { + "type": "array", + "items": { + "$ref": "#/$defs/T6" + } + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/$defs/T7" + } + }, + "ready": { + "type": "boolean" + }, + "recovered": { + "type": "number" + }, + "envelopeCount": { + "type": "number" + }, + "nonControlEnvelopeCount": { + "type": "number" + }, + "envelopesByKind": { + "type": "array", + "items": { + "$ref": "#/$defs/T8" + } + }, + "trackingTruncated": { + "type": "boolean" + }, + "firstObservedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "lastObservedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "schemaVersion", + "visualId", + "revision", + "state", + "timeInStateMs", + "observed", + "everLeftDeclared", + "declaredStreamCount", + "respondingStreamCount", + "closedStreamCount", + "streamsMissingTransport", + "streams", + "gaps", + "conflicts", + "ready", + "recovered", + "envelopeCount", + "nonControlEnvelopeCount", + "envelopesByKind", + "trackingTruncated", + "firstObservedAt", + "lastObservedAt" + ], + "additionalProperties": false + }, + "T4": { + "type": "object", + "properties": { + "streamId": { + "type": "string" + }, + "declaredSource": { + "type": "string" + }, + "sseSource": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "pollAttempts": { + "type": "number" + }, + "pollResponses": { + "type": "number" + }, + "pollFailures": { + "type": "number" + }, + "firstResponseLatencyMs": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "lastSequence": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "cursorNext": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "envelopeCount": { + "type": "number" + }, + "distinctEnvelopeCount": { + "type": "number" + }, + "closed": { + "type": "boolean" + }, + "lastFailure": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T5" + } + ] + } + }, + "required": [ + "streamId", + "declaredSource", + "sseSource", + "pollAttempts", + "pollResponses", + "pollFailures", + "firstResponseLatencyMs", + "lastSequence", + "cursorNext", + "envelopeCount", + "distinctEnvelopeCount", + "closed", + "lastFailure" + ], + "additionalProperties": false + }, + "T5": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "retryable": { + "type": "boolean" + }, + "observedAt": { + "type": "string" + } + }, + "required": [ + "code", + "message", + "status", + "retryable", + "observedAt" + ], + "additionalProperties": false + }, + "T6": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "after": { + "type": "number" + }, + "before": { + "type": "number" + } + }, + "required": [ + "scope", + "after", + "before" + ], + "additionalProperties": false + }, + "T7": { + "type": "object", + "properties": { + "identity": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "identity", + "scope", + "message" + ], + "additionalProperties": false + }, + "T8": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "count": { + "type": "number" + }, + "control": { + "type": "boolean" + } + }, + "required": [ + "kind", + "count", + "control" + ], + "additionalProperties": false + } + } }, "annotations": { "readOnlyHint": false, @@ -31199,6 +31609,13 @@ } ] }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, "requireTerminal": { "anyOf": [ { @@ -31469,6 +31886,13 @@ } ] }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, "requireTerminal": { "anyOf": [ { @@ -31660,257 +32084,257 @@ { "const": "failed" }, - { - "const": "draft" - }, { "const": "live" }, - { - "const": "saved" - }, - { - "const": "archived" - } - ] - }, - "rendererKind": { - "anyOf": [ - { - "const": "template" - }, - { - "const": "tsx" - }, - { - "const": "html" - }, - { - "const": "mermaid" - }, - { - "const": "systems" - }, - { - "const": "systems-dynamic" - }, - { - "const": "chart" - } - ] - }, - "bindings": {}, - "sessionId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "workspaceId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "messageId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "runId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "traceId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "parentVisualId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "sourceAgentId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "sourceModel": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "contentDigest": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "previewDigest": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "metadata": {}, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "schemaVersion", - "id", - "currentRevision", - "title", - "displayName", - "templateId", - "status", - "rendererKind", - "bindings", - "sessionId", - "messageId", - "runId", - "traceId", - "parentVisualId", - "sourceAgentId", - "sourceModel", - "contentDigest", - "previewDigest", - "metadata", - "createdAt", - "updatedAt" - ], - "additionalProperties": false - } - } - }, - "annotations": { - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - }, - "_meta": { - "workshop/operationId": "desktop.visuals_list.v1" - } - }, - { - "name": "visuals_get", - "description": "Workshop visuals get. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", - "inputSchema": { - "type": "object", - "properties": { - "visualId": { - "type": "string" - } - }, - "required": [ - "visualId" - ], - "additionalProperties": false, - "$defs": {} - }, - "outputSchema": { - "type": "object", - "properties": { - "result": { - "$ref": "#/$defs/T1" - } - }, - "required": [ - "result" - ], - "$defs": { - "T1": { - "type": "object", - "properties": { - "schemaVersion": { - "type": "string" - }, - "id": { - "type": "string" - }, - "currentRevision": { - "type": "number" - }, - "title": { - "type": "string" - }, - "displayName": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "templateId": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "const": "failed" - }, { "const": "draft" }, - { - "const": "live" - }, + { + "const": "saved" + }, + { + "const": "archived" + } + ] + }, + "rendererKind": { + "anyOf": [ + { + "const": "template" + }, + { + "const": "tsx" + }, + { + "const": "html" + }, + { + "const": "mermaid" + }, + { + "const": "systems" + }, + { + "const": "systems-dynamic" + }, + { + "const": "chart" + } + ] + }, + "bindings": {}, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "workspaceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "messageId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "runId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "traceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "parentVisualId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceAgentId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceModel": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "contentDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "previewDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "metadata": {}, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "schemaVersion", + "id", + "currentRevision", + "title", + "displayName", + "templateId", + "status", + "rendererKind", + "bindings", + "sessionId", + "messageId", + "runId", + "traceId", + "parentVisualId", + "sourceAgentId", + "sourceModel", + "contentDigest", + "previewDigest", + "metadata", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_list.v1" + } + }, + { + "name": "visuals_get", + "description": "Workshop visuals get. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "visualId": { + "type": "string" + } + }, + "required": [ + "visualId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "currentRevision": { + "type": "number" + }, + "title": { + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "templateId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "const": "failed" + }, + { + "const": "live" + }, + { + "const": "draft" + }, { "const": "saved" }, @@ -33635,10 +34059,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -33824,10 +34248,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -34050,10 +34474,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -34207,10 +34631,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -34462,10 +34886,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -34727,10 +35151,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -34971,267 +35395,267 @@ { "const": "failed" }, - { - "const": "draft" - }, { "const": "live" }, - { - "const": "saved" - }, - { - "const": "archived" - } - ] - }, - "rendererKind": { - "anyOf": [ - { - "const": "template" - }, - { - "const": "tsx" - }, - { - "const": "html" - }, - { - "const": "mermaid" - }, - { - "const": "systems" - }, - { - "const": "systems-dynamic" - }, - { - "const": "chart" - } - ] - }, - "bindings": {}, - "sessionId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "workspaceId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "messageId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "runId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "traceId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "parentVisualId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "sourceAgentId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "sourceModel": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "contentDigest": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "previewDigest": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "metadata": {}, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "schemaVersion", - "id", - "currentRevision", - "title", - "displayName", - "templateId", - "status", - "rendererKind", - "bindings", - "sessionId", - "messageId", - "runId", - "traceId", - "parentVisualId", - "sourceAgentId", - "sourceModel", - "contentDigest", - "previewDigest", - "metadata", - "createdAt", - "updatedAt" - ], - "additionalProperties": false - } - } - }, - "annotations": { - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - }, - "_meta": { - "workshop/operationId": "desktop.visuals_archive.v1" - } - }, - { - "name": "visuals_show", - "description": "Workshop visuals show. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", - "inputSchema": { - "type": "object", - "properties": { - "visualId": { - "type": "string" - }, - "sessionId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - } - }, - "required": [ - "visualId" - ], - "additionalProperties": false, - "$defs": {} - }, - "outputSchema": { - "type": "object", - "properties": { - "result": { - "$ref": "#/$defs/T1" - } - }, - "required": [ - "result" - ], - "$defs": { - "T1": { - "type": "object", - "properties": { - "schemaVersion": { - "type": "string" - }, - "id": { - "type": "string" - }, - "currentRevision": { - "type": "number" - }, - "title": { - "type": "string" - }, - "displayName": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] - }, - "templateId": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "const": "failed" - }, { "const": "draft" }, - { - "const": "live" - }, + { + "const": "saved" + }, + { + "const": "archived" + } + ] + }, + "rendererKind": { + "anyOf": [ + { + "const": "template" + }, + { + "const": "tsx" + }, + { + "const": "html" + }, + { + "const": "mermaid" + }, + { + "const": "systems" + }, + { + "const": "systems-dynamic" + }, + { + "const": "chart" + } + ] + }, + "bindings": {}, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "workspaceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "messageId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "runId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "traceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "parentVisualId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceAgentId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceModel": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "contentDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "previewDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "metadata": {}, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "schemaVersion", + "id", + "currentRevision", + "title", + "displayName", + "templateId", + "status", + "rendererKind", + "bindings", + "sessionId", + "messageId", + "runId", + "traceId", + "parentVisualId", + "sourceAgentId", + "sourceModel", + "contentDigest", + "previewDigest", + "metadata", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_archive.v1" + } + }, + { + "name": "visuals_show", + "description": "Workshop visuals show. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "visualId": { + "type": "string" + }, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "visualId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "currentRevision": { + "type": "number" + }, + "title": { + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "templateId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "const": "failed" + }, + { + "const": "live" + }, + { + "const": "draft" + }, { "const": "saved" }, @@ -35830,10 +36254,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -59343,6 +59767,16 @@ }, "decision": { "type": "string" + }, + "approvalDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] } }, "required": [ @@ -65003,6 +65437,2881 @@ "_meta": { "workshop/operationId": "desktop.product_telemetry_flush_now.v1" } + }, + { + "name": "workspace_read_file", + "description": "Workshop workspace read file. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "sessionId", + "path" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "const": "markdown" + }, + { + "const": "code" + }, + { + "const": "plain_text" + }, + { + "const": "directory" + } + ] + }, + "language": { + "type": "string" + }, + "text": { + "type": "string" + }, + "byteSize": { + "type": "number" + }, + "truncated": { + "type": "boolean" + }, + "contentDigest": { + "type": "string" + }, + "modifiedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "breadcrumbs": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + } + }, + "required": [ + "schemaVersion", + "path", + "root", + "relativePath", + "name", + "kind", + "language", + "text", + "byteSize", + "truncated", + "contentDigest", + "modifiedAt", + "breadcrumbs" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "path": { + "type": "string" + }, + "isDirectory": { + "type": "boolean" + } + }, + "required": [ + "label", + "path", + "isDirectory" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.workspace_read_file.v1" + } + }, + { + "name": "workspace_list_dir", + "description": "Workshop workspace list dir. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "sessionId", + "path" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + }, + "truncated": { + "type": "boolean" + }, + "breadcrumbs": { + "type": "array", + "items": { + "$ref": "#/$defs/T3" + } + } + }, + "required": [ + "schemaVersion", + "path", + "root", + "relativePath", + "entries", + "truncated", + "breadcrumbs" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "const": "markdown" + }, + { + "const": "code" + }, + { + "const": "plain_text" + }, + { + "const": "directory" + } + ] + }, + "language": { + "type": "string" + }, + "byteSize": { + "type": "number" + }, + "modifiedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "openable": { + "type": "boolean" + }, + "reason": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "name", + "path", + "kind", + "language", + "byteSize", + "modifiedAt", + "openable", + "reason" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "path": { + "type": "string" + }, + "isDirectory": { + "type": "boolean" + } + }, + "required": [ + "label", + "path", + "isDirectory" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.workspace_list_dir.v1" + } + }, + { + "name": "document_show", + "description": "Workshop document show. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "sessionId", + "path" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "visual": { + "$ref": "#/$defs/T2" + }, + "document": { + "$ref": "#/$defs/T3" + } + }, + "required": [ + "visual", + "document" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "currentRevision": { + "type": "number" + }, + "title": { + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "templateId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "const": "failed" + }, + { + "const": "live" + }, + { + "const": "draft" + }, + { + "const": "saved" + }, + { + "const": "archived" + } + ] + }, + "rendererKind": { + "anyOf": [ + { + "const": "template" + }, + { + "const": "tsx" + }, + { + "const": "html" + }, + { + "const": "mermaid" + }, + { + "const": "systems" + }, + { + "const": "systems-dynamic" + }, + { + "const": "chart" + } + ] + }, + "bindings": {}, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "workspaceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "messageId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "runId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "traceId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "parentVisualId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceAgentId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceModel": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "contentDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "previewDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "metadata": {}, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "schemaVersion", + "id", + "currentRevision", + "title", + "displayName", + "templateId", + "status", + "rendererKind", + "bindings", + "sessionId", + "messageId", + "runId", + "traceId", + "parentVisualId", + "sourceAgentId", + "sourceModel", + "contentDigest", + "previewDigest", + "metadata", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "const": "markdown" + }, + { + "const": "code" + }, + { + "const": "plain_text" + }, + { + "const": "directory" + } + ] + }, + "language": { + "type": "string" + }, + "text": { + "type": "string" + }, + "byteSize": { + "type": "number" + }, + "truncated": { + "type": "boolean" + }, + "contentDigest": { + "type": "string" + }, + "modifiedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "breadcrumbs": { + "type": "array", + "items": { + "$ref": "#/$defs/T4" + } + } + }, + "required": [ + "schemaVersion", + "path", + "root", + "relativePath", + "name", + "kind", + "language", + "text", + "byteSize", + "truncated", + "contentDigest", + "modifiedAt", + "breadcrumbs" + ], + "additionalProperties": false + }, + "T4": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "path": { + "type": "string" + }, + "isDirectory": { + "type": "boolean" + } + }, + "required": [ + "label", + "path", + "isDirectory" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.document_show.v1" + } + }, + { + "name": "visuals_template_shell_source", + "description": "Workshop visuals template shell source. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string" + } + }, + "required": [ + "templateId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + }, + "required": [ + "result" + ], + "$defs": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_template_shell_source.v1" + } + }, + { + "name": "visuals_template_save", + "description": "Workshop visuals template save. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "templateId": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "sessionId", + "templateId", + "manifest", + "source" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "templateDigest": { + "anyOf": [ + { + "type": "string" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + } + ] + }, + "genre": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "family": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "rendererKind": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "tags": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "path": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "shellPath": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "rendererPath": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceKind": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "exampleBinding": {}, + "inputs": {}, + "slots": {}, + "components": {}, + "bindingSchema": {}, + "observationContract": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T2" + } + ] + } + }, + "required": [ + "schemaVersion", + "id" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "readiness": { + "$ref": "#/$defs/T3" + } + }, + "required": [ + "schemaVersion", + "readiness" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "rejectTransportStates": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "minimumRolloutCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumRenderedFrameCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumSemanticEventCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "requireTerminal": { + "anyOf": [ + { + "const": false + }, + { + "const": true + } + ] + }, + "authoringAffordances": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "const": "temporalControls" + }, + { + "const": "traceInspector" + }, + { + "const": "realEvidence" + } + ] + } + } + ] + } + }, + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_template_save.v1" + } + }, + { + "name": "visuals_template_create", + "description": "Workshop visuals template create. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "templateId": { + "type": "string" + }, + "fromTemplateId": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "sessionId", + "templateId", + "fromTemplateId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "templateDigest": { + "anyOf": [ + { + "type": "string" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + } + ] + }, + "genre": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "family": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "rendererKind": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "tags": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "path": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "shellPath": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "rendererPath": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "sourceKind": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "exampleBinding": {}, + "inputs": {}, + "slots": {}, + "components": {}, + "bindingSchema": {}, + "observationContract": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T2" + } + ] + } + }, + "required": [ + "schemaVersion", + "id" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "string" + }, + "readiness": { + "$ref": "#/$defs/T3" + } + }, + "required": [ + "schemaVersion", + "readiness" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "rejectTransportStates": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "minimumRolloutCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumRenderedFrameCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumSemanticEventCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, + "requireTerminal": { + "anyOf": [ + { + "const": false + }, + { + "const": true + } + ] + }, + "authoringAffordances": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "const": "temporalControls" + }, + { + "const": "traceInspector" + }, + { + "const": "realEvidence" + } + ] + } + } + ] + } + }, + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_template_create.v1" + } + }, + { + "name": "visuals_template_validate", + "description": "Workshop visuals template validate. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string" + } + }, + "required": [ + "templateId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": {} + }, + "required": [ + "result" + ], + "$defs": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.visuals_template_validate.v1" + } + }, + { + "name": "approvals_pending", + "description": "Workshop approvals pending. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "$ref": "#/$defs/T1" + } + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "approvalId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "requiresHuman": { + "type": "boolean" + }, + "preparationDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "approvalId", + "sessionId", + "kind", + "requiresHuman", + "preparationDigest" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.approvals_pending.v1" + } + }, + { + "name": "approvals_approve_digest", + "description": "Workshop approvals approve digest. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "request": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "request" + ], + "additionalProperties": false, + "$defs": { + "T1": { + "type": "object", + "properties": { + "executionSpecDigest": { + "type": "string" + } + }, + "required": [ + "executionSpecDigest" + ], + "additionalProperties": false + } + } + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "approvalId": { + "type": "string" + }, + "alreadySettled": { + "type": "boolean" + }, + "executionSpecDigest": { + "type": "string" + } + }, + "required": [ + "approvalId", + "alreadySettled", + "executionSpecDigest" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.approvals_approve_digest.v1" + } + }, + { + "name": "project_sources_get", + "description": "Workshop project sources get. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + }, + "implicitRoots": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + } + }, + "required": [ + "configPath", + "sources", + "implicitRoots" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "origin": { + "anyOf": [ + { + "const": "configured" + }, + { + "const": "environment" + } + ] + }, + "inspection": { + "$ref": "#/$defs/T3" + }, + "lastScannedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "path", + "containers", + "recipes", + "origin", + "inspection", + "lastScannedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "status", + "code", + "message", + "containers", + "recipes" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_sources_get.v1" + } + }, + { + "name": "project_sources_refresh", + "description": "Workshop project sources refresh. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + }, + "implicitRoots": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + } + }, + "required": [ + "configPath", + "sources", + "implicitRoots" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "origin": { + "anyOf": [ + { + "const": "configured" + }, + { + "const": "environment" + } + ] + }, + "inspection": { + "$ref": "#/$defs/T3" + }, + "lastScannedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "path", + "containers", + "recipes", + "origin", + "inspection", + "lastScannedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "status", + "code", + "message", + "containers", + "recipes" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_sources_refresh.v1" + } + }, + { + "name": "project_source_add", + "description": "Workshop project source add. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + } + }, + "required": [ + "containers", + "recipes" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T1" + } + ] + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + }, + "implicitRoots": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + } + }, + "required": [ + "configPath", + "sources", + "implicitRoots" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "origin": { + "anyOf": [ + { + "const": "configured" + }, + { + "const": "environment" + } + ] + }, + "inspection": { + "$ref": "#/$defs/T3" + }, + "lastScannedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "path", + "containers", + "recipes", + "origin", + "inspection", + "lastScannedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "status", + "code", + "message", + "containers", + "recipes" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_add.v1" + } + }, + { + "name": "project_source_remove", + "description": "Workshop project source remove. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + }, + "implicitRoots": { + "type": "array", + "items": { + "$ref": "#/$defs/T2" + } + } + }, + "required": [ + "configPath", + "sources", + "implicitRoots" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "origin": { + "anyOf": [ + { + "const": "configured" + }, + { + "const": "environment" + } + ] + }, + "inspection": { + "$ref": "#/$defs/T3" + }, + "lastScannedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "path", + "containers", + "recipes", + "origin", + "inspection", + "lastScannedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "status", + "code", + "message", + "containers", + "recipes" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_remove.v1" + } + }, + { + "name": "project_source_request", + "description": "Workshop project source request. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "request": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "request" + ], + "additionalProperties": false, + "$defs": { + "T1": { + "type": "object", + "properties": { + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "attachToConversation": { + "anyOf": [ + { + "const": false + }, + { + "const": true + } + ] + } + }, + "required": [ + "sessionId", + "path", + "reason", + "containers", + "recipes" + ], + "additionalProperties": false + } + } + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "requestedPath": { + "type": "string" + }, + "canonicalPath": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "attachToConversation": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "id", + "sessionId", + "requestedPath", + "canonicalPath", + "reason", + "containers", + "recipes", + "attachToConversation", + "status", + "createdAt", + "resolvedAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_request.v1" + } + }, + { + "name": "project_source_requests_list", + "description": "Workshop project source requests list. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "$ref": "#/$defs/T1" + } + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "requestedPath": { + "type": "string" + }, + "canonicalPath": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "attachToConversation": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "id", + "sessionId", + "requestedPath", + "canonicalPath", + "reason", + "containers", + "recipes", + "attachToConversation", + "status", + "createdAt", + "resolvedAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_requests_list.v1" + } + }, + { + "name": "project_source_deny", + "description": "Workshop project source deny. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + }, + "required": [ + "requestId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/$defs/T1" + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "requestedPath": { + "type": "string" + }, + "canonicalPath": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "attachToConversation": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "id", + "sessionId", + "requestedPath", + "canonicalPath", + "reason", + "containers", + "recipes", + "attachToConversation", + "status", + "createdAt", + "resolvedAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_deny.v1" + } + }, + { + "name": "project_source_approve", + "description": "Workshop project source approve. Uses the same typed native handler as the desktop. Explicitly human-controlled decisions require the desktop workflow.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + }, + "required": [ + "requestId" + ], + "additionalProperties": false, + "$defs": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T1" + } + ] + } + }, + "required": [ + "result" + ], + "$defs": { + "T1": { + "type": "object", + "properties": { + "request": { + "$ref": "#/$defs/T2" + }, + "source": { + "$ref": "#/$defs/T3" + }, + "catalog": { + "$ref": "#/$defs/T5" + }, + "scope": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/T6" + } + ] + }, + "attachmentError": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "request", + "source", + "catalog", + "scope", + "attachmentError" + ], + "additionalProperties": false + }, + "T2": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "requestedPath": { + "type": "string" + }, + "canonicalPath": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "attachToConversation": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "id", + "sessionId", + "requestedPath", + "canonicalPath", + "reason", + "containers", + "recipes", + "attachToConversation", + "status", + "createdAt", + "resolvedAt" + ], + "additionalProperties": false + }, + "T3": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "containers": { + "type": "boolean" + }, + "recipes": { + "type": "boolean" + }, + "origin": { + "anyOf": [ + { + "const": "configured" + }, + { + "const": "environment" + } + ] + }, + "inspection": { + "$ref": "#/$defs/T4" + }, + "lastScannedAt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "path", + "containers", + "recipes", + "origin", + "inspection", + "lastScannedAt" + ], + "additionalProperties": false + }, + "T4": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "status", + "code", + "message", + "containers", + "recipes" + ], + "additionalProperties": false + }, + "T5": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/T3" + } + }, + "implicitRoots": { + "type": "array", + "items": { + "$ref": "#/$defs/T3" + } + } + }, + "required": [ + "configPath", + "sources", + "implicitRoots" + ], + "additionalProperties": false + }, + "T6": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/T7" + } + }, + "revision": { + "type": "number" + }, + "boundRevision": { + "type": "number" + }, + "bindingStatus": { + "type": "string" + }, + "bindingError": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "sessionId", + "workspace", + "attachments", + "revision", + "boundRevision", + "bindingStatus", + "bindingError" + ], + "additionalProperties": false + }, + "T7": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "access": { + "anyOf": [ + { + "const": "read_only" + }, + { + "const": "read_write" + } + ] + }, + "source": { + "anyOf": [ + { + "const": "user_picker" + }, + { + "const": "recent_folder" + }, + { + "const": "agent_request" + }, + { + "const": "migrated_default" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "path", + "access", + "source", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "_meta": { + "workshop/operationId": "desktop.project_source_approve.v1" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index 8cb71babc..d9ce89789 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -446,6 +446,23 @@ pub fn builder() -> Builder { crate::telemetry::product_telemetry_set_consent, crate::telemetry::product_telemetry_recent, crate::telemetry::product_telemetry_flush_now, + crate::documents::commands::workspace_read_file, + crate::documents::commands::workspace_list_dir, + crate::documents::commands::document_show, + crate::visuals::user_templates::visuals_template_shell_source, + crate::visuals::user_templates::visuals_template_save, + crate::visuals::user_templates::visuals_template_create, + crate::visuals::user_templates::visuals_template_validate, + crate::session::approval::inspection::approvals_pending, + crate::session::approval::inspection::approvals_approve_digest, + crate::project_sources::commands::project_sources_get, + crate::project_sources::commands::project_sources_refresh, + crate::project_sources::commands::project_source_add, + crate::project_sources::commands::project_source_remove, + crate::project_sources::commands::project_source_request, + crate::project_sources::commands::project_source_requests_list, + crate::project_sources::commands::project_source_deny, + crate::project_sources::commands::project_source_approve, ]) } @@ -603,8 +620,14 @@ mod tests { // and supersession. The previous 323 expectation undercounted six. // 329 → 338: seven ACP commands and two runtime-owned desktop state commands. // 348 → 351: consent, recent telemetry, and flush commands. + // 351 → 354: scoped workspace read/list and document presentation. + // 354 → 358: user-template source, approved save/fork and validation. + // 358 → 360: approval inbox and human-only digest resolution. + // 360 → 364: live project-source catalog/refresh and native admission/removal. + // 364 → 367: durable source request, inspection of requests, and human denial. + // 367 → 368: exact-picker source approval with optional conversation attachment. assert_eq!( - exported, 351, + exported, 368, "generated bindings must contain the complete desktop command set" ); assert_eq!( diff --git a/apps/synth_desktop/src-tauri/src/data.rs b/apps/synth_desktop/src-tauri/src/data.rs index 385551566..b564049ea 100644 --- a/apps/synth_desktop/src-tauri/src/data.rs +++ b/apps/synth_desktop/src-tauri/src/data.rs @@ -1267,7 +1267,7 @@ impl DataStore { let lookup_digest = trace_digest.clone(); let resolved = self.db.clone().run(move |conn| { conn.query_row( - "SELECT tpc.projection_schema,tpc.payload_digest,tb.archive_path,ta.relative_path + "SELECT tpc.projection_schema,tpc.payload_digest,tb.archive_digest,ta.relative_path FROM trace_projection_cache tpc JOIN trace_bundle_members tbm ON tbm.trace_digest=tpc.trace_digest JOIN trace_bundles tb ON tb.bundle_digest=tbm.bundle_digest @@ -1278,7 +1278,7 @@ impl DataStore { |row| Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,String>(2)?,row.get::<_,String>(3)?)), ).optional().map_err(Into::into) }).await?; - let Some((projection_schema, payload_digest, archive_path, relative_path)) = resolved + let Some((projection_schema, payload_digest, archive_digest, relative_path)) = resolved else { let lookup_digest = trace_digest.clone(); let archive_path = self.db.clone().run(move |conn| { @@ -1310,13 +1310,15 @@ impl DataStore { payload: derived.payload, }); }; - let archive_path = std::path::PathBuf::from(archive_path); + let content = self.content.clone(); let entry_path = relative_path.clone(); let payload = tokio::task::spawn_blocking(move || -> Result { - let file = std::fs::File::open(&archive_path).with_context(|| { - format!("open trusted trace archive {}", archive_path.display()) - })?; - let mut archive = zip::ZipArchive::new(file).context("open trusted trace ZIP")?; + // Import-time trust does not authorize changed bytes. Parse the + // exact buffer CAS verified, not a path reopened after validation. + let digest = qualified_sha256(&archive_digest)?; + let bytes = content.get_bytes("traces", &digest[7..])?; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .context("open verified trace ZIP")?; let mut entry = archive.by_name(&entry_path).with_context(|| { format!("projection asset missing from trusted archive: {entry_path}") })?; @@ -1987,6 +1989,45 @@ mod tests { } } + #[tokio::test] + async fn cached_projection_rechecks_archive_bytes_before_returning_evidence() { + let dir = tempdir().unwrap(); + let storage = Storage::open(dir.path()).unwrap(); + let content = ContentStore::new(storage.content_root()); + let data = DataStore::new(storage.database().clone(), content.clone()); + let payload = json!({"schema_version":"synth.trace-projection.rollout-inspector.v1","rollouts":[]}); + let body = json!({"payload":payload}).to_string(); + let mut archive = Vec::new(); + { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut archive)); + writer.start_file("projection.json", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut writer, body.as_bytes()).unwrap(); + writer.finish().unwrap(); + } + let trace_hex = "e".repeat(64); + let trace_digest = format!("sha256:{trace_hex}"); + let mut inspected = trusted_inspection(&archive, &trace_hex); + inspected.inspection_json["assets"] = json!([{ + "path":"projection.json", "kind":"projection", "role":"rollout-inspector", + "bytes_digest":format!("sha256:{:x}", Sha256::digest(body.as_bytes())), + "media_type":"application/json", "byte_size":body.len(), "available":true, "verified":true + }]); + inspected.inspection_json["projections"] = json!([{ + "path":"projection.json", "source_trace_digest":trace_digest, + "format":"synth.trace-projection.rollout-inspector.v1", + "digest":format!("sha256:{:x}", Sha256::digest(payload.to_string().as_bytes())), + "available":true, "verified":true + }]); + inspected.inspection = serde_json::from_value(inspected.inspection_json.clone()).unwrap(); + data.commit_inspected_trace(ingest_request(None), inspected).await.unwrap(); + assert_eq!(data.resolve_trace_projection(trace_digest.clone(), "rollout-inspector".into()).await.unwrap().payload, payload); + let digest = format!("{:x}", Sha256::digest(&archive)); + // Corrupt only the isolated test store, leaving its trusted SQL receipt unchanged. + std::fs::write(content.path_for("traces", &digest), b"changed after import").unwrap(); + let error = data.resolve_trace_projection(trace_digest, "rollout-inspector".into()).await.err().unwrap(); + assert!(format!("{error:#}").contains("digest verification")); + } + async fn stored_owner(db: &crate::storage::Database, digest: &str) -> Option { let digest = digest.to_string(); db.clone() diff --git a/apps/synth_desktop/src-tauri/src/documents/commands.rs b/apps/synth_desktop/src-tauri/src/documents/commands.rs new file mode 100644 index 000000000..06e7671d6 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/documents/commands.rs @@ -0,0 +1,87 @@ +//! Tauri command edge for workspace documents. +//! +//! Three commands and no fourth. There is deliberately no write, no delete, and +//! no "read arbitrary path": the pane's whole job is to display bytes the +//! conversation is already allowed to see, and a viewer that could also write +//! would need the approval machinery the agent path already owns. +//! +//! Every command takes `session_id` because the scope that authorizes the read +//! belongs to the conversation, not to the window. Passing the window's current +//! session implicitly would make the grant ambient, which is the property +//! `workspace_scope` exists to remove. +//! +//! # Registration +//! +//! These commands are registered in `contract/specta.rs` and named in +//! `contract/commands.rs`; Specta generates their renderer bindings. + +use std::sync::Arc; +use tauri::State; + +use super::{DocumentShown, WorkspaceDirectory, WorkspaceDocument}; +use crate::core_runtime::CoreRuntime; +use crate::error::AppError; + +async fn blocking(work: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + // Reading up to 2 MiB and stat-ing a thousand directory entries is real + // blocking I/O; it does not belong on a runtime worker that a live stream + // is also using. + match tokio::task::spawn_blocking(work).await { + Ok(result) => result.map_err(AppError::from), + Err(error) => Err(AppError::internal(error)), + } +} + +/// Read one workspace document for display. +/// +/// Refuses with `document_outside_workspace` for a path outside every session +/// root, and with `document_unavailable` plus the named reason for a path that +/// is in scope but cannot be typeset. Neither is an empty string. +#[tauri::command] +#[specta::specta] +pub async fn workspace_read_file( + core: State<'_, Arc>, + session_id: String, + path: String, +) -> Result { + let core = core.inner().clone(); + blocking(move || super::read(core.storage().database(), &session_id, &path)).await +} + +/// List one workspace directory — the breadcrumb's and the file picker's data. +/// +/// Rows that cannot be opened are listed with the reason rather than filtered +/// out, so a folder of binaries reads as a folder of binaries and not as empty. +#[tauri::command] +#[specta::specta] +pub async fn workspace_list_dir( + core: State<'_, Arc>, + session_id: String, + path: String, +) -> Result { + let core = core.inner().clone(); + blocking(move || super::list_dir(core.storage().database(), &session_id, &path)).await +} + +/// Open one workspace document in the right panel. +/// +/// The same rail a visual takes: resolve or create the deterministic pane +/// record, emit the durable `visual.show` event, and let the panel's existing +/// listener open it. The renderer does not open the pane itself, so a document +/// the agent shows and a document the reader clicks arrive by one path. +#[tauri::command] +#[specta::specta] +pub async fn document_show( + core: State<'_, Arc>, + session_id: String, + path: String, +) -> Result { + let core = core.inner().clone(); + super::show(&core, &session_id, &path) + .await + .map_err(AppError::from) +} diff --git a/apps/synth_desktop/src-tauri/src/documents/ipc.rs b/apps/synth_desktop/src-tauri/src/documents/ipc.rs new file mode 100644 index 000000000..9af2d9546 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/documents/ipc.rs @@ -0,0 +1,102 @@ +//! Agent-facing document routes over the loopback visuals IPC. +//! +//! Mirrors `dispatch_traces`: the agent names a path, the host resolves it +//! against the conversation's workspace scope, and the same durable +//! `visual.show` event that opens a visual opens the document. The agent never +//! receives a file handle and never names a path the host has not re-resolved. +//! +//! Deliberately read-only. `document_show` is the whole agent surface plus the +//! two reads a viewer needs to navigate; there is no write route, because a +//! panel that could write would be a second, unapproved edit path beside the +//! agent's own tools. +//! +//! # Registration +//! +//! `visuals_ipc::dispatch_request` routes to this module with one guard, +//! beside the `/v1/traces` one it is modelled on: +//! +//! ```ignore +//! if path.starts_with("/v1/documents") { +//! return crate::documents::ipc::dispatch_documents(method, path, json_body, core).await; +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; + +use crate::core_runtime::CoreRuntime; +use crate::presentation; + +/// Session the route acts for. +/// +/// The body wins over the environment so a multiplexed agent can name the +/// conversation explicitly; `SYNTH_SESSION_ID` is the single-session fallback +/// the visual routes already use, kept identical so one rail does not have two +/// session conventions. +fn session_ref(body: &Value) -> Result { + body.get("sessionRef") + .or_else(|| body.get("session_id")) + .or_else(|| body.get("sessionId")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| std::env::var("SYNTH_SESSION_ID").ok()) + .filter(|value| !value.trim().is_empty()) + .context("session_id required: a document is read through its conversation's workspace") +} + +fn requested_path(body: &Value) -> Result { + body.get("path") + .or_else(|| body.get("document_path")) + .or_else(|| body.get("documentPath")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .context("path required") +} + +pub async fn dispatch_documents( + method: &str, + path: &str, + body: Value, + core: &CoreRuntime, +) -> Result { + match (method, path) { + ("POST", "/v1/documents/show") => { + let session_id = session_ref(&body)?; + let requested = requested_path(&body)?; + let shown = crate::documents::show(core, &session_id, &requested).await?; + Ok(json!({ + "opened": true, + "path": shown.document.path, + "relativePath": shown.document.relative_path, + "language": shown.document.language, + "truncated": shown.document.truncated, + "contentDigest": shown.document.content_digest, + "visualId": shown.visual.id, + "templateId": shown.visual.template_id, + "visual": shown.visual, + })) + } + ("POST", "/v1/documents/read") => { + let session_id = session_ref(&body)?; + let requested = requested_path(&body)?; + let document = + crate::documents::read(core.storage().database(), &session_id, &requested)?; + Ok(serde_json::to_value(document)?) + } + ("POST", "/v1/documents/list") => { + let session_id = session_ref(&body)?; + let requested = requested_path(&body)?; + let listing = + crate::documents::list_dir(core.storage().database(), &session_id, &requested)?; + Ok(serde_json::to_value(listing)?) + } + ("GET", "/v1/documents/template") => Ok(json!({ + "templateId": presentation::DOCUMENT_VIEWER_TEMPLATE, + "projectionSchema": presentation::DOCUMENT_PROJECTION_SCHEMA, + "bindingKind": presentation::WORKSPACE_FILE_BINDING_KIND, + })), + _ => anyhow::bail!("unsupported document IPC route {method} {path}"), + } +} diff --git a/apps/synth_desktop/src-tauri/src/documents/mod.rs b/apps/synth_desktop/src-tauri/src/documents/mod.rs new file mode 100644 index 000000000..ad676c1b0 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/documents/mod.rs @@ -0,0 +1,842 @@ +//! Workspace documents: the domain the right panel's document pane answers for. +//! +//! The renderer has no filesystem. Every byte it displays crosses a typed +//! command whose path was first resolved against the conversation's +//! [`crate::workspace_scope`] session roots, which is why this module exists +//! rather than `tauri-plugin-fs`: a plugin grant is a static allowlist decided +//! at package time, and the thing that must decide here is *this conversation's* +//! workspace plus the folders a human attached to it. +//! +//! Two vocabularies, deliberately kept apart: +//! +//! * **Refusal** — the trust boundary. A path outside every session root, or a +//! conversation with no workspace at all, is a [`StructuredFailure`] with a +//! stable code. It is not a `DocumentRecord` with a sad face on it, because +//! describing a file we are not allowed to look at is itself a disclosure. +//! * **Unavailability** — the catalog law. A path inside scope that still +//! cannot be typeset (missing, a directory, binary, unreadable) becomes a +//! [`crate::presentation::Presentability::Unavailable`] with a named reason. +//! The pane and the directory listing both say the reason out loud rather +//! than omitting the row, exactly as the trace catalog does. +//! +//! [`StructuredFailure`]: crate::error::StructuredFailure + +pub mod commands; +pub mod ipc; + +use anyhow::{anyhow, Result}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + ffi::OsString, + path::{Component, Path, PathBuf}, +}; + +use crate::error::StructuredFailure; +use crate::presentation::{Pane, Presentability, UnavailableReason}; +use crate::storage::Database; +use crate::workspace_scope; + +/// Wire schema for one read document. Also the projection schema the document +/// pane's binding declares, so the pane and the command agree by name. +pub const DOCUMENT_SCHEMA: &str = "synth.workspace-document.v1"; +/// Wire schema for one directory listing — the breadcrumb's data source. +pub const DIRECTORY_SCHEMA: &str = "synth.workspace-directory.v1"; + +/// Largest text payload one read returns. Past this the read is *truncated and +/// says so*, which is different from refusing: a 40 MB log still has a +/// legible first page, and the pane states which page it is showing. +pub const DOCUMENT_MAX_BYTES: u64 = 2 * 1024 * 1024; +/// Bytes sniffed to decide whether a file is text at all. +const SNIFF_BYTES: usize = 8 * 1024; +/// Directory rows one listing returns. A truncated listing says so. +pub const DIRECTORY_MAX_ENTRIES: usize = 1_000; + +// --------------------------------------------------------------------------- +// Refusals — the scope boundary +// --------------------------------------------------------------------------- + +pub const CODE_NO_PATH: &str = "document_path_missing"; +pub const CODE_SCOPE_UNBOUND: &str = "document_scope_unbound"; +pub const CODE_OUTSIDE_WORKSPACE: &str = "document_outside_workspace"; +pub const CODE_UNAVAILABLE: &str = "document_unavailable"; + +fn scope_unbound(session_id: &str) -> anyhow::Error { + anyhow!(StructuredFailure::new( + CODE_SCOPE_UNBOUND, + format!("conversation `{session_id}` has no workspace"), + "Open this conversation on a folder, or attach one with Add folder, then try again.", + ) + .with_details(serde_json::json!({ "sessionId": session_id }))) +} + +fn outside_workspace(requested: &str, roots: &[PathBuf]) -> anyhow::Error { + anyhow!(StructuredFailure::new( + CODE_OUTSIDE_WORKSPACE, + format!("`{requested}` is outside this conversation's workspace"), + "Attach the folder to this conversation with Add folder, then try again.", + ) + .with_details(serde_json::json!({ + "requested": requested, + "roots": roots.iter().map(|root| root.to_string_lossy()).collect::>(), + }))) +} + +/// An in-scope path that still cannot be presented, raised where a caller +/// expects bytes back. The reason is the same value the catalog shows. +fn unavailable(document: &DocumentRecord, reason: UnavailableReason) -> anyhow::Error { + anyhow!(StructuredFailure::new( + CODE_UNAVAILABLE, + format!("{} cannot be shown: {}", document.name, reason.label()), + reason.remediation(), + ) + .with_details(serde_json::json!({ + "path": document.path, + "reason": reason.label(), + "byteSize": document.byte_size, + }))) +} + +// --------------------------------------------------------------------------- +// The record +// --------------------------------------------------------------------------- + +/// What kind of surface a path is, as far as the pane is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum DocumentKind { + /// Typeset by default, with a View source toggle. + Markdown, + /// Syntax highlighted, with a language badge. + Code, + /// Monospaced, unhighlighted. + PlainText, + /// A folder. Presentable as a listing, never as a document. + Directory, +} + +/// One located path inside the conversation's workspace. +/// +/// Locating is what costs a scope check; every question after that — is it +/// eligible, what identity does its pane have, what language badge does it +/// carry — is a pure function of this record. That is what lets the panel host +/// answer for documents the same way it answers for traces. +#[derive(Clone, Debug)] +pub struct DocumentRecord { + /// Canonical absolute path. Symlinks are already resolved, which is what + /// makes the scope check meaningful. + pub path: String, + /// The session root this path resolved under. + pub root: String, + /// `path` relative to `root` — the breadcrumb trail, computed once here so + /// the renderer does not re-derive a second path helper. + pub relative_path: String, + pub name: String, + pub kind: DocumentKind, + /// Language id for the badge and the highlighter, e.g. `rust`, `markdown`. + pub language: String, + pub byte_size: u64, + pub exists: bool, + /// `false` when the first [`SNIFF_BYTES`] are not valid UTF-8 or contain a + /// NUL. Sniffed at locate time so eligibility stays pure. + pub is_text: bool, + /// Set when metadata could be read but the bytes could not. + pub read_error: Option, + pub modified_at: Option, +} + +impl DocumentRecord { + /// Whether this path can be typeset in the pane, and when it cannot, why. + pub fn presentability(&self) -> Presentability { + Pane::Document(self).presentable() + } + + /// Deterministic pane identity for this path. + pub fn viewer_visual_id(&self) -> String { + Pane::Document(self).visual_id() + } + + /// Breadcrumb segments, root first. Each carries the absolute path the + /// "list this directory" command takes, so a segment is clickable without + /// the renderer rebuilding a path. + pub fn breadcrumbs(&self) -> Vec { + let root = PathBuf::from(&self.root); + let root_label = root + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.root.clone()); + let mut trail = vec![Breadcrumb { + label: root_label, + path: self.root.clone(), + is_directory: true, + }]; + let mut walked = root; + let segments: Vec<&str> = self + .relative_path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + let last = segments.len().saturating_sub(1); + for (index, segment) in segments.iter().enumerate() { + walked.push(segment); + trail.push(Breadcrumb { + label: (*segment).to_owned(), + path: walked.to_string_lossy().into_owned(), + is_directory: index < last || self.kind == DocumentKind::Directory, + }); + } + trail + } +} + +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Breadcrumb { + pub label: String, + pub path: String, + pub is_directory: bool, +} + +// --------------------------------------------------------------------------- +// Wire payloads +// --------------------------------------------------------------------------- + +/// One read document, as the pane receives it. +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDocument { + pub schema_version: String, + pub path: String, + pub root: String, + pub relative_path: String, + pub name: String, + pub kind: DocumentKind, + pub language: String, + pub text: String, + /// Size of the file on disk, not of `text`. + #[specta(type = specta_typescript::Number)] + pub byte_size: u64, + /// True when `text` is a prefix. The pane says which prefix rather than + /// pretending the file ended. + pub truncated: bool, + /// sha256 of the returned bytes. When `truncated`, it names the prefix that + /// was rendered — not the file — which is the only claim it can honestly make. + pub content_digest: String, + pub modified_at: Option, + pub breadcrumbs: Vec, +} + +/// One directory row. Rows that cannot be opened are listed with the reason. +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryEntry { + pub name: String, + pub path: String, + pub kind: DocumentKind, + pub language: String, + #[specta(type = specta_typescript::Number)] + pub byte_size: u64, + pub modified_at: Option, + /// Whether opening this row lands on a document. + pub openable: bool, + /// Why it does not, when it does not. Never `None` while `openable` is + /// false: a row the user cannot act on still owes them a sentence. + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDirectory { + pub schema_version: String, + pub path: String, + pub root: String, + pub relative_path: String, + pub entries: Vec, + pub truncated: bool, + pub breadcrumbs: Vec, +} + +// --------------------------------------------------------------------------- +// Scope resolution +// --------------------------------------------------------------------------- + +/// Lexical normalization, with no filesystem access. +/// +/// Done before touching the disk so a `..` cannot be smuggled past the scope +/// check by a path that does not exist yet, and so the check does not depend on +/// whether the caller's shell already collapsed the segments. +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +/// Resolve one caller-supplied path against the conversation's session roots. +/// +/// Canonicalizing the deepest *existing* ancestor is what makes this safe for +/// paths that do not exist: a symlinked ancestor pointing out of the workspace +/// is resolved and then rejected, and only after the scope check does the +/// caller learn that the leaf is missing. Refusing first and reporting absence +/// second is deliberate — "no such file" outside the workspace is already a +/// disclosure about a filesystem the conversation cannot see. +fn resolve_in_scope(roots: &[PathBuf], requested: &str) -> Result<(PathBuf, PathBuf)> { + let raw = Path::new(requested.trim()); + if raw.as_os_str().is_empty() { + // Distinct from "outside the workspace": nothing was asked for, and + // saying it was refused would misname the problem. + return Err(anyhow!(StructuredFailure::new( + CODE_NO_PATH, + "no document path was given", + "Name a file inside this conversation's workspace.", + ))); + } + let absolute = if raw.is_absolute() { + raw.to_path_buf() + } else { + // A relative path is relative to the conversation workspace, which is + // the first root by construction in `workspace_scope`. + roots + .first() + .ok_or_else(|| outside_workspace(requested, roots))? + .join(raw) + }; + let normalized = normalize(&absolute); + + let mut existing = normalized.clone(); + let mut tail: Vec = Vec::new(); + while !existing.exists() { + let Some(name) = existing.file_name().map(|name| name.to_os_string()) else { + return Err(outside_workspace(requested, roots)); + }; + tail.push(name); + let Some(parent) = existing.parent().map(Path::to_path_buf) else { + return Err(outside_workspace(requested, roots)); + }; + existing = parent; + } + let mut resolved = existing + .canonicalize() + .map_err(|_| outside_workspace(requested, roots))?; + for name in tail.iter().rev() { + resolved.push(name); + } + + let root = roots + .iter() + .find(|root| resolved == **root || resolved.starts_with(root)) + .cloned() + .ok_or_else(|| outside_workspace(requested, roots))?; + Ok((resolved, root)) +} + +fn session_roots(db: &Database, session_id: &str) -> Result> { + let roots = workspace_scope::approved_search_roots(db, session_id) + .map_err(|_| scope_unbound(session_id))?; + if roots.is_empty() { + return Err(scope_unbound(session_id)); + } + Ok(roots) +} + +// --------------------------------------------------------------------------- +// Locating +// --------------------------------------------------------------------------- + +fn modified_at(metadata: &std::fs::Metadata) -> Option { + let modified = metadata.modified().ok()?; + Some(chrono::DateTime::::from(modified).to_rfc3339()) +} + +/// Sniff whether a file is text. A NUL byte or invalid UTF-8 in the first +/// [`SNIFF_BYTES`] means the pane would render mojibake, so the record says +/// binary and the pane offers Open externally instead. +fn sniff_text(path: &Path) -> (bool, Option) { + use std::io::Read; + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) => return (false, Some(error.to_string())), + }; + let mut buffer = vec![0_u8; SNIFF_BYTES]; + let read = match file.read(&mut buffer) { + Ok(read) => read, + Err(error) => return (false, Some(error.to_string())), + }; + buffer.truncate(read); + if buffer.contains(&0) { + return (false, None); + } + match std::str::from_utf8(&buffer) { + Ok(_) => (true, None), + // A multi-byte character straddling the sniff boundary is not binary. + Err(error) if error.error_len().is_none() && error.valid_up_to() + 4 >= buffer.len() => { + (true, None) + } + Err(_) => (false, None), + } +} + +/// Locate one path inside the conversation's workspace. +/// +/// Returns a record for anything in scope — including a path that does not +/// exist — because absence is a state the pane must name, and it can only name +/// it for a file it is allowed to talk about. +pub fn locate(db: &Database, session_id: &str, requested: &str) -> Result { + let roots = session_roots(db, session_id)?; + let (path, root) = resolve_in_scope(&roots, requested)?; + Ok(record_for(&path, &root)) +} + +fn record_for(path: &Path, root: &Path) -> DocumentRecord { + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + let relative_path = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let metadata = std::fs::metadata(path).ok(); + let exists = metadata.is_some(); + let is_directory = metadata.as_ref().is_some_and(std::fs::Metadata::is_dir); + let byte_size = metadata.as_ref().map(std::fs::Metadata::len).unwrap_or(0); + let (is_text, read_error) = if !exists || is_directory { + (false, None) + } else { + sniff_text(path) + }; + let language = language_for(path, is_directory); + DocumentRecord { + path: path.to_string_lossy().into_owned(), + root: root.to_string_lossy().into_owned(), + relative_path, + name, + kind: kind_for(&language, is_directory), + language, + byte_size, + exists, + is_text, + read_error, + modified_at: metadata.as_ref().and_then(modified_at), + } +} + +// --------------------------------------------------------------------------- +// Language +// --------------------------------------------------------------------------- + +/// Extension → language id. The id is the badge text, the highlighter's key, +/// and the fenced-code language name, so there is one spelling of "rust" in +/// the product rather than three. +const LANGUAGES: &[(&str, &str)] = &[ + ("md", "markdown"), + ("markdown", "markdown"), + ("mdx", "markdown"), + ("rs", "rust"), + ("ts", "typescript"), + ("tsx", "tsx"), + ("js", "javascript"), + ("mjs", "javascript"), + ("cjs", "javascript"), + ("jsx", "jsx"), + ("py", "python"), + ("toml", "toml"), + ("json", "json"), + ("jsonc", "json"), + ("yaml", "yaml"), + ("yml", "yaml"), + ("sh", "shell"), + ("bash", "shell"), + ("zsh", "shell"), + ("fish", "shell"), + ("sql", "sql"), + ("css", "css"), + ("html", "html"), + ("svg", "html"), + ("go", "go"), + ("c", "c"), + ("h", "c"), + ("cc", "cpp"), + ("cpp", "cpp"), + ("hpp", "cpp"), + ("java", "java"), + ("rb", "ruby"), + ("swift", "swift"), + ("kt", "kotlin"), + ("tf", "hcl"), + ("ini", "ini"), + ("cfg", "ini"), + ("csv", "csv"), + ("diff", "diff"), + ("patch", "diff"), + ("txt", "text"), + ("log", "text"), +]; + +/// Filenames with no extension that still name a language. +const NAMED_FILES: &[(&str, &str)] = &[ + ("dockerfile", "docker"), + ("makefile", "make"), + ("cargo.lock", "toml"), + ("license", "text"), + ("notice", "text"), +]; + +pub fn language_for(path: &Path, is_directory: bool) -> String { + if is_directory { + return "directory".to_owned(); + } + let name = path + .file_name() + .map(|value| value.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if let Some((_, language)) = NAMED_FILES.iter().find(|(candidate, _)| *candidate == name) { + return (*language).to_owned(); + } + let extension = path + .extension() + .map(|value| value.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + LANGUAGES + .iter() + .find(|(candidate, _)| *candidate == extension) + .map(|(_, language)| (*language).to_owned()) + .unwrap_or_else(|| "text".to_owned()) +} + +fn kind_for(language: &str, is_directory: bool) -> DocumentKind { + if is_directory { + return DocumentKind::Directory; + } + match language { + "markdown" => DocumentKind::Markdown, + "text" | "csv" => DocumentKind::PlainText, + _ => DocumentKind::Code, + } +} + +// --------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------- + +/// Read one in-scope document for display. +/// +/// Truncation is disclosed rather than hidden; unavailability is named rather +/// than returned as an empty string. +pub fn read(db: &Database, session_id: &str, requested: &str) -> Result { + let document = locate(db, session_id, requested)?; + match document.presentability() { + Presentability::Present => {} + Presentability::Unavailable(reason) => return Err(unavailable(&document, reason)), + } + + let path = PathBuf::from(&document.path); + let unreadable = |error: std::io::Error| { + anyhow!(StructuredFailure::new( + CODE_UNAVAILABLE, + format!("{} could not be read: {error}", document.name), + "Check the file's permissions, then try again.", + ) + .retryable(true)) + }; + // Bounded read, not read-then-truncate: a multi-gigabyte log must not be + // resident in memory just to render its first page. + let mut bytes = Vec::new(); + { + use std::io::Read; + let file = std::fs::File::open(&path).map_err(&unreadable)?; + file.take(DOCUMENT_MAX_BYTES) + .read_to_end(&mut bytes) + .map_err(&unreadable)?; + } + + let truncated = document.byte_size > DOCUMENT_MAX_BYTES; + let slice = if truncated { + // Never split a character in half: cut back to the last boundary. + let end = match std::str::from_utf8(&bytes) { + Ok(_) => bytes.len(), + Err(error) => error.valid_up_to(), + }; + &bytes[..end] + } else { + &bytes[..] + }; + let text = String::from_utf8_lossy(slice).into_owned(); + let content_digest = format!("sha256:{:x}", Sha256::digest(slice)); + + Ok(WorkspaceDocument { + schema_version: DOCUMENT_SCHEMA.to_owned(), + path: document.path.clone(), + root: document.root.clone(), + relative_path: document.relative_path.clone(), + name: document.name.clone(), + kind: document.kind, + language: document.language.clone(), + text, + byte_size: document.byte_size, + truncated, + content_digest, + modified_at: document.modified_at.clone(), + breadcrumbs: document.breadcrumbs(), + }) +} + +/// List one in-scope directory. +/// +/// Every child is a row. A child that cannot be opened keeps its row and +/// carries the reason, because a directory that silently hides its binaries +/// tells the reader the folder is empty when it is not. +pub fn list_dir(db: &Database, session_id: &str, requested: &str) -> Result { + let directory = locate(db, session_id, requested)?; + if !directory.exists { + return Err(unavailable(&directory, UnavailableReason::Missing)); + } + if directory.kind != DocumentKind::Directory { + return Err(unavailable(&directory, UnavailableReason::NotADirectory)); + } + + let root = PathBuf::from(&directory.root); + let mut rows: Vec = Vec::new(); + let mut truncated = false; + let reader = std::fs::read_dir(&directory.path).map_err(|error| { + anyhow!(StructuredFailure::new( + CODE_UNAVAILABLE, + format!("{} could not be listed: {error}", directory.name), + "Check the folder's permissions, then try again.", + ) + .retryable(true)) + })?; + for entry in reader.flatten() { + if rows.len() >= DIRECTORY_MAX_ENTRIES { + truncated = true; + break; + } + let child = record_for(&entry.path(), &root); + let presentability = child.presentability(); + let openable = presentability.eligible() || child.kind == DocumentKind::Directory; + rows.push(DirectoryEntry { + name: child.name.clone(), + path: child.path.clone(), + kind: child.kind, + language: child.language.clone(), + byte_size: child.byte_size, + modified_at: child.modified_at.clone(), + openable, + reason: if openable { + None + } else { + Some(presentability.label().to_owned()) + }, + }); + } + // Folders first, then files, each alphabetically — the order a reader + // scanning for a filename expects, and stable across calls. + rows.sort_by(|left, right| { + let left_dir = left.kind == DocumentKind::Directory; + let right_dir = right.kind == DocumentKind::Directory; + right_dir + .cmp(&left_dir) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + + Ok(WorkspaceDirectory { + schema_version: DIRECTORY_SCHEMA.to_owned(), + path: directory.path.clone(), + root: directory.root.clone(), + relative_path: directory.relative_path.clone(), + entries: rows, + truncated, + breadcrumbs: directory.breadcrumbs(), + }) +} + +// --------------------------------------------------------------------------- +// Showing +// --------------------------------------------------------------------------- + +/// What the pane receives when a document is opened: the durable pane record +/// and the first read, in one round trip. +/// +/// Two calls would let the pane render a viewer whose document then refuses, +/// and the reader would watch an empty pane appear before the reason arrived. +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct DocumentShown { + pub visual: crate::visuals::VisualRecord, + pub document: WorkspaceDocument, +} + +/// Open one workspace document in the right panel. +/// +/// One implementation, two callers: the Tauri command the renderer's Open +/// affordance uses, and the agent-facing `document_show` IPC route. A second +/// copy on the agent path is exactly what `presentation` was extracted to +/// prevent. +pub async fn show( + core: &crate::core_runtime::CoreRuntime, + session_id: &str, + requested: &str, +) -> Result { + let visual = crate::presentation::ensure_document_viewer(core, session_id, requested).await?; + let path = crate::presentation::document_path_binding(&visual) + .ok_or_else(|| anyhow!("document viewer `{}` declares no path", visual.id))?; + let document = read(core.storage().database(), session_id, &path)?; + let (shown, event) = core + .visuals() + .show(visual.id.clone(), Some(session_id.to_owned())) + .await?; + core.broadcast_committed(Some(serde_json::from_value(event)?)); + Ok(DocumentShown { + visual: shown, + document, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roots(paths: &[&Path]) -> Vec { + paths.iter().map(|path| path.to_path_buf()).collect() + } + + #[test] + fn traversal_out_of_the_workspace_is_a_named_refusal() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(workspace.join("docs")).unwrap(); + std::fs::write(workspace.join("docs/readme.md"), "# hi").unwrap(); + std::fs::write(temp.path().join("secret.txt"), "no").unwrap(); + let workspace = workspace.canonicalize().unwrap(); + let allowed = roots(&[&workspace]); + + let error = resolve_in_scope(&allowed, "../secret.txt").unwrap_err(); + let failure = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .expect("structured refusal"); + assert_eq!(failure.code, CODE_OUTSIDE_WORKSPACE); + + let (resolved, root) = resolve_in_scope(&allowed, "docs/readme.md").unwrap(); + assert_eq!(root, workspace); + assert!(resolved.ends_with("docs/readme.md")); + } + + #[test] + fn a_symlink_escaping_the_workspace_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let outside = temp.path().join("outside.txt"); + std::fs::write(&outside, "no").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, workspace.join("link.txt")).unwrap(); + let workspace = workspace.canonicalize().unwrap(); + let allowed = roots(&[&workspace]); + #[cfg(unix)] + assert!(resolve_in_scope(&allowed, "link.txt").is_err()); + } + + #[test] + fn a_missing_file_inside_scope_resolves_and_is_named_missing() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().canonicalize().unwrap(); + let allowed = roots(&[&workspace]); + let (resolved, root) = resolve_in_scope(&allowed, "not-here.md").unwrap(); + let record = record_for(&resolved, &root); + assert!(!record.exists); + assert_eq!( + record.presentability(), + Presentability::Unavailable(UnavailableReason::Missing) + ); + assert_eq!(record.presentability().label(), "Missing"); + } + + #[test] + fn a_binary_file_is_named_rather_than_rendered_as_mojibake() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().canonicalize().unwrap(); + std::fs::write(workspace.join("blob.bin"), [0_u8, 1, 2, 3, 255]).unwrap(); + let record = record_for(&workspace.join("blob.bin"), &workspace); + assert!(!record.is_text); + assert_eq!( + record.presentability(), + Presentability::Unavailable(UnavailableReason::NotText) + ); + } + + #[test] + fn breadcrumbs_carry_the_path_each_segment_lists() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().canonicalize().unwrap(); + std::fs::create_dir_all(workspace.join("docs/api")).unwrap(); + std::fs::write(workspace.join("docs/api/index.md"), "# api").unwrap(); + let record = record_for(&workspace.join("docs/api/index.md"), &workspace); + let trail = record.breadcrumbs(); + assert_eq!(trail.len(), 4); + assert_eq!(trail[1].label, "docs"); + assert!(trail[1].is_directory); + assert_eq!(trail[3].label, "index.md"); + assert!(!trail[3].is_directory); + assert_eq!(trail[2].path, workspace.join("docs/api").to_string_lossy()); + } + + #[test] + fn language_ids_have_one_spelling() { + assert_eq!(language_for(Path::new("/a/b.rs"), false), "rust"); + assert_eq!(language_for(Path::new("/a/README.md"), false), "markdown"); + assert_eq!(language_for(Path::new("/a/Dockerfile"), false), "docker"); + assert_eq!(language_for(Path::new("/a/mystery"), false), "text"); + assert_eq!(kind_for("markdown", false), DocumentKind::Markdown); + assert_eq!(kind_for("rust", false), DocumentKind::Code); + assert_eq!(kind_for("rust", true), DocumentKind::Directory); + } + + #[test] + fn a_listing_keeps_the_rows_it_cannot_open_and_says_why() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().canonicalize().unwrap(); + std::fs::write(workspace.join("readme.md"), "# hi").unwrap(); + std::fs::write(workspace.join("blob.bin"), [0_u8, 0, 0]).unwrap(); + std::fs::create_dir(workspace.join("src")).unwrap(); + + let root = workspace.clone(); + let mut rows: Vec = std::fs::read_dir(&workspace) + .unwrap() + .flatten() + .map(|entry| { + let child = record_for(&entry.path(), &root); + let presentability = child.presentability(); + let openable = presentability.eligible() || child.kind == DocumentKind::Directory; + DirectoryEntry { + name: child.name.clone(), + path: child.path.clone(), + kind: child.kind, + language: child.language.clone(), + byte_size: child.byte_size, + modified_at: None, + openable, + reason: if openable { + None + } else { + Some(presentability.label().to_owned()) + }, + } + }) + .collect(); + rows.sort_by(|left, right| left.name.cmp(&right.name)); + assert_eq!(rows.len(), 3); + let blob = rows.iter().find(|row| row.name == "blob.bin").unwrap(); + assert!(!blob.openable); + assert_eq!(blob.reason.as_deref(), Some("Not text")); + assert!(rows.iter().find(|row| row.name == "src").unwrap().openable); + } +} diff --git a/apps/synth_desktop/src-tauri/src/eval_driver.rs b/apps/synth_desktop/src-tauri/src/eval_driver.rs index 0509ea900..2cc773476 100644 --- a/apps/synth_desktop/src-tauri/src/eval_driver.rs +++ b/apps/synth_desktop/src-tauri/src/eval_driver.rs @@ -402,6 +402,7 @@ async fn dispatch(method: &str, path: &str, body: Value, deps: &EvalDriverDeps) session_id: session_id.clone(), approval_id: approval_id.clone(), decision, + approval_digest: body.get("approvalDigest").and_then(Value::as_str).map(str::to_owned), }, ) .await?; diff --git a/apps/synth_desktop/src-tauri/src/lib.rs b/apps/synth_desktop/src-tauri/src/lib.rs index e8c41bd90..d09ef935b 100644 --- a/apps/synth_desktop/src-tauri/src/lib.rs +++ b/apps/synth_desktop/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod credential_broker; pub mod data; mod device_auth; mod desktop_links; +pub mod documents; pub mod diagnostics; mod domain; mod domains; @@ -51,6 +52,7 @@ mod model_catalog; mod optimizers; mod platform; mod plugins; +mod project_sources; pub mod presentation; pub mod recovery; mod reports; @@ -60,6 +62,7 @@ mod services; mod session; mod skills; pub mod storage; +pub mod stream_fold; mod synth_config; mod tariffs; mod telemetry; @@ -2868,16 +2871,65 @@ struct VisualStreamPollRequest { limit: u16, } +/// Envelope version for a poll answer. A renderer that does not know this +/// string should read the page and fold it itself rather than guess at fields. +const VISUAL_STREAM_POLL_SCHEMA: &str = "synth.visual-stream-poll.v1"; + +/// What one poll of a declared live stream answers with. +/// +/// The seam used to hand back the producer's page verbatim, which made the +/// renderer the only thing in the system that knew what a live eval showed — +/// so a review capture, a seal and the pane each had to be trusted to fold the +/// same way, and the spool already proved they did not. The projection and the +/// receipt are computed here, from bytes this process saw, and travel together +/// so the pane, the capture and the seal read one answer. +/// +/// `events` is the page's envelopes, verbatim and unfolded, and stays. A +/// sourced visual may aggregate an eval in a way nobody anticipated, and +/// making a novel aggregation require a Rust change would spend expressiveness +/// — already this system's weakest axis against general codegen — to buy +/// tidiness. The projection is authoritative for the built-in templates and +/// for the readiness gate; it is not a ceiling on what a visual may compute. +/// +/// The projection carries no envelope bodies of its own: it is the same +/// derived object `visuals::live_eval::seal_projection` freezes into a sealed +/// bundle, so the pane and the seal cannot render different numbers, and one +/// poll's answer stays bounded by the page rather than by the run. +#[derive(Clone, Debug, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +struct VisualStreamPollResult { + schema_version: String, + /// The producer's envelopes for this page, exactly as they arrived. + events: contract::specta::OpaqueJson, + /// The producer's own cursor, passed through rather than recomputed. + cursor: visuals::stream_receipt::PageCursor, + /// `synth.live-eval-projection.v1` over everything this host has observed + /// for the visual at this revision, or `null` when it has observed + /// nothing — which is the honest answer for a stream that has only ever + /// carried control envelopes. + projection: Option, + /// The retained evidence prefix stopped short of the run, so the + /// projection is a lower bound rather than the whole eval. + evidence_truncated: bool, + /// The host's own account of the transport. Not renderer-reported and not + /// agent-authored: an agent reading this is reading the transport. + receipt: visuals::stream_receipt::StreamReceipt, +} + /// Fetch a visual's persisted, declaration-validated poll authority through -/// the native process. WKWebView cannot reliably read loopback HTTP because -/// its CORS/CSP boundary differs from the backend's; this command is narrowly -/// scoped to exact URLs already stored on the named visual. +/// the native process, and answer with what the host made of it. +/// +/// WKWebView cannot reliably read loopback HTTP because its CORS/CSP boundary +/// differs from the backend's; this command is narrowly scoped to exact URLs +/// already stored on the named visual. Since every envelope already passes +/// through here, this is also where the fold, the receipt and the projection +/// happen — see [`VisualStreamPollResult`]. #[tauri::command] #[specta::specta] async fn visual_stream_poll( state: State<'_, Arc>, request: VisualStreamPollRequest, -) -> Result { +) -> Result { let visual = state .visuals() .get(request.visual_id) @@ -2890,6 +2942,12 @@ async fn visual_stream_poll( let declared = declared_urls .iter() .any(|url| url == request.poll_url.as_str()); + // The receipt reads the same canonical `live_sse` bindings this check + // reads, and keeps what a receipt additionally has to name: the stream id + // the renderer polls under, and the declared streams that carry no durable + // poll authority at all. Nothing here decides whether a poll is allowed — + // `declared_urls` above remains the only authority for that. + let receipt_streams = visuals::stream_receipt::declared_streams(&visual.bindings); // Every renderer poll of a live stream lands here, so this is where a live // stream going quiet becomes a record rather than an empty pane. let diagnose_at = |severity: diagnostics::Severity, @@ -2930,6 +2988,22 @@ async fn visual_stream_poll( false, serde_json::json!({"declared_stream_count": declared_urls.len()}), ); + // The refusal belongs on the receipt too. A visual whose only poll was + // refused has still never had a stream opened, and the receipt is what + // says so out loud instead of leaving it resting in `declared`. + visuals::stream_receipt::record_poll_failure( + &visual.id, + visual.current_revision, + &receipt_streams, + &request.poll_url, + visuals::stream_receipt::StreamPollFailure { + code: diagnostics::codes::VISUAL_BINDING_UNRESOLVED.to_string(), + message: "visual stream poll URL is not declared on this visual".to_string(), + status: None, + retryable: false, + observed_at: chrono::Utc::now().to_rfc3339(), + }, + ); return Err(AppError::from(anyhow::anyhow!( "visual stream poll URL is not declared on this visual; \ the visual declares {} live stream(s)", @@ -2938,6 +3012,15 @@ async fn visual_stream_poll( } let limit = request.limit.clamp(1, 500); let started = std::time::Instant::now(); + // Recorded before the request, not after it. Without the attempt, a stream + // that is being asked and a stream nobody asked read identically, and + // `replaying` would be a state the host could never observe. + visuals::stream_receipt::record_poll_attempt( + &visual.id, + visual.current_revision, + &receipt_streams, + &request.poll_url, + ); let response = async { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) @@ -2969,6 +3052,38 @@ async fn visual_stream_poll( .and_then(|cursor| cursor.get("closed")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + // Fold bytes observed by the host, independently of anything the + // renderer reports about its DOM or local replay state. + let outcome = visuals::stream_receipt::record_poll_page( + &visual.id, + visual.current_revision, + &receipt_streams, + &request.poll_url, + &page, + ); + // `STREAM_REPLAY_GAP` has had a code and a remediation and no + // emitter. This is the emitter: once per gap rather than once per + // poll, because a 500 ms loop over a permanent hole would otherwise + // file the same diagnostic twice a second forever. + for gap in &outcome.new_gaps { + diagnose_at( + diagnostics::Severity::Error, + "stream.replay.gap", + diagnostics::codes::STREAM_REPLAY_GAP, + format!( + "replayed history skips sequence {} to {} on scope {}", + gap.after, gap.before, gap.scope + ), + true, + serde_json::json!({ + "scope": gap.scope, + "after": gap.after, + "before": gap.before, + "missing": gap.before.saturating_sub(gap.after).saturating_sub(1), + "transport_state": outcome.state_str(), + }), + ); + } diagnose_at( diagnostics::Severity::Debug, if closed { @@ -2993,24 +3108,62 @@ async fn visual_stream_poll( "high_water": cursor.and_then(|cursor| cursor.get("high_water")), "closed": closed, "duration_ms": started.elapsed().as_millis() as u64, + "transport_state": outcome.state_str(), }), ); - Ok(contract::specta::OpaqueJson(page)) + // The projection is folded from the evidence prefix this host was + // already retaining for the seal, so serving it costs a read of + // memory that is spent either way — not a second copy of the run. + // It is recomputed per poll rather than folded incrementally, + // which is the same O(page history) the renderer's own ingest + // already pays on every batch; if that bites, the fix is an + // incremental fold inside `stream_fold`, not a second projector. + let (receipt, evidence, evidence_truncated) = visuals::stream_receipt::evidence_snapshot( + &visual.id, visual.current_revision, &receipt_streams); + let projection = if evidence.is_empty() { None } else { + Some(contract::specta::OpaqueJson(visuals::live_eval::seal_projection(&evidence) + .map_err(AppError::from)?)) + }; + Ok(VisualStreamPollResult { + schema_version: VISUAL_STREAM_POLL_SCHEMA.to_string(), + events: contract::specta::OpaqueJson(serde_json::Value::Array( + visuals::stream_receipt::page_events(&page).to_vec(), + )), + cursor: visuals::stream_receipt::page_cursor(&page), + projection, + evidence_truncated, + receipt, + }) } Err(error) => { let status = error.status().map(|status| status.as_u16()); + // A refused or 5xx poll may recover; a 4xx says the stream is + // gone and retrying only repeats the question. + let retryable = + error.is_timeout() || error.is_connect() || status.is_none_or(|code| code >= 500); fail( diagnostics::codes::STREAM_INTERRUPTED, error.to_string(), - // A refused or 5xx poll may recover; a 4xx says the stream is - // gone and retrying only repeats the question. - error.is_timeout() || error.is_connect() || status.is_none_or(|code| code >= 500), + retryable, serde_json::json!({ "status": status, "after": request.after, "duration_ms": started.elapsed().as_millis() as u64, }), ); + visuals::stream_receipt::record_poll_failure( + &visual.id, + visual.current_revision, + &receipt_streams, + &request.poll_url, + visuals::stream_receipt::StreamPollFailure { + code: diagnostics::codes::STREAM_INTERRUPTED.to_string(), + message: error.to_string(), + status, + retryable, + observed_at: chrono::Utc::now().to_rfc3339(), + }, + ); Err(AppError::from(error)) } } @@ -5362,7 +5515,7 @@ async fn codex_approval_resolve( ) -> Result<(), AppError> { if approvals.is_pending(&request.approval_id).await { let decision = approvals - .decision_from_shell(&request.approval_id, &request.decision) + .decision_from_view(&request.approval_id, &request.decision, request.approval_digest.as_deref()) .await .map_err(AppError::from)?; approvals diff --git a/apps/synth_desktop/src-tauri/src/optimizers/container_eval.rs b/apps/synth_desktop/src-tauri/src/optimizers/container_eval.rs index ffbe29560..7c30ad6d4 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/container_eval.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/container_eval.rs @@ -490,6 +490,7 @@ pub(super) async fn start( find_ready_container(service, &spec.family, request.container_id.as_deref()).await?; let info = fresh_container_info(&container.base_url, "workspace eval identity").await?; bind_workspace_container_identity(&mut spec, &info); + crate::project_sources::require_manifest(&recipe.source_path, crate::project_sources::Capability::Recipes)?; // `start_eval` carries the state for the complete rollout/evidence // pipeline. In debug builds that future is large enough that embedding it // directly in each caller's state can overflow a Tokio worker before the diff --git a/apps/synth_desktop/src-tauri/src/optimizers/container_lifecycle.rs b/apps/synth_desktop/src-tauri/src/optimizers/container_lifecycle.rs index 1677bcbf5..d5123c3c0 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/container_lifecycle.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/container_lifecycle.rs @@ -226,6 +226,7 @@ pub fn resolve_declared_spec( "launch_declaration_missing: container `{container_id}` has no persisted declaration origin" ) })?; + crate::project_sources::require_manifest(&stored.manifest_path, crate::project_sources::Capability::Containers)?; workspace_recipe::load_container_specs_from_manifest(&stored.manifest_path)? .into_iter() .find(|candidate| candidate.id == spec_id) @@ -284,6 +285,7 @@ pub async fn ensure_from_session( } pub async fn ensure_spec(db: &Arc, spec: &ContainerSpec) -> Result { + require_source_grant(spec)?; let broker_secret = new_broker_secret(); let (base_url, launch) = if let Some(url) = spec.url.as_deref() { let base = url.trim_end_matches('/').to_string(); @@ -292,7 +294,7 @@ pub async fn ensure_spec(db: &Arc, spec: &ContainerSpec) -> Result, spec: &ContainerSpec) -> Result, spec: &ContainerSpec, ) -> Result { + require_source_grant(spec)?; let base_url = spec .url .as_deref() @@ -337,8 +344,10 @@ pub async fn replace_declared( .map(|value| value.trim_end_matches('/').to_string()) .ok_or_else(|| anyhow!("container `{}` must declare url", spec.id))?; let broker_secret = new_broker_secret(); - let launch = start_command(spec, Some(&broker_secret))?; + let launch = start_with_source_grant(spec, Some(&broker_secret)).await?; wait_healthy(&base_url, &spec.health, spec).await?; + let _resolution = crate::project_sources::requests::RESOLUTION.lock().await; + require_source_grant(spec)?; let process = launch.receipt(); let container_id = upsert_ready(db, spec, &base_url, Some(process)).await?; store_broker_secret(db, &container_id, &broker_secret).await?; @@ -368,7 +377,18 @@ async fn store_broker_secret(db: &Arc, container_id: &str, secret: &st .await } +fn require_source_grant(spec: &ContainerSpec) -> Result<()> { + crate::project_sources::require_manifest(&spec.origin.manifest_path, crate::project_sources::Capability::Containers)?; + Ok(()) +} + +async fn start_with_source_grant(spec: &ContainerSpec, broker_secret: Option<&str>) -> Result { + let _resolution = crate::project_sources::requests::RESOLUTION.lock().await; + start_command(spec, broker_secret) +} + fn start_command(spec: &ContainerSpec, broker_secret: Option<&str>) -> Result { + require_source_grant(spec)?; let source_root = spec .origin .source_root @@ -1260,6 +1280,9 @@ include = ["launch-a.sh", "launch-b.sh"] let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); register_declared(&db, &root, &initial, "unhealthy"); + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + crate::project_sources::TEST_SOURCE_CONFIG.sync_scope(config, || { let validated = reconcile_declaration(&db, "session", "ctr_fixture").unwrap(); assert_eq!( approval_declaration_digest(&validated).unwrap(), @@ -1276,6 +1299,7 @@ include = ["launch-a.sh", "launch-b.sh"] approved_digest, "the complete validated declaration, not only its optional source digest, is approval-bound" ); + }); } #[tokio::test] @@ -1357,8 +1381,10 @@ include = ["launch-a.sh", "launch-b.sh"] let mut continuation = ContainerReplacementContinuation::new(Ok("approval-once".into()), digest); - let outcome = continuation - .consume(&db, "session", "ctr_fixture", &spec) + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + let outcome = crate::project_sources::TEST_SOURCE_CONFIG.scope(config, continuation + .consume(&db, "session", "ctr_fixture", &spec)) .await .unwrap(); let marker = root.join("launch-marker"); @@ -1424,7 +1450,9 @@ include = ["launch-a.sh", "launch-b.sh"] spec.launch.readiness_timeout_seconds = 4; let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); - let ensured = ensure_spec(&db, &spec).await.unwrap(); + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + let ensured = crate::project_sources::TEST_SOURCE_CONFIG.scope(config, ensure_spec(&db, &spec)).await.unwrap(); assert!( marker.exists(), "old health must not let ensure return before the declared replacement is live" @@ -1451,7 +1479,9 @@ include = ["launch-a.sh", "launch-b.sh"] spec.launch.shutdown_grace_seconds = 1; let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); - let error = ensure_spec(&db, &spec).await.unwrap_err().to_string(); + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + let error = crate::project_sources::TEST_SOURCE_CONFIG.scope(config, ensure_spec(&db, &spec)).await.unwrap_err().to_string(); assert!(error.contains("readiness_timeout"), "{error}"); tokio::time::sleep(Duration::from_millis(1_250)).await; assert!( @@ -1470,12 +1500,14 @@ include = ["launch-a.sh", "launch-b.sh"] write_manifest(&root, "launch-a.sh", 31999, "fixture-target"); let spec = workspace_recipe::find_container_spec(&root, "fixture-container").unwrap(); - let cancelled = tokio::time::timeout(Duration::from_millis(150), async { + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + let cancelled = crate::project_sources::TEST_SOURCE_CONFIG.scope(config, tokio::time::timeout(Duration::from_millis(150), async { let launch = start_command(&spec, None)?; wait_healthy("http://127.0.0.1:31999", "/health", &spec).await?; launch.commit(); Ok::<_, anyhow::Error>(()) - }) + })) .await; assert!( cancelled.is_err(), @@ -1487,4 +1519,45 @@ include = ["launch-a.sh", "launch-b.sh"] "cancelling ensure must reap the launcher and its delayed children" ); } + + #[tokio::test] + async fn revoked_source_cannot_launch_replace_or_register_ready() { + let dir = tempdir().unwrap(); + let root = dir.path().join("source"); + fs::create_dir_all(&root).unwrap(); + write_launcher(&root.join("launch-a.sh")); + write_launcher(&root.join("launch-b.sh")); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let live = Arc::new(AtomicBool::new(true)); + let server = serve_health(listener, "fixture-target".into(), live.clone()); + write_manifest(&root, "launch-a.sh", port, "fixture-target"); + let mut spec = workspace_recipe::find_container_spec(&root, "fixture-container").unwrap(); + let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, false, true); + crate::project_sources::TEST_SOURCE_CONFIG.scope(config.clone(), async { + assert!(ensure_spec(&db, &spec).await.unwrap_err().to_string().contains("launch_source_root_not_approved")); + assert!(replace_declared(&db, &spec).await.unwrap_err().to_string().contains("launch_source_root_not_approved")); + assert!(!root.join("launch-marker").exists()); + crate::project_sources::test_grant(&config, &root, true, false); + // An externally served endpoint still requires authority at final + // registration. Revoke while ensure waits for the commit lock. + spec.command.clear(); + let guard = crate::project_sources::requests::RESOLUTION.lock().await; + let ensure = ensure_spec(&db, &spec); + let revoke = async { + tokio::time::sleep(Duration::from_millis(150)).await; + crate::synth_config::forget_project_source_at(&config, root.to_str().unwrap()).unwrap(); + drop(guard); + }; + let (result, ()) = tokio::join!(ensure, revoke); + assert!(result.unwrap_err().to_string().contains("launch_source_root_not_approved")); + let count: i64 = db.with_conn(|conn| Ok(conn.query_row("SELECT count(*) FROM containers", [], |row| row.get(0))?)).unwrap(); + assert_eq!(count, 0, "revocation must prevent a ready registry record"); + assert!(start_command(&spec, None).err().unwrap().to_string().contains("launch_source_root_not_approved")); + }).await; + live.store(false, Ordering::Release); + server.join().unwrap(); + } } diff --git a/apps/synth_desktop/src-tauri/src/optimizers/manager.rs b/apps/synth_desktop/src-tauri/src/optimizers/manager.rs index 4a77268c1..8d27b3fb0 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/manager.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/manager.rs @@ -1055,6 +1055,7 @@ impl OptimizerManager { pub async fn spawn_gepa_recipe( &self, run_id: &str, + recipe_source: &Path, cookbook: &Path, config_path: &Path, stdout: fs::File, @@ -1095,7 +1096,14 @@ impl OptimizerManager { ) { bail!("GEPA supervisor cancelled `{run_id}` while it was queued"); } - match launch_gepa_recipe_process( + // Capacity waits must not block native source revocation. Check only + // after admission, then serialize the check and synchronous spawn. + let resolution = crate::project_sources::requests::RESOLUTION.lock().await; + if let Err(error) = crate::project_sources::require_manifest(recipe_source, crate::project_sources::Capability::Recipes) { + self.gepa_workers.lock().await.remove(run_id); + return Err(error); + } + let launched = launch_gepa_recipe_process( &self.home, &selected.version, cookbook, @@ -1105,7 +1113,9 @@ impl OptimizerManager { openai_api_key, openai_base_url, extra_env, - ) { + ); + drop(resolution); + match launched { Ok(mut child) => { let pid = child .id() @@ -4148,6 +4158,7 @@ mod tests { let error = mgr .spawn_gepa_recipe( "gepa_luna", + &home.path().join("recipe.toml"), home.path(), &home.path().join("recipe.toml"), stdout, @@ -4233,9 +4244,13 @@ mod tests { mgr.start().await.unwrap(); let config = home.path().join("recipe.toml"); fs::write(&config, "").unwrap(); + let grants = home.path().join("sources.toml"); + crate::project_sources::test_grant(&grants, home.path(), false, true); + crate::project_sources::TEST_SOURCE_CONFIG.scope(grants, async { let child_luna = mgr .spawn_gepa_recipe( "gepa_luna", + &config, home.path(), &config, fs::File::create(home.path().join("luna.out")).unwrap(), @@ -4249,6 +4264,7 @@ mod tests { let child_sol = mgr .spawn_gepa_recipe( "gepa_sol", + &config, home.path(), &config, fs::File::create(home.path().join("sol.out")).unwrap(), @@ -4270,6 +4286,34 @@ mod tests { mgr.release_gepa_recipe("gepa_luna").await; mgr.release_gepa_recipe("gepa_sol").await; assert!(mgr.active_gepa_run_ids().await.is_empty()); + }).await; + let _ = mgr.stop().await; + } + + #[tokio::test] + async fn queued_gepa_spawn_rechecks_revocation_after_capacity_wait() { + let (mgr, home) = manager(); + mgr.install(None).unwrap(); + mgr.start().await.unwrap(); + let config = home.path().join("recipe.toml"); + fs::write(&config, "").unwrap(); + let grants = home.path().join("sources.toml"); + crate::project_sources::test_grant(&grants, home.path(), false, true); + let permits = mgr.gepa_capacity.acquire_many(2).await.unwrap(); + crate::project_sources::TEST_SOURCE_CONFIG.scope(grants.clone(), async { + let spawn = mgr.spawn_gepa_recipe("gepa_revoked", &config, home.path(), &config, + fs::File::create(home.path().join("out")).unwrap(), + fs::File::create(home.path().join("err")).unwrap(), "sk-test", None, &[]); + let revoke = async { + tokio::time::sleep(Duration::from_millis(50)).await; + let _resolution = crate::project_sources::requests::RESOLUTION.lock().await; + crate::synth_config::forget_project_source_at(&grants, home.path().to_str().unwrap()).unwrap(); + drop(permits); + }; + let result = tokio::time::timeout(Duration::from_secs(3), async { tokio::join!(spawn, revoke) }).await.unwrap(); + assert!(result.0.unwrap_err().to_string().contains("launch_source_root_not_approved")); + assert!(mgr.active_gepa_run_ids().await.is_empty()); + }).await; let _ = mgr.stop().await; } diff --git a/apps/synth_desktop/src-tauri/src/optimizers/mlx_runtime.rs b/apps/synth_desktop/src-tauri/src/optimizers/mlx_runtime.rs index 6c4223249..73d602462 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/mlx_runtime.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/mlx_runtime.rs @@ -24,7 +24,7 @@ pub(super) const TRAINING_MODEL_ID: &str = "Qwen/Qwen3.5-2B"; const HEALTH_TRIES: u32 = 480; const HEALTH_WAIT: Duration = Duration::from_millis(250); const MLX_RUNTIME_VERSION: &str = "0.6.0"; -const MLX_RUNTIME_SOURCE_REVISION: &str = "5d6db14330babcff170d2afbb8535de2138385a9"; +const MLX_RUNTIME_SOURCE_REVISION: &str = "99a87a650059fe0091f92d1dced955fb1ef32328"; const MLX_RUNTIME_LOCK_SHA256: &str = "7f14b704ba9a6c30e6ced5cc88fc2ba6a58a936a9531cfaf168cbb664f83c420"; pub const LOCAL_TRAINING_MAX_SEQ_LENGTH: u64 = 1024; diff --git a/apps/synth_desktop/src-tauri/src/optimizers/mod.rs b/apps/synth_desktop/src-tauri/src/optimizers/mod.rs index 269ceed6e..be6138666 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/mod.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/mod.rs @@ -37,6 +37,8 @@ mod normalize; mod recipes; mod results; mod service; +mod snapshot; +pub use snapshot::{OptimizerSnapshotImportRequest, OptimizerSnapshotReceipt}; mod sft_client; mod sft_recipes; mod sft_result; diff --git a/apps/synth_desktop/src-tauri/src/optimizers/recipes.rs b/apps/synth_desktop/src-tauri/src/optimizers/recipes.rs index a177dd7fe..76fe714d5 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/recipes.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/recipes.rs @@ -62,12 +62,14 @@ async fn start_inner( } let manager = service.manager().clone(); require_plugin_ready(&manager).await?; + crate::project_sources::require_manifest(&recipe.source_path, crate::project_sources::Capability::Recipes)?; let ensured = super::container_lifecycle::ensure_from_session( service.database(), session, &recipe.container, ) .await?; + crate::project_sources::require_manifest(&recipe.source_path, crate::project_sources::Capability::Recipes)?; let run_id = format!( "gepa_{}_{}", recipe @@ -166,6 +168,7 @@ async fn start_inner( "recipeId": recipe.id, "task": recipe.family, "source": "workspace", + "recipeSourcePath": recipe.source_path, "containerId": ensured.container_id, "locality": recipe.locality.as_str(), "sourceHash": recipe.source_hash, @@ -266,6 +269,8 @@ pub(super) async fn start_prepared( )> { require_plugin_ready(service.manager()).await?; let run = service.get(run_id.to_string()).await?; + let source_path = recipe_source_path(&run.summary)?; + crate::project_sources::require_manifest(&source_path, crate::project_sources::Capability::Recipes)?; if run.status != "waiting_for_viewer" && run.status != "queued" { bail!( "optimizer run `{run_id}` is not prepared for start (status {})", @@ -489,6 +494,13 @@ fn run_index_wait() -> Duration { crate::limits::OPTIMIZER_RUN_INDEX_WAIT } +fn recipe_source_path(summary: &Value) -> Result { + let path = summary.get("recipeSourcePath").and_then(Value::as_str) + .map(PathBuf::from).filter(|path| path.is_absolute()) + .ok_or_else(|| anyhow!("prepared recipe has no executable source provenance; prepare it again from an approved recipe source"))?; + Ok(path) +} + async fn run_recipe_worker( service: OptimizerService, run_id: String, @@ -500,6 +512,9 @@ async fn run_recipe_worker( ) -> Result<()> { let _revoke_capabilities = crate::secrets::RevokeRunOnDrop(run_id.clone()); let _ownership = service.hold_run_ownership(&run_id)?; + let run = service.get(run_id.clone()).await?; + let source_path = recipe_source_path(&run.summary)?; + crate::project_sources::require_manifest(&source_path, crate::project_sources::Capability::Recipes)?; append_status_event(&service, &run_id, "optimizer.run.started", "running").await?; let provider = fs::read_to_string(&config_path) .context("read run-owned recipe provider")? @@ -530,6 +545,7 @@ async fn run_recipe_worker( let mut child = manager .spawn_gepa_recipe( &run_id, + &source_path, &cookbook, &config_path, stdout, @@ -1445,6 +1461,28 @@ fn gepa_runs_root() -> Result { mod tests { use super::*; + #[test] + fn prepared_recipe_requires_persisted_source_and_current_recipe_capability() { + assert!(recipe_source_path(&json!({})).is_err()); + assert!(recipe_source_path(&json!({"recipeSourcePath": "relative.toml"})).is_err()); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("source"); + fs::create_dir(&root).unwrap(); + let recipe = root.join("workshop.recipe.toml"); + fs::write(&recipe, "id = 'prepared'").unwrap(); + let source = recipe_source_path(&json!({"recipeSourcePath": recipe})).unwrap(); + let config = dir.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &root, true, false); + crate::project_sources::TEST_SOURCE_CONFIG.sync_scope(config.clone(), || { + let check = || crate::project_sources::require_manifest(&source, crate::project_sources::Capability::Recipes); + assert!(check().is_err()); + crate::project_sources::test_grant(&config, &root, false, true); + assert_eq!(check().unwrap(), recipe.canonicalize().unwrap()); + crate::synth_config::forget_project_source_at(&config, root.to_str().unwrap()).unwrap(); + assert!(check().is_err()); + }); + } + #[test] fn provider_policy_uses_the_run_owned_model_and_reasoning_effort() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/synth_desktop/src-tauri/src/optimizers/service.rs b/apps/synth_desktop/src-tauri/src/optimizers/service.rs index ad4e6a97e..136ad8728 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/service.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/service.rs @@ -1837,6 +1837,59 @@ impl OptimizerService { .await } + pub async fn export_snapshot( + &self, + optimizer_run_id: String, + ) -> Result { + let db = self.db.clone(); + let content = self.content().clone(); + let source_instance_id = crate::instance::name().unwrap_or_else(|| "canonical".into()); + let source_bundle_id = crate::instance::bundle_id().unwrap_or_else(|| "unknown".into()); + tokio::task::spawn_blocking(move || { + // One WAL snapshot owns the run, result, manifest and event cursor. + // Independent reads can straddle a concurrent terminal append. + let snapshot = db.read_transaction(|conn| { + let mut run = load_run(conn, &optimizer_run_id)?; + let state = super::kernel::persist::load_state(conn, &optimizer_run_id)? + .context("optimizer run has no saved kernel projection")?; + if OptimizerRunStatus::str_is_terminal(&run.status) { + rewrite_terminal_summary_progress(&mut run, &state); + } + let manifest = terminal::load(conn, &optimizer_run_id)?; + let settled = super::kernel::settle_result(&state).map_err(|error| anyhow!("{error}"))?; + let result = results::from_kernel(&run, &state, settled, manifest.as_ref())?; + let events = load_events_upto(conn, &optimizer_run_id, run.cursor_seq)?; + Ok(super::snapshot::OptimizerRunSnapshot { + schema_version: super::snapshot::OPTIMIZER_SNAPSHOT_SCHEMA.into(), + source_instance_id, source_bundle_id, source_run_id: optimizer_run_id, + captured_at: Utc::now().to_rfc3339(), terminal_cursor: run.cursor_seq, + sealed: manifest.is_some(), run, result, terminal_manifest: manifest, events, + }) + })?; + super::snapshot::persist(db, &content, &snapshot) + }).await.context("optimizer snapshot export worker failed")? + } + + pub async fn import_snapshot( + &self, + request: super::OptimizerSnapshotImportRequest, + ) -> Result { + let db = self.db.clone(); + let content = self.content().clone(); + tokio::task::spawn_blocking(move || super::snapshot::import_path(db, &content, request)) + .await.context("optimizer snapshot import worker failed")? + } + + pub async fn get_snapshot(&self, snapshot_id: String) -> Result { + let db = self.db.clone(); + let content = self.content().clone(); + tokio::task::spawn_blocking(move || { + let (snapshot, receipt) = super::snapshot::load(db, &content, &snapshot_id)?; + let evidence_summary = super::snapshot::evidence_summary(&snapshot); + Ok(json!({"snapshot": snapshot, "receipt": receipt, "evidenceSummary": evidence_summary})) + }).await.context("optimizer snapshot read worker failed")? + } + pub async fn create( &self, request: OptimizerCreateRequest, @@ -8272,6 +8325,31 @@ pub(in crate::optimizers) mod tests { assert!(result.get("selectedCandidate").is_none()); } + #[tokio::test] + async fn optimizer_snapshot_roundtrip_preserves_kernel_result_across_instances() { + let (svc, _source_dir, _) = service().await; + let run = eval_run(&svc, "opt_snapshot_result", "chat_snapshot").await; + svc.append_event_payloads(run.id.clone(), vec![ + draft("optimizer.run.started"), + draft("eval.run.planned").snapshot(Map::from_iter([("planned_trials".into(), json!(1))])), + measured_eval_trial("t1", 1.0), + draft("optimizer.run.completed"), + ]).await.unwrap(); + let expected = svc.get_result(run.id.clone()).await.unwrap(); + let receipt = svc.export_snapshot(run.id.clone()).await.unwrap(); + let target_dir = tempdir().unwrap(); + let target = reopen(&target_dir).await; + let imported = target.import_snapshot(super::super::OptimizerSnapshotImportRequest { + path: receipt.artifact_path, expected_digest: Some(receipt.content_digest.clone()), + }).await.unwrap(); + let loaded = target.get_snapshot(imported.snapshot_id).await.unwrap(); + assert_eq!(loaded["snapshot"]["result"], expected); + assert_eq!(loaded["snapshot"]["events"].as_array().unwrap().len(), 4); + assert_eq!(loaded["receipt"]["terminalStatus"], "completed"); + assert_eq!(loaded["receipt"]["contentDigest"], receipt.content_digest); + assert!(target.get(run.id).await.is_err(), "imported evidence must not become an executable run"); + } + /// GEPA, eval, and SFT settle into their own typed results. A shared /// GEPA-shaped reader is what made a baseline eval demand a prompt it was /// never designed to have. diff --git a/apps/synth_desktop/src-tauri/src/optimizers/snapshot.rs b/apps/synth_desktop/src-tauri/src/optimizers/snapshot.rs new file mode 100644 index 000000000..ef84c5277 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/optimizers/snapshot.rs @@ -0,0 +1,419 @@ +use super::{OptimizerEventEnvelope, OptimizerRunRecord}; +use crate::storage::{ContentStore, Database}; +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{fs, io::Read, path::PathBuf, sync::Arc}; + +pub const OPTIMIZER_SNAPSHOT_SCHEMA: &str = "synth.optimizer-run-snapshot.v1"; +const MAX_SNAPSHOT_BYTES: usize = 128 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OptimizerRunSnapshot { + pub schema_version: String, + pub source_instance_id: String, + pub source_bundle_id: String, + pub source_run_id: String, + pub captured_at: String, + pub terminal_cursor: u64, + pub sealed: bool, + pub run: OptimizerRunRecord, + pub result: Value, + pub terminal_manifest: Option, + pub events: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OptimizerSnapshotReceipt { + pub schema_version: String, + pub snapshot_id: String, + pub content_digest: String, + pub source_instance_id: String, + pub source_run_id: String, + pub terminal_cursor: u64, + pub sealed: bool, + pub terminal_status: Option, + pub captured_at: String, + pub imported_at: String, + pub artifact_path: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptimizerSnapshotImportRequest { + pub path: String, + #[serde(default)] + pub expected_digest: Option, +} + +/// Deterministic, comparison-oriented projection over the immutable run +/// evidence. This does not rewrite the source run's terminal usage lanes: +/// container-reported per-rollout policy usage is kept separate and labeled +/// with its own completeness signal. +pub fn evidence_summary(snapshot: &OptimizerRunSnapshot) -> Value { + let records = snapshot + .run + .summary + .get("records") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let projected = records + .iter() + .map(|record| { + json!({ + "rolloutId": record.get("rolloutId").cloned().unwrap_or(Value::Null), + "seed": record.get("seed").cloned().unwrap_or(Value::Null), + "status": record.get("status").cloned().unwrap_or(Value::Null), + "reward": record.get("reward").cloned().unwrap_or(Value::Null), + "costUsd": record.pointer("/usage/cost").cloned().unwrap_or(Value::Null), + "tokens": record.pointer("/usage/tokens").cloned().unwrap_or(Value::Null), + }) + }) + .collect::>(); + let rewards = projected + .iter() + .filter_map(|record| record.get("reward").and_then(Value::as_f64)) + .collect::>(); + let costs = projected + .iter() + .filter_map(|record| record.get("costUsd").and_then(Value::as_f64)) + .collect::>(); + let tokens = projected + .iter() + .filter_map(|record| record.get("tokens").and_then(Value::as_u64)) + .collect::>(); + let total_reward = rewards.iter().sum::(); + let total_cost = costs.iter().sum::(); + let rollout_count = projected.len(); + let total_tokens = tokens.iter().try_fold(0u64, |sum, value| sum.checked_add(*value)); + let reward_complete = rollout_count > 0 && rewards.len() == rollout_count && total_reward.is_finite(); + let cost_complete = rollout_count > 0 && costs.len() == rollout_count && total_cost.is_finite(); + let token_complete = rollout_count > 0 && tokens.len() == rollout_count && total_tokens.is_some(); + let score_per_dollar = + (reward_complete && cost_complete && total_cost > 0.0).then_some(total_reward / total_cost) + .filter(|value| value.is_finite()); + + json!({ + "schemaVersion": "optimizer_evidence_summary.v1", + "runId": snapshot.source_run_id, + "status": snapshot.run.status, + "objective": snapshot.run.objective, + "policyRef": snapshot.run.summary.get("policyRef").cloned().unwrap_or(Value::Null), + "rolloutCount": rollout_count, + "records": projected, + "reward": { + "complete": reward_complete, + "reportedRollouts": rewards.len(), + "total": reward_complete.then_some(total_reward), + "mean": reward_complete.then_some(total_reward / rollout_count as f64), + }, + "cost": { + "basis": "container_reported_rollout_policy_usage", + "complete": cost_complete, + "reportedRollouts": costs.len(), + "totalUsd": cost_complete.then_some(total_cost), + }, + "tokens": { + "basis": "container_reported_rollout_policy_usage", + "complete": token_complete, + "reportedRollouts": tokens.len(), + "total": if token_complete { total_tokens } else { None }, + }, + "efficiency": { + "basis": "total_reward_divided_by_container_reported_rollout_policy_cost", + "scorePerDollar": score_per_dollar, + }, + "terminalUsage": snapshot.terminal_manifest.as_ref() + .and_then(|manifest| manifest.get("usage")) + .cloned() + .unwrap_or(Value::Null), + }) +} + +pub fn canonical_bytes(snapshot: &OptimizerRunSnapshot) -> Result> { + validate(snapshot)?; + serde_json::to_vec(snapshot).context("serialize optimizer snapshot") +} + +pub fn validate(snapshot: &OptimizerRunSnapshot) -> Result<()> { + if snapshot.schema_version != OPTIMIZER_SNAPSHOT_SCHEMA { + bail!( + "unsupported optimizer snapshot schema {}", + snapshot.schema_version + ); + } + if snapshot.source_instance_id.trim().is_empty() || snapshot.source_run_id.trim().is_empty() { + bail!("optimizer snapshot source identity is required"); + } + if snapshot.run.id != snapshot.source_run_id { + bail!("optimizer snapshot run identity does not match sourceRunId"); + } + if snapshot.run.cursor_seq != snapshot.terminal_cursor { + bail!("optimizer snapshot cursor does not match run cursor"); + } + if snapshot.terminal_cursor > i64::MAX as u64 { + bail!("optimizer snapshot cursor exceeds storage range"); + } + let last = snapshot + .events + .last() + .map(|event| event.sequence_number) + .unwrap_or(0); + if last != snapshot.terminal_cursor + || snapshot.events.iter().enumerate().any(|(i, e)| { + e.optimizer_run_id != snapshot.source_run_id || e.sequence_number != i as u64 + 1 + }) + { + bail!("optimizer snapshot event chain is incomplete or non-contiguous"); + } + if snapshot.sealed != snapshot.terminal_manifest.is_some() { + bail!("optimizer snapshot sealed state disagrees with terminal manifest"); + } + if let Some(manifest) = snapshot.terminal_manifest.as_ref() { + super::terminal::snapshot_status(&snapshot.run, manifest)?; + } + Ok(()) +} + +pub fn persist( + db: Arc, + content: &ContentStore, + snapshot: &OptimizerRunSnapshot, +) -> Result { + let bytes = canonical_bytes(snapshot)?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + bail!("optimizer snapshot exceeds 128 MiB limit"); + } + let digest = content.put_bytes("optimizer_snapshots", &bytes)?; + // An existing CAS path may have been damaged outside the app. Never issue + // a successful import receipt for bytes the reader would later refuse. + content.get_bytes_bounded("optimizer_snapshots", &digest, MAX_SNAPSHOT_BYTES)?; + let snapshot_id = format!("optsnap_{}", &digest[..24]); + let imported_at = Utc::now().to_rfc3339(); + let terminal_status = snapshot + .terminal_manifest + .as_ref() + .map(|manifest| super::terminal::snapshot_status(&snapshot.run, manifest)) + .transpose()?; + let export_dir = content + .root() + .parent() + .unwrap_or(content.root()) + .join("exports") + .join("optimizer-snapshots"); + fs::create_dir_all(&export_dir)?; + let artifact = export_dir.join(format!("{snapshot_id}.json")); + if !artifact.exists() { + let mut file = tempfile::NamedTempFile::new_in(&export_dir)?; + std::io::Write::write_all(&mut file, &bytes)?; + file.persist(&artifact)?; + } else if read_bounded_file(&artifact)? != bytes { + bail!("optimizer snapshot export artifact failed content verification"); + } + let metadata = + json!({"sourceBundleId": snapshot.source_bundle_id, "eventCount": snapshot.events.len()}); + let receipt = OptimizerSnapshotReceipt { + schema_version: OPTIMIZER_SNAPSHOT_SCHEMA.into(), + snapshot_id: snapshot_id.clone(), + content_digest: digest.clone(), + source_instance_id: snapshot.source_instance_id.clone(), + source_run_id: snapshot.source_run_id.clone(), + terminal_cursor: snapshot.terminal_cursor, + sealed: snapshot.sealed, + terminal_status: terminal_status.clone(), + captured_at: snapshot.captured_at.clone(), + imported_at: imported_at.clone(), + artifact_path: artifact.display().to_string(), + }; + db.with_conn(|conn| { + conn.execute("INSERT INTO optimizer_snapshots(snapshot_id,schema_version,content_digest,source_instance_id,source_run_id,terminal_status,terminal_cursor,sealed,captured_at,imported_at,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11) ON CONFLICT(snapshot_id) DO UPDATE SET imported_at=excluded.imported_at, metadata_json=excluded.metadata_json", + params![snapshot_id, OPTIMIZER_SNAPSHOT_SCHEMA, digest, snapshot.source_instance_id, snapshot.source_run_id, terminal_status, snapshot.terminal_cursor as i64, snapshot.sealed as i64, snapshot.captured_at, imported_at, serde_json::to_string(&metadata)?])?; + Ok(()) + })?; + Ok(receipt) +} + +fn read_bounded_file(path: &std::path::Path) -> Result> { + let file = fs::File::open(path).with_context(|| format!("read optimizer snapshot {}", path.display()))?; + let metadata = file.metadata()?; + if !metadata.is_file() { bail!("optimizer snapshot must be a regular file"); } + if metadata.len() > MAX_SNAPSHOT_BYTES as u64 { bail!("optimizer snapshot exceeds 128 MiB limit"); } + let mut bytes = Vec::new(); + file.take(MAX_SNAPSHOT_BYTES as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_SNAPSHOT_BYTES { bail!("optimizer snapshot exceeds 128 MiB limit"); } + Ok(bytes) +} + +pub fn import_path( + db: Arc, + content: &ContentStore, + request: OptimizerSnapshotImportRequest, +) -> Result { + let path = PathBuf::from(&request.path); + let bytes = read_bounded_file(&path)?; + let snapshot: OptimizerRunSnapshot = + serde_json::from_slice(&bytes).context("parse optimizer snapshot")?; + let canonical = canonical_bytes(&snapshot)?; + let actual_digest = format!("{:x}", Sha256::digest(&canonical)); + if request + .expected_digest + .as_deref() + .is_some_and(|expected| expected != actual_digest) + { + bail!("optimizer snapshot digest did not match expected digest"); + } + persist(db, content, &snapshot) +} + +pub fn load( + db: Arc, + content: &ContentStore, + snapshot_id: &str, +) -> Result<(OptimizerRunSnapshot, OptimizerSnapshotReceipt)> { + let id = snapshot_id.to_string(); + let row: Option<(String,String,String,String,i64,i64,Option,String,String)> = db.with_conn(|conn| conn.query_row( + "SELECT content_digest,source_instance_id,source_run_id,captured_at,terminal_cursor,sealed,terminal_status,imported_at,schema_version FROM optimizer_snapshots WHERE snapshot_id=?1", + [id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?))).optional().map_err(Into::into))?; + let ( + digest, + source_instance_id, + source_run_id, + captured_at, + cursor, + sealed, + terminal_status, + imported_at, + schema_version, + ) = row.ok_or_else(|| anyhow::anyhow!("optimizer snapshot not found"))?; + let bytes = content.get_bytes_bounded("optimizer_snapshots", &digest, MAX_SNAPSHOT_BYTES)?; + let snapshot: OptimizerRunSnapshot = serde_json::from_slice(&bytes)?; + validate(&snapshot)?; + if source_instance_id != snapshot.source_instance_id || source_run_id != snapshot.source_run_id + || cursor < 0 || cursor as u64 != snapshot.terminal_cursor || (sealed != 0) != snapshot.sealed + || captured_at != snapshot.captured_at || schema_version != snapshot.schema_version { + bail!("optimizer snapshot receipt disagrees with immutable content"); + } + let artifact = content + .root() + .parent() + .unwrap_or(content.root()) + .join("exports") + .join("optimizer-snapshots") + .join(format!("{snapshot_id}.json")); + Ok(( + snapshot, + OptimizerSnapshotReceipt { + schema_version, + snapshot_id: snapshot_id.into(), + content_digest: digest, + source_instance_id, + source_run_id, + terminal_cursor: cursor as u64, + sealed: sealed != 0, + terminal_status, + captured_at, + imported_at, + artifact_path: artifact.display().to_string(), + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> OptimizerRunSnapshot { + serde_json::from_value(json!({ + "schemaVersion": OPTIMIZER_SNAPSHOT_SCHEMA, "sourceInstanceId": "test-source", + "sourceBundleId": "test-bundle", "sourceRunId": "run-test", "capturedAt": "2026-09-10T00:00:00Z", + "terminalCursor": 1, "sealed": true, + "run": {"schemaVersion": "synth.optimizer-run.v1", "id": "run-test", "algorithmId": "eval", + "status": "completed", "source": "local", "createdAt": "2026-09-10T00:00:00Z", "cursorSeq": 1, + "summary": {"records": [{"rolloutId": "rollout-1", "reward": 1, "usage": {"cost": 0.5, "tokens": 10}}]}}, + "result": {"reward": 1}, + "terminalManifest": {"schemaVersion": "optimizer_terminal_manifest.v2", "optimizerRunId": "run-test", + "algorithmId": "eval", "terminalCursor": 1, "terminal": {"kind": "completed"}}, + "events": [{"schemaVersion": "synth.optimizer-event.v1", "type": "run.completed", "sequenceNumber": 1, + "occurredAt": "2026-09-10T00:00:00Z", "optimizerRunId": "run-test", "algorithmId": "eval"}] + })).unwrap() + } + + #[test] + fn snapshot_refuses_gaps_identity_and_seal_drift() { + let original = sample(); + assert!(validate(&original).is_ok()); + let mut invalid = vec![original.clone(); 6]; + invalid[0].events.clear(); + invalid[1].events[0].optimizer_run_id = "other".into(); + invalid[2].events[0].sequence_number = 2; + invalid[3].sealed = false; + invalid[4].terminal_manifest.as_mut().unwrap()["optimizerRunId"] = json!("other"); + invalid[5].terminal_manifest.as_mut().unwrap()["terminalCursor"] = json!(2); + for snapshot in invalid { assert!(validate(&snapshot).is_err()); } + } + + #[test] + fn snapshot_roundtrip_is_content_addressed_and_never_creates_a_run() { + let dir = tempfile::tempdir().unwrap(); + let db = Arc::new(Database::open(dir.path().join("db.sqlite3")).unwrap()); + let content = ContentStore::new(dir.path().join("store")); + let original = sample(); + let receipt = persist(db.clone(), &content, &original).unwrap(); + assert_eq!(receipt.terminal_status.as_deref(), Some("completed")); + let imported = import_path(db.clone(), &content, OptimizerSnapshotImportRequest { + path: receipt.artifact_path.clone(), expected_digest: Some(receipt.content_digest.clone()), + }).unwrap(); + assert_eq!(imported.snapshot_id, receipt.snapshot_id); + let (loaded, _) = load(db.clone(), &content, &receipt.snapshot_id).unwrap(); + assert_eq!(loaded, original); + db.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM optimizer_snapshots", [], |r| r.get::<_, i64>(0))?, 1); + assert_eq!(conn.query_row("SELECT count(*) FROM optimizer_runs", [], |r| r.get::<_, i64>(0))?, 0); + Ok(()) + }).unwrap(); + assert!(import_path(db.clone(), &content, OptimizerSnapshotImportRequest { + path: receipt.artifact_path, expected_digest: Some("wrong".into()), + }).is_err()); + fs::write(content.path_for("optimizer_snapshots", &receipt.content_digest), b"corrupted").unwrap(); + assert!(load(db, &content, &receipt.snapshot_id).is_err()); + } + + #[test] + fn snapshot_summary_does_not_turn_missing_usage_into_zero() { + let mut snapshot = sample(); + assert_eq!(evidence_summary(&snapshot)["efficiency"]["scorePerDollar"], 2.0); + snapshot.run.summary["records"][0].as_object_mut().unwrap().remove("usage"); + let summary = evidence_summary(&snapshot); + assert_eq!(summary["cost"]["complete"], false); + assert!(summary["cost"]["totalUsd"].is_null()); + assert!(summary["efficiency"]["scorePerDollar"].is_null()); + } + + #[test] + fn snapshot_usage_overflow_is_incomplete_not_a_panic_or_infinity() { + let mut snapshot = sample(); + snapshot.run.summary["records"] = json!([ + {"reward": f64::MAX, "usage": {"cost": f64::MAX, "tokens": u64::MAX}}, + {"reward": f64::MAX, "usage": {"cost": f64::MAX, "tokens": u64::MAX}} + ]); + let summary = evidence_summary(&snapshot); + for field in ["reward", "cost", "tokens"] { assert_eq!(summary[field]["complete"], false); } + assert!(summary["tokens"]["total"].is_null()); + assert!(summary["efficiency"]["scorePerDollar"].is_null()); + } + + #[test] + fn snapshot_read_refuses_directories_and_oversized_files() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_bounded_file(dir.path()).is_err()); + let path = dir.path().join("oversized.json"); + fs::File::create(&path).unwrap().set_len(MAX_SNAPSHOT_BYTES as u64 + 1).unwrap(); + assert!(read_bounded_file(&path).is_err()); + } +} diff --git a/apps/synth_desktop/src-tauri/src/optimizers/terminal.rs b/apps/synth_desktop/src-tauri/src/optimizers/terminal.rs index 33e5bc6f0..ce9a42fdf 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/terminal.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/terminal.rs @@ -413,6 +413,14 @@ struct ManifestEnvelope<'a> { terminal_cursor: u64, } +pub(super) fn snapshot_status(run: &OptimizerRunRecord, manifest: &Value) -> Result { + let envelope = validate_manifest(&run.id, manifest)?; + if envelope.algorithm_id != run.algorithm_id || envelope.terminal_cursor > run.cursor_seq { + anyhow::bail!("optimizer snapshot terminal manifest does not match the run algorithm/cursor"); + } + Ok(envelope.terminal_status.to_owned()) +} + fn validate_manifest<'a>(run_id: &str, manifest: &'a Value) -> Result> { let object = manifest .as_object() diff --git a/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs b/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs index 8622e92e0..86ebbe9b3 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs @@ -647,11 +647,22 @@ pub fn load_recipes(workspace: &Path) -> Result> { fn recipe_paths(workspace: &Path) -> Result> { let mut paths = Vec::new(); + let canonical_root = workspace.canonicalize().context("recipe source is unavailable")?; + let contained = |path: &Path| -> Result { + let canonical = path.canonicalize().context("recipe declaration is unavailable")?; + if !canonical.starts_with(&canonical_root) { + bail!("recipe_source_root_not_approved: {} escapes its source", path.display()); + } + Ok(canonical) + }; let root_file = workspace.join(RECIPE_FILE); - if root_file.is_file() { - paths.push(root_file); + if root_file.exists() || root_file.is_symlink() { + paths.push(contained(&root_file)?); } let recipes_dir = workspace.join(RECIPES_DIR); + if recipes_dir.exists() || recipes_dir.is_symlink() { + contained(&recipes_dir)?; + } if recipes_dir.is_dir() { let mut entries: Vec = fs::read_dir(&recipes_dir) .with_context(|| format!("read {}", recipes_dir.display()))? @@ -663,7 +674,7 @@ fn recipe_paths(workspace: &Path) -> Result> { }) .collect(); entries.sort(); - paths.extend(entries); + for entry in entries { paths.push(contained(&entry)?); } } Ok(paths) } @@ -693,16 +704,14 @@ pub fn find_recipe(workspace: &Path, recipe_id: &str) -> Result }) } -/// Resolve a workspace recipe from every repository the conversation has -/// explicitly approved. The working workspace remains first, followed by -/// user-attached folders. Container declarations already use this authority; -/// recipe discovery must not silently apply a narrower boundary. +/// Resolve recipes only from executable project sources with recipe capability. +/// Conversation file attachments do not grant execution authority. pub fn find_session_recipe( - db: &crate::storage::Database, - session_id: &str, + _db: &crate::storage::Database, + _session_id: &str, recipe_id: &str, ) -> Result<(PathBuf, WorkspaceRecipe)> { - let roots = session_search_roots(db, session_id)?; + let roots = crate::project_sources::discovery_roots(crate::project_sources::Capability::Recipes)?; let mut matches = Vec::new(); for root in roots { for path in recipe_paths(&root)? { @@ -718,11 +727,11 @@ pub fn find_session_recipe( } match matches.len() { 0 => Err(anyhow!( - "workspace recipe `{recipe_id}` is not declared in any approved workspace or attached folder" + "workspace recipe `{recipe_id}` is not declared in any approved recipe source" )), 1 => Ok(matches.remove(0)), _ => Err(anyhow!( - "workspace recipe `{recipe_id}` is declared in more than one approved workspace or attached folder" + "workspace recipe `{recipe_id}` is declared in more than one approved recipe source" )), } } @@ -731,11 +740,11 @@ pub fn find_session_recipe( /// ids are retained here so start can reject the ambiguity instead of the /// catalog silently choosing one source. pub fn load_session_recipes( - db: &crate::storage::Database, - session_id: &str, + _db: &crate::storage::Database, + _session_id: &str, ) -> Result> { let mut recipes = Vec::new(); - for root in session_search_roots(db, session_id)? { + for root in crate::project_sources::discovery_roots(crate::project_sources::Capability::Recipes)? { for path in recipe_paths(&root)? { if let Ok(recipe) = parse_recipe(&path) { recipes.push(recipe); @@ -798,22 +807,30 @@ const MANIFEST_WALK_SKIP: &[&str] = &[ pub fn discover_container_manifests(search_roots: &[PathBuf]) -> Result> { let mut manifests = Vec::new(); for root in search_roots { - let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - collect_container_manifests(&canonical, 2, &mut manifests); + let Ok(canonical) = root.canonicalize() else { + continue; + }; + collect_container_manifests(&canonical, &canonical, 2, &mut manifests); } manifests.sort(); manifests.dedup(); Ok(manifests) } -fn collect_container_manifests(root: &Path, depth: usize, out: &mut Vec) { +fn collect_container_manifests(root: &Path, approved: &Path, depth: usize, out: &mut Vec) { + let Ok(canonical_root) = root.canonicalize() else { + return; + }; + if !canonical_root.starts_with(approved) { + return; + } let candidate = root.join(CONTAINERS_FILE); if candidate.is_file() { - out.push( - candidate - .canonicalize() - .unwrap_or_else(|_| candidate.clone()), - ); + if let Ok(canonical) = candidate.canonicalize() { + if canonical.starts_with(approved) { + out.push(canonical); + } + } } if depth == 0 { return; @@ -831,7 +848,7 @@ fn collect_container_manifests(root: &Path, depth: usize, out: &mut Vec if name.starts_with('.') || MANIFEST_WALK_SKIP.contains(&name.as_ref()) { continue; } - collect_container_manifests(&path, depth.saturating_sub(1), out); + collect_container_manifests(&path, approved, depth.saturating_sub(1), out); } } @@ -839,17 +856,20 @@ pub fn origin_is_under_approved_roots( origin: &ContainerDeclarationOrigin, search_roots: &[PathBuf], ) -> bool { + let (Ok(source), Ok(manifest)) = ( + origin.source_root.canonicalize(), + origin.manifest_path.canonicalize(), + ) else { + return false; + }; search_roots.iter().any(|root| { - let root = root.canonicalize().unwrap_or_else(|_| root.clone()); - paths_related(&origin.source_root, &root) || origin.manifest_path.starts_with(&root) + let Ok(root) = root.canonicalize() else { + return false; + }; + source.starts_with(&root) && manifest.starts_with(&source) && manifest.is_file() }) } -fn paths_related(path: &Path, root: &Path) -> bool { - let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - path == *root || path.starts_with(root) -} - /// Recover declaration provenance from registry metadata. /// /// Current records store `declarationOrigin`. Older records stored only @@ -951,10 +971,10 @@ pub fn resolve_container_spec( } pub fn session_search_roots( - db: &crate::storage::Database, - session_id: &str, + _db: &crate::storage::Database, + _session_id: &str, ) -> Result> { - crate::workspace_scope::approved_search_roots(db, session_id) + crate::project_sources::discovery_roots(crate::project_sources::Capability::Containers) } pub fn catalog_entry(recipe: &WorkspaceRecipe) -> Value { @@ -2027,6 +2047,67 @@ mod tests { use super::*; use tempfile::tempdir; + #[cfg(unix)] + #[test] + fn recipe_discovery_refuses_symlinked_files_and_directories_outside_source() { + use std::os::unix::fs::symlink; + let directory = tempdir().unwrap(); + let outside = directory.path().join("outside"); + fs::create_dir(&outside).unwrap(); + let target = outside.join("recipe.toml"); + fs::write(&target, "invalid outside fixture").unwrap(); + for mode in ["root-file", "directory", "nested-file"] { + let root = directory.path().join(mode); + fs::create_dir(&root).unwrap(); + match mode { + "root-file" => symlink(&target, root.join(RECIPE_FILE)).unwrap(), + "directory" => symlink(&outside, root.join(RECIPES_DIR)).unwrap(), + _ => { + fs::create_dir(root.join(RECIPES_DIR)).unwrap(); + symlink(&target, root.join(RECIPES_DIR).join("escape.toml")).unwrap(); + } + } + let error = load_recipes(&root).unwrap_err().to_string(); + assert!(error.contains("recipe_source_root_not_approved"), "{mode}: {error}"); + } + } + + #[cfg(unix)] + #[test] + fn discovery_and_stored_origin_refuse_symlink_escape_from_approved_roots() { + use std::os::unix::fs::symlink; + let directory = tempdir().unwrap(); + let root = directory.path().join("approved"); + let nested = root.join("nested"); + let outside = directory.path().join("outside"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir_all(&outside).unwrap(); + let valid = nested.join(CONTAINERS_FILE); + let escaped = outside.join(CONTAINERS_FILE); + fs::write(&valid, "# valid path fixture").unwrap(); + fs::write(&escaped, "# outside path fixture").unwrap(); + symlink(&escaped, root.join(CONTAINERS_FILE)).unwrap(); + symlink(&outside, root.join("escaped-folder")).unwrap(); + let roots = vec![root.canonicalize().unwrap()]; + assert_eq!( + discover_container_manifests(&roots).unwrap(), + vec![valid.canonicalize().unwrap()] + ); + let mut origin = ContainerDeclarationOrigin { + source_root: root.clone(), + manifest_path: root.join(CONTAINERS_FILE), + declaration_id: "fixture".into(), + source_revision: None, + source_digest: None, + }; + assert!(!origin_is_under_approved_roots(&origin, &roots)); + origin.source_root = nested; + origin.manifest_path = valid; + assert!(origin_is_under_approved_roots(&origin, &roots)); + origin.source_root = outside; + assert!(!origin_is_under_approved_roots(&origin, &roots)); + } + fn write_workspace() -> (tempfile::TempDir, PathBuf) { let dir = tempdir().unwrap(); let workspace = dir.path().join("workspace"); @@ -2099,7 +2180,7 @@ max_total_rollouts = 2 } #[tokio::test] - async fn attached_repository_recipes_are_cataloged_and_resolved_for_execution() { + async fn attachments_require_separate_recipe_grants_for_catalog_and_execution() { let root = tempdir().unwrap(); let primary = root.path().join("primary"); let attached = root.path().join("attached"); @@ -2165,6 +2246,17 @@ max_total_rollouts = 1 .await .unwrap(); + assert!(load_session_recipes(storage.database(), "attached-session").unwrap().is_empty()); + assert!(find_session_recipe(storage.database(), "attached-session", "eval.attached.v1").is_err()); + let config = data.path().join("sources.toml"); + crate::project_sources::test_grant(&config, &attached, true, false); + crate::project_sources::TEST_SOURCE_CONFIG.sync_scope(config.clone(), || { + assert!(load_session_recipes(storage.database(), "attached-session").unwrap().is_empty()); + assert_eq!(session_search_roots(storage.database(), "attached-session").unwrap(), vec![attached.canonicalize().unwrap()]); + }); + crate::project_sources::test_grant(&config, &attached, false, true); + crate::project_sources::test_grant(&config, &primary, false, true); + crate::project_sources::TEST_SOURCE_CONFIG.sync_scope(config.clone(), || { let catalog = load_session_recipes(storage.database(), "attached-session").unwrap(); assert!(catalog.iter().any(|recipe| recipe.id == "eval.attached.v1")); let (source_root, recipe) = @@ -2175,6 +2267,9 @@ max_total_rollouts = 1 let error = find_session_recipe(storage.database(), "attached-session", "gepa.stale.v1") .unwrap_err(); assert!(error.to_string().contains("exceeds product cap")); + crate::synth_config::forget_project_source_at(&config, attached.to_str().unwrap()).unwrap(); + assert!(find_session_recipe(storage.database(), "attached-session", "eval.attached.v1").is_err()); + }); } #[test] diff --git a/apps/synth_desktop/src-tauri/src/plugins/policy.rs b/apps/synth_desktop/src-tauri/src/plugins/policy.rs index 3a111a2b6..88fb789e4 100644 --- a/apps/synth_desktop/src-tauri/src/plugins/policy.rs +++ b/apps/synth_desktop/src-tauri/src/plugins/policy.rs @@ -35,6 +35,7 @@ pub fn classify(kind: &ApprovalKind, active_runs: u64) -> PluginRisk { ApprovalKind::ContainerLifecycle { .. } => PluginRisk::High, ApprovalKind::PaidCompute { .. } => PluginRisk::High, ApprovalKind::CredentialAccess { .. } => PluginRisk::High, + ApprovalKind::VisualTemplatePersist { .. } => PluginRisk::HandOff, ApprovalKind::ShellCommand { .. } => PluginRisk::High, // Non-hazard computer use: driving an app the operator has not yet // allowed. Hazard actions never reach here — `requires_human` above diff --git a/apps/synth_desktop/src-tauri/src/presentation.rs b/apps/synth_desktop/src-tauri/src/presentation.rs index 7139c6d47..53c945f30 100644 --- a/apps/synth_desktop/src-tauri/src/presentation.rs +++ b/apps/synth_desktop/src-tauri/src/presentation.rs @@ -7,6 +7,11 @@ //! second copy on the agent path would have drifted from it immediately. use anyhow::{bail, Result}; +mod document; +mod host; +pub use document::{document_path_binding, ensure_document_viewer, DOCUMENT_PROJECTION_SCHEMA, + DOCUMENT_VIEWER_TEMPLATE, WORKSPACE_FILE_BINDING_KIND}; +pub use host::{Pane, Presentability, UnavailableReason}; use serde_json::{json, Value}; use crate::core_runtime::CoreRuntime; diff --git a/apps/synth_desktop/src-tauri/src/presentation/document.rs b/apps/synth_desktop/src-tauri/src/presentation/document.rs new file mode 100644 index 000000000..dc9122b05 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/presentation/document.rs @@ -0,0 +1,398 @@ +//! The document pane: the panel host's second provider, and the one that was +//! not trace-shaped. +//! +//! A trace is an immutable sealed archive, so its pane's identity is its +//! digest. A document is a mutable place on disk, so its pane's identity is its +//! **canonical path**. That divergence is the point of this module existing +//! beside the trace presentation functions rather than parameterizing them: the host +//! vocabulary — presentability, deterministic identity, a declared binding, one +//! show event — held without change, while everything domain-shaped underneath +//! it moved. +//! +//! Why not path + content digest, which the design note proposed: a document is +//! edited. Digest identity would mint a fresh visual on every save, orphan the +//! pane the reader was looking at, and grow the registry by one row per +//! keystroke-batch. The digest is a *read receipt* — it travels on the read +//! result, where it describes the bytes actually rendered — not an identity. +//! The pane addresses the place; each read says what was there at the time. + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use super::{Pane, Presentability, UnavailableReason}; +use crate::core_runtime::CoreRuntime; +use crate::documents::{self, DocumentKind, DocumentRecord}; +use crate::visuals::{ + binding_descriptors, descriptor_input_name, VisualCreateRequest, VisualRecord, +}; + +/// Shares the id namespace with plugins and with the `trace` pane. +pub(super) const PROVIDER_ID: &str = "document"; + +pub const DOCUMENT_VIEWER_TEMPLATE: &str = "document.viewer.v1"; +pub const DOCUMENT_PROJECTION_SCHEMA: &str = documents::DOCUMENT_SCHEMA; + +/// The binding kind a document pane declares. +/// +/// This is the whole read grant: the pane may read the one path its visual +/// declares, and `workspace_read_file` re-resolves that path through the +/// session roots on every call rather than trusting the declaration. A binding +/// is what the pane is *allowed to ask for*, never what it is handed. +/// +/// Both binding vocabularies admit this kind. Generic template loaders refuse +/// it: only DocumentPane reads its bytes through session-scoped host commands. +pub const WORKSPACE_FILE_BINDING_KIND: &str = "workspace_file"; + +/// The binding input name. One input, so the pane cannot silently address a +/// second file behind the one the reader sees in the breadcrumb. +const DOCUMENT_INPUT: &str = "document"; + +/// Whether a located path can be typeset in the pane, and when it cannot, why. +/// +/// Pure over the record: scope was already decided when the record was located, +/// so nothing here touches the filesystem and every reason is one the catalog +/// and the pane can both show. +pub(super) fn presentable(document: &DocumentRecord) -> Presentability { + if !document.exists { + return Presentability::Unavailable(UnavailableReason::Missing); + } + if document.kind == DocumentKind::Directory { + return Presentability::Unavailable(UnavailableReason::NotADocument); + } + if document.read_error.is_some() { + return Presentability::Unavailable(UnavailableReason::Unreadable); + } + if !document.is_text { + return Presentability::Unavailable(UnavailableReason::NotText); + } + Presentability::Present +} + +/// Deterministic per-path identity, stable across restarts, windows, and +/// callers. +/// +/// The path is hashed rather than sanitized into the id. A sanitized path +/// collides — `/a/b.md` and `/a_b.md` sanitize alike — and the id has a 128 +/// character ceiling that real paths exceed. The path itself stays legible on +/// the visual's metadata and binding, which is where a human reading the +/// registry looks for it. +pub(super) fn visual_id(document: &DocumentRecord) -> String { + let digest = Sha256::digest(document.path.as_bytes()); + format!("vis_doc_{:x}", digest).chars().take(48).collect() +} + +/// The workspace path a document visual's pane is bound to. +/// +/// Returns `None` for any other template, so a caller cannot read this +/// binding off a visual that never declared one. +pub fn document_path_binding(visual: &VisualRecord) -> Option { + if visual.template_id != DOCUMENT_VIEWER_TEMPLATE { + return None; + } + binding_descriptors(&visual.bindings) + .ok()? + .into_iter() + .find_map(|slot| { + if descriptor_input_name(&slot).ok().as_deref() == Some(DOCUMENT_INPUT) + && slot.get("kind").and_then(Value::as_str) == Some(WORKSPACE_FILE_BINDING_KIND) + { + slot.get("source") + .and_then(Value::as_str) + .map(str::to_owned) + } else { + None + } + }) +} + +fn document_viewer_create_request( + document: &DocumentRecord, + session_id: Option, +) -> VisualCreateRequest { + let pane = Pane::Document(document); + VisualCreateRequest { + template_id: pane.template_id().into(), + title: Some(document.name.clone()), + bindings: Some(json!({ + "schemaVersion": "synth.visual-bindings.v1", + "inputs": [{ + "input": DOCUMENT_INPUT, + "kind": WORKSPACE_FILE_BINDING_KIND, + "source": document.path, + "schema": pane.projection_schema(), + }] + })), + id: Some(pane.visual_id()), + status: None, + renderer_kind: None, + session_id, + message_id: None, + run_id: None, + trace_id: None, + parent_visual_id: None, + source_agent_id: None, + source_model: None, + content: None, + // Durable facts about the *place* only. The content digest is + // deliberately absent: it would be stale the moment the file is saved, + // and a stale receipt on a durable record is worse than no receipt. + metadata: Some(json!({ + "documentPath": document.path, + "documentRoot": document.root, + "documentRelativePath": document.relative_path, + "documentLanguage": document.language, + "projectionSchema": pane.projection_schema(), + "providerId": pane.provider_id(), + })), + } +} + +/// Resolve, or create, the viewer visual for one workspace document. +/// +/// `session_id` is not optional the way it is on the trace path: the scope that +/// decides whether this path may be read at all belongs to the conversation, so +/// a document viewer without one could not be created honestly. +pub async fn ensure_document_viewer( + core: &CoreRuntime, + session_id: &str, + path: &str, +) -> Result { + let document = documents::locate(core.storage().database(), session_id, path)?; + let pane = Pane::Document(&document); + let presentability = pane.presentable(); + if !presentability.eligible() { + bail!( + "`{}` cannot be shown: {} — {}", + document.relative_path, + presentability.label(), + presentability_remediation(presentability) + ); + } + + let registry = core.visuals(); + let visual_id = pane.visual_id(); + // Identity is the path, so the direct lookup is the whole reuse check — + // there is no digest to compare and therefore no list-and-scan. + if let Ok(existing) = registry.get(visual_id.clone()).await { + if document_path_binding(&existing).as_deref() == Some(document.path.as_str()) { + return Ok(existing); + } + // The id exists but addresses a different path: a hash collision, or a + // record written by something that is not this provider. Either way it + // is not this document's pane, and adopting it would show the reader + // the wrong file under the right name. + bail!( + "visual `{visual_id}` already exists and is not bound to `{}`", + document.path + ); + } + + match registry + .create(document_viewer_create_request( + &document, + Some(session_id.to_owned()), + )) + .await + { + Ok((visual, _event)) => Ok(visual), + Err(error) => { + // Another caller may have created the deterministic identity since + // the lookup above. Adopt it only when it is bound to this exact + // path. + let raced = registry.get(visual_id).await.ok(); + match raced { + Some(raced) + if document_path_binding(&raced).as_deref() == Some(document.path.as_str()) => + { + Ok(raced) + } + _ => Err(error), + } + } + } +} + +fn presentability_remediation(presentability: Presentability) -> &'static str { + match presentability { + Presentability::Present => "", + Presentability::Unavailable(reason) => reason.remediation(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(path: &str, exists: bool, is_text: bool, kind: DocumentKind) -> DocumentRecord { + DocumentRecord { + path: path.into(), + root: "/workspace".into(), + relative_path: path.trim_start_matches("/workspace/").into(), + name: path.rsplit('/').next().unwrap_or(path).into(), + kind, + language: "markdown".into(), + byte_size: 12, + exists, + is_text, + read_error: None, + modified_at: None, + } + } + + #[test] + fn the_pane_answers_for_its_own_domain() { + let document = record( + "/workspace/docs/readme.md", + true, + true, + DocumentKind::Markdown, + ); + let pane = Pane::Document(&document); + assert_eq!(pane.provider_id(), PROVIDER_ID); + assert_eq!(pane.template_id(), DOCUMENT_VIEWER_TEMPLATE); + assert_eq!(pane.projection_schema(), DOCUMENT_PROJECTION_SCHEMA); + } + + #[test] + fn identity_follows_the_path_and_survives_an_edit() { + let before = record( + "/workspace/docs/readme.md", + true, + true, + DocumentKind::Markdown, + ); + let mut after = before.clone(); + after.byte_size = 900; + after.modified_at = Some("2026-08-28T00:00:00Z".into()); + assert_eq!(visual_id(&before), visual_id(&after)); + + let other = record( + "/workspace/docs/other.md", + true, + true, + DocumentKind::Markdown, + ); + assert_ne!(visual_id(&before), visual_id(&other)); + assert!(visual_id(&before).starts_with("vis_doc_")); + assert!(visual_id(&before).len() <= 128); + } + + #[test] + fn every_unavailable_reason_is_named_rather_than_hidden() { + assert_eq!( + presentable(&record( + "/workspace/gone.md", + false, + false, + DocumentKind::Markdown + )), + Presentability::Unavailable(UnavailableReason::Missing) + ); + assert_eq!( + presentable(&record( + "/workspace/src", + true, + false, + DocumentKind::Directory + )), + Presentability::Unavailable(UnavailableReason::NotADocument) + ); + assert_eq!( + presentable(&record( + "/workspace/blob.bin", + true, + false, + DocumentKind::Code + )), + Presentability::Unavailable(UnavailableReason::NotText) + ); + let mut unreadable = record("/workspace/locked.md", true, true, DocumentKind::Markdown); + unreadable.read_error = Some("permission denied".into()); + assert_eq!( + presentable(&unreadable), + Presentability::Unavailable(UnavailableReason::Unreadable) + ); + assert_eq!( + presentable(&record( + "/workspace/readme.md", + true, + true, + DocumentKind::Markdown + )), + Presentability::Present + ); + } + + #[test] + fn every_reason_carries_a_next_step() { + for reason in [ + UnavailableReason::Missing, + UnavailableReason::NotText, + UnavailableReason::NotADocument, + UnavailableReason::NotADirectory, + UnavailableReason::Unreadable, + UnavailableReason::Quarantined, + UnavailableReason::ArchiveIncomplete, + UnavailableReason::Unsupported, + ] { + assert!(!reason.label().is_empty()); + assert!(!reason.remediation().is_empty(), "{reason:?}"); + } + } + + #[test] + fn the_binding_is_read_back_only_for_the_viewer_template() { + let document = record( + "/workspace/docs/readme.md", + true, + true, + DocumentKind::Markdown, + ); + let request = document_viewer_create_request(&document, Some("sess_1".into())); + let bindings = request.bindings.clone().unwrap(); + let visual = |template: &str| -> VisualRecord { + serde_json::from_value(json!({ + "schemaVersion": "synth.visual.v1", + "id": "vis_doc_abcdef", + "currentRevision": 1, + "title": "readme.md", + "templateId": template, + "status": "draft", + "rendererKind": "template", + "bindings": bindings, + "metadata": {}, + "createdAt": "2026-08-28T00:00:00Z", + "updatedAt": "2026-08-28T00:00:00Z" + })) + .expect("visual fixture") + }; + assert_eq!( + document_path_binding(&visual(DOCUMENT_VIEWER_TEMPLATE)).as_deref(), + Some("/workspace/docs/readme.md") + ); + assert_eq!( + document_path_binding(&visual(super::super::TRACE_INSPECTOR_TEMPLATE)), + None + ); + } + + #[test] + fn the_pane_declares_exactly_one_readable_path() { + let document = record( + "/workspace/docs/readme.md", + true, + true, + DocumentKind::Markdown, + ); + let request = document_viewer_create_request(&document, None); + let inputs = request.bindings.unwrap()["inputs"] + .as_array() + .unwrap() + .clone(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0]["kind"], WORKSPACE_FILE_BINDING_KIND); + assert_eq!(inputs[0]["input"], DOCUMENT_INPUT); + assert_eq!(inputs[0]["source"], "/workspace/docs/readme.md"); + } +} diff --git a/apps/synth_desktop/src-tauri/src/presentation/host.rs b/apps/synth_desktop/src-tauri/src/presentation/host.rs new file mode 100644 index 000000000..6fa469f41 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/presentation/host.rs @@ -0,0 +1,198 @@ +//! Deterministic right-panel presentation, shared by the native UI and the +//! agent-facing MCP facades. +//! +//! Visual lifecycle for a domain record — identity, eligibility, binding, +//! reuse, and the show event — lives here rather than in whichever caller got +//! there first. The renderer's `DataPage` grew its own copy of this logic; a +//! second copy on the agent path would have drifted from it immediately. +//! +//! This module is the panel *host*: it owns the vocabulary every pane answers +//! in — whether a record can be shown, why not when it cannot, and the +//! deterministic identity that makes reuse possible. A *pane* answers only for +//! its own domain — trace and document today. The host decides whether +//! a record is ready to present; a pane declares only what it would present. + +use super::{ + document, trace_inspectability, trace_inspector_visual_id, TraceInspectability, + DOCUMENT_PROJECTION_SCHEMA, DOCUMENT_VIEWER_TEMPLATE, TRACE_INSPECTOR_TEMPLATE, + TRACE_PROJECTION_SCHEMA, +}; + +use crate::data::TraceRecord; +use crate::documents::DocumentRecord; + +/// Whether a domain record can be presented in the right panel, and when it +/// cannot, why. The catalog shows every record and names the reason rather than +/// silently omitting the unavailable ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Presentability { + Present, + Unavailable(UnavailableReason), +} + +/// Why a record that exists still cannot be presented. Each reason is a +/// distinct thing the catalog says out loud, so reasons are never merged: a +/// quarantined record and an incomplete archive are different problems with +/// different fixes. +/// +/// Reasons are host vocabulary, not per-pane vocabulary: `Missing` means the +/// same thing whichever pane raised it, and a pane that needed a private reason +/// would be telling the catalog something the catalog cannot render. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnavailableReason { + Quarantined, + ArchiveIncomplete, + Unsupported, + /// The record names a place that is not there. Distinct from `Unsupported`: + /// nothing about the request was wrong, the thing is simply gone. + Missing, + /// Bytes the pane would render as mojibake — a binary, or a file in an + /// encoding this build does not decode. + NotText, + /// A folder where a document was asked for. The folder is fine; it is not + /// a thing the document pane can typeset, and the listing view is. + NotADocument, + /// A document where a folder was asked for. + NotADirectory, + /// Metadata read, bytes refused — a permissions or I/O failure that is a + /// property of this machine rather than of the record. + Unreadable, +} + +impl Presentability { + /// The catalog row label. These strings are wire values: the renderer keeps + /// a by-hand mirror of this eligibility logic in + /// `src/renderer/src/runtime/traceInspector.ts`, and the agent-facing trace + /// rows carry them verbatim. `Present` reads as the pane's affordance + /// rather than a state, which is why it is `Inspect`; when a second pane + /// needs a different verb, the affordance moves onto [`Pane`] and this arm + /// delegates to it. + pub fn label(self) -> &'static str { + match self { + Self::Present => "Inspect", + Self::Unavailable(reason) => reason.label(), + } + } + + pub fn eligible(self) -> bool { + matches!(self, Self::Present) + } +} + +impl UnavailableReason { + pub fn label(self) -> &'static str { + match self { + Self::Quarantined => "Quarantined", + Self::ArchiveIncomplete => "Archive incomplete", + Self::Unsupported => "Unsupported", + Self::Missing => "Missing", + Self::NotText => "Not text", + Self::NotADocument => "Not a document", + Self::NotADirectory => "Not a folder", + Self::Unreadable => "Unreadable", + } + } + + /// What the reader can do next. A named reason with no next step is still a + /// dead end; §6 of the style guide asks for the recovery action beside the + /// state, and the panel is where the reader is standing when they read it. + pub fn remediation(self) -> &'static str { + match self { + Self::Quarantined => "Re-import the archive from a trusted source.", + Self::ArchiveIncomplete => "Re-seal the trace so its archive is self-contained.", + Self::Unsupported => "Open it with an application that understands this format.", + Self::Missing => "Check the path, or reopen it from the folder listing.", + Self::NotText => "Open it externally with the Open menu.", + Self::NotADocument => "Open it as a folder to see what is inside.", + Self::NotADirectory => "Open the containing folder instead.", + Self::Unreadable => "Check the file's permissions, then try again.", + } + } +} + +/// A pane, paired with the domain record it would present. +/// +/// Deliberately an enum with a `match` rather than a trait. At this provider +/// count a trait buys indirection and gives up exhaustiveness checking: adding +/// a domain to an enum makes every arm below a compiler error until it is +/// answered, where adding an `impl` is silent. Every arm is written +/// one-per-provider and delegates to its pane module, so lifting to a trait +/// once a third provider lands is mechanical — each arm becomes an `impl` +/// method and each `match` becomes a dynamic call. Pairing the pane with its +/// record in one value also means the pane and the record it answers for can +/// never disagree. +/// +/// Two providers now, and the enum still earns its keep. [`Document`] is the +/// one that tested it: it is not trace-shaped — no digest identity, no sealed +/// archive, a mutable subject — and adding it needed exactly one new arm per +/// method plus two host reasons, with the compiler naming every place that had +/// to answer. Lifting to a `trait PanelProvider` buys dynamic dispatch nothing +/// asks for here and loses that exhaustiveness. The rule for the next person: +/// a third provider that is again a variation on "a record with an identity and +/// an eligibility test" still belongs in the enum; the trait becomes right when +/// providers arrive from *outside* this crate — a plugin supplying a pane — +/// because at that point the set is no longer closed and there is nothing left +/// for the compiler to be exhaustive over. +/// +/// [`Document`]: Pane::Document +#[derive(Clone, Copy, Debug)] +pub enum Pane<'a> { + Trace(&'a TraceRecord), + Document(&'a DocumentRecord), +} + +impl<'a> Pane<'a> { + /// Shares the plugin id namespace, not the plugin lifecycle: a pane is + /// compiled in and has no install phases. + pub fn provider_id(self) -> &'static str { + match self { + Self::Trace(_) => "trace", + Self::Document(_) => document::PROVIDER_ID, + } + } + + /// The template the host renders this pane through — host vocabulary, so + /// the host resolves it rather than asking the record. + pub fn template_id(self) -> &'static str { + match self { + Self::Trace(_) => TRACE_INSPECTOR_TEMPLATE, + Self::Document(_) => DOCUMENT_VIEWER_TEMPLATE, + } + } + + /// The schema of the projection the pane's binding addresses. + pub fn projection_schema(self) -> &'static str { + match self { + Self::Trace(_) => TRACE_PROJECTION_SCHEMA, + Self::Document(_) => DOCUMENT_PROJECTION_SCHEMA, + } + } + + /// Whether this record can be shown, and when it cannot, why. + pub fn presentable(self) -> Presentability { + match self { + Self::Trace(record) => match trace_inspectability(record) { + TraceInspectability::Inspect => Presentability::Present, + TraceInspectability::Quarantined => { + Presentability::Unavailable(UnavailableReason::Quarantined) + } + TraceInspectability::ArchiveIncomplete => { + Presentability::Unavailable(UnavailableReason::ArchiveIncomplete) + } + TraceInspectability::Unsupported => { + Presentability::Unavailable(UnavailableReason::Unsupported) + } + }, + Self::Document(record) => document::presentable(record), + } + } + + /// Deterministic identity for this record's visual, stable across restarts, + /// windows, and callers. Reuse is decided by it alone. + pub fn visual_id(self) -> String { + match self { + Self::Trace(record) => trace_inspector_visual_id(record), + Self::Document(record) => document::visual_id(record), + } + } +} diff --git a/apps/synth_desktop/src-tauri/src/project_sources.rs b/apps/synth_desktop/src-tauri/src/project_sources.rs new file mode 100644 index 000000000..0c482cabc --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -0,0 +1,343 @@ +//! Executable project-source authority. Conversation read/write attachments +//! are deliberately not inputs. Native picker admission owns persisted grants. + +use crate::synth_config::{self, ProjectSourceEntry}; +use anyhow::{anyhow, bail, Context, Result}; +use serde::Serialize; +use std::{ + collections::HashSet, + env, + path::{Path, PathBuf}, +}; + +mod approval; +pub mod commands; +mod inspection; +pub mod requests; +pub use approval::{approve, ProjectSourceApproval}; +pub use inspection::{catalog, ProjectSourceCatalog}; + +async fn admit_picked_root( + db: &std::sync::Arc, + path: &str, + containers: bool, + recipes: bool, +) -> Result { + let _resolution = requests::RESOLUTION.lock().await; + if !containers && !recipes { + bail!("choose containers, recipes, or both"); + } + let root = canonical_project_root(path)?; + let inspection = inspection::inspect(&root); + if inspection.status != "valid" { + bail!( + "{}: {}", + inspection.code.as_deref().unwrap_or("source_invalid"), + inspection.message.as_deref().unwrap_or("invalid source") + ); + } + let change = synth_config::begin_project_source_grant(ProjectSourceEntry { + path: root.display().to_string(), + containers, + recipes, + })?; + if let Err(error) = requests::audit(db, "project_source.approved", serde_json::json!({ + "path": root.display().to_string(), "containers": inspection.containers, + "recipes": inspection.recipes, "grant": { "containers": containers, "recipes": recipes }, + "method": "native_picker" + })).await { return Err(compensate(change, error)); } + catalog() +} + +async fn remove_root( + db: &std::sync::Arc, + path: &str, +) -> Result { + let _resolution = requests::RESOLUTION.lock().await; + if path.trim().is_empty() { + bail!("a project source path is required"); + } + synth_config::forget_project_source(path)?; + requests::audit( + db, + "project_source.removed", + serde_json::json!({ "path": path.trim() }), + ) + .await + .context("source was revoked, but its journal event could not be recorded")?; + catalog() +} + +fn compensate(change: synth_config::ProjectSourceChange, error: anyhow::Error) -> anyhow::Error { + match change.rollback() { + Ok(()) => error.context("source change was rolled back because its durable decision could not be recorded"), + Err(rollback) => anyhow!("source decision failed: {error}; rollback failed: {rollback}; inspect current source permissions before retrying"), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Capability { + Containers, + Recipes, +} + +impl Capability { + fn enabled(self, entry: &ProjectSourceEntry) -> bool { + match self { + Self::Containers => entry.containers, + Self::Recipes => entry.recipes, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum RootOrigin { + Configured, + Environment, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedRoot { + pub path: PathBuf, + pub origin: RootOrigin, +} + +/// Reject ambient machine-wide roots even when they arrive through an alias. +/// A missing path is not admissible, but persisted missing grants remain visible +/// to Settings and may be removed without canonicalizing them. +pub fn canonical_project_root(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() || !Path::new(raw).is_absolute() { + bail!("project source path must be nonempty and absolute"); + } + let root = Path::new(raw) + .canonicalize() + .context("project source path is unavailable")?; + if !root.is_dir() { + bail!("project source must be a directory"); + } + let home = env::var_os("HOME") + .map(PathBuf::from) + .and_then(|path| path.canonicalize().ok()); + validate_root(&root, home.as_deref())?; + Ok(root) +} + +fn validate_root(root: &Path, home: Option<&Path>) -> Result<()> { + let broad = [ + "/", + "/Users", + "/home", + "/Applications", + "/Library", + "/System", + "/Volumes", + "/private", + "/private/tmp", + "/private/var", + "/private/etc", + "/tmp", + "/var", + "/etc", + "/usr", + "/opt", + "/bin", + "/sbin", + ]; + if root.parent().is_none() + || home == Some(root) + || broad.iter().any(|path| root == Path::new(path)) + { + bail!("project source must be a specific project folder, not a machine-wide root"); + } + Ok(()) +} + +pub fn resolve_roots(capability: Capability) -> Result> { + #[cfg(test)] + { + // Tests supply real, isolated config files. Never inherit operator grants + // or environment roots, and never replace the production root predicate. + let entries = TEST_SOURCE_CONFIG.try_with(|path| synth_config::project_source_settings_at(path)) + .ok().transpose()?.map(|settings| settings.entries).unwrap_or_default(); + resolve_entries(&entries, capability, &[]) + } + #[cfg(not(test))] + { + let settings = synth_config::project_source_settings()?; + let containers = env::var_os("SYNTH_CONTAINER_SOURCE_ROOTS"); + let roots = match capability { + Capability::Containers => containers, + // Preserve the explicitly configured legacy environment alias only. + Capability::Recipes => env::var_os("SYNTH_RECIPE_SOURCE_ROOTS").or(containers), + }; + let environment: Vec<_> = roots + .as_deref() + .map(env::split_paths) + .into_iter() + .flatten() + .filter(|path| !path.as_os_str().is_empty()) + .collect(); + resolve_entries(&settings.entries, capability, &environment) + } +} + +#[cfg(test)] +tokio::task_local! { pub(crate) static TEST_SOURCE_CONFIG: PathBuf; } + +#[cfg(test)] +pub(crate) fn test_grant(config: &Path, root: &Path, containers: bool, recipes: bool) { + synth_config::begin_project_source_grant_at(config, ProjectSourceEntry { + path: root.display().to_string(), containers, recipes, + }).unwrap(); +} + +fn resolve_entries( + entries: &[ProjectSourceEntry], + capability: Capability, + environment: &[PathBuf], +) -> Result> { + let mut seen = HashSet::new(); + let mut roots = Vec::new(); + let requested = entries + .iter() + .filter(|entry| capability.enabled(entry)) + .map(|entry| (PathBuf::from(&entry.path), RootOrigin::Configured)) + .chain( + environment + .iter() + .cloned() + .map(|path| (path, RootOrigin::Environment)), + ); + for (path, origin) in requested { + // Missing/unmounted roots have no current execution authority. Other + // malformed roots fail closed rather than invoking an ambient fallback. + match std::fs::metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + Ok(_) => {} + } + let canonical = canonical_project_root( + path.to_str() + .ok_or_else(|| anyhow!("project source path must be UTF-8"))?, + )?; + if seen.insert(canonical.clone()) { + roots.push(ResolvedRoot { + path: canonical, + origin, + }); + } + } + Ok(roots) +} + +pub fn discovery_roots(capability: Capability) -> Result> { + Ok(resolve_roots(capability)? + .into_iter() + .map(|root| root.path) + .collect()) +} + +pub fn require_manifest(manifest: &Path, capability: Capability) -> Result { + require_manifest_in(manifest, &discovery_roots(capability)?) +} + +fn require_manifest_in(manifest: &Path, roots: &[PathBuf]) -> Result { + let canonical = manifest + .canonicalize() + .context("project source manifest is unavailable")?; + if !canonical.is_file() || !roots.iter().any(|root| canonical.starts_with(root)) { + bail!( + "launch_source_root_not_approved: manifest {} requires a project-source grant", + manifest.display() + ); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn capabilities_are_separate_and_no_roots_means_no_ambient_grant() { + let directory = tempfile::tempdir().unwrap(); + let entries = vec![ProjectSourceEntry { + path: directory.path().display().to_string(), + containers: true, + recipes: false, + }]; + assert_eq!( + resolve_entries(&entries, Capability::Containers, &[]) + .unwrap() + .len(), + 1 + ); + assert!(resolve_entries(&entries, Capability::Recipes, &[]) + .unwrap() + .is_empty()); + assert!(resolve_entries(&[], Capability::Containers, &[]) + .unwrap() + .is_empty()); + assert_eq!( + resolve_entries(&[], Capability::Recipes, &[directory.path().to_owned()]) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn removed_and_missing_sources_have_no_authority() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join("workshop.containers.toml"); + fs::write(&manifest, "# declaration fixture").unwrap(); + let roots = vec![directory.path().canonicalize().unwrap()]; + assert!(require_manifest_in(&manifest, &roots).is_ok()); + assert!(require_manifest_in(&manifest, &[]).is_err()); + let missing = vec![ProjectSourceEntry { + path: directory.path().join("missing").display().to_string(), + containers: true, + recipes: true, + }]; + assert!(resolve_entries(&missing, Capability::Containers, &[]) + .unwrap() + .is_empty()); + } + + #[test] + fn broad_roots_and_relative_admission_refuse() { + for path in [ + "/", + "/Users", + "/private/tmp", + "/private/var", + "/usr", + "/home/test", + ] { + assert!(validate_root(Path::new(path), Some(Path::new("/home/test"))).is_err()); + } + assert!(canonical_project_root(".").is_err()); + assert!(canonical_project_root("").is_err()); + } + + #[cfg(unix)] + #[test] + fn symlink_aliases_deduplicate_but_manifest_escape_refuses() { + use std::os::unix::fs::symlink; + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("project"); + fs::create_dir(&root).unwrap(); + let alias = directory.path().join("alias"); + symlink(&root, &alias).unwrap(); + let roots = resolve_entries(&[], Capability::Containers, &[root.clone(), alias]).unwrap(); + assert_eq!(roots.len(), 1); + let outside = directory.path().join("outside.toml"); + fs::write(&outside, "# fixture").unwrap(); + let manifest = root.join("workshop.containers.toml"); + symlink(&outside, &manifest).unwrap(); + assert!(require_manifest_in(&manifest, &[root.canonicalize().unwrap()]).is_err()); + } +} diff --git a/apps/synth_desktop/src-tauri/src/project_sources/approval.rs b/apps/synth_desktop/src-tauri/src/project_sources/approval.rs new file mode 100644 index 000000000..f71d337dc --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources/approval.rs @@ -0,0 +1,252 @@ +use super::{ + canonical_project_root, catalog, compensate, inspection, requests, ProjectSourceCatalog, + RootOrigin, +}; +use crate::{ + storage::{append_event, Database, EventAppend}, + synth_config, workspace_scope, +}; +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceApproval { + pub request: requests::ProjectSourceRequest, + pub source: inspection::ProjectSourceRow, + pub catalog: ProjectSourceCatalog, + pub scope: Option, + pub attachment_error: Option, +} + +pub async fn approve(db: &Arc, id: &str, picked: &str) -> Result { + let request = approve_core(db, id, picked, synth_config::begin_project_source_grant).await?; + let (scope, attachment_error) = if request.attach_to_conversation { + match workspace_scope::attach( + db, + request + .session_id + .as_deref() + .context("approved attachment has no session")?, + &request.canonical_path, + workspace_scope::WorkspaceAccessMode::ReadWrite, + workspace_scope::AttachmentSource::AgentRequest, + ) + .await + { + Ok(scope) => (Some(scope), None), + Err(error) => ( + None, + Some(format!( + "Source approved, but conversation attachment failed: {error}" + )), + ), + } + } else { + (None, None) + }; + let catalog = catalog().context("source approved, but its catalog could not be refreshed")?; + let source = catalog + .sources + .iter() + .find(|row| row.path == request.canonical_path) + .cloned() + .unwrap_or_else(|| inspection::ProjectSourceRow { + path: request.canonical_path.clone(), + containers: request.containers, + recipes: request.recipes, + origin: RootOrigin::Configured, + inspection: inspection::inspect(std::path::Path::new(&request.canonical_path)), + last_scanned_at: None, + }); + Ok(ProjectSourceApproval { + request, + source, + catalog, + scope, + attachment_error, + }) +} + +async fn approve_core( + db: &Arc, + id: &str, + picked: &str, + grant: F, +) -> Result +where + F: FnOnce(synth_config::ProjectSourceEntry) -> Result + Send, +{ + let _resolution = requests::RESOLUTION.lock().await; + let canonical = canonical_project_root(picked)?; + let id = id.to_owned(); + let request = db.run(move |conn| requests::load(conn, &id)).await?; + if request.status != "pending" { + bail!("project source request is no longer pending"); + } + if canonical.to_string_lossy() != request.canonical_path { + bail!("selected folder does not match the exact requested folder"); + } + let inspection = inspection::inspect(&canonical); + if inspection.status != "valid" { + bail!( + "{}", + inspection + .message + .as_deref() + .unwrap_or("invalid source declaration") + ); + } + let change = grant(synth_config::ProjectSourceEntry { + path: request.canonical_path.clone(), + containers: request.containers, + recipes: request.recipes, + })?; + let result = db.run_transaction(move |conn| { + let changed = conn.execute("UPDATE project_source_requests SET status='approved',resolved_at=datetime('now') WHERE id=?1 AND status='pending'", [&request.id])?; + if changed != 1 { bail!("pending source request could not be settled"); } + let request = requests::load(conn, &request.id)?; + append_event(conn, EventAppend::system("project_source.approved", serde_json::json!({ + "requestId": request.id, "path": request.canonical_path, "sessionId": request.session_id, + "containers": inspection.containers, "recipes": inspection.recipes, + "grant": { "containers": request.containers, "recipes": request.recipes }, "method": "requested_native_picker" + })))?; + Ok(request) + }).await; + result.map_err(|error| compensate(change, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, path::Path}; + + fn recipe(root: &Path) { + fs::write(root.join("workshop.recipe.toml"), "id='eval.source.v1'\nalgorithm='eval'\ncontainer='fixture'\nprovider='openrouter'\nmodel='openai/gpt-4.1-nano'\nlocality='container'\ntrain_seeds=[0]\n[bounds]\nmax_cost_usd=0.5\nmax_total_rollouts=10\n").unwrap(); + } + async fn pending(db: &Arc, root: &Path) -> requests::ProjectSourceRequest { + requests::request( + db, + requests::ProjectSourceRequestInput { + session_id: None, + path: root.display().to_string(), + reason: "Use this declared recipe".into(), + containers: false, + recipes: true, + attach_to_conversation: false, + }, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn approval_requires_exact_picker_and_pending_request() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("source"); + fs::create_dir(&root).unwrap(); + recipe(&root); + let config = dir.path().join("config.toml"); + let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); + let request = pending(&db, &root).await; + let grant = |entry| synth_config::begin_project_source_grant_at(&config, entry); + assert!( + approve_core(&db, &request.id, &dir.path().display().to_string(), grant) + .await + .is_err() + ); + assert!(!config.exists()); + let approved = approve_core(&db, &request.id, &root.display().to_string(), grant) + .await + .unwrap(); + assert_eq!(approved.status, "approved"); + assert!(approved.resolved_at.is_some()); + let before = fs::read_to_string(&config).unwrap(); + assert!(before.contains("recipes = true")); + assert!( + approve_core(&db, &request.id, &root.display().to_string(), grant) + .await + .is_err() + ); + assert_eq!(fs::read_to_string(&config).unwrap(), before); + } + + #[tokio::test] + async fn approval_reinspects_declarations_before_granting() { + let dir = tempfile::tempdir().unwrap(); + recipe(dir.path()); + let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); + let request = pending(&db, dir.path()).await; + fs::write( + dir.path().join("workshop.recipe.toml"), + "invalid changed declaration [", + ) + .unwrap(); + assert!(approve_core( + &db, + &request.id, + &dir.path().display().to_string(), + |_| panic!("invalid declaration must never grant access") + ) + .await + .is_err()); + assert_eq!( + requests::list(&db, None).await.unwrap()[0].status, + "pending" + ); + } + + #[tokio::test] + async fn failed_settlement_restores_previous_capabilities_and_preserves_other_roots() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("source"); + fs::create_dir(&root).unwrap(); + recipe(&root); + let root = root.canonicalize().unwrap(); + let config = dir.path().join("config.toml"); + synth_config::begin_project_source_grant_at( + &config, + synth_config::ProjectSourceEntry { + path: root.display().to_string(), + containers: true, + recipes: false, + }, + ) + .unwrap(); + let db = Arc::new(Database::open(dir.path().join("state.sqlite3")).unwrap()); + let request = pending(&db, &root).await; + db.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER fail_source_audit BEFORE INSERT ON events BEGIN SELECT RAISE(ABORT,'fixture audit error'); END;")?; Ok(()) }).unwrap(); + let result = approve_core(&db, &request.id, &root.display().to_string(), |entry| { + let change = synth_config::begin_project_source_grant_at(&config, entry)?; + synth_config::begin_project_source_grant_at( + &config, + synth_config::ProjectSourceEntry { + path: "/other/source".into(), + containers: false, + recipes: true, + }, + )?; + Ok(change) + }) + .await; + assert!(result.is_err()); + assert_eq!( + requests::list(&db, None).await.unwrap()[0].status, + "pending" + ); + let value: toml::Value = fs::read_to_string(&config).unwrap().parse().unwrap(); + let entries = value["desktop"]["project_sources"]["entries"] + .as_array() + .unwrap(); + let original = entries + .iter() + .find(|entry| entry["path"].as_str() == root.to_str()) + .unwrap(); + assert_eq!(original["containers"].as_bool(), Some(true)); + assert_eq!(original["recipes"].as_bool(), Some(false)); + assert!(entries + .iter() + .any(|entry| entry["path"].as_str() == Some("/other/source"))); + } +} diff --git a/apps/synth_desktop/src-tauri/src/project_sources/commands.rs b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs new file mode 100644 index 000000000..7a99e4207 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs @@ -0,0 +1,122 @@ +//! Human admission is performed by a native folder picker, not an agent path. +use super::requests::{self, ProjectSourceRequest, ProjectSourceRequestInput}; +use super::{ + admit_picked_root, approve, catalog, remove_root, ProjectSourceApproval, ProjectSourceCatalog, +}; +use crate::core_runtime::CoreRuntime; +use crate::error::AppError; +use crate::session::codex::CodexManager; +use std::sync::Arc; +use tauri::State; +use tauri_plugin_dialog::DialogExt; + +#[tauri::command] +#[specta::specta] +pub fn project_sources_get() -> Result { + catalog().map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub fn project_sources_refresh() -> Result { + // Declarations and grants are read on every discovery; there is no stale + // secondary catalog to populate or mistake for current execution authority. + catalog().map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_add( + app: tauri::AppHandle, + core: State<'_, Arc>, + containers: bool, + recipes: bool, +) -> Result, AppError> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_title("Choose an executable project source") + .pick_folder(move |path| { + let _ = sender.send(path.map(|value| value.to_string())); + }); + let Some(path) = receiver.await.map_err(AppError::from)? else { + return Ok(None); + }; + admit_picked_root(core.storage().database(), &path, containers, recipes) + .await + .map(Some) + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_remove( + core: State<'_, Arc>, + path: String, +) -> Result { + remove_root(core.storage().database(), &path) + .await + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_request( + core: State<'_, Arc>, + request: ProjectSourceRequestInput, +) -> Result { + requests::request(core.storage().database(), request) + .await + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_requests_list( + core: State<'_, Arc>, + session_id: Option, +) -> Result, AppError> { + requests::list(core.storage().database(), session_id) + .await + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_deny( + core: State<'_, Arc>, + request_id: String, +) -> Result { + requests::deny(core.storage().database(), &request_id) + .await + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_source_approve( + app: tauri::AppHandle, + core: State<'_, Arc>, + codex: State<'_, Arc>, + request_id: String, +) -> Result, AppError> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_title("Confirm the exact requested project folder") + .pick_folder(move |path| { + let _ = sender.send(path.map(|value| value.to_string())); + }); + let Some(path) = receiver.await.map_err(AppError::from)? else { + return Ok(None); + }; + let mut approval = approve(core.storage().database(), &request_id, &path) + .await + .map_err(AppError::from)?; + if let Some(scope) = &approval.scope { + if let Err(error) = codex.fence_attachment(&scope.session_id).await { + approval.attachment_error = Some(format!("Source approved and folder attached, but the active agent could not be fenced: {error}")); + } + } + Ok(Some(approval)) +} diff --git a/apps/synth_desktop/src-tauri/src/project_sources/inspection.rs b/apps/synth_desktop/src-tauri/src/project_sources/inspection.rs new file mode 100644 index 000000000..4ce39818b --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources/inspection.rs @@ -0,0 +1,207 @@ +use super::{canonical_project_root, require_manifest_in, resolve_roots, Capability, RootOrigin}; +use crate::{optimizers::workspace_recipe, synth_config}; +use anyhow::Result; +use serde::Serialize; +use std::path::Path; + +#[derive(Clone, Debug, Serialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceInspection { + pub path: String, + pub status: String, + pub code: Option, + pub message: Option, + pub containers: Vec, + pub recipes: Vec, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceRow { + pub path: String, + pub containers: bool, + pub recipes: bool, + pub origin: RootOrigin, + pub inspection: ProjectSourceInspection, + pub last_scanned_at: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceCatalog { + pub config_path: String, + pub sources: Vec, + pub implicit_roots: Vec, +} + +pub fn inspect(root: &Path) -> ProjectSourceInspection { + let mut result = ProjectSourceInspection { + path: root.display().to_string(), + status: "invalid".into(), + code: None, + message: None, + containers: Vec::new(), + recipes: Vec::new(), + }; + if !root.is_dir() { + result.status = "missing".into(); + result.code = Some("source_path_missing".into()); + result.message = Some("Source is not an available directory".into()); + return result; + } + let root = match canonical_project_root(&root.display().to_string()) { + Ok(root) => root, + Err(error) => { + result.code = Some("source_path_invalid".into()); + result.message = Some(error.to_string()); + return result; + } + }; + let manifest = root.join("workshop.containers.toml"); + if manifest.exists() || manifest.is_symlink() { + if let Err(error) = require_manifest_in(&manifest, std::slice::from_ref(&root)) { + result.code = Some("container_manifest_invalid".into()); + result.message = Some(error.to_string()); + return result; + } + } + match workspace_recipe::load_container_specs(&root) { + Ok(specs) => result.containers = specs.into_iter().map(|spec| spec.id).collect(), + Err(error) => { + result.code = Some("container_manifest_invalid".into()); + result.message = Some(error.to_string()); + return result; + } + } + match workspace_recipe::load_recipes(&root) { + Ok(recipes) => result.recipes = recipes.into_iter().map(|recipe| recipe.id).collect(), + Err(error) => { + result.code = Some("recipe_manifest_invalid".into()); + result.message = Some(error.to_string()); + return result; + } + } + if result.containers.is_empty() && result.recipes.is_empty() { + result.code = Some("no_declaration".into()); + result.message = Some("Source declares no containers or recipes".into()); + } else { + result.status = "valid".into(); + } + result +} + +pub fn catalog() -> Result { + let settings = synth_config::project_source_settings()?; + let sources = settings + .entries + .into_iter() + .map(|entry| ProjectSourceRow { + inspection: inspect(Path::new(&entry.path)), + path: entry.path, + containers: entry.containers, + recipes: entry.recipes, + origin: RootOrigin::Configured, + // This implementation scans live; it does not fabricate a persisted timestamp. + last_scanned_at: None, + }) + .collect(); + let mut implicit: Vec = Vec::new(); + for capability in [Capability::Containers, Capability::Recipes] { + for root in resolve_roots(capability)? + .into_iter() + .filter(|root| root.origin == RootOrigin::Environment) + { + let path = root.path.display().to_string(); + let index = if let Some(index) = implicit.iter().position(|row| row.path == path) { + index + } else { + implicit.push(ProjectSourceRow { + path, + containers: false, + recipes: false, + origin: RootOrigin::Environment, + inspection: inspect(&root.path), + last_scanned_at: None, + }); + implicit.len() - 1 + }; + match capability { + Capability::Containers => implicit[index].containers = true, + Capability::Recipes => implicit[index].recipes = true, + } + } + } + Ok(ProjectSourceCatalog { + config_path: settings.config_path, + sources, + implicit_roots: implicit, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn inspection_returns_recipe_identity_without_running_a_workload() { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("workshop.recipe.toml"), + r#" +id = "eval.inspection.v1" +algorithm = "eval" +container = "fixture" +provider = "openrouter" +model = "openai/gpt-4.1-nano" +locality = "container" +train_seeds = [0] +[bounds] +max_cost_usd = 0.50 +max_total_rollouts = 10 +"#, + ) + .unwrap(); + let result = inspect(directory.path()); + assert_eq!(result.status, "valid", "{result:?}"); + assert_eq!(result.recipes, vec!["eval.inspection.v1"]); + assert!(result.containers.is_empty()); + assert!(result.code.is_none()); + } + + #[test] + fn inspection_distinguishes_absence_from_invalid_declarations() { + let directory = tempfile::tempdir().unwrap(); + assert_eq!(inspect(&directory.path().join("missing")).status, "missing"); + assert_eq!( + inspect(directory.path()).code.as_deref(), + Some("no_declaration") + ); + fs::write( + directory.path().join("workshop.containers.toml"), + "not valid toml [", + ) + .unwrap(); + assert_eq!( + inspect(directory.path()).code.as_deref(), + Some("container_manifest_invalid") + ); + } + + #[cfg(unix)] + #[test] + fn inspection_refuses_external_manifest_before_loading_it() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("project"); + fs::create_dir(&root).unwrap(); + let outside = directory.path().join("outside.toml"); + fs::write(&outside, "invalid outside fixture").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("workshop.containers.toml")).unwrap(); + let result = inspect(&root); + assert_eq!(result.code.as_deref(), Some("container_manifest_invalid")); + assert!(result + .message + .unwrap() + .contains("launch_source_root_not_approved")); + } +} diff --git a/apps/synth_desktop/src-tauri/src/project_sources/requests.rs b/apps/synth_desktop/src-tauri/src/project_sources/requests.rs new file mode 100644 index 000000000..2fae8ae41 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources/requests.rs @@ -0,0 +1,251 @@ +//! Pending source requests are evidence, never executable authority. +//! State transitions and their journal entries commit in one SQLite transaction. +use super::canonical_project_root; +use crate::storage::{append_event, Database, EventAppend}; +use anyhow::{anyhow, bail, Result}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub(crate) static RESOLUTION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +pub(super) async fn audit( + db: &Arc, + kind: &'static str, + payload: serde_json::Value, +) -> Result<()> { + db.run_transaction(move |conn| { + append_event(conn, EventAppend::system(kind, payload))?; + Ok(()) + }) + .await +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceRequest { + pub id: String, + pub session_id: Option, + pub requested_path: String, + pub canonical_path: String, + pub reason: String, + pub containers: bool, + pub recipes: bool, + pub attach_to_conversation: bool, + pub status: String, + pub created_at: String, + pub resolved_at: Option, +} + +#[derive(Clone, Debug, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectSourceRequestInput { + pub session_id: Option, + pub path: String, + pub reason: String, + pub containers: bool, + pub recipes: bool, + #[serde(default)] + pub attach_to_conversation: bool, +} + +const COLUMNS: &str = "id,session_id,requested_path,canonical_path,reason,containers,recipes,attach_to_conversation,status,created_at,resolved_at"; + +fn row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ProjectSourceRequest { + id: row.get(0)?, + session_id: row.get(1)?, + requested_path: row.get(2)?, + canonical_path: row.get(3)?, + reason: row.get(4)?, + containers: row.get(5)?, + recipes: row.get(6)?, + attach_to_conversation: row.get(7)?, + status: row.get(8)?, + created_at: row.get(9)?, + resolved_at: row.get(10)?, + }) +} + +pub(super) fn load(conn: &Connection, id: &str) -> Result { + conn.query_row( + &format!("SELECT {COLUMNS} FROM project_source_requests WHERE id=?1"), + [id], + row, + ) + .optional()? + .ok_or_else(|| anyhow!("project source request was not found")) +} + +pub async fn request( + db: &Arc, + input: ProjectSourceRequestInput, +) -> Result { + if !input.containers && !input.recipes { + bail!("request containers, recipes, or both"); + } + let reason = input.reason.trim().to_owned(); + if reason.is_empty() || reason.len() > 2048 { + bail!("project source request reason must be 1–2048 bytes"); + } + let session_id = input.session_id.map(|id| id.trim().to_owned()); + if session_id.as_deref() == Some("") { + bail!("session ID must not be blank"); + } + if input.attach_to_conversation && session_id.is_none() { + bail!("attachment requires a conversation"); + } + let canonical = canonical_project_root(&input.path)?.display().to_string(); + db.run_transaction(move |conn| { + if let Some(session) = &session_id { + let exists: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE id=?1)", [session], |row| row.get(0))?; + if !exists { bail!("source request conversation was not found"); } + } + let existing = conn.query_row( + &format!("SELECT {COLUMNS} FROM project_source_requests WHERE canonical_path=?1 AND session_id IS ?2 AND status='pending'"), + params![canonical, session_id], row, + ).optional()?; + if let Some(existing) = existing { + if existing.containers != input.containers || existing.recipes != input.recipes || existing.attach_to_conversation != input.attach_to_conversation { + bail!("a pending request for this source has different permissions; resolve it before requesting a changed grant"); + } + return Ok(existing); + } + let id = uuid::Uuid::new_v4().to_string(); + conn.execute("INSERT INTO project_source_requests(id,session_id,requested_path,canonical_path,reason,containers,recipes,attach_to_conversation,status,created_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,'pending',datetime('now'))", + params![id, session_id, input.path.trim(), canonical, reason, input.containers, input.recipes, input.attach_to_conversation])?; + let request = load(conn, &id)?; + append_event(conn, EventAppend::system("project_source.requested", serde_json::to_value(&request)?))?; + Ok(request) + }).await +} + +pub async fn list( + db: &Arc, + session_id: Option, +) -> Result> { + db.run(move |conn| { + let mut statement = conn.prepare(&format!("SELECT {COLUMNS} FROM project_source_requests WHERE (?1 IS NULL OR session_id=?1) ORDER BY created_at DESC,id DESC"))?; + let rows = statement.query_map([session_id], row)?.collect::>>()?; + Ok(rows) + }).await +} + +pub async fn deny(db: &Arc, id: &str) -> Result { + let _resolution = RESOLUTION.lock().await; + let id = id.to_owned(); + db.run_transaction(move |conn| { + let changed = conn.execute("UPDATE project_source_requests SET status='denied',resolved_at=datetime('now') WHERE id=?1 AND status='pending'", [&id])?; + if changed != 1 { bail!("pending project source request was not found"); } + let request = load(conn, &id)?; + append_event(conn, EventAppend::system("project_source.denied", serde_json::to_value(&request)?))?; + Ok(request) + }).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(path: &std::path::Path) -> ProjectSourceRequestInput { + ProjectSourceRequestInput { + session_id: None, + path: path.display().to_string(), + reason: "Run this project's declared evaluation".into(), + containers: true, + recipes: false, + attach_to_conversation: false, + } + } + + #[tokio::test] + async fn requests_survive_reopen_dedupe_and_refuse_permission_rewrites() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite3"); + let db = Arc::new(Database::open(&path).unwrap()); + let first = request(&db, input(directory.path())).await.unwrap(); + let retry = request(&db, input(directory.path())).await.unwrap(); + assert_eq!(first, retry); + let mut changed = input(directory.path()); + changed.recipes = true; + assert!(request(&db, changed).await.is_err()); + assert_eq!(first.status, "pending"); + assert!(first.resolved_at.is_none()); + drop(db); + let db = Arc::new(Database::open(&path).unwrap()); + assert_eq!(list(&db, None).await.unwrap(), vec![first.clone()]); + let denied = deny(&db, &first.id).await.unwrap(); + assert_eq!(denied.status, "denied"); + assert!(denied.resolved_at.is_some()); + assert!(deny(&db, &first.id).await.is_err()); + let new_request = request(&db, input(directory.path())).await.unwrap(); + assert_ne!(new_request.id, first.id); + let events = db + .with_conn(|conn| { + let mut statement = conn.prepare( + "SELECT kind FROM events WHERE kind LIKE 'project_source.%' ORDER BY sequence", + )?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows) + }) + .unwrap(); + assert_eq!( + events, + [ + "project_source.requested", + "project_source.denied", + "project_source.requested" + ] + ); + } + + #[tokio::test] + async fn concurrent_retries_create_one_pending_request() { + let directory = tempfile::tempdir().unwrap(); + let db = Arc::new(Database::open(directory.path().join("state.sqlite3")).unwrap()); + let (one, two) = tokio::join!( + request(&db, input(directory.path())), + request(&db, input(directory.path())) + ); + assert_eq!(one.unwrap().id, two.unwrap().id); + assert_eq!(list(&db, None).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn denial_and_request_rows_roll_back_when_their_journal_write_fails() { + let directory = tempfile::tempdir().unwrap(); + let db = Arc::new(Database::open(directory.path().join("state.sqlite3")).unwrap()); + let pending = request(&db, input(directory.path())).await.unwrap(); + db.with_conn(|conn| { + conn.execute_batch("CREATE TRIGGER reject_source_journal BEFORE INSERT ON events BEGIN SELECT RAISE(ABORT,'fixture journal failure'); END;")?; + Ok(()) + }).unwrap(); + assert!(deny(&db, &pending.id).await.is_err()); + assert_eq!(list(&db, None).await.unwrap(), vec![pending]); + let other = directory.path().join("another-source"); + std::fs::create_dir(&other).unwrap(); + assert!(request(&db, input(&other)).await.is_err()); + assert_eq!(list(&db, None).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn invalid_requests_do_not_create_rows() { + let directory = tempfile::tempdir().unwrap(); + let db = Arc::new(Database::open(directory.path().join("state.sqlite3")).unwrap()); + for case in 0..5 { + let mut value = input(directory.path()); + match case { + 0 => value.containers = false, + 1 => value.reason = " ".into(), + 2 => value.reason = "x".repeat(2049), + 3 => value.attach_to_conversation = true, + _ => value.session_id = Some("missing-session".into()), + } + assert!(request(&db, value).await.is_err()); + } + assert!(list(&db, None).await.unwrap().is_empty()); + } +} diff --git a/apps/synth_desktop/src-tauri/src/session/annotation_projection.rs b/apps/synth_desktop/src-tauri/src/session/annotation_projection.rs index ee44b21f5..bb5e1902e 100644 --- a/apps/synth_desktop/src-tauri/src/session/annotation_projection.rs +++ b/apps/synth_desktop/src-tauri/src/session/annotation_projection.rs @@ -258,6 +258,7 @@ pub fn projection_payload(conn: &Connection, kind: &str, digest: &str) -> Result "kind": kind, "digest": row.digest, "payload": row.summary, + "reviews": list_reviews_for_head(conn, digest)?, })) } "verifier_result_v2" => { @@ -1402,6 +1403,28 @@ pub fn list_findings_for_trace(conn: &Connection, trace_digest: &str) -> Result< Ok(rows) } +fn list_reviews_for_head(conn: &Connection, digest: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT review_id, finding_id, decision, reviewer, rationale, created_at + FROM annotation_reviews + WHERE evidence_head_digest = ?1 + ORDER BY rowid ASC", + )?; + let rows = stmt + .query_map(params![digest], |row| { + Ok(json!({ + "reviewId": row.get::<_, String>(0)?, + "findingId": row.get::<_, Option>(1)?, + "decision": row.get::<_, String>(2)?, + "reviewer": row.get::<_, Option>(3)?, + "rationale": row.get::<_, Option>(4)?, + "createdAt": row.get::<_, String>(5)?, + })) + })? + .collect::>>()?; + Ok(rows) +} + pub fn record_local_review( conn: &Connection, finding_id: &str, @@ -1410,7 +1433,7 @@ pub fn record_local_review( reviewer: &str, rationale: &str, ) -> Result { - let review_id = format!("arev_{}", chrono::Utc::now().timestamp_millis()); + let review_id = format!("arev_{}", uuid::Uuid::new_v4().simple()); conn.execute( "INSERT INTO annotation_reviews( review_id, finding_id, evidence_head_digest, decision, reviewer, rationale, created_at @@ -1680,6 +1703,71 @@ mod tests { assert!(review_id.starts_with("arev_")); } + #[test] + fn local_reviews_overlay_projection_without_rewriting_sealed_evidence() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + apply_migrations(&conn).unwrap(); + upsert_campaign( + &conn, + "acmp_review", + "sealed", + &json!({ + "containerId": "ctr_review", + "evalRunId": "opt_eval_review", + "domain": "craftax", + "label": "post_rollout", + "coverage": { "jobs": 1, "sealed": 1 } + }), + ) + .unwrap(); + let sealed = json!({ + "schemaVersion": WORKBENCH_SCHEMA, + "campaign": { "id": "acmp_review", "status": "sealed" }, + "findings": [{ + "id": "ann_pos_1", + "label": "recovery.failure_not_detected", + "target": { "kind": "span", "id": "span_42", "selector": "span:span_42" } + }], + "rubric": { "available": true, "score": 0.47, "passed": false }, + "cost": { "usd": 1.25 } + }); + upsert_evidence_head(&conn, "sha256:review-head", "sha256:review-trace", &sealed).unwrap(); + let before = projection_payload(&conn, "annotation_evidence_head", "sha256:review-head").unwrap(); + assert_eq!(before["payload"]["rubric"]["score"], json!(0.47)); + assert_eq!(before["payload"]["cost"]["usd"], json!(1.25)); + assert_eq!(before["reviews"], json!([])); + assert!(before["payload"].get("reviews").is_none()); + + record_local_review( + &conn, + "ann_pos_1", + "sha256:review-head", + "accept", + "workshop", + "source-anchored span_42", + ) + .unwrap(); + record_local_review( + &conn, + "ann_pos_1", + "sha256:review-head", + "supersede", + "workshop", + "later correction", + ) + .unwrap(); + + // Equal timestamps and reverse-sorted UUIDs must not reorder decisions. + conn.execute("UPDATE annotation_reviews SET created_at = '2026-09-10T00:00:00Z', review_id = CASE decision WHEN 'accept' THEN 'arev_z' ELSE 'arev_a' END", []).unwrap(); + let after = projection_payload(&conn, "annotation_evidence_head", "sha256:review-head").unwrap(); + assert_eq!(after["payload"], sealed, "sealed evidence must stay byte-identical"); + assert_eq!(after["payload"]["rubric"]["score"], json!(0.47)); + assert_eq!(after["reviews"].as_array().unwrap().len(), 2); + assert_eq!(after["reviews"][0]["decision"], json!("accept")); + assert_eq!(after["reviews"][1]["decision"], json!("supersede")); + assert_eq!(after["reviews"][0]["findingId"], json!("ann_pos_1")); + } + #[test] fn restart_resumes_running_jobs_without_a_second_reservation() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/synth_desktop/src-tauri/src/session/approval.rs b/apps/synth_desktop/src-tauri/src/session/approval.rs index b33dde995..5d452c584 100644 --- a/apps/synth_desktop/src-tauri/src/session/approval.rs +++ b/apps/synth_desktop/src-tauri/src/session/approval.rs @@ -21,6 +21,9 @@ use std::{ use tauri::AppHandle; use tokio::sync::Mutex; +#[path = "approval_inspection.rs"] +pub(crate) mod inspection; + pub(crate) type ResolverFuture<'a> = Pin> + Send + 'a>>; @@ -200,6 +203,14 @@ pub(crate) enum ApprovalKind { action: String, effect: String, }, + VisualTemplatePersist { + template_id: String, + destination: String, + package_digest: String, + byte_size: u64, + overwrites: bool, + source_kind: String, + }, PluginLifecycle { plugin_id: String, action: String, @@ -250,6 +261,7 @@ impl ApprovalKind { Self::SidecarLifecycle { .. } => "sidecar_lifecycle", Self::ContainerLifecycle { .. } => "container_lifecycle", Self::PluginLifecycle { .. } => "plugin_lifecycle", + Self::VisualTemplatePersist { .. } => "visual_template_persist", Self::CredentialAccess { .. } => "credential_access", Self::ComputerUse { .. } => "computer_use", } @@ -275,6 +287,7 @@ impl ApprovalKind { Self::ComputerUse { hazard: true, .. } | Self::PaidCompute { .. } | Self::CredentialAccess { .. } + | Self::VisualTemplatePersist { .. } ) } @@ -322,6 +335,7 @@ impl ApprovalKind { }, ) => Ok(()), (Self::PluginLifecycle { .. }, ApprovalDecision::Approve { .. }) => Ok(()), + (Self::VisualTemplatePersist { .. }, ApprovalDecision::Approve { scope: ApprovalScope::Once }) => Ok(()), // Remembered scopes on a hazard action were already refused above, // so what reaches here is either a once-off hazard approval or an // app-scope grant, and both are valid. @@ -420,6 +434,7 @@ impl ApprovalKind { "timeoutSeconds": timeout_seconds, "credentialNames": credential_names, "preparationDigest": preparation_digest, + "approvalDigest": preparation_digest, "alwaysSupported": false, }), Self::SidecarLifecycle { sidecar, action } => json!({ @@ -453,6 +468,17 @@ impl ApprovalKind { "effect": effect, "alwaysSupported": false, }), + Self::VisualTemplatePersist { template_id, destination, package_digest, byte_size, overwrites, source_kind } => json!({ + "approvalId": approval_id, + "kind": self.name(), + "templateId": template_id, + "destination": destination, + "packageDigest": package_digest, + "sourceKind": source_kind, + "byteSize": byte_size, + "overwrites": overwrites, + "alwaysSupported": false, + }), Self::PluginLifecycle { plugin_id, action, @@ -1827,6 +1853,75 @@ mod tests { assert!(missing.to_string().contains("no longer pending")); } + #[tokio::test] + async fn approval_inspection_reports_live_state_and_exact_resolution_preserves_cap() { + let broker = ApprovalBroker::new(SessionPersistence::Null); + let app = tauri::test::mock_app(); + let (resolver, rx) = HostDecisionResolver::pair(); + let id = broker.request(app.handle(), ApprovalOrigin { + session_id: "operator-session".into(), instance_id: "operator-test".into(), + }, openrouter_paid(Some(10_000)), resolver).await.unwrap(); + let rows = broker.pending_snapshot().await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].approval_id, id); + assert_eq!(rows[0].session_id, "operator-session"); + assert_eq!(rows[0].preparation_digest.as_deref(), Some("sha256:spec")); + assert!(rows[0].requires_human); + assert!(broker.approve_digest(app.handle(), "sha256:wrong").await.is_err()); + let (first, second) = tokio::join!( + broker.approve_digest(app.handle(), "sha256:spec"), + broker.approve_digest(app.handle(), "sha256:spec")); + assert_ne!(first.is_ok(), second.is_ok()); + assert!(matches!(rx.await.unwrap().unwrap(), ApprovalDecision::ApproveWithCap { cap } + if cap.max_cost_usd_micros == Some(10_000) && cap.max_rollouts == Some(8))); + assert!(broker.pending_snapshot().await.is_empty()); + assert!(broker.approve_digest(app.handle(), "sha256:spec").await.is_err()); + } + + #[tokio::test] + async fn approval_inspection_refuses_ambiguous_and_expired_digests() { + let broker = ApprovalBroker::new(SessionPersistence::Null); + let app = tauri::test::mock_app(); + let mut receivers = Vec::new(); + for session in ["first", "second"] { + let (resolver, rx) = HostDecisionResolver::pair(); + receivers.push(rx); + broker.request(app.handle(), ApprovalOrigin { session_id: session.into(), + instance_id: "ambiguous-test".into() }, openrouter_paid(Some(10_000)), resolver).await.unwrap(); + } + assert!(broker.approve_digest(app.handle(), "sha256:spec").await.unwrap_err().to_string().contains("multiple")); + assert_eq!(broker.pending_snapshot().await.len(), 2); + for session in ["first", "second"] { + broker.expire_origin(app.handle(), &ApprovalOrigin { session_id: session.into(), + instance_id: "ambiguous-test".into() }, "test-complete").await.unwrap(); + } + assert!(broker.pending_snapshot().await.is_empty()); + assert!(broker.approve_digest(app.handle(), "sha256:spec").await.is_err()); + assert!(broker.approve_digest(app.handle(), " ").await.is_err()); + for rx in receivers { assert!(rx.await.unwrap().is_err()); } + } + + #[tokio::test] + async fn viewed_proposal_digest_is_required_and_must_match_before_approval() { + let broker = ApprovalBroker::new(SessionPersistence::Null); + let app = tauri::test::mock_app(); + let (resolver, rx) = HostDecisionResolver::pair(); + let kind = openrouter_paid(Some(10_000)); + assert_eq!(kind.safe_payload("test")["approvalDigest"], "sha256:spec"); + let id = broker.request(app.handle(), ApprovalOrigin { + session_id: "viewed-session".into(), instance_id: "viewed-test".into(), + }, kind, resolver).await.unwrap(); + assert!(broker.decision_from_view(&id, "once", None).await.is_err()); + assert!(broker.decision_from_view(&id, "once", Some("sha256:other")).await.is_err()); + assert!(matches!(broker.decision_from_view(&id, "reject", None).await.unwrap(), ApprovalDecision::Reject)); + assert!(broker.is_pending(&id).await); + let decision = broker.decision_from_view(&id, "once", Some("sha256:spec")).await.unwrap(); + broker.resolve(app.handle(), "viewed-session", &id, decision).await.unwrap(); + assert!(matches!(rx.await.unwrap().unwrap(), ApprovalDecision::ApproveWithCap { cap } + if cap.max_cost_usd_micros == Some(10_000))); + assert!(broker.decision_from_view(&id, "once", Some("sha256:spec")).await.is_err()); + } + fn openrouter_paid(max_cost_usd_micros: Option) -> ApprovalKind { openrouter_paid_with_digest(max_cost_usd_micros, "sha256:spec") } diff --git a/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs b/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs new file mode 100644 index 000000000..596d720a3 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs @@ -0,0 +1,165 @@ +//! Operator inspection and exact-digest resolution of live approval sheets. +//! Inspection reports broker state, never transcript guesses. Resolution is +//! human-only in desktop_policy; it does not mint a remembered permission. +use super::*; +use crate::error::AppError; + +#[derive(Clone, Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PendingApprovalView { + pub approval_id: String, + pub session_id: String, + pub kind: String, + pub requires_human: bool, + pub preparation_digest: Option, +} + +fn preparation_digest(kind: &ApprovalKind) -> Option<&str> { + match kind { + ApprovalKind::PaidCompute { + preparation_digest, .. + } => preparation_digest.as_deref(), + _ => None, + } +} + +impl ApprovalBroker { + /// Resolve only the proposal displayed by the caller. A rejection needs + /// no digest, so a stale/incomplete card can always be dismissed. + pub(crate) async fn decision_from_view( + &self, + id: &str, + requested: &str, + viewed_digest: Option<&str>, + ) -> Result { + let kind = self + .pending_kind(id) + .await + .ok_or_else(|| anyhow!("approval is no longer pending: {id}"))?; + if requested != "reject" { + match (preparation_digest(&kind), viewed_digest) { + (Some(actual), Some(viewed)) if actual == viewed => {} + (Some(_), None) => { + return Err(anyhow!( + "paid-compute approval requires the active proposal digest" + )) + } + (Some(_), Some(_)) => return Err(anyhow!("approval digest mismatch")), + (None, Some(_)) => { + return Err(anyhow!("approval is not bound to a proposal digest")) + } + (None, None) => {} + } + } + self.decision_from_shell(id, requested).await + } + + pub(crate) async fn pending_snapshot(&self) -> Vec { + let entries = self + .pending + .lock() + .await + .iter() + .map(|(id, pending)| (id.clone(), pending.clone())) + .collect::>(); + let mut views = Vec::new(); + for (approval_id, pending) in entries { + // A slow resolver must not freeze the read-only operator inbox. + if pending.settle.try_lock().is_ok_and(|settled| *settled) { + continue; + } + views.push(PendingApprovalView { + approval_id, + session_id: pending.origin.session_id.clone(), + kind: pending.kind.name().into(), + requires_human: pending.kind.requires_human(), + preparation_digest: preparation_digest(&pending.kind).map(str::to_owned), + }); + } + views.sort_by(|a, b| a.approval_id.cmp(&b.approval_id)); + views + } + + pub(crate) async fn approve_digest( + &self, + app: &AppHandle, + digest: &str, + ) -> Result { + if digest.trim().is_empty() { + return Err(anyhow!("approval digest must not be empty")); + } + let matched = { + let pending = self.pending.lock().await; + let mut matches = pending + .iter() + .filter(|(_, entry)| preparation_digest(&entry.kind) == Some(digest)); + let first = matches + .next() + .map(|(id, entry)| (id.clone(), entry.clone())); + if matches.next().is_some() { + return Err(anyhow!("multiple approval sheets share this digest; resolve by session and approval id")); + } + first + }; + let (id, pending) = + matched.ok_or_else(|| anyhow!("no approval sheet is open for this digest"))?; + let ApprovalKind::PaidCompute { requested_cap, .. } = &pending.kind else { + return Err(anyhow!( + "approval is not a digest-bound paid-compute request" + )); + }; + self.resolve( + app, + &pending.origin.session_id, + &id, + ApprovalDecision::ApproveWithCap { + cap: requested_cap.clone(), + }, + ) + .await?; + Ok(id) + } +} + +#[tauri::command] +#[specta::specta] +pub async fn approvals_pending( + approvals: tauri::State<'_, Arc>, +) -> Result, AppError> { + Ok(approvals.pending_snapshot().await) +} + +#[derive(Debug, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ApproveDigestRequest { + pub execution_spec_digest: String, +} + +#[derive(Debug, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ApproveDigestOutcome { + pub approval_id: String, + pub already_settled: bool, + pub execution_spec_digest: String, +} + +/// Human-only compatibility operation. Only a unique currently pending sheet +/// may settle. As on the previous implementation, a replay fails closed; the +/// legacy alreadySettled field is false, not a promise of durable idempotence. +#[tauri::command] +#[specta::specta] +pub async fn approvals_approve_digest( + app: tauri::AppHandle, + approvals: tauri::State<'_, Arc>, + request: ApproveDigestRequest, +) -> Result { + let approval_id = approvals + .approve_digest(&app, &request.execution_spec_digest) + .await + .map_err(AppError::from)?; + Ok(ApproveDigestOutcome { + approval_id, + already_settled: false, + execution_spec_digest: request.execution_spec_digest, + }) +} diff --git a/apps/synth_desktop/src-tauri/src/session/codex/event_pump.rs b/apps/synth_desktop/src-tauri/src/session/codex/event_pump.rs index 12f6c25e2..ad0bf0920 100644 --- a/apps/synth_desktop/src-tauri/src/session/codex/event_pump.rs +++ b/apps/synth_desktop/src-tauri/src/session/codex/event_pump.rs @@ -111,6 +111,7 @@ pub(crate) struct SpawnServerRequest<'a> { /// Shared pump state cloned into the stdout reader task. #[derive(Clone)] pub(crate) struct EventPumpState { + pub notification_closed: Arc>, pub records: Arc>>, pub state_path: PathBuf, pub persistence: SessionPersistence, @@ -535,6 +536,10 @@ async fn read_stdout( } continue; } + let notification_guard = persistence.notification_closed.lock().await; + if *notification_guard { + continue; + } let raw_method = message["method"].as_str().unwrap_or_default(); let mut params = message.get("params").cloned().unwrap_or(Value::Null); crate::codex_oauth::redact_event_value(&mut params); @@ -762,7 +767,8 @@ async fn read_stdout( .await; } } - if settlement.ready_for_eof_completion() { + let notification_guard = persistence.notification_closed.lock().await; + if !*notification_guard && settlement.ready_for_eof_completion() { // The child closed stdout after tools settled and an assistant item // completed, without `turn/completed` or `phase: final_answer`. That // is process-exit evidence, not a mid-turn commentary gap. @@ -776,6 +782,7 @@ async fn read_stdout( .await; apply_codex_terminal(&app, &session_id, &persistence, "turn/completed", params).await; } + drop(notification_guard); let owned_attachment = { let mut sessions = persistence.sessions.write().await; let owns_current = sessions @@ -1306,6 +1313,11 @@ pub(crate) fn normalized_turn_method<'a>(method: &'a str, params: &Value) -> &'a return method; } let turn = params.get("turn").unwrap_or(params); + if turn.get("status").and_then(Value::as_str).is_some_and(|status| { + matches!(status.to_ascii_lowercase().as_str(), "interrupted" | "cancelled" | "canceled") + }) { + return "turn/interrupted"; + } let status_is_failure = turn .get("status") .and_then(Value::as_str) @@ -1443,6 +1455,18 @@ pub(crate) async fn write_message(stdin: &RpcWriter, value: &Value) -> Result<() #[cfg(test)] mod child_path_tests { + #[test] + fn completed_envelope_preserves_interruption() { + for status in ["interrupted", "cancelled", "canceled", "CANCELLED"] { + assert_eq!( + super::normalized_turn_method( + "turn/completed", + &serde_json::json!({"turn": {"id": "cancelled-turn", "status": status}}), + ), + "turn/interrupted" + ); + } + } use super::codex_child_path; use std::{ffi::OsStr, path::Path}; diff --git a/apps/synth_desktop/src-tauri/src/session/codex/manager.rs b/apps/synth_desktop/src-tauri/src/session/codex/manager.rs index 79a3bb274..93305fade 100644 --- a/apps/synth_desktop/src-tauri/src/session/codex/manager.rs +++ b/apps/synth_desktop/src-tauri/src/session/codex/manager.rs @@ -302,6 +302,7 @@ impl CodexManager { install_local_laguna_catalog(&home, &request)?; ensure_home(&home, &request)?; let attachment_id = uuid::Uuid::new_v4(); + let notification_closed = Arc::new(Mutex::new(false)); let server = spawn_server( app.clone(), SpawnServerRequest { @@ -314,6 +315,7 @@ impl CodexManager { == super::home::ProviderClass::OpenaiCodexOauth, }, EventPumpState { + notification_closed: notification_closed.clone(), records: self.records.clone(), state_path: self.state_path.clone(), persistence: self.persistence.clone(), @@ -402,6 +404,7 @@ impl CodexManager { .ok_or_else(|| anyhow!("Codex {method} response missing thread id: {result}"))?; let mcp_reload_pending = server.persistent; let session = Arc::new(Session { + notification_closed, attachment_id, server, thread_id: thread_id.clone(), @@ -1223,6 +1226,9 @@ impl CodexManager { let Some(turn_id) = session.turn_id.read().await.clone() else { return Ok(()); }; + // Drain any in-flight projection, then reject late notifications while + // still allowing RPC acknowledgements through the stdout reader. + *session.notification_closed.lock().await = true; // Terminalize durable state before asking the provider. Its own // turn/interrupted notification can race the request acknowledgement; // recording cancellation first keeps a deliberate Stop distinct from @@ -1253,6 +1259,27 @@ impl CodexManager { ), ) .await?; + // Finalize the interrupted turn exactly once, retaining usage already + // observed before Stop. Late notifications cannot contaminate it. + let measurements = super::telemetry::finalize_performance_tracker( + &self.persistence, + &self.performance_trackers, + &self.receipts(), + session_id, + RunStatus::Interrupted.as_str(), + None, + ) + .await; + for measurement in measurements { + self.persistence + .notify_codex_event( + &app, + session_id.to_owned(), + super::generation_speed::MEASUREMENT_EVENT, + serde_json::to_value(measurement)?, + ) + .await; + } // Give the provider a bounded opportunity to stop leases and seal // partial evidence. An acknowledgement is not proof that a child tool // died, so the owned process group is fenced below in every case. @@ -1454,7 +1481,7 @@ impl CodexManager { ) -> Result<()> { let decision = self .approvals - .decision_from_shell(&request.approval_id, &request.decision) + .decision_from_view(&request.approval_id, &request.decision, request.approval_digest.as_deref()) .await?; self.approvals .resolve(&app, &request.session_id, &request.approval_id, decision) diff --git a/apps/synth_desktop/src-tauri/src/session/codex/proto.rs b/apps/synth_desktop/src-tauri/src/session/codex/proto.rs index 7042c739f..d81c12865 100644 --- a/apps/synth_desktop/src-tauri/src/session/codex/proto.rs +++ b/apps/synth_desktop/src-tauri/src/session/codex/proto.rs @@ -290,6 +290,8 @@ pub struct CodexApprovalDecisionRequest { pub session_id: String, pub approval_id: String, pub decision: String, + #[serde(default)] + pub approval_digest: Option, } #[derive(Clone, Debug, Deserialize, specta::Type)] @@ -609,6 +611,8 @@ pub(crate) fn default_sandbox() -> String { } pub(crate) struct Session { + /// Serializes cancellation against notification projection for this attachment. + pub(crate) notification_closed: Arc>, pub(crate) attachment_id: uuid::Uuid, pub(crate) server: Arc, pub(crate) thread_id: String, diff --git a/apps/synth_desktop/src-tauri/src/session/codex/tests.rs b/apps/synth_desktop/src-tauri/src/session/codex/tests.rs index b847e18dd..92374c1fa 100644 --- a/apps/synth_desktop/src-tauri/src/session/codex/tests.rs +++ b/apps/synth_desktop/src-tauri/src/session/codex/tests.rs @@ -394,6 +394,7 @@ async fn shell_approval_resolves_through_the_broker_and_drains_pending_state() { session_id: request.session_id.clone(), approval_id: approval_id.clone(), decision: "once".into(), + approval_digest: None, }, ) .await @@ -667,6 +668,21 @@ async fn interrupt_terminates_non_cooperative_tool_tree_and_allows_a_new_turn() .unwrap() .unwrap(); assert_eq!(first_run.outcome.unwrap()["reason"], "operator_cancelled"); + let usage_turn = first_turn.clone(); + let usage = core.storage().database().run(move |conn| { + Ok(conn.query_row( + "SELECT status, input_tokens, output_tokens FROM usage_records WHERE request_id = ?1", + [usage_turn], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)) + )?) + }).await.unwrap(); + assert_eq!(usage, ("interrupted".into(), None, None), + "Stop must persist interrupted usage without stale or late token counts"); + let after_stop = core.journal().session_events_after(request.session_id.clone(), 0, 200) + .await.unwrap(); + assert!(!after_stop.iter().any(|event| event.payload.to_string().contains("LATE_CANCEL_SENTINEL")), + "late output must not enter durable history after Stop"); + assert!(!after_stop.iter().any(|event| event.kind == "turn/completed"), + "late provider completion must not overwrite cancellation"); let cancelled = core .journal() .session_events_after(request.session_id.clone(), 0, 200) @@ -2018,6 +2034,7 @@ async fn app_server_approval_is_journaled_and_resumes_after_one_approval() { session_id: request.session_id.clone(), approval_id, decision: "once".into(), + approval_digest: None, }, ) .await diff --git a/apps/synth_desktop/src-tauri/src/session/mod.rs b/apps/synth_desktop/src-tauri/src/session/mod.rs index 9237f0cbe..6d7e61d29 100644 --- a/apps/synth_desktop/src-tauri/src/session/mod.rs +++ b/apps/synth_desktop/src-tauri/src/session/mod.rs @@ -7,6 +7,7 @@ pub mod codex; pub mod acp; pub(crate) mod live_annotation_projection; pub(crate) mod paid_compute_budget; +pub(crate) mod template_persist; mod persistence; pub use persistence::SessionPersistence; diff --git a/apps/synth_desktop/src-tauri/src/session/template_persist.rs b/apps/synth_desktop/src-tauri/src/session/template_persist.rs new file mode 100644 index 000000000..a5dec1a81 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/session/template_persist.rs @@ -0,0 +1,97 @@ +//! Once-only consent for the exact persistent renderer package being written. +use super::approval::{ApprovalBroker, ApprovalKind}; +use anyhow::{anyhow, Result}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PersistRequest { + pub template_id: String, + pub destination: String, + pub package_digest: String, + pub byte_size: u64, + pub overwrites: bool, + pub source_kind: String, +} + +impl PersistRequest { + pub(crate) fn kind(&self) -> ApprovalKind { + ApprovalKind::VisualTemplatePersist { + template_id: self.template_id.clone(), + destination: self.destination.clone(), + package_digest: self.package_digest.clone(), + byte_size: self.byte_size, + overwrites: self.overwrites, + source_kind: self.source_kind.clone(), + } + } +} + +/// Not cloneable or publicly constructible: the writer consumes this proof. +pub(crate) struct PersistConsent { + request: PersistRequest, +} + +impl PersistConsent { + #[cfg(test)] + pub(crate) fn for_test(request: PersistRequest) -> Self { + Self { request } + } + + pub(crate) fn bind(self, request: &PersistRequest) -> Result<()> { + if self.request != *request { + return Err(anyhow!("template package or destination changed after approval")); + } + Ok(()) + } +} + +pub(crate) async fn authorize( + app: &AppHandle, + session_id: Option<&str>, + request: &PersistRequest, +) -> Result { + let broker = app.try_state::>() + .ok_or_else(|| anyhow!("approval broker unavailable"))?; + broker.authorize_host(app, session_id, request.kind()).await?; + Ok(PersistConsent { request: request.clone() }) +} + +pub(crate) fn unapproved() -> anyhow::Error { + anyhow!("persisting visual template code requires a once-only visual_template_persist approval in a conversation") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::approval::{ApprovalDecision, ApprovalScope}; + + fn request() -> PersistRequest { + PersistRequest { template_id: "custom.viewer.v1".into(), destination: "/approved/template".into(), package_digest: "sha256:reviewed".into(), byte_size: 12, overwrites: false, source_kind: "managed".into() } + } + + #[test] + fn consent_binds_every_material_field() { + let original = request(); + let mut changes = vec![original.clone(); 6]; + changes[0].template_id = "other".into(); + changes[1].destination = "/other".into(); + changes[2].package_digest = "sha256:changed".into(); + changes[3].byte_size += 1; + changes[4].overwrites = true; + changes[5].source_kind = "user".into(); + for changed in changes { + assert!(PersistConsent { request: original.clone() }.bind(&changed).is_err()); + } + assert!(PersistConsent { request: original.clone() }.bind(&original).is_ok()); + } + + #[test] + fn persistent_code_requires_a_person_even_under_never_policy() { + let kind = request().kind(); + assert!(kind.requires_human()); + assert!(crate::session::approval_policy::auto_decision("never", &kind).unwrap().is_none()); + assert!(kind.validate_decision(&ApprovalDecision::Approve { scope: ApprovalScope::Session }).is_err()); + assert!(kind.validate_decision(&ApprovalDecision::Approve { scope: ApprovalScope::Once }).is_ok()); + } +} diff --git a/apps/synth_desktop/src-tauri/src/storage/content_store.rs b/apps/synth_desktop/src-tauri/src/storage/content_store.rs index 23ad944ec..1410da0df 100644 --- a/apps/synth_desktop/src-tauri/src/storage/content_store.rs +++ b/apps/synth_desktop/src-tauri/src/storage/content_store.rs @@ -90,7 +90,7 @@ fn hex_sha256(bytes: &[u8]) -> String { fn validate_kind(kind: &str) -> Result<()> { match kind { "blobs" | "previews" | "traces" | "trace_imports" | "exports" | "artifact_bundles" - | "report_bundles" | "trace_views" + | "report_bundles" | "trace_views" | "optimizer_snapshots" // Native environment frames relayed off a running container. Their own // kind so a PNG is never served where a JSON document is expected, and // so frame retention can be dropped without touching any other diff --git a/apps/synth_desktop/src-tauri/src/storage/live_spool.rs b/apps/synth_desktop/src-tauri/src/storage/live_spool.rs index cc7554376..6ec5df511 100644 --- a/apps/synth_desktop/src-tauri/src/storage/live_spool.rs +++ b/apps/synth_desktop/src-tauri/src/storage/live_spool.rs @@ -7,7 +7,8 @@ use super::ContentStore; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::HashSet; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; pub const LIVE_SPOOL_SCHEMA: &str = "synth.live-eval-spool.v1"; @@ -37,45 +38,18 @@ pub fn envelopes_from_event_log(log: &Value) -> Vec { } pub fn envelope_identity(event: &Value, index: usize) -> String { - if let Some(id) = event - .get("event_id") - .or_else(|| event.get("id")) - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - { - return id.to_string(); - } - let sequence = event - .get("sequence_number") - .or_else(|| event.get("sequence")) - .or_else(|| event.get("seq")); - if let Some(sequence) = sequence { - if !sequence.is_null() { - let run = event - .get("run_id") - .or_else(|| event.get("rollout_id")) - .and_then(Value::as_str) - .unwrap_or("run"); - let lane = event.get("lane").and_then(Value::as_str).unwrap_or(""); - return format!("{run}:{lane}:{sequence}"); + // Preserve the older spool wire aliases at this boundary only. + let mut normalized = event.clone(); + if let Some(object) = normalized.as_object_mut() { + if !object.contains_key("event_id") { + if let Some(id) = event.get("id") { object.insert("event_id".into(), id.clone()); } + } + if !object.contains_key("sequence_number") && !object.contains_key("sequence") { + if let Some(seq) = event.get("seq") { object.insert("sequence".into(), seq.clone()); } } } - let run = event - .get("run_id") - .or_else(|| event.get("rollout_id")) - .and_then(Value::as_str) - .unwrap_or("run"); - let kind = event - .get("kind") - .or_else(|| event.get("type")) - .and_then(Value::as_str) - .unwrap_or("event"); - let ts = event - .get("occurred_at") - .or_else(|| event.get("ts")) - .and_then(Value::as_str) - .unwrap_or(""); - format!("{run}:{kind}:{ts}:{index}") + let scope = crate::stream_fold::envelope_scope(&normalized); + crate::stream_fold::envelope_identity(&normalized, &scope, index as u64 + 1) } pub fn persist_live_envelopes( @@ -84,11 +58,13 @@ pub fn persist_live_envelopes( rollout_id: Option<&str>, envelopes: impl IntoIterator, ) -> Result { - let mut seen = HashSet::new(); + let mut seen = HashMap::new(); let mut unique = Vec::new(); for (index, envelope) in envelopes.into_iter().enumerate() { let id = envelope_identity(&envelope, index); - if !seen.insert(id) { + let body_digest: [u8; 32] = Sha256::digest(serde_json::to_vec(&envelope)?).into(); + if let Some(prior) = seen.insert(id.clone(), body_digest) { + if prior != body_digest { bail!("conflicting live spool envelope identity {id}"); } continue; } unique.push(envelope); diff --git a/apps/synth_desktop/src-tauri/src/storage/migrations.rs b/apps/synth_desktop/src-tauri/src/storage/migrations.rs index 593e17bbf..912c4a15f 100644 --- a/apps/synth_desktop/src-tauri/src/storage/migrations.rs +++ b/apps/synth_desktop/src-tauri/src/storage/migrations.rs @@ -79,6 +79,8 @@ const MIGRATIONS: &[&str] = &[ MIGRATION_74, MIGRATION_75, MIGRATION_76, + MIGRATION_77, + MIGRATION_78, ]; const MIGRATION_70: &str = r#" @@ -246,6 +248,8 @@ CREATE TABLE IF NOT EXISTS optimizer_evidence_amendments ( "#; const REQUIRED_TABLES: &[(&str, &str)] = &[ + ("project_source_requests", MIGRATION_78), + ("optimizer_snapshots", MIGRATION_77), ("optimizer_terminal_manifests", MIGRATION_23), ("secret_refs", MIGRATION_25), ("credential_locators", CREDENTIAL_LOCATORS_TABLE_DDL), @@ -3765,6 +3769,26 @@ mod tests { /// every test that asserts the database reached the newest version. const LATEST_VERSION: i64 = super::MIGRATIONS.len() as i64; + #[test] + fn project_source_request_upgrade_preserves_legacy_history_and_session_identity() { + let conn = seed_at_version(77); + conn.execute_batch("CREATE TABLE project_source_requests ( + id TEXT PRIMARY KEY, session_id TEXT, requested_path TEXT NOT NULL, + canonical_path TEXT NOT NULL, reason TEXT NOT NULL, containers INTEGER NOT NULL, + recipes INTEGER NOT NULL, attach_to_conversation INTEGER NOT NULL, + status TEXT NOT NULL, created_at TEXT NOT NULL, resolved_at TEXT); + INSERT INTO project_source_requests VALUES + ('global',NULL,'/fixture','/fixture','legacy',1,0,0,'pending','2026-09-09',NULL), + ('empty','','/fixture','/fixture','legacy',1,0,0,'pending','2026-09-09',NULL), + ('history',NULL,'/fixture','/fixture','legacy',1,0,0,'expired','2026-09-08','2026-09-09');").unwrap(); + assert_eq!(apply_migrations(&conn).unwrap(), LATEST_VERSION); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM project_source_requests", [], |row| row.get(0)).unwrap(); + assert_eq!(count, 3); + let status: String = conn.query_row("SELECT status FROM project_source_requests WHERE id='history'", [], |row| row.get(0)).unwrap(); + assert_eq!(status, "expired"); + assert!(conn.execute("INSERT INTO project_source_requests SELECT 'duplicate',session_id,requested_path,canonical_path,reason,containers,recipes,attach_to_conversation,status,created_at,resolved_at FROM project_source_requests WHERE id='global'", []).is_err()); + } + use super::*; /// Every `MIGRATION_N` constant is registered exactly once, and the registry @@ -5771,6 +5795,48 @@ CREATE TABLE visual_corpus_details ( ); "#; +const MIGRATION_78: &str = r#" +CREATE TABLE IF NOT EXISTS project_source_requests ( + id TEXT PRIMARY KEY, + session_id TEXT, + requested_path TEXT NOT NULL, + canonical_path TEXT NOT NULL, + reason TEXT NOT NULL CHECK(length(reason) BETWEEN 1 AND 2048), + containers INTEGER NOT NULL CHECK(containers IN (0,1)), + recipes INTEGER NOT NULL CHECK(recipes IN (0,1)), + attach_to_conversation INTEGER NOT NULL CHECK(attach_to_conversation IN (0,1)), + status TEXT NOT NULL CHECK(status IN ('pending','approved','denied','expired')), + created_at TEXT NOT NULL, + resolved_at TEXT, + CHECK(containers=1 OR recipes=1), + CHECK(attach_to_conversation=0 OR session_id IS NOT NULL) +); +CREATE UNIQUE INDEX IF NOT EXISTS project_source_requests_pending +ON project_source_requests(session_id,canonical_path) WHERE status='pending' AND session_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS project_source_requests_pending_global +ON project_source_requests(canonical_path) WHERE status='pending' AND session_id IS NULL; +CREATE INDEX IF NOT EXISTS project_source_requests_session +ON project_source_requests(session_id,created_at DESC,id DESC); +"#; + +const MIGRATION_77: &str = r#" +CREATE TABLE IF NOT EXISTS optimizer_snapshots ( + snapshot_id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL, + content_digest TEXT NOT NULL UNIQUE, + source_instance_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + terminal_status TEXT, + terminal_cursor INTEGER NOT NULL, + sealed INTEGER NOT NULL CHECK(sealed IN (0,1)), + captured_at TEXT NOT NULL, + imported_at TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS optimizer_snapshots_source_run +ON optimizer_snapshots(source_instance_id, source_run_id, captured_at DESC); +"#; + const MIGRATION_76: &str = r#" CREATE TABLE visual_evidence_cuts ( visual_id TEXT NOT NULL, visual_revision INTEGER NOT NULL, digest TEXT NOT NULL, diff --git a/apps/synth_desktop/src-tauri/src/stream_fold.rs b/apps/synth_desktop/src-tauri/src/stream_fold.rs new file mode 100644 index 000000000..309e3384d --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/stream_fold.rs @@ -0,0 +1,1007 @@ +//! The one fold: identity, scope, dedupe, conflict, gap scan, projection. +//! +//! Everything that reads a producer's ordered journal — the live-eval replay +//! seam, the live spool, the receipt, the optimizer adapters, the Intern +//! ingestion loop — asks the same questions of it: *have I seen this record, +//! is this the record I expected next, and does the history I hold have a hole +//! in it?* Those questions were answered in seven places, in three languages, +//! with rules that had already drifted: the spool treated a bare `event_id` as +//! globally unique and lane-collapsed every multiplexed run, while the +//! renderer's ingest had a comment warning about exactly that bug. +//! +//! This module is the answer. It is deliberately not a visuals module and not +//! an optimizer module: the two families ask the same questions of different +//! journals, and a boundary that only half the callers can reach is how the +//! second implementation gets written. +//! +//! # Two journal shapes, one set of rules +//! +//! * **Envelope streams** (live-eval): identity is producer-declared and +//! rollout-local, sequences may be opaque strings, and history is folded by +//! dedupe rather than by cursor arithmetic. [`LiveFold`]. +//! * **Cursor journals** (optimizer runs, Intern sessions): a dense `u64` +//! sequence per run, where the only questions are replay, next, and hole. +//! [`sequence_step`]. +//! +//! # The rules, and why each one is the way it is +//! +//! 1. **Identity keeps the producer lane.** `sequence` and `event_id` are +//! monotonic only within a rollout, so a multiplexed run legitimately +//! carries ten `event_id: "1"` records. `streamId:sequence` first, then +//! `scope:event_id`, then `scope:sequence`, then kind and stamp. Treating a +//! bare `event_id` as globally unique drops all but one lane while leaving +//! the aggregate lane count looking valid. +//! 2. **Control envelopes keep their sequence numbers.** A gap is a claim +//! about the *producer's* sequence space and control records occupy that +//! space, so skipping one before recording its sequence manufactures a +//! permanent phantom gap for any producer that sequences its heartbeats. +//! 3. **`control: true` is honored** alongside the control kinds, so the fold +//! and the projector cannot disagree about what counts as evidence. +//! 4. **The evidence high-water mark is evidence-only.** Rule 2 admits control +//! records to the *gap scan*; [`LiveFold::last_sequence`] excludes them. +//! The two answer different questions, and the divergence recorded in +//! `visuals/stream_receipt.rs` — per-stream and control-advanced there, +//! per-scope and evidence-only in TypeScript — is resolved here in favour +//! of evidence-only. A heartbeat that advances the high-water mark lets a +//! stream carrying nothing but heartbeats report progress it never made, +//! which is precisely the failure the receipt exists to expose. +//! 5. **An absent sequence is absent, never zero.** `Number(null)` is `0` and +//! `Number("")` is `0`; reading either as sequence zero invents a hole +//! before sequence one. +//! 6. **Only integral sequences are gap-scannable.** A producer may sequence +//! with opaque strings — the multiplexed Craftax fixture does — and those +//! lanes are simply not scannable. The same holds for a fractional +//! sequence: it has no successor, so "the number missing after it" is not a +//! claim anyone can make. Coercing either would invent a sequence space and +//! then report holes in it. +//! +//! # The TypeScript mirror +//! +//! Browser preview, fixture replay and the two shipped shells run with no Rust +//! underneath them and still have to draw something, so `visuals/runtime/` +//! keeps a mirror of *identity, dedupe, the control predicate and the +//! projection*, plus browser-only gap/conflict diagnostics. Native readiness +//! uses the host receipt, not these renderer-reported diagnostics. +//! +//! The mirror is pinned to this module by a golden capture over every +//! selected checked-in fixtures and edge cases in +//! `visuals/fixtures/live_fold_golden.json`, asserted by golden_tests.rs and +//! visuals/tests/live_fold_golden.test.mjs in batch and one-event pages. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +#[cfg(test)] +mod golden_tests; + +// =========================================================================== +// Cursor journals: replay, next, hole. +// =========================================================================== + +/// Where one sequence falls relative to a cursor over a dense journal. +/// +/// Every caller that folds an optimizer or Intern journal asks this and only +/// this. Naming the four answers once means a caller chooses a *policy* — +/// skip a replay, or refuse it — instead of re-deriving the arithmetic and +/// getting `<=` where it meant `<`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SequenceStep { + /// The sequence the cursor already stands on. Callers that carry an event + /// id can tell an honest retransmission from a collision here. + Duplicate, + /// Behind the cursor: already folded, and folding it again would double + /// count. + Replay, + /// Exactly one past the cursor: the record this fold was waiting for. + Next, + /// Past the cursor with numbers missing in between. A gap means the + /// producer's journal and this cursor disagree about history, and a viewer + /// folded from a gapped stream shows a trajectory that never happened. + Gap { + /// The sequence the fold expected instead. + expected: u64, + }, +} + +/// Classify one sequence against a cursor. See [`SequenceStep`]. +pub fn sequence_step(cursor: u64, sequence: u64) -> SequenceStep { + if sequence == cursor { + SequenceStep::Duplicate + } else if sequence < cursor { + SequenceStep::Replay + } else if sequence == cursor.saturating_add(1) { + SequenceStep::Next + } else { + SequenceStep::Gap { + expected: cursor.saturating_add(1), + } + } +} + +/// True when `sequence` is the next contiguous record after `cursor`. +/// +/// The shape for callers that treat anything else — replay included — as a +/// contract violation rather than as something to skip. +pub fn is_next_sequence(cursor: u64, sequence: u64) -> bool { + matches!(sequence_step(cursor, sequence), SequenceStep::Next) +} + +/// Advance `cursor` if `sequence` is ahead of it, reporting whether it moved. +/// +/// The looser policy some producers require: a page whose numbering is +/// monotonic but not dense, where a skipped number is the producer's business +/// and not evidence of loss. Returns false without moving the cursor when the +/// record is at or behind it. +pub fn accept_if_ahead(cursor: &mut u64, sequence: u64) -> bool { + if sequence <= *cursor { + return false; + } + *cursor = sequence; + true +} + +/// Whether a page's declared next cursor agrees with what the fold committed. +/// +/// A producer that hands back a cursor the committed page does not reach has +/// either dropped records or renumbered them; either way the fold's history is +/// not the producer's history. +pub fn cursor_reconciles(committed: u64, page_next: u64) -> bool { + committed == page_next +} + +// =========================================================================== +// Envelope streams: reading one envelope. +// =========================================================================== + +/// Control envelope kinds. Transport bookkeeping, never evidence. +pub const CONTROL_KINDS: &[&str] = &["stream.subscribed", "heartbeat", "stream.heartbeat", "ping"]; + +/// The control kind that declares a subscription established. +pub const SUBSCRIBED_KIND: &str = "stream.subscribed"; + +/// Blob names a projection may never carry off the fold. +const FORBIDDEN_BLOBS: &[&str] = &["collector", "capability_blob", "capabilities_blob"]; + +/// A scalar JSON value as the string a template literal would produce, with +/// `null` and absent both reading as absent. +/// +/// The renderer reaches these fields through `??`, which skips only null and +/// undefined — so an empty string is a value, and a number is a stamp. Objects +/// and arrays are not scalars and read as absent rather than as +/// `[object Object]`. +fn scalar(value: Option<&Value>) -> Option { + match value? { + Value::Null => None, + Value::String(text) => Some(text.clone()), + Value::Bool(flag) => Some(flag.to_string()), + Value::Number(number) => Some(number_string(number)), + _ => None, + } +} + +/// A JSON number as JavaScript would print it: integral values without a +/// trailing `.0`, everything else in its shortest round-tripping form. +fn number_string(number: &serde_json::Number) -> String { + if let Some(value) = number.as_i64() { + return value.to_string(); + } + if let Some(value) = number.as_u64() { + return value.to_string(); + } + match number.as_f64() { + Some(value) if value.fract() == 0.0 && value.abs() < 9.0e15 => (value as i64).to_string(), + Some(value) => value.to_string(), + None => number.to_string(), + } +} + +/// A non-empty string field, the way the renderer's `||` chain reads one. +fn non_empty(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn payload_string(event: &Value, keys: &[&str]) -> Option { + let payload = event.get("payload")?; + keys.iter().find_map(|key| non_empty(payload.get(*key))) +} + +/// The envelope's kind: `kind`, else `type`, else empty. +pub fn envelope_kind(event: &Value) -> String { + scalar(event.get("kind")) + .or_else(|| scalar(event.get("type"))) + .unwrap_or_default() +} + +/// Whether this envelope is transport bookkeeping rather than evidence. +/// +/// The single definition of "control" for the whole pipeline. An explicit +/// `control: true` flag counts, not just a known control kind: the projector +/// already honoured the flag while the fold checked kind only, so an envelope +/// flagged `control: true` under an ordinary kind was evidence to one and not +/// the other. +pub fn is_control(event: &Value) -> bool { + is_control_kind(event, &envelope_kind(event)) +} + +/// [`is_control`] for a caller that has already read the kind. +pub fn is_control_kind(event: &Value, kind: &str) -> bool { + if event.get("control").and_then(Value::as_bool) == Some(true) { + return true; + } + CONTROL_KINDS.contains(&kind) +} + +/// The producer's declared stream id, from the envelope or its payload. +pub fn stream_id(event: &Value) -> Option { + non_empty(event.get("stream_id")).or_else(|| payload_string(event, &["stream_id", "stream.id"])) +} + +/// The producer lane an envelope belongs to: stream, rollout, lane, or run. +/// +/// Producers may carry transport identity in the payload, so the declared +/// identity is promoted at the ingestion boundary — every viewer then gets the +/// same rollout-local dedupe without knowing a producer's wire shape. +pub fn envelope_scope(event: &Value) -> String { + stream_id(event) + .or_else(|| non_empty(event.get("rollout_id"))) + .or_else(|| payload_string(event, &["rollout_id"])) + .or_else(|| non_empty(event.get("lane"))) + .or_else(|| payload_string(event, &["lane"])) + .or_else(|| non_empty(event.get("run_id"))) + .or_else(|| payload_string(event, &["run_id"])) + .unwrap_or_else(|| "run".to_string()) +} + +/// The stream an envelope was delivered on, for the cutoff cursor vector. +/// +/// The declared stream when the producer names one, the lane otherwise. A +/// cutoff addresses arrival order *within a stream*, which is the one total +/// order that exists whatever a producer does with its sequence numbers. +pub fn envelope_stream(event: &Value) -> String { + stream_id(event).unwrap_or_else(|| envelope_scope(event)) +} + +/// `sequence_number ?? sequence`, with an explicit `null` read as absent. +fn raw_sequence(event: &Value) -> Option<&Value> { + for key in ["sequence_number", "sequence"] { + match event.get(key) { + Some(value) if !value.is_null() => return Some(value), + _ => {} + } + } + None +} + +/// The sequence as the string that names it in an identity, if it has one. +pub fn sequence_label(event: &Value) -> Option { + scalar(raw_sequence(event)).filter(|label| !label.is_empty()) +} + +/// The sequence as a gap-scannable integer, or nothing. +/// +/// See rule 6: opaque strings and fractional numbers are legitimate producer +/// choices that simply carry no scannable sequence space. +pub fn numeric_sequence(event: &Value) -> Option { + match raw_sequence(event)? { + Value::Number(number) => number.as_i64().or_else(|| { + number + .as_f64() + .filter(|value| value.fract() == 0.0) + .map(|value| value as i64) + }), + Value::String(text) if !text.is_empty() => text.trim().parse::().ok(), + _ => None, + } +} + +/// The envelope's identity: what makes two deliveries the same record. +/// +/// `ordinal` is the delivered-envelope ordinal, one-based, and is consulted +/// only for an envelope carrying no identity of its own at all — no stream, +/// no event id, no sequence and no timestamp. +pub fn envelope_identity(event: &Value, scope: &str, ordinal: u64) -> String { + let sequence = sequence_label(event); + if let (Some(stream), Some(sequence)) = (stream_id(event), sequence.as_deref()) { + return format!("{stream}:{sequence}"); + } + if let Some(event_id) = non_empty(event.get("event_id")) { + return format!("{scope}:{event_id}"); + } + if let Some(sequence) = sequence.as_deref() { + return format!("{scope}:{sequence}"); + } + let kind = scalar(event.get("kind")) + .or_else(|| scalar(event.get("type"))) + .unwrap_or_else(|| "event".to_string()); + let stamp = scalar(event.get("occurred_at")) + .or_else(|| scalar(event.get("ts"))) + .unwrap_or_else(|| ordinal.to_string()); + format!("{scope}:{kind}:{stamp}") +} + +/// Promote payload-carried identity onto the envelope itself. +/// +/// A viewer that only reads top-level fields still sees the lane the producer +/// declared in its payload, so the same envelope projects the same way +/// wherever it is read. +pub fn normalize_identity(event: &Value) -> Value { + let rollout_id = + non_empty(event.get("rollout_id")).or_else(|| payload_string(event, &["rollout_id"])); + let lane = non_empty(event.get("lane")) + .or_else(|| payload_string(event, &["lane"])) + .or_else(|| rollout_id.clone()); + let run_id = non_empty(event.get("run_id")).or_else(|| payload_string(event, &["run_id"])); + let stream = stream_id(event); + if rollout_id.is_none() && lane.is_none() && run_id.is_none() && stream.is_none() { + return event.clone(); + } + let mut normalized = event.clone(); + let Some(object) = normalized.as_object_mut() else { + return normalized; + }; + for (key, value) in [ + ("rollout_id", rollout_id), + ("lane", lane), + ("run_id", run_id), + ("stream_id", stream), + ] { + if let Some(value) = value { + object.insert(key.to_string(), Value::String(value)); + } + } + normalized +} + +/// The producer's own digest when it declares one, the body otherwise. +/// +/// Only equality matters: this decides whether one identity arrived twice with +/// two different bodies. Envelope bodies carry model output and rollout +/// payloads, so the fold keeps the hash and never the body. +pub fn digest_hash(event: &Value) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + match event.get("digest").and_then(Value::as_str) { + Some(digest) => digest.hash(&mut hasher), + None => serde_json::to_string(event) + .unwrap_or_default() + .hash(&mut hasher), + } + hasher.finish() +} + +// =========================================================================== +// Gaps and conflicts. +// =========================================================================== + +/// A hole in one scope's sequence space, reported as the two envelopes that +/// bracket it rather than as a rendered sentence. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SequenceGap { + /// Producer lane, as [`envelope_scope`] derives it. + pub scope: String, + #[specta(type = specta_typescript::Number)] + pub after: i64, + #[specta(type = specta_typescript::Number)] + pub before: i64, +} + +/// One envelope identity delivered twice with two different bodies. +/// +/// Structured rather than a formatted string: the identity and the lane are +/// the parts a caller acts on, and a message already formatted for a human +/// cannot be grouped, counted or matched. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvelopeConflict { + pub identity: String, + pub scope: String, + pub message: String, +} + +impl EnvelopeConflict { + fn new(identity: &str, scope: &str) -> Self { + Self { + identity: identity.to_string(), + scope: scope.to_string(), + message: format!("Conflicting duplicate envelope {identity}"), + } + } +} + +/// Scan one scope's observed sequences for holes. +pub fn scan_gaps(scope: &str, observed: &BTreeSet) -> Vec { + let mut gaps = Vec::new(); + let mut previous: Option = None; + for sequence in observed { + if let Some(last) = previous { + if *sequence > last.saturating_add(1) { + gaps.push(SequenceGap { + scope: scope.to_string(), + after: last, + before: *sequence, + }); + } + } + previous = Some(*sequence); + } + gaps +} + +// =========================================================================== +// The envelope fold. +// =========================================================================== + +/// Bounds on a fold's bookkeeping. +/// +/// A live stream has an unbounded lifetime and may carry hundreds of thousands +/// of envelopes, so the bookkeeping is bounded and says when it stopped being +/// complete. A truncated fold reports lower bounds; it never reports a smaller +/// number as if it were the whole count. +#[derive(Clone, Copy, Debug)] +pub struct FoldLimits { + pub max_identities: usize, + pub max_sequences_per_scope: usize, + pub max_defects: usize, + /// Whether accepted evidence bodies are retained for projection. + /// + /// Off for every live caller, deliberately, and the decision is not + /// "projections are not worth the memory" — it is that this retention has + /// no byte bound and no ceiling on `events`, so a hundred-thousand-envelope + /// run would hold every body in a process-global for as long as the process + /// lives. The live seam needs a projection and does not need this: the host + /// already retains a *bounded* evidence prefix per stream so a live-eval + /// visual can be sealed at all, and a projection folded from that prefix + /// costs a read of memory that is spent either way. See + /// `visuals/stream_receipt.rs`, which owns that bound and reports when it + /// is reached. + /// + /// So this stays what it is: the affordance for a caller folding a + /// *finite* log it already holds — a fixture, a closed rollout, a test. + /// Turning it on for a live stream would be a second, unbounded copy of + /// evidence the host is already keeping under a bound. + pub retain_events: bool, +} + +impl Default for FoldLimits { + fn default() -> Self { + Self { + max_identities: 50_000, + max_sequences_per_scope: 50_000, + max_defects: 64, + retain_events: false, + } + } +} + +impl FoldLimits { + /// Limits for a fold whose evidence will be projected. + pub fn retaining() -> Self { + Self { + retain_events: true, + ..Self::default() + } + } +} + +/// What the fold decided about one delivered envelope. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FoldVerdict { + /// Accounting reached its bound; this delivery is not proven distinct. + Untracked, + /// A new, non-control envelope: evidence. + Evidence, + /// A new control envelope: bookkeeping, not evidence. + Control, + /// An identity already folded, with the same body. + Duplicate, + /// An identity already folded, with a different body. + Conflict, +} + +impl FoldVerdict { + /// Whether this delivery was folded rather than dropped as a repeat. + pub fn accepted(self) -> bool { + matches!(self, Self::Evidence | Self::Control) + } +} + +/// The fold's reading of one delivered envelope, for a caller keeping its own +/// per-stream books beside it. +#[derive(Clone, Debug)] +pub struct FoldStep { + pub identity: String, + pub scope: String, + pub stream: String, + pub kind: String, + pub control: bool, + /// The gap-scannable sequence, when the producer has one. + pub sequence: Option, + pub verdict: FoldVerdict, +} + +/// What a batch changed, for the caller that has to report it. +#[derive(Clone, Debug, Default)] +pub struct FoldBatch { + pub steps: Vec, + /// Gaps observed for the first time by this batch. A caller emits these + /// once per gap rather than once per poll: a 500 ms loop over a permanent + /// hole would otherwise file the same diagnostic twice a second forever. + pub new_gaps: Vec, + pub new_conflicts: Vec, +} + +/// A live envelope stream, folded. +/// +/// Stateful across deliveries: the dedupe set, the per-scope sequence space, +/// the conflict ledger and the evidence high-water marks all persist, so a +/// caller polls repeatedly and the fold answers as if it had seen one log. +#[derive(Debug)] +pub struct LiveFold { + limits: FoldLimits, + identities: HashMap, + sequences: BTreeMap>, + last_sequence_by_scope: BTreeMap, + gaps: Vec, + conflicts: Vec, + kinds: BTreeMap, + stream_evidence: BTreeMap, + events: Vec, + delivered: u64, + delivered_non_control: u64, + distinct: u64, + evidence: u64, + ready: bool, + truncated: bool, + ordinal: u64, +} + +impl Default for LiveFold { + fn default() -> Self { + Self::new(FoldLimits::default()) + } +} + +impl LiveFold { + pub fn new(limits: FoldLimits) -> Self { + Self { + limits, + identities: HashMap::new(), + sequences: BTreeMap::new(), + last_sequence_by_scope: BTreeMap::new(), + gaps: Vec::new(), + conflicts: Vec::new(), + kinds: BTreeMap::new(), + stream_evidence: BTreeMap::new(), + events: Vec::new(), + delivered: 0, + delivered_non_control: 0, + distinct: 0, + evidence: 0, + ready: false, + truncated: false, + ordinal: 0, + } + } + + /// A fold that keeps evidence bodies, for a caller that will project them. + pub fn retaining() -> Self { + Self::new(FoldLimits::retaining()) + } + + /// Fold a batch of delivered envelopes. + /// + /// Gaps are rescanned once per touched scope at the end of the batch + /// rather than per envelope: a live transport can deliver thousands of + /// messages in one task, and rescanning per message made a 100k-envelope + /// run quadratic. + pub fn accept_batch<'a>(&mut self, events: impl IntoIterator) -> FoldBatch { + let mut batch = FoldBatch::default(); + let mut touched: BTreeSet = BTreeSet::new(); + for event in events { + let step = self.accept_one(event, &mut batch, &mut touched); + batch.steps.push(step); + } + for scope in touched { + let observed = self.sequences.get(&scope).cloned().unwrap_or_default(); + let rescanned = scan_gaps(&scope, &observed); + let known: BTreeSet<(i64, i64)> = self + .gaps + .iter() + .filter(|gap| gap.scope == scope) + .map(|gap| (gap.after, gap.before)) + .collect(); + for gap in &rescanned { + if !known.contains(&(gap.after, gap.before)) { + batch.new_gaps.push(gap.clone()); + } + } + self.gaps.retain(|gap| gap.scope != scope); + self.gaps.extend(rescanned); + if self.gaps.len() > self.limits.max_defects { + self.gaps.truncate(self.limits.max_defects); + self.truncated = true; + } + } + batch + } + + /// Fold one delivered envelope. + pub fn accept(&mut self, event: &Value) -> FoldStep { + let mut batch = self.accept_batch(std::iter::once(event)); + batch + .steps + .pop() + .expect("accept_batch yields one step per envelope") + } + + fn accept_one( + &mut self, + event: &Value, + batch: &mut FoldBatch, + touched: &mut BTreeSet, + ) -> FoldStep { + self.delivered += 1; + self.ordinal += 1; + let ordinal = self.ordinal; + + let kind = envelope_kind(event); + let control = is_control_kind(event, &kind); + if self.kinds.contains_key(&kind) || self.kinds.len() < self.limits.max_identities { + let counted = self.kinds.entry(kind.clone()).or_insert((0, control)); + counted.0 += 1; + counted.1 = control; + } else { + self.truncated = true; + } + if !control { + self.delivered_non_control += 1; + } + if kind == SUBSCRIBED_KIND { + self.ready = true; + } + + let scope = envelope_scope(event); + let stream = envelope_stream(event); + let identity = envelope_identity(event, &scope, ordinal); + let digest = digest_hash(event); + let sequence = numeric_sequence(event); + + if let Some(previous) = self.identities.get(&identity).copied() { + let verdict = if previous == digest { + FoldVerdict::Duplicate + } else { + if self.conflicts.len() < self.limits.max_defects { + let conflict = EnvelopeConflict::new(&identity, &scope); + self.conflicts.push(conflict.clone()); + batch.new_conflicts.push(conflict); + } else { + self.truncated = true; + } + FoldVerdict::Conflict + }; + // A duplicate is delivered, not accepted: it never becomes + // evidence and it never re-opens a closed sequence gap. + return FoldStep { + identity, + scope, + stream, + kind, + control, + sequence, + verdict, + }; + } + + if self.identities.len() >= self.limits.max_identities { + self.truncated = true; + return FoldStep { identity, scope, stream, kind, control, sequence, verdict: FoldVerdict::Untracked }; + } else { + self.identities.insert(identity.clone(), digest); + } + self.distinct += 1; + if !control { + self.evidence += 1; + *self.stream_evidence.entry(stream.clone()).or_insert(0) += 1; + if self.limits.retain_events { + self.events.push(normalize_identity(event)); + } + } + + // Rule 2: a control envelope keeps its sequence. Rule 4: it does not + // advance the evidence high-water mark. + if let Some(sequence) = sequence { + let observed = self.sequences.entry(scope.clone()).or_default(); + if observed.len() >= self.limits.max_sequences_per_scope { + self.truncated = true; + } else { + observed.insert(sequence); + touched.insert(scope.clone()); + } + if !control { + let last = self + .last_sequence_by_scope + .entry(scope.clone()) + .or_insert(sequence); + *last = (*last).max(sequence); + } + } + + FoldStep { + identity, + scope, + stream, + kind, + control, + sequence, + verdict: if control { + FoldVerdict::Control + } else { + FoldVerdict::Evidence + }, + } + } + + pub fn gaps(&self) -> &[SequenceGap] { + &self.gaps + } + + pub fn conflicts(&self) -> &[EnvelopeConflict] { + &self.conflicts + } + + /// Accepted evidence bodies, in arrival order. Empty unless the fold was + /// built with [`FoldLimits::retaining`]. + pub fn events(&self) -> &[Value] { + &self.events + } + + /// Envelopes delivered, duplicates included: what the transport handed + /// over, before the fold has an opinion about it. + pub fn delivered(&self) -> u64 { + self.delivered + } + + /// Delivered envelopes that are not heartbeats, pings or subscription + /// notices. A stream can be healthy on every other count and still have + /// carried no evidence; this is the number that says so. + pub fn delivered_non_control(&self) -> u64 { + self.delivered_non_control + } + + /// Envelopes with a distinct identity: what the fold kept. + pub fn distinct(&self) -> u64 { + self.distinct + } + + /// Distinct non-control envelopes: the evidence a projection works from. + pub fn evidence_count(&self) -> u64 { + self.evidence + } + + /// A `stream.subscribed` control envelope was delivered. + pub fn ready(&self) -> bool { + self.ready + } + + /// Set once bookkeeping hit its bound. Dedupe, gaps and conflicts become + /// lower bounds from that point; the delivered counts do not. + pub fn truncated(&self) -> bool { + self.truncated + } + + /// Envelopes delivered under each kind, with whether that kind is control. + pub fn kinds(&self) -> impl Iterator + '_ { + self.kinds + .iter() + .map(|(kind, (count, control))| (kind.as_str(), *count, *control)) + } + + /// The highest sequence *evidence* reached in one scope. See rule 4. + pub fn last_sequence(&self, scope: &str) -> Option { + self.last_sequence_by_scope.get(scope).copied() + } + + pub fn last_sequence_by_scope(&self) -> &BTreeMap { + &self.last_sequence_by_scope + } + + /// The cutoff addressing everything folded so far. + pub fn cursor(&self) -> CursorVector { + CursorVector(self.stream_evidence.clone()) + } +} + +// =========================================================================== +// Cutoff: a per-stream cursor vector. +// =========================================================================== + +/// A logical cutoff into a folded stream set: how many evidence envelopes of +/// each stream to include. +/// +/// Not a sequence. Verification killed both simpler candidates on the real +/// multiplexed fixture (`live.craftax.v1/examples/cua-luna-low-10.json`, one +/// stream and ten lanes): `sequence` there is a non-numeric string +/// (`"suites/…#s0::frame:0"`), so a scalar numeric cutoff is a no-op and +/// a per-scope numeric vector cannot address the events either. The one +/// durable total order that always exists is arrival order within a stream — +/// persisted verbatim by the spool and preserved by the fold — so a cutoff is +/// a prefix length per stream. +/// +/// Streams absent from the vector contribute nothing: a cutoff names what is +/// included, so an unnamed stream is excluded rather than silently whole. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CursorVector(pub BTreeMap); + +impl CursorVector { + pub fn new(counts: impl IntoIterator) -> Self { + Self(counts.into_iter().collect()) + } + + pub fn get(&self, stream: &str) -> u64 { + self.0.get(stream).copied().unwrap_or(0) + } + + /// Total envelopes addressed. The filmstrip orders snapshots by this, + /// breaking ties on stream id. + pub fn total(&self) -> u64 { + self.0.values().sum() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +// =========================================================================== +// The live-eval projection. +// =========================================================================== + +/// What a live-eval template renders: literal values, never raw envelopes to +/// be re-folded downstream. +/// +/// `events` is the folded evidence prefix and stays available beside the +/// derived fields on purpose. A sourced visual may aggregate an eval in a way +/// nobody anticipated, and making a novel aggregation require a Rust change +/// would spend expressiveness — already this system's weak axis against +/// general codegen — on tidiness. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LiveEvalProjection { + pub events: Vec, + pub kinds: Vec, + pub has_live_frames: bool, + pub has_reward_txt: bool, + pub reward: Option, + pub usage: Option, + /// The cutoff this projection was folded at, or absent for the whole + /// prefix. Reported so a filmstrip frame carries the cutoff that made it. + pub cutoff: Option, +} + +/// Token and cost accounting, as the last envelope that carried any reported +/// it. A field the producer omitted stays absent rather than becoming zero. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LiveEvalUsage { + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, + pub cost_usd: Option, +} + +fn finite_number(value: Option<&Value>) -> Option { + value + .and_then(Value::as_f64) + .filter(|number| number.is_finite()) +} + +fn payload_number(payload: Option<&Value>, keys: &[&str]) -> Option { + let payload = payload?; + keys.iter().find_map(|key| finite_number(payload.get(*key))) +} + +/// Whether any key anywhere under `value` is named `name`. +fn has_key(value: &Value, name: &str) -> bool { + match value { + Value::Object(map) => map + .iter() + .any(|(key, nested)| key == name || has_key(nested, name)), + Value::Array(rows) => rows.iter().any(|nested| has_key(nested, name)), + _ => false, + } +} + +/// Fold an ordered evidence log into the values a live-eval template renders. +/// +/// `events` is a deduped log in arrival order. Control envelopes are filtered +/// here as well as by the fold, so a caller that hands over a raw log gets the +/// same answer as one that hands over folded evidence. +pub fn project_live_eval( + events: &[Value], + cutoff: Option<&CursorVector>, +) -> anyhow::Result { + let mut rows: Vec<&Value> = Vec::new(); + let mut taken: BTreeMap = BTreeMap::new(); + for event in events { + if is_control(event) { + continue; + } + if let Some(cutoff) = cutoff { + let stream = envelope_stream(event); + let taken_here = taken.entry(stream.clone()).or_insert(0); + if *taken_here >= cutoff.get(&stream) { + continue; + } + *taken_here += 1; + } + rows.push(event); + } + + let kinds: Vec = rows.iter().copied().map(envelope_kind).collect(); + let has_live_frames = kinds.iter().any(|kind| kind == "frame"); + let has_reward_txt = rows.iter().copied().any(|event: &Value| { + event + .get("payload") + .is_some_and(|payload| has_key(payload, "reward.txt")) + }); + + let mut reward = rows + .iter() + .copied() + .rev() + .find(|event: &&Value| envelope_kind(event) == "verifier") + .and_then(|event| finite_number(event.get("payload").and_then(|p| p.get("reward.txt")))); + if reward.is_none() { + reward = rows + .iter() + .copied() + .rev() + .find(|event: &&Value| { + let kind = envelope_kind(event); + kind == "reward_signal" || kind == "eval.run.terminal" + }) + .and_then(|event| payload_number(event.get("payload"), &["value", "reward", "total"])); + } + + let usage = rows + .iter() + .copied() + .rev() + .find_map(|event: &Value| { + event + .get("payload") + .and_then(|payload| payload.get("usage")) + .filter(|usage| usage.is_object()) + }) + .map(|usage| LiveEvalUsage { + prompt_tokens: finite_number(usage.get("prompt_tokens")), + completion_tokens: finite_number(usage.get("completion_tokens")), + total_tokens: finite_number(usage.get("total_tokens")), + cost_usd: finite_number(usage.get("cost_usd")), + }); + + let projection = LiveEvalProjection { + events: rows.iter().copied().cloned().collect(), + kinds, + has_live_frames, + has_reward_txt, + reward, + usage, + cutoff: cutoff.cloned(), + }; + + // The same refusal the renderer's projector makes, for the same reason: a + // projection is the thing that gets sealed, and a collector or capability + // blob that reaches it is exfiltrated evidence, not a rendering bug. + let blob = serde_json::to_string(&projection)?; + for name in FORBIDDEN_BLOBS { + if blob.contains(name) { + anyhow::bail!("live eval projection leaked forbidden blob \"{name}\""); + } + } + Ok(projection) +} diff --git a/apps/synth_desktop/src-tauri/src/stream_fold/golden_tests.rs b/apps/synth_desktop/src-tauri/src/stream_fold/golden_tests.rs new file mode 100644 index 000000000..756845488 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/stream_fold/golden_tests.rs @@ -0,0 +1,86 @@ +use super::*; +use serde_json::json; + +// JSON has one numeric type. The JS-generated reference serializes 6.0 as 6, +// whereas serde retains the Rust f64 representation. Compare numbers by value, +// recursively, while keeping every key, array position and null exact. +fn assert_json_value(actual: &Value, expected: &Value, label: &str) { + match (actual, expected) { + (Value::Number(a), Value::Number(b)) => assert_eq!(a.as_f64(), b.as_f64(), "{label}"), + (Value::Object(a), Value::Object(b)) => { + assert_eq!( + a.keys().collect::>(), + b.keys().collect::>(), + "{label}" + ); + for (key, value) in a { + assert_json_value(value, &b[key], &format!("{label}.{key}")); + } + } + (Value::Array(a), Value::Array(b)) => { + assert_eq!(a.len(), b.len(), "{label}"); + for (index, (a, b)) in a.iter().zip(b).enumerate() { + assert_json_value(a, b, &format!("{label}[{index}]")); + } + } + _ => assert_eq!(actual, expected, "{label}"), + } +} + +#[test] +fn checked_in_live_fixtures_match_shared_golden_in_batch_and_pages() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let golden: Value = serde_json::from_slice( + &std::fs::read(root.join("visuals/fixtures/live_fold_golden.json")).unwrap(), + ) + .unwrap(); + assert_eq!(golden["schema"], "synth.live-fold-golden.v1"); + for expected in golden["cases"].as_array().unwrap() { + let document: Value = if let Some(file) = expected["source"]["file"].as_str() { + serde_json::from_slice(&std::fs::read(root.join(file)).unwrap()).unwrap() + } else { + expected["source"]["inline"].clone() + }; + let events = document + .as_array() + .or_else(|| document["events"].as_array()) + .unwrap(); + for paged in [false, true] { + let mut fold = LiveFold::new(FoldLimits::retaining()); + let mut accepted = Vec::new(); + for batch in events.chunks(if paged { 1 } else { events.len().max(1) }) { + for step in fold.accept_batch(batch).steps { + if step.verdict.accepted() { + accepted.push(json!({"identity":step.identity,"scope":step.scope,"control":step.control})); + } + } + } + let p = project_live_eval(fold.events(), None).unwrap(); + let mut gaps = fold.gaps().to_vec(); + gaps.sort_by(|a, b| a.scope.cmp(&b.scope).then(a.after.cmp(&b.after))); + let actual = json!({"accepted":accepted,"acceptedCount":fold.distinct(),"deliveredCount":fold.delivered(), + "evidenceCount":fold.evidence_count(),"ready":fold.ready(),"gaps":gaps, + "conflicts":fold.conflicts().iter().map(|c|c.message.clone()).collect::>(), + "lastSequenceByScope":fold.last_sequence_by_scope, + "projection":{"kinds":p.kinds,"hasLiveFrames":p.has_live_frames,"hasRewardTxt":p.has_reward_txt, + "reward":p.reward,"usage":p.usage,"eventCount":p.events.len()}}); + for (key, value) in actual.as_object().unwrap() { + if key == "projection" { + for (field, projected) in value.as_object().unwrap() { + assert_json_value( + projected, + &expected[key][field], + &format!("{} paged={paged} projection.{field}", expected["name"]), + ); + } + continue; + } + assert_eq!( + value, &expected[key], + "{} paged={paged} field={key}", + expected["name"] + ); + } + } + } +} diff --git a/apps/synth_desktop/src-tauri/src/synth_config.rs b/apps/synth_desktop/src-tauri/src/synth_config.rs index 362fe682b..666497757 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config.rs @@ -7,6 +7,18 @@ use std::{ path::{Path, PathBuf}, }; +#[path = "synth_config/project_sources.rs"] +mod project_sources; +#[cfg(test)] +pub(crate) use project_sources::begin_project_source_grant_at; +#[cfg(test)] +pub(crate) use project_sources::{settings_at as project_source_settings_at, forget_at as forget_project_source_at}; +pub(crate) use project_sources::{begin_project_source_grant, ProjectSourceChange}; +pub use project_sources::{ + forget_project_source, merge_project_source, project_source_settings, ProjectSourceEntry, + ProjectSourceSettings, +}; + const DEFAULT_PROFILE: &str = "prod"; const DEFAULT_API_KEY_ENV: &str = "SYNTH_API_KEY"; const DEFAULT_WORKER_KEY_ENV: &str = "SMR_WORKER_API_KEY"; @@ -348,7 +360,12 @@ pub fn get() -> Result { let resolved = resolve()?; let mut document = read_toml(&resolved.config_path)?; if ensure_default_model_config(&mut document) { - write_toml(&resolved.config_path, &document)?; + // Keep complete, read-only configurations readable. When defaults are + // missing, re-read under the shared lock before applying them. + document = mutate_config(&resolved.config_path, |document| { + ensure_default_model_config(document); + Ok(document.clone()) + })?; } let intern = document.get("intern").and_then(toml::Value::as_table); let profile = intern @@ -464,31 +481,32 @@ pub fn update(request: BackendSettingsUpdate) -> Result { &request.env_file, config_path.parent().unwrap_or(Path::new(".")), )?; - let mut document = read_toml(&config_path)?; - let root = document - .as_table_mut() - .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; - let intern = root - .entry("intern") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[intern] must be a TOML table"))?; - intern.insert("profile".into(), toml::Value::String(profile.clone())); - intern.insert( - "env_file".into(), - toml::Value::String(path_for_toml(&env_file)), - ); - intern.insert( - "api_key_env".into(), - toml::Value::String(api_key_env.clone()), - ); - let endpoints = intern - .entry("endpoints") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[intern.endpoints] must be a TOML table"))?; - endpoints.insert(profile, toml::Value::String(backend_url)); - write_toml(&config_path, &document)?; + mutate_config(&config_path, |document| { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; + let intern = root + .entry("intern") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[intern] must be a TOML table"))?; + intern.insert("profile".into(), toml::Value::String(profile.clone())); + intern.insert( + "env_file".into(), + toml::Value::String(path_for_toml(&env_file)), + ); + intern.insert( + "api_key_env".into(), + toml::Value::String(api_key_env.clone()), + ); + let endpoints = intern + .entry("endpoints") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[intern.endpoints] must be a TOML table"))?; + endpoints.insert(profile, toml::Value::String(backend_url)); + Ok(()) + })?; if let Some(api_key) = request.api_key.as_deref() { store_api_key(api_key)?; @@ -682,26 +700,27 @@ pub(crate) fn select_default_workspace_path( pub fn update_workspace_access(request: WorkspaceAccessUpdate) -> Result { let allowed_roots = validate_workspace_roots(request.allowed_roots)?; let path = config_path(); - let mut document = read_toml(&path)?; - let root = document - .as_table_mut() - .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; - let workspace = root - .entry("workspace") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[workspace] must be a TOML table"))?; - workspace.insert( - "allowed_roots".into(), - toml::Value::Array( - allowed_roots - .iter() - .cloned() - .map(toml::Value::String) - .collect(), - ), - ); - write_toml(&path, &document)?; + mutate_config(&path, |document| { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; + let workspace = root + .entry("workspace") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[workspace] must be a TOML table"))?; + workspace.insert( + "allowed_roots".into(), + toml::Value::Array( + allowed_roots + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + Ok(()) + })?; Ok(WorkspaceAccessSettings { allowed_roots }) } @@ -853,53 +872,54 @@ fn update_desktop_permissions_at( if !is_sandbox_mode(&request.sandbox_mode) { return Err(anyhow!("unsupported sandbox mode")); } - let mut document = read_toml(path)?; - let root = document - .as_table_mut() - .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; - let desktop = root - .entry("desktop") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[desktop] must be a TOML table"))?; - let permissions = desktop - .entry("permissions") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[desktop.permissions] must be a TOML table"))?; - permissions.insert( - "approval_policy".into(), - toml::Value::String(request.approval_policy), - ); - permissions.insert( - "sandbox_mode".into(), - toml::Value::String(request.sandbox_mode), - ); - if let Some(paid_compute) = request.paid_compute { - let policy = paid_compute.policy()?; - let mut table = toml::value::Table::new(); - table.insert("auto_approve".into(), toml::Value::Boolean(policy.enabled)); - table.insert( - "max_request_usd".into(), - toml::Value::String(format_usd_micros(policy.max_request_usd_micros)), + mutate_config(path, |document| { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; + let desktop = root + .entry("desktop") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[desktop] must be a TOML table"))?; + let permissions = desktop + .entry("permissions") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[desktop.permissions] must be a TOML table"))?; + permissions.insert( + "approval_policy".into(), + toml::Value::String(request.approval_policy), ); - table.insert( - "max_conversation_usd".into(), - toml::Value::String(format_usd_micros(policy.max_conversation_usd_micros)), + permissions.insert( + "sandbox_mode".into(), + toml::Value::String(request.sandbox_mode), ); - table.insert( - "providers".into(), - toml::Value::Array( - policy - .providers - .into_iter() - .map(toml::Value::String) - .collect(), - ), - ); - permissions.insert("paid_compute".into(), toml::Value::Table(table)); - } - write_toml(path, &document)?; + if let Some(paid_compute) = request.paid_compute { + let policy = paid_compute.policy()?; + let mut table = toml::value::Table::new(); + table.insert("auto_approve".into(), toml::Value::Boolean(policy.enabled)); + table.insert( + "max_request_usd".into(), + toml::Value::String(format_usd_micros(policy.max_request_usd_micros)), + ); + table.insert( + "max_conversation_usd".into(), + toml::Value::String(format_usd_micros(policy.max_conversation_usd_micros)), + ); + table.insert( + "providers".into(), + toml::Value::Array( + policy + .providers + .into_iter() + .map(toml::Value::String) + .collect(), + ), + ); + permissions.insert("paid_compute".into(), toml::Value::Table(table)); + } + Ok(()) + })?; desktop_permission_settings_at(path) } @@ -1065,32 +1085,33 @@ pub fn update_model_multi_agent( return Err(anyhow!("modelId is required")); } let path = config_path(); - let mut document = read_toml(&path)?; - let root = document - .as_table_mut() - .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; - let models = root - .entry("models") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[models] must be a TOML table"))?; - let multi_agent = models - .entry("multi_agent") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[models.multi_agent] must be a TOML table"))?; - match request.version { - Some(version) => { - multi_agent.insert( - model_id, - toml::Value::String(multi_agent_version_name(version).to_owned()), - ); - } - None => { - multi_agent.remove(&model_id); + mutate_config(&path, |document| { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; + let models = root + .entry("models") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[models] must be a TOML table"))?; + let multi_agent = models + .entry("multi_agent") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[models.multi_agent] must be a TOML table"))?; + match request.version { + Some(version) => { + multi_agent.insert( + model_id, + toml::Value::String(multi_agent_version_name(version).to_owned()), + ); + } + None => { + multi_agent.remove(&model_id); + } } - } - write_toml(&path, &document)?; + Ok(()) + })?; model_multi_agent_settings() } @@ -1382,6 +1403,53 @@ fn is_openrouter_model_slug(value: &str) -> bool { }) } +/// Serialize every config read-modify-write, including across app processes. +/// The lock file stays at a stable inode while the document is atomically +/// replaced. Readers see the old or new complete document, never half a grant. +static CONFIG_MUTATION: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn mutate_config(path: &Path, edit: impl FnOnce(&mut toml::Value) -> Result) -> Result { + use fs2::FileExt; + let _guard = CONFIG_MUTATION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Preserve user-managed config symlinks and share the same lock for aliases. + let resolved = match fs::canonicalize(path) { + Ok(resolved) => resolved, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !path.is_symlink() => { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + fs::create_dir_all(parent)?; + fs::canonicalize(parent)?.join( + path.file_name() + .ok_or_else(|| anyhow!("Config path must name a file"))?, + ) + } + Err(error) => return Err(error.into()), + }; + let path = resolved.as_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path.with_extension("toml.lock"))?; + lock.lock_exclusive() + .with_context(|| format!("lock config {}", path.display()))?; + let mut document = read_toml(path)?; + let before = document.clone(); + let result = edit(&mut document)?; + if document != before { + write_toml(path, &document)?; + } + Ok(result) +} + fn read_toml(path: &Path) -> Result { match fs::read_to_string(path) { Ok(raw) => raw @@ -1398,7 +1466,14 @@ fn write_toml(path: &Path, document: &toml::Value) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::write(path, toml::to_string_pretty(document)?)?; + let parent = path.parent().unwrap_or(Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(parent)?; + if let Ok(metadata) = fs::metadata(path) { + file.as_file().set_permissions(metadata.permissions())?; + } + file.write_all(toml::to_string_pretty(document)?.as_bytes())?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; Ok(()) } @@ -1408,65 +1483,66 @@ pub(crate) fn rewrite_credential_locator_export( locators: &[crate::secrets::CredentialLocatorSummary], ) -> Result<()> { let path = config_path(); - let mut document = read_toml(&path)?; - let root = document - .as_table_mut() - .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; - let desktop = root - .entry("desktop") - .or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("[desktop] must be a TOML table"))?; - let entries = locators - .iter() - .map(|locator| { - let mut entry = toml::map::Map::new(); - entry.insert("id".into(), toml::Value::String(locator.id.clone())); - entry.insert( - "kind".into(), - toml::Value::String(locator.kind.as_str().into()), - ); - if let Some(reference) = locator.workspace_root_ref.as_ref() { + mutate_config(&path, |document| { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("Synth config root must be a TOML table"))?; + let desktop = root + .entry("desktop") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[desktop] must be a TOML table"))?; + let entries = locators + .iter() + .map(|locator| { + let mut entry = toml::map::Map::new(); + entry.insert("id".into(), toml::Value::String(locator.id.clone())); entry.insert( - "workspace_root_ref".into(), - toml::Value::String(reference.clone()), + "kind".into(), + toml::Value::String(locator.kind.as_str().into()), ); - } - if let Some(relative) = locator.relative_path.as_ref() { + if let Some(reference) = locator.workspace_root_ref.as_ref() { + entry.insert( + "workspace_root_ref".into(), + toml::Value::String(reference.clone()), + ); + } + if let Some(relative) = locator.relative_path.as_ref() { + entry.insert( + "relative_path".into(), + toml::Value::String(relative.clone()), + ); + } + if matches!( + locator.kind, + crate::secrets::CredentialLocatorKind::ExternalEnvFile + ) && locator.display_path.starts_with("~/") + { + entry.insert( + "external_path".into(), + toml::Value::String(locator.display_path.clone()), + ); + } + entry.insert("format".into(), toml::Value::String(locator.format.clone())); entry.insert( - "relative_path".into(), - toml::Value::String(relative.clone()), + "provider".into(), + toml::Value::String(locator.provider.clone()), ); - } - if matches!( - locator.kind, - crate::secrets::CredentialLocatorKind::ExternalEnvFile - ) && locator.display_path.starts_with("~/") - { entry.insert( - "external_path".into(), - toml::Value::String(locator.display_path.clone()), + "variable".into(), + toml::Value::String(locator.variable.clone()), ); - } - entry.insert("format".into(), toml::Value::String(locator.format.clone())); - entry.insert( - "provider".into(), - toml::Value::String(locator.provider.clone()), - ); - entry.insert( - "variable".into(), - toml::Value::String(locator.variable.clone()), - ); - entry.insert("label".into(), toml::Value::String(locator.label.clone())); - entry.insert( - "state".into(), - toml::Value::String(locator.state.as_str().into()), - ); - toml::Value::Table(entry) - }) - .collect::>(); - desktop.insert("credential_locators".into(), toml::Value::Array(entries)); - write_toml(&path, &document) + entry.insert("label".into(), toml::Value::String(locator.label.clone())); + entry.insert( + "state".into(), + toml::Value::String(locator.state.as_str().into()), + ); + toml::Value::Table(entry) + }) + .collect::>(); + desktop.insert("credential_locators".into(), toml::Value::Array(entries)); + Ok(()) + }) } fn resolve_secret(key: &str, env_file: &Path) -> (Option, Option) { @@ -1745,6 +1821,104 @@ fn secret_fingerprint(secret: &str) -> String { mod tests { use super::*; + #[test] + fn config_mutation_preserves_bytes_on_error_and_noop() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = "# Preserve comments when unchanged\nvalue = 7\n"; + fs::write(&path, original).unwrap(); + mutate_config(&path, |_| Ok(())).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + let result: Result<()> = mutate_config(&path, |document| { + document["value"] = toml::Value::Integer(8); + Err(anyhow!("Rejected mutation")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + + #[test] + fn config_mutation_serializes_processes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + fs::write(&path, "preserved = true\n").unwrap(); + let mut children = Vec::new(); + for key in ["first", "second"] { + children.push( + std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "synth_config::tests::config_mutation_child_writer", + "--ignored", + ]) + .env("SYNTH_TEST_CONFIG_MUTATION_PATH", &path) + .env("SYNTH_TEST_CONFIG_MUTATION_KEY", key) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(), + ); + } + for child in children { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + let document = read_toml(&path).unwrap(); + assert_eq!(document["preserved"].as_bool(), Some(true)); + assert_eq!(document["first"].as_integer(), Some(20)); + assert_eq!(document["second"].as_integer(), Some(20)); + } + + #[test] + #[ignore = "invoked in isolated child processes by config_mutation_serializes_processes"] + fn config_mutation_child_writer() { + let path = std::path::PathBuf::from( + std::env::var_os("SYNTH_TEST_CONFIG_MUTATION_PATH").expect("test config path"), + ); + let key = std::env::var("SYNTH_TEST_CONFIG_MUTATION_KEY").expect("test config key"); + for _ in 0..20 { + mutate_config(&path, |document| { + let value = document + .get(&key) + .and_then(toml::Value::as_integer) + .unwrap_or(0); + std::thread::sleep(std::time::Duration::from_millis(2)); + document + .as_table_mut() + .unwrap() + .insert(key.clone(), toml::Value::Integer(value + 1)); + Ok(()) + }) + .unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn config_mutation_preserves_symlinks_and_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.toml"); + let alias = directory.path().join("alias.toml"); + fs::write(&target, "value = 1\n").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &alias).unwrap(); + mutate_config(&alias, |document| { + document["value"] = toml::Value::Integer(2); + Ok(()) + }) + .unwrap(); + assert!(alias.is_symlink()); + assert_eq!(read_toml(&target).unwrap()["value"].as_integer(), Some(2)); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + #[test] fn configurable_openrouter_model_accepts_minimal_ox_alpha_entry() { let entry: OpenRouterModelConfig = toml::from_str( diff --git a/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs b/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs new file mode 100644 index 000000000..ce8cda23a --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs @@ -0,0 +1,371 @@ +//! Persisted executable-source grants, independent of conversation attachments. +//! Admission belongs to the native picker service; these internal functions +//! only persist its decisions. Never expose a whole-list agent write command. + +use super::{config_path, mutate_config, read_toml}; +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceEntry { + pub path: String, + pub containers: bool, + pub recipes: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSourceSettings { + pub config_path: String, + pub entries: Vec, +} + +pub fn project_source_settings() -> Result { + settings_at(&config_path()) +} + +pub(crate) fn settings_at(path: &Path) -> Result { + Ok(ProjectSourceSettings { + config_path: path.display().to_string(), + entries: entries_from_document(&read_toml(path)?)?, + }) +} + +pub fn merge_project_source(entry: ProjectSourceEntry) -> Result { + merge_at(&config_path(), entry) +} + +fn merge_at(path: &Path, entry: ProjectSourceEntry) -> Result { + mutate_at(path, |entries| { + entries.push(entry); + Ok(()) + }) +} + +pub fn forget_project_source(path: &str) -> Result { + forget_at(&config_path(), path) +} + +pub(crate) fn forget_at(config: &Path, path: &str) -> Result { + // Do not canonicalize: a deleted/unmounted source must still be revocable. + let path = path.trim(); + mutate_at(config, |entries| { + entries.retain(|entry| entry.path != path); + Ok(()) + }) +} + +/// A compensatable, single-root mutation. Rollback never restores a stale +/// whole-list snapshot or overwrites a newer change to the same root. +pub(crate) struct ProjectSourceChange { + config: std::path::PathBuf, + path: String, + previous: Option, + written: Option, +} + +impl ProjectSourceChange { + pub(crate) fn rollback(self) -> Result<()> { + mutate_at(&self.config, |entries| { + let current = entries + .iter() + .find(|entry| entry.path == self.path) + .cloned(); + if current != self.written { + bail!("project source changed again; refusing to overwrite the newer grant during rollback"); + } + entries.retain(|entry| entry.path != self.path); + if let Some(previous) = self.previous { + entries.push(previous); + } + Ok(()) + })?; + Ok(()) + } +} + +pub(crate) fn begin_project_source_grant(entry: ProjectSourceEntry) -> Result { + change_at(&config_path(), entry.path.trim().to_owned(), Some(entry)) +} + +fn change_at( + config: &Path, + path: String, + entry: Option, +) -> Result { + let mut previous = None; + let settings = mutate_at(config, |entries| { + previous = entries.iter().find(|entry| entry.path == path).cloned(); + match entry { + Some(entry) => entries.push(entry), + None => entries.retain(|entry| entry.path != path), + } + Ok(()) + })?; + let written = settings + .entries + .into_iter() + .find(|entry| entry.path == path); + Ok(ProjectSourceChange { + config: config.to_owned(), + path, + previous, + written, + }) +} + +#[cfg(test)] +pub(crate) fn begin_project_source_grant_at( + config: &Path, + entry: ProjectSourceEntry, +) -> Result { + change_at(config, entry.path.trim().to_owned(), Some(entry)) +} + +fn mutate_at( + path: &Path, + edit: impl FnOnce(&mut Vec) -> Result<()>, +) -> Result { + let entries = mutate_config(path, |document| { + let mut entries = entries_from_document(document)?; + edit(&mut entries)?; + let entries = normalize(entries)?; + let root = document + .as_table_mut() + .ok_or_else(|| anyhow!("config must be a table"))?; + let desktop = root + .entry("desktop") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[desktop] must be a table"))?; + let sources = desktop + .entry("project_sources") + .or_insert_with(|| toml::Value::Table(Default::default())) + .as_table_mut() + .ok_or_else(|| anyhow!("[desktop.project_sources] must be a table"))?; + sources.remove("roots"); + sources.insert("entries".into(), toml::Value::try_from(&entries)?); + Ok(entries) + })?; + Ok(ProjectSourceSettings { + config_path: path.display().to_string(), + entries, + }) +} + +/// Retain both public spellings, but never turn malformed permission state +/// into an empty list or silently default an incorrectly typed flag to true. +fn entries_from_document(document: &toml::Value) -> Result> { + let Some(desktop) = document.get("desktop") else { + return Ok(Vec::new()); + }; + let desktop = desktop + .as_table() + .ok_or_else(|| anyhow!("[desktop] must be a table"))?; + let Some(sources) = desktop.get("project_sources") else { + return Ok(Vec::new()); + }; + let sources = sources + .as_table() + .ok_or_else(|| anyhow!("[desktop.project_sources] must be a table"))?; + let mut entries = Vec::new(); + if let Some(roots) = sources.get("roots") { + for root in roots + .as_array() + .ok_or_else(|| anyhow!("project source roots must be an array"))? + { + entries.push(ProjectSourceEntry { + path: root + .as_str() + .ok_or_else(|| anyhow!("project source root must be a string"))? + .to_owned(), + containers: true, + recipes: true, + }); + } + } + if let Some(declared) = sources.get("entries") { + for entry in declared + .as_array() + .ok_or_else(|| anyhow!("project source entries must be an array"))? + { + let entry = entry + .as_table() + .ok_or_else(|| anyhow!("project source entry must be a table"))?; + let path = entry + .get("path") + .and_then(toml::Value::as_str) + .ok_or_else(|| anyhow!("project source entry requires a path string"))?; + let capability = |name: &str| -> Result { + match entry.get(name) { + None => Ok(true), // Published legacy entries grant both by default. + Some(value) => value + .as_bool() + .ok_or_else(|| anyhow!("project source {name} must be a boolean")), + } + }; + entries.push(ProjectSourceEntry { + path: path.to_owned(), + containers: capability("containers")?, + recipes: capability("recipes")?, + }); + } + } + normalize(entries) +} + +fn normalize(requested: Vec) -> Result> { + let mut entries: Vec = Vec::new(); + for mut entry in requested { + entry.path = entry.path.trim().to_owned(); + if entry.path.is_empty() || !Path::new(&entry.path).is_absolute() { + bail!("project source paths must be nonempty and absolute"); + } + if !entry.containers && !entry.recipes { + bail!("project source must enable containers, recipes, or both"); + } + if let Some(existing) = entries + .iter_mut() + .find(|candidate| candidate.path == entry.path) + { + existing.containers |= entry.containers; + existing.recipes |= entry.recipes; + } else { + entries.push(entry); + } + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn grant_rollback_removes_only_its_new_root_and_refuses_newer_changes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let change = + begin_project_source_grant_at(&path, grant("/projects/first", false, true)).unwrap(); + merge_at(&path, grant("/projects/other", true, false)).unwrap(); + change.rollback().unwrap(); + assert_eq!( + settings_at(&path).unwrap().entries, + vec![grant("/projects/other", true, false)] + ); + let change = + begin_project_source_grant_at(&path, grant("/projects/first", false, true)).unwrap(); + merge_at(&path, grant("/projects/first", true, false)).unwrap(); + let before = fs::read_to_string(&path).unwrap(); + assert!(change.rollback().is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), before); + } + + fn grant(path: &str, containers: bool, recipes: bool) -> ProjectSourceEntry { + ProjectSourceEntry { + path: path.into(), + containers, + recipes, + } + } + + #[test] + fn legacy_forms_merge_without_losing_capability_flags() { + let document: toml::Value = r#" +[desktop.project_sources] +roots = ["/projects/legacy"] +[[desktop.project_sources.entries]] +path = "/projects/one" +containers = true +recipes = false +[[desktop.project_sources.entries]] +path = " /projects/one " +containers = false +recipes = true +[[desktop.project_sources.entries]] +path = "/projects/default" +"# + .parse() + .unwrap(); + assert_eq!( + entries_from_document(&document).unwrap(), + vec![ + grant("/projects/legacy", true, true), + grant("/projects/one", true, true), + grant("/projects/default", true, true), + ] + ); + } + + #[test] + fn malformed_grants_fail_closed_and_are_not_overwritten() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + for body in [ + "desktop = false", "[desktop]\nproject_sources = []", + "[desktop.project_sources]\nroots = false", + "[desktop.project_sources]\nroots = [7]", + "[desktop.project_sources]\nentries = false", + "[desktop.project_sources]\nentries = [7]", + "[[desktop.project_sources.entries]]\ncontainers = true", + "[[desktop.project_sources.entries]]\npath = '/projects/one'\ncontainers = 'false'", + "[[desktop.project_sources.entries]]\npath = '/projects/one'\ncontainers = false\nrecipes = false", + "[desktop.project_sources]\nroots = ['relative']", + "[desktop.project_sources]\nroots = ['']", + ] { + fs::write(&path, body).unwrap(); + assert!(settings_at(&path).is_err(), "{body}"); + assert!(merge_at(&path, grant("/projects/new", true, true)).is_err(), "{body}"); + assert_eq!(fs::read_to_string(&path).unwrap(), body); + } + } + + #[test] + fn mutation_preserves_unrelated_settings_and_revokes_missing_sources() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + fs::write(&path, "[models.default]\nmodel = 'keep'\n[desktop.project_sources]\nroots = ['/missing/old']\nscan_hint = 'keep'").unwrap(); + merge_at(&path, grant("/missing/new", true, false)).unwrap(); + merge_at(&path, grant("/missing/new", false, true)).unwrap(); + let result = forget_at(&path, " /missing/old ").unwrap(); + assert_eq!(result.entries, vec![grant("/missing/new", true, true)]); + let document = read_toml(&path).unwrap(); + assert_eq!( + document["models"]["default"]["model"].as_str(), + Some("keep") + ); + assert_eq!( + document["desktop"]["project_sources"]["scan_hint"].as_str(), + Some("keep") + ); + assert!(document["desktop"]["project_sources"] + .get("roots") + .is_none()); + assert_eq!(settings_at(&path).unwrap(), result); + } + + #[test] + fn concurrent_admissions_preserve_every_grant() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let threads: Vec<_> = (0..12) + .map(|index| { + let path = path.clone(); + std::thread::spawn(move || { + merge_at(&path, grant(&format!("/projects/{index}"), true, false)).unwrap() + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + let entries = settings_at(&path).unwrap().entries; + assert_eq!(entries.len(), 12); + for index in 0..12 { + assert!(entries.contains(&grant(&format!("/projects/{index}"), true, false))); + } + } +} diff --git a/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs b/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs index 9174e3c1d..1b587e2da 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs @@ -151,7 +151,7 @@ impl VisualRegistry { if !optimizer_evidence_gate_ready && !authoring_gate_ready { bail!("visual revision has not passed the E1 quality gate"); } - let frozen_bindings = freeze_bindings(bindings)?; + let (frozen_bindings, mut views) = super::seal_evidence::freeze(self, bindings, &visual.id, revision).await?; let annotations = self .annotations(visual_id.clone()) .await? @@ -176,7 +176,7 @@ impl VisualRegistry { "builder_run_id": builder_run_id, }); let limitations = declared_limitations(&frozen_bindings); - let data = json!({ + let mut data = json!({ "schema_version": BUNDLE_SCHEMA, "artifact_id": artifact_id, "source": source_identity, @@ -189,6 +189,22 @@ impl VisualRegistry { "claims": [], "limitations": limitations, }); + let folded_live = !views.is_empty(); + views.extend(super::seal_evidence::locate_sealed_projections(&data["bindings"])); + if !views.is_empty() { + let mut produced_by = json!({ "compiler": COMPILER_NAME, + "compiler_version": env!("CARGO_PKG_VERSION"), "template_id": source.template_id }); + if folded_live { produced_by["fold"] = json!("stream_fold::project_live_eval"); } + data["projection"] = json!({ + "schema_version": "synth.visual-projection.v1", + "produced_by": produced_by, + "views": views, + }); + } + if let Some(source) = super::seal_template::embedded_source(&source.template_id, + visual.metadata.pointer("/qualityGate/certificationIdentity/templateDigest").and_then(Value::as_str))? { + data["template_source"] = source; + } scan_forbidden(&data, "$")?; let data_bytes = canonical_json(&data)?; let runtime_digest = hex_sha256(FROZEN_RUNTIME.as_bytes()); @@ -922,43 +938,11 @@ fn optimizer_runtime_evidence_rejected(summary: &Value) -> bool { }) } -fn freeze_bindings(mut value: Value) -> Result { - fn walk(value: &mut Value) -> Result<()> { - match value { - Value::Object(object) => { - if object.get("kind").and_then(Value::as_str) == Some("live_sse") { - let snapshot = object - .remove("snapshot") - .ok_or_else(|| anyhow!("live SSE binding has no frozen snapshot"))?; - object.insert("kind".into(), Value::String("inline".into())); - object.insert("data".into(), snapshot); - object.remove("source"); - object.remove("poll_url"); - object.remove("pollUrl"); - } - for child in object.values_mut() { - walk(child)?; - } - } - Value::Array(items) => { - for child in items { - walk(child)?; - } - } - _ => {} - } - Ok(()) - } - walk(&mut value)?; - if value.get("inputs").is_some() || value.get("slots").is_some() { - if let Some(object) = value.as_object_mut() { - object - .entry("schemaVersion") - .or_insert_with(|| json!(super::VISUAL_BINDINGS_SCHEMA_VERSION)); - } - return Ok(super::canonicalize_bindings(&value)?.value); - } - Ok(value) +#[cfg(test)] +fn freeze_bindings(value: Value) -> Result { + let directory = tempfile::tempdir()?; + let content = crate::storage::ContentStore::new(directory.path().to_path_buf()); + super::seal_evidence::freeze_fixture(value, &content, "fixture", 1).map(|frozen| frozen.0) } fn evidence_refs(visual: &super::VisualRecord) -> Vec { @@ -994,11 +978,18 @@ fn declared_limitations(value: &Value) -> Vec { out } -fn scan_forbidden(value: &Value, path: &str) -> Result<()> { +pub(super) fn scan_forbidden(value: &Value, path: &str) -> Result<()> { match value { Value::Object(object) => { for (key, child) in object { let normalized = key.to_ascii_lowercase().replace('-', "_"); + // Trace V5 identifiers and boolean frame markers are not + // process-environment dumps. Keep scanning reference strings; + // objects and other environment-shaped keys remain forbidden. + let benign_environment_field = (normalized == "environment_ref" && child.is_string()) + || (normalized == "environment_frame" && child.is_boolean()) + || (normalized == "environment_events" && matches!(child.as_str(), + Some("complete" | "partial" | "aggregate_only" | "unavailable" | "not_captured" | "unsupported"))); if [ "api_key", "access_token", @@ -1016,7 +1007,7 @@ fn scan_forbidden(value: &Value, path: &str) -> Result<()> { "object_key", ] .iter() - .any(|needle| normalized.contains(needle)) + .any(|needle| normalized.contains(needle) && !(benign_environment_field && *needle == "environment")) { bail!("seal policy forbids {path}.{key}"); } @@ -1042,7 +1033,9 @@ fn scan_forbidden(value: &Value, path: &str) -> Result<()> { } fn build_index_html(data: &Value, runtime_digest: &str) -> Result { - let inline = serde_json::to_string(data)?.replace("Sealed Workshop visual
"# @@ -1184,7 +1177,7 @@ fn canonical_json(value: &Value) -> Result> { Ok(bytes) } -fn hex_sha256(bytes: &[u8]) -> String { +pub(super) fn hex_sha256(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); format!("{:x}", hasher.finalize()) @@ -1218,7 +1211,21 @@ mod tests { #[test] fn redaction_and_network_policy_fail_closed() { assert!(scan_forbidden(&json!({"api_key":"nope"}), "$").is_err()); + assert!(scan_forbidden(&json!({"environment_ref":"env:dungeongrid_gold"}), "$").is_ok()); + assert!(scan_forbidden(&json!({"environment_frame":true}), "$").is_ok()); + assert!(scan_forbidden(&json!({"environment_events":"partial"}), "$").is_ok()); + assert!(scan_forbidden(&json!({"environment_events":"private arbitrary text"}), "$").is_err()); + assert!(scan_forbidden(&json!({"environment_frame":{"secret":"nope"}}), "$").is_err()); + assert!(scan_forbidden(&json!({"environment_ref":{"API_KEY":"nope"}}), "$").is_err()); + assert!(scan_forbidden(&json!({"environment_ref":"s3://private/object"}), "$").is_err()); + assert!(scan_forbidden(&json!({"environment":{"PATH":"private"}}), "$").is_err()); + assert!(scan_forbidden(&json!({"kind":"inline","evidence":{"origin":"trace_inventory"},"data":{"api_key":"nope"}}), "$").is_err()); assert!(refuse_network_html("").is_err()); + let data = json!({"text":""]], strings: ['"', "'"] }), + hcl: grammar({ + lineComment: ["#", "//"], + blockComment: C_LIKE_BLOCK, + keywords: words("resource variable module output provider data locals terraform true false null for in if") + }), + docker: grammar({ + lineComment: ["#"], + keywords: words(`FROM RUN CMD LABEL EXPOSE ENV ADD COPY ENTRYPOINT VOLUME USER WORKDIR ARG ONBUILD + STOPSIGNAL HEALTHCHECK SHELL AS`) + }), + make: grammar({ lineComment: ["#"], keywords: words(".PHONY include ifeq ifneq endif else define endef export") }) +}; + +const ALIASES: Record = { + tsx: "typescript", + jsx: "typescript", + javascript: "typescript", + js: "typescript", + ts: "typescript", + mjs: "typescript", + cjs: "typescript", + py: "python", + rs: "rust", + sh: "shell", + bash: "shell", + zsh: "shell", + console: "shell", + cpp: "c", + "c++": "c", + h: "c", + hpp: "c", + kotlin: "java", + swift: "java", + yml: "yaml", + svg: "html", + xml: "html", + terraform: "hcl", + tf: "hcl", + dockerfile: "docker", + makefile: "make" +}; + +/** Whether this build can colour a language, for the badge's benefit. */ +export function isHighlightable(language: string): boolean { + const key = language.toLowerCase(); + return Boolean(GRAMMARS[ALIASES[key] ?? key]); +} + +const IDENTIFIER = /^[A-Za-z_$][\w$]*/; +const NUMBER = /^(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?\d+)?)[a-zA-Z_]*/; +const PUNCTUATION = /^[{}()[\].,;:!?<>=+\-*/%&|^~@#$]/; + +/** + * Line-oriented, because a diff's meaning is the line prefix and nothing else. + * Running the generic lexer over a patch would colour `-` as an operator and + * lose the only signal the reader wants. + */ +function highlightDiff(source: string): Token[] { + return source.split("\n").flatMap((line, index) => { + const kind: TokenKind = line.startsWith("+++") || line.startsWith("---") + ? "meta" + : line.startsWith("@@") + ? "meta" + : line.startsWith("+") + ? "inserted" + : line.startsWith("-") + ? "deleted" + : "plain"; + const token: Token = { kind, value: line }; + return index === 0 ? [token] : [{ kind: "plain" as TokenKind, value: "\n" }, token]; + }); +} + +/** + * Tokenize `source` for display. + * + * An unknown language is not an error and not a blank pane: it returns one + * plain token, which renders as monospaced, selectable, uncoloured text. + */ +export function highlight(source: string, language: string): Token[] { + const key = language.toLowerCase(); + if (key === "diff" || key === "patch") return highlightDiff(source); + const spec = GRAMMARS[ALIASES[key] ?? key]; + if (!spec) return source ? [{ kind: "plain", value: source }] : []; + + const tokens: Token[] = []; + let plain = ""; + let index = 0; + const push = (kind: TokenKind, value: string) => { + if (plain) { + tokens.push({ kind: "plain", value: plain }); + plain = ""; + } + tokens.push({ kind, value }); + }; + + while (index < source.length) { + const rest = source.slice(index); + const atLineStart = index === 0 || source[index - 1] === "\n"; + + if (atLineStart && spec.metaLine?.test(rest.slice(0, rest.indexOf("\n") + 1 || undefined))) { + const end = rest.indexOf("\n"); + const line = end === -1 ? rest : rest.slice(0, end); + push("meta", line); + index += line.length; + continue; + } + + const lineComment = spec.lineComment.find((marker) => rest.startsWith(marker)); + if (lineComment) { + const end = rest.indexOf("\n"); + const value = end === -1 ? rest : rest.slice(0, end); + push("comment", value); + index += value.length; + continue; + } + + const block = spec.blockComment.find(([open]) => rest.startsWith(open)); + if (block) { + const close = source.indexOf(block[1], index + block[0].length); + const end = close === -1 ? source.length : close + block[1].length; + push("comment", source.slice(index, end)); + index = end; + continue; + } + + const multiline = spec.multilineStrings.find(([open]) => rest.startsWith(open)); + if (multiline) { + const close = source.indexOf(multiline[1], index + multiline[0].length); + const end = close === -1 ? source.length : close + multiline[1].length; + push("string", source.slice(index, end)); + index = end; + continue; + } + + const quote = spec.strings.find((mark) => rest.startsWith(mark)); + if (quote) { + let cursor = index + quote.length; + while (cursor < source.length) { + if (source[cursor] === "\\") { + cursor += 2; + continue; + } + if (source.startsWith(quote, cursor)) { + cursor += quote.length; + break; + } + // An unterminated string ends at the line, not at the file: + // one stray quote must not paint the rest of the document. + if (source[cursor] === "\n" && quote !== "`") break; + cursor += 1; + } + push("string", source.slice(index, cursor)); + index = cursor; + continue; + } + + const number = NUMBER.exec(rest); + if (number && !/[\w$]/.test(source[index - 1] ?? "")) { + push("number", number[0]); + index += number[0].length; + continue; + } + + const identifier = IDENTIFIER.exec(rest); + if (identifier) { + const value = identifier[0]; + const after = rest.slice(value.length).match(/^\s*/)![0].length; + const next = rest[value.length + after]; + const kind: TokenKind = spec.keywords.has(value) + ? "keyword" + : spec.types.has(value) + ? "type" + : next === "(" + ? "function" + : /^[A-Z]/.test(value) && spec.types.size > 0 + ? "type" + : "plain"; + if (kind === "plain") plain += value; + else push(kind, value); + index += value.length; + continue; + } + + if (PUNCTUATION.test(rest)) { + push("punctuation", rest[0]); + index += 1; + continue; + } + + plain += source[index]; + index += 1; + } + + if (plain) tokens.push({ kind: "plain", value: plain }); + return tokens; +} diff --git a/apps/synth_desktop/src/renderer/src/documents/markdown.ts b/apps/synth_desktop/src/renderer/src/documents/markdown.ts new file mode 100644 index 000000000..25715d0e6 --- /dev/null +++ b/apps/synth_desktop/src/renderer/src/documents/markdown.ts @@ -0,0 +1,470 @@ +/** + * Markdown → node tree. No dependency, and no HTML. + * + * Two reasons this is written rather than installed. The build cannot add a + * package right now, so an unverifiable dependency would be a claim rather + * than a fact. And every renderer worth installing hands back an HTML string, + * which in this pane would mean `dangerouslySetInnerHTML` over bytes read from + * a file an agent chose — the exact shape the visuals boundary exists to + * refuse. This produces a typed tree the pane renders as React elements, so a + * `\n\n\n'; + const blocks = parseMarkdown(hostile); + const flattened = JSON.stringify(blocks); + // The angle brackets survive as literal text in a `text` node — they are + // never a tag, and there is no field anywhere that holds markup. + assert.equal(flattened.includes(""), true); + + const tokens = highlight(hostile, "html"); + assert.equal(tokens.map((token) => token.value).join(""), hostile); + assert.equal(tokens.every((token) => typeof token.value === "string"), true); +}); diff --git a/apps/synth_desktop/tests/playwright/annotation-paid-card.spec.ts b/apps/synth_desktop/tests/playwright/annotation-paid-card.spec.ts index b8e0f7aac..1d396a2c4 100644 --- a/apps/synth_desktop/tests/playwright/annotation-paid-card.spec.ts +++ b/apps/synth_desktop/tests/playwright/annotation-paid-card.spec.ts @@ -27,6 +27,7 @@ test("annotation campaign paid card click-through resolves once", async ({ page journalEvent(3, "approval.requested", { approvalId: "appr_annotation_campaign_1", kind: "paid_compute", + approvalDigest: "sha256:annotation-reviewed", operation: "annotation.post_rollout_campaign", requestingAgent: "eval-worker", estimatedCostUsdMicros: 2_000_000, @@ -42,7 +43,7 @@ test("annotation campaign paid card click-through resolves once", async ({ page ]; await page.addInitScript(({ rows }) => { type Event = { sessionId: string; method: string; params: Record }; - const decisions: Array<{ sessionId: string; approvalId: string; decision: string }> = []; + const decisions: Array<{ sessionId: string; approvalId: string; decision: string; approvalDigest?: string }> = []; (window as typeof window & { __approvalDecisions?: () => typeof decisions }).__approvalDecisions = () => decisions; (window as typeof window & { synthLaguna?: unknown }).synthLaguna = { getStatus: async () => ({ @@ -73,8 +74,8 @@ test("annotation campaign paid card click-through resolves once", async ({ page start: async () => ({ sessionId: "annotation-paid-card-session", threadId: "annotation-paid-card-thread" }), startTurn: async () => ({ sessionId: "annotation-paid-card-session", threadId: "annotation-paid-card-thread", turnId: "turn-annotation" }), interrupt: async () => undefined, - resolveApproval: async (id: string, approvalId: string, decision: string) => { - decisions.push({ sessionId: id, approvalId, decision }); + resolveApproval: async (id: string, approvalId: string, decision: string, approvalDigest?: string) => { + decisions.push({ sessionId: id, approvalId, decision, approvalDigest }); }, close: async () => undefined, onEvent: (_next: (event: Event) => void) => () => undefined @@ -102,6 +103,7 @@ test("annotation campaign paid card click-through resolves once", async ({ page const modal = page.getByTestId("paid-compute-approval-modal"); await expect(modal).toBeVisible(); + await expect(modal).toHaveAttribute("data-approval-digest", "sha256:annotation-reviewed"); await expect(modal).toContainText("Approve this paid annotation?"); await expect(modal).toContainText("evals-banking77"); await expect(modal).toContainText("annotation.post_rollout_campaign"); @@ -111,6 +113,35 @@ test("annotation campaign paid card click-through resolves once", async ({ page await modal.getByRole("button", { name: "Approve", exact: true }).click(); await expect(modal).toBeHidden(); expect(await page.evaluate(() => (window as typeof window & { __approvalDecisions: () => unknown[] }).__approvalDecisions())).toEqual([ - { sessionId, approvalId: "appr_annotation_campaign_1", decision: "once" } + { sessionId, approvalId: "appr_annotation_campaign_1", decision: "once", approvalDigest: "sha256:annotation-reviewed" } ]); }); + +test("operator training approval forwards the viewed digest without a chat", async ({ page }) => { + await page.addInitScript(() => { + const listeners: Array<(event: unknown) => void> = []; + const decisions: unknown[] = []; + (window as any).__operatorProof = { emit: (event: unknown) => listeners.forEach(next => next(event)), decisions, ready: () => listeners.length > 0 }; + (window as any).synthCore = { onEvent: (next: (event: unknown) => void) => { listeners.push(next); return () => undefined; } }; + (window as any).synthCodex = { + list: async () => [], defaultWorkspace: async () => "/workspaces/operator-test", + onEvent: () => () => undefined, + resolveApproval: async (...args: unknown[]) => { decisions.push(args); } + }; + }); + await page.reload(); + await page.waitForFunction(() => (window as any).__operatorProof.ready()); + await page.evaluate(() => (window as any).__operatorProof.emit({ + schemaVersion: "synth.desktop-app-event.v1", eventId: "operator-approval-event", + sessionId: "operator-training-digest-proof", source: "codex", sequence: 1, sessionSequence: 1, + createdAt: "2026-09-10T12:00:00Z", kind: "approval.requested", + payload: { approvalId: "operator-approval", kind: "paid_compute", operation: "optimizer.train", + preparationDigest: "sha256:operator-reviewed", requestedCap: { maxCostUsdMicros: 10000 }, requestingAgent: "operator" } + })); + const modal = page.getByTestId("paid-compute-approval-modal"); + await expect(modal).toHaveAttribute("data-approval-digest", "sha256:operator-reviewed"); + await modal.getByRole("button", {name: "Approve", exact: true}).click(); + await expect.poll(() => page.evaluate(() => (window as any).__operatorProof.decisions)).toEqual([ + ["operator-training-digest-proof", "operator-approval", "once", "sha256:operator-reviewed"] + ]); +}); diff --git a/apps/synth_desktop/tests/playwright/frozen-artifact.spec.ts b/apps/synth_desktop/tests/playwright/frozen-artifact.spec.ts new file mode 100644 index 000000000..209d36703 --- /dev/null +++ b/apps/synth_desktop/tests/playwright/frozen-artifact.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +test("native sealed live evidence renders offline without the app or producer", async ({ page }) => { + const file = process.env.WORKSHOP_TEST_SEAL_EXPORT; + expect(file, "Run the native registry seal test with WORKSHOP_TEST_SEAL_EXPORT first").toBeTruthy(); + const errors: string[] = []; + const network: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.route("**/*", route => { network.push(route.request().url()); return route.abort(); }); + await page.setContent(readFileSync(file!, "utf8")); + await expect(page.getByRole("heading", { name: "Offline live evidence" })).toBeVisible(); + const metric = (name: string) => page.locator("dt").filter({ hasText: new RegExp(`^${name}$`) }).locator("xpath=following-sibling::dd[1]"); + await expect(metric("projected envelopes")).toHaveText("2"); + await expect(metric("reward")).toHaveText("0.75"); + await expect(metric("cost \\(usd\\)")).toHaveText("—"); + await expect(page.locator(".visual pre")).toHaveCount(0); + expect(network).toEqual([]); + expect(errors).toEqual([]); + await page.screenshot({ path: resolve("test-results/frozen-live-evidence.png"), fullPage: true }); +}); diff --git a/apps/synth_desktop/tests/playwright/frozen-trace-artifact.spec.ts b/apps/synth_desktop/tests/playwright/frozen-trace-artifact.spec.ts new file mode 100644 index 000000000..82afcdb03 --- /dev/null +++ b/apps/synth_desktop/tests/playwright/frozen-trace-artifact.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +test("captured Trace V5 native export remains interactive without a producer", async ({page}) => { + const path = process.env.WORKSHOP_TEST_TRACE_SEAL_EXPORT; + if (!path) throw new Error("WORKSHOP_TEST_TRACE_SEAL_EXPORT must name the real native exported HTML"); + const errors: string[] = [], requests: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.route("**/*", route => { requests.push(route.request().url()); return route.abort(); }); + await page.setContent(await readFile(path, "utf8")); + await expect(page.getByRole("heading", {name:"Captured trace offline",exact:true}).first()).toBeVisible(); + const inspector=page.getByTestId("visual-trace-rollout-inspector"); + await expect(inspector).toBeVisible(); + await inspector.locator('[data-density="full"]').click(); + await expect(inspector.locator(".sv-count")).toContainText("complete projection"); + const itemCount=await inspector.locator('[data-role="events"]').evaluate(node => node.children.length); + expect(itemCount).toBeGreaterThan(0); + await expect(inspector.getByText("No projected items match these filters.")).toHaveCount(0); + await inspector.locator('[data-tab="metadata"]').click(); + await expect(inspector.locator('[data-tab="metadata"]')).toHaveClass("active"); + await inspector.locator('[data-tab="trace"]').click(); + expect(errors).toEqual([]); expect(requests).toEqual([]); + await inspector.screenshot({path:resolve("test-results/frozen-captured-trace.png")}); +}); diff --git a/apps/synth_desktop/tests/playwright/host-live-evidence.spec.ts b/apps/synth_desktop/tests/playwright/host-live-evidence.spec.ts new file mode 100644 index 000000000..b29c50df8 --- /dev/null +++ b/apps/synth_desktop/tests/playwright/host-live-evidence.spec.ts @@ -0,0 +1,60 @@ +import { resolve } from "node:path"; +import { test, expect } from "./browser.fixture"; + +for (const mode of ["reordered", "truncated", "browser", "failure"] as const) { + test(`live eval shell consumes ${mode} evidence through its real hook`, async ({ page }) => { + const shellPath = resolve(import.meta.dirname, "../../../../packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/shell.tsx"); + await page.evaluate(async ({ shellPath, mode }) => { + const entry = await fetch("/src/main.tsx").then(r => r.text()); + const specifiers = entry.split('"').filter(token => token.startsWith("/")); + const dependency = (suffix: string) => { + const match = specifiers.find(token => token.split("?")[0].endsWith(suffix)); + if (!match) throw new Error(`Missing Vite dependency ${suffix}`); + return match; + }; + const [reactModule, domModule, shell] = await Promise.all([ + import(/* @vite-ignore */ dependency("/react.js")), + import(/* @vite-ignore */ dependency("react-dom_client.js")), + import(/* @vite-ignore */ `/@fs${shellPath}`) + ]); + const react = reactModule.createElement ? reactModule : reactModule.default; + const dom = domModule.createRoot ? domModule : domModule.default; + const streams = [{streamId:"a",pollUrl:"/test-a"},{streamId:"b",pollUrl:"/test-b"}]; + const replay = { streams, async poll(stream: {streamId: string}) { + const older = stream.streamId === "a"; + if (older) { + await new Promise(resolve => setTimeout(resolve, 100)); + document.getElementById("host-evidence-test")!.dataset.delayed = "done"; + } + if (mode === "failure" && !older) throw new Error("test poll failed"); + const counts = older ? [1,0] : [1,1]; + return { events:[{kind:"reward_signal",event_id:stream.streamId,rollout_id:stream.streamId,sequence:1,payload:{reward:.1}}], + cursor:{next:1,hasMore:false,closed:true}, + ...(mode === "browser" ? {} : { + projection:{schema_version:"synth.live-eval-projection.v1",event_count:older?1:2, + kinds:["reward_signal"],reward:older ? .2 : .75,usage:null,has_live_frames:false,has_reward_txt:false}, + evidenceTruncated: mode === "truncated", + receipt:{schemaVersion:"synth.visual-stream-receipt.v1",visualId:"hook-test",revision:1, + ready:true,recovered:older?1:2,declaredStreamCount:2,respondingStreamCount:older?1:2, + streamsMissingTransport:[],gaps:[],conflicts:[], + streams:streams.map((row,index)=>({streamId:row.streamId,declaredSource:row.pollUrl,pollResponses:counts[index]}))} + })}; + }}; + const host=document.createElement("div"); host.id="host-evidence-test"; document.body.appendChild(host); + dom.createRoot(host).render(react.createElement(shell.Shell,{replay,visualId:"hook-test",revision:1,title:"Host evidence integration"})); + }, {shellPath,mode}); + const host=page.locator("#host-evidence-test"); + await expect(host).toHaveAttribute("data-delayed", "done"); + if (mode === "failure") { + await expect(host.getByRole("alert")).toHaveText("test poll failed"); + await expect(host.getByTestId("compose-metrics-count")).toHaveText("0"); + return; + } + await expect(host.getByTestId("compose-event-stream").locator("button[data-event-kind]")).toHaveCount(2); + await expect(host.getByTestId("compose-event-stream").getByText("terminal", {exact:true})).toBeVisible(); + await expect(host.getByTestId("compose-metrics-count")).toHaveText("2"); + await expect(host.getByTestId("compose-metrics-scalar")).toHaveText(mode === "browser" ? "0.10" : "0.75"); + if (mode === "truncated") await expect(host.getByText(/Host evidence is truncated/)).toBeVisible(); + await host.screenshot({path:resolve("test-results", `host-live-evidence-${mode}.png`)}); + }); +} diff --git a/apps/synth_desktop/tests/playwright/project-sources-settings.spec.ts b/apps/synth_desktop/tests/playwright/project-sources-settings.spec.ts new file mode 100644 index 000000000..73a41eb3e --- /dev/null +++ b/apps/synth_desktop/tests/playwright/project-sources-settings.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "./browser.fixture"; +import type { Page } from "@playwright/test"; + +async function installSourceHost(page: Page) { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.evaluate(() => { + const host = window as typeof window & { __sourceCalls?: Array<{ name: string; args: any }>; __sourceApproval?: string; __TAURI_INTERNALS__?: unknown }; + const row = (path: string, origin = "configured") => ({ path, containers: true, recipes: true, origin, + inspection: { path, status: "valid", code: null, message: null, containers: ["fixture"], recipes: ["eval.fixture"] }, lastScannedAt: null }); + const catalog = { configPath: "/fixture/config.toml", sources: [row("/fixture/approved")], implicitRoots: [row("/fixture/launcher", "environment")] }; + let requests = [{ id: "request-1", sessionId: "chat-1", requestedPath: "/fixture/requested", canonicalPath: "/fixture/requested", + reason: "Run the declared fixture", containers: false, recipes: true, attachToConversation: true, status: "pending", createdAt: "2026-09-10", resolvedAt: null }]; + host.__sourceCalls = []; + host.__sourceApproval = "cancel"; + (window as any).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => undefined }; + host.__TAURI_INTERNALS__ = { transformCallback: () => 1, invoke: async (name: string, args: any) => { + if (name === "plugin:event|listen") return 1; + if (name === "plugin:event|unlisten") return; + host.__sourceCalls!.push({ name, args }); + if (name === "project_sources_get" || name === "project_sources_refresh") return structuredClone(catalog); + if (name === "project_source_requests_list") return structuredClone(requests); + if (name === "project_source_add") { catalog.sources.push({ ...row("/fixture/new"), containers: args.containers, recipes: args.recipes }); return structuredClone(catalog); } + if (name === "project_source_remove") { catalog.sources = catalog.sources.filter((source) => source.path !== args.path); return structuredClone(catalog); } + if (name === "project_source_deny") { const request = requests[0]; requests = []; return { ...request, status: "denied", resolvedAt: "2026-09-10" }; } + if (name === "project_source_approve") { + if (host.__sourceApproval === "cancel") return null; + if (host.__sourceApproval === "mismatch") throw new Error("selected folder does not match the exact requested folder"); + const request = { ...requests[0], status: "approved", resolvedAt: "2026-09-10" }; + requests = []; catalog.sources.push(row("/fixture/requested")); + return structuredClone({ request, catalog, source: catalog.sources.at(-1), scope: null, attachmentError: "Source approved, but conversation attachment failed: fixture failure" }); + } + throw new Error(`Unexpected native call: ${name}`); + } }; + }); + await page.getByTestId("account-menu-trigger").click(); + await page.getByTestId("account-menu-settings").click(); + await page.getByTestId("settings-nav-workspace").click(); + await expect(page.getByTestId("project-source-request")).toBeVisible(); + return pageErrors; +} + +test("source controls preserve capability choices and distinguish launcher grants", async ({ page }) => { + const errors = await installSourceHost(page); + const panel = page.getByTestId("project-sources-settings"); + await expect(panel.getByText("Not scanned yet")).toHaveCount(0); + await expect(panel.getByRole("button", { name: "Remove project source /fixture/launcher", exact: true })).toHaveCount(0); + await panel.getByRole("checkbox", { name: "Recipes", exact: true }).uncheck(); + await panel.getByTestId("add-project-source").click(); + await expect(panel.getByRole("button", { name: "Remove project source /fixture/new", exact: true })).toBeVisible(); + const calls = await page.evaluate(() => (window as any).__sourceCalls); + expect(calls.find((call: any) => call.name === "project_source_add").args).toEqual({ containers: true, recipes: false }); + await panel.getByRole("button", { name: "Remove project source /fixture/new", exact: true }).click(); + await expect(panel.getByRole("button", { name: "Remove project source /fixture/new", exact: true })).toHaveCount(0); + await panel.getByRole("button", { name: "Deny", exact: true }).click(); + await expect(panel.getByText("No pending source requests.")).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("unavailable native controls do not present an empty request list as verified", async ({ page }) => { + await page.getByTestId("account-menu-trigger").click(); + await page.getByTestId("account-menu-settings").click(); + await page.getByTestId("settings-nav-workspace").click(); + const panel = page.getByTestId("project-sources-settings"); + await expect(panel.getByRole("alert")).toContainText("require Synth Desktop"); + await expect(panel.getByText("Source requests are unavailable.")).toBeVisible(); + await expect(panel.getByText("No pending source requests.")).toHaveCount(0); +}); + +test("cancelled or mismatched picker does not settle a request; partial attachment failure is visible", async ({ page }) => { + const errors = await installSourceHost(page); + const panel = page.getByTestId("project-sources-settings"); + const approve = panel.getByRole("button", { name: "Choose exact folder and approve…", exact: true }); + await expect(panel.getByText(/Also attach with read\/write access to conversation chat-1/)).toBeVisible(); + await approve.click(); + await expect(approve).toBeEnabled(); + await expect(page.getByTestId("project-source-request")).toBeVisible(); + await page.evaluate(() => { (window as any).__sourceApproval = "mismatch"; }); + await approve.click(); + await expect(panel.getByRole("alert")).toContainText("does not match"); + await expect(page.getByTestId("project-source-request")).toBeVisible(); + await page.evaluate(() => { (window as any).__sourceApproval = "approve"; }); + await approve.click(); + await expect(panel.getByRole("status")).toContainText("Source approved, but conversation attachment failed"); + await expect(page.getByTestId("project-source-request")).toHaveCount(0); + const calls = await page.evaluate(() => (window as any).__sourceCalls.filter((call: any) => call.name === "project_source_approve")); + expect(calls).toHaveLength(3); + for (const call of calls) expect(call.args).toEqual({ requestId: "request-1" }); + await panel.screenshot({ path: "test-results/project-sources-settings.png" }); + expect(errors).toEqual([]); +}); diff --git a/apps/synth_desktop/tests/playwright/user-template.spec.ts b/apps/synth_desktop/tests/playwright/user-template.spec.ts new file mode 100644 index 000000000..e56241c7f --- /dev/null +++ b/apps/synth_desktop/tests/playwright/user-template.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "./browser.fixture"; +import { liveVisual, openVisual } from "./v02-helpers"; + +test("instance TSX templates render, reload edits, and reject forbidden source", async ({ page }) => { + const row = liveVisual({id: "vis_user_template", templateId: "user.release-proof.v1", title: "Local template"}); + await page.addInitScript(row => { + const state = { source: 'export default function Shell() { return
First revision
; }', digest: "sha256:first" }; + (window as any).userTemplateProof = state; + const meta = () => ({id: row.templateId, schemaVersion: "synth.visual-template.v1", version: "1.0.0", title: row.title, + genre: "custom", inputs: [], sourceKind: "user", rendererKind: "template", templateDigest: state.digest}); + (window as any).synthVisuals = { + listTemplates: async () => [meta()], getTemplate: async () => meta(), + templateShellSource: async () => state.source, + list: async () => [row], get: async () => row, show: async () => row, + onEvent: () => () => undefined, onShow: () => () => undefined + }; + }, row); + await page.reload(); + const pane = await openVisual(page, row.id); + await expect(pane.getByTestId("user-proof")).toHaveText("First revision"); + await page.evaluate(() => { + (window as any).userTemplateProof.source = 'export default function Shell() { return
Edited revision
; }'; + (window as any).userTemplateProof.digest = "sha256:second"; + }); + await expect(pane.getByTestId("user-proof")).toHaveText("Edited revision"); + await page.evaluate(() => { + (window as any).userTemplateProof.source = 'export default function Shell() { fetch("https://example.test"); return
Must not render
; }'; + (window as any).userTemplateProof.digest = "sha256:third"; + }); + await expect(pane.getByTestId("visual-sourced-invalid")).toContainText("fetch"); + await expect(pane.getByTestId("user-proof")).toHaveCount(0); +}); diff --git a/apps/synth_desktop/tests/release-build-contract.test.mjs b/apps/synth_desktop/tests/release-build-contract.test.mjs index 2d5492e14..79f01cff2 100644 --- a/apps/synth_desktop/tests/release-build-contract.test.mjs +++ b/apps/synth_desktop/tests/release-build-contract.test.mjs @@ -8,6 +8,38 @@ const root = new URL("../../../", import.meta.url); const script = fileURLToPath(new URL("scripts/build-tier.sh", root)); const build = readFileSync(script, "utf8"); +test("desktop patch version agrees across packaging authorities", () => { + const pkg = JSON.parse(readFileSync(new URL("apps/synth_desktop/package.json", root), "utf8")); + const config = JSON.parse(readFileSync(new URL("apps/synth_desktop/src-tauri/tauri.conf.json", root), "utf8")); + const lock = JSON.parse(readFileSync(new URL("package-lock.json", root), "utf8")); + assert.equal(config.version, pkg.version); + assert.equal(lock.packages["apps/synth_desktop"].version, pkg.version); + for (const file of ["Cargo.toml", "Cargo.lock"]) { + const cargo = readFileSync(new URL(`apps/synth_desktop/src-tauri/${file}`, root), "utf8"); + assert.equal(cargo.match(/name = "synth-desktop"\nversion = "([^"]+)"/)[1], pkg.version); + } +}); + +test("managed MLX and clean-clone builds use one exact source revision", () => { + const runtime = readFileSync(new URL("apps/synth_desktop/src-tauri/src/optimizers/mlx_runtime.rs", root), "utf8"); + const sources = readFileSync(new URL("scripts/prepare-build-sources.sh", root), "utf8"); + const revision = runtime.match(/MLX_RUNTIME_SOURCE_REVISION: &str = "([a-f0-9]{40})"/)[1]; + assert.ok(sources.includes(`fetch_build_source synth-mlx-rl ${revision}`)); + assert.ok(sources.includes(`synth-mlx-rl-${revision}"`)); +}); + +test("the shipped browser is a direct exact dependency, independent of private test tools", () => { + const pkg = JSON.parse(readFileSync(new URL("package.json", root), "utf8")); + const npmLock = JSON.parse(readFileSync(new URL("package-lock.json", root), "utf8")); + const browserLock = JSON.parse(readFileSync(new URL("apps/synth_desktop/browser/runtime.lock.json", root), "utf8")); + const version = browserLock.playwright.version; + assert.equal(pkg.dependencies.playwright, version); + assert.equal(npmLock.packages[""].dependencies.playwright, version); + assert.equal(npmLock.packages["node_modules/playwright"].version, version); + assert.equal(npmLock.packages["node_modules/playwright-core"].version, version); + assert.notEqual(npmLock.packages["node_modules/playwright"].dev, true); +}); + test("macOS browser return is registered, delivered, and never authenticates", () => { const plist = readFileSync(new URL("apps/synth_desktop/src-tauri/Info.plist", root), "utf8"); assert.match(plist, /CFBundleURLTypes[\s\S]*CFBundleURLSchemes[\s\S]*synth-workshop<\/string>/); diff --git a/apps/synth_desktop/tests/report-draft-recovery.test.mjs b/apps/synth_desktop/tests/report-draft-recovery.test.mjs new file mode 100644 index 000000000..96f2871a1 --- /dev/null +++ b/apps/synth_desktop/tests/report-draft-recovery.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { chromium } from "playwright"; + +// Exercise the real Report editor with an in-memory bridge. No native app, +// credentials, network provider, or published Report is involved. +test("Report drafts survive navigation/reload and asynchronous saves", async () => { + const root = fileURLToPath(new URL("../../..", import.meta.url)); + const result = await build({ + absWorkingDir: root, bundle: true, write: false, format: "iife", jsx: "automatic", + stdin: { resolveDir: root, loader: "tsx", contents: ` + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import { ReportsPage } from './apps/synth_desktop/src/renderer/src/components/ReportsPage'; + const records = ['a','b'].map(id => ({ id, title: 'Report '+id, currentRevision: 1, status: 'draft' })); + const revisions = Object.fromEntries(records.map(r => [r.id, { + reportId:r.id, revision:1, schemaVersion:'report.v1', title:r.title, summary:'', + blocks:[{blockId:'findings',anchor:'findings',kind:'report.prose.v1',title:'Findings',payload:{markdown:''}}, + {blockId:'methods',anchor:'methods',kind:'report.prose.v1',title:'Methods',payload:{markdown:''}}], + claims:[], limitations:[] + }])); + window.fixture = { wait:false, fail:false, seals:0, updates:0, bridge:{ + list:async()=>records, listSeals:async()=>[], getRevision:async id=>structuredClone(revisions[id]), + listExperiments:async()=>[], listLog:async()=>[], listVisibilityRequests:async()=>[], + validate:async()=>({sealable:true,findings:[]}), listComments:async()=>[], onEvent:()=>()=>{}, + update:async(id,input)=>{ + window.fixture.updates++; + if(window.fixture.fail) throw new Error('save failed'); + if(window.fixture.wait) await new Promise(resolve=>window.fixture.finish=resolve); + if(input.expectedRevision!==revisions[id].revision) throw new Error('revision conflict'); + Object.assign(revisions[id],input,{revision:revisions[id].revision+1}); + const row=records.find(r=>r.id===id); row.currentRevision=revisions[id].revision; row.title=input.title; + return {...row}; + }, seal:async()=>{window.fixture.seals++; throw new Error('seal should not be reached');} + }}; + createRoot(document.getElementById('root')).render({}} initialReportId="a"/>); + ` }, + plugins: [{ name: "local-fixtures", setup(builder) { + builder.onResolve({ filter: /desktopBridge$|DocumentContent$|^@synth\/visual-templates\// }, args => ({ path: args.path, namespace: "fixture" })); + builder.onLoad({ filter: /.*/, namespace: "fixture" }, args => ({ contents: + args.path.endsWith("desktopBridge") ? "export const bridges={get reports(){return window.fixture.bridge}};" : + args.path.endsWith("DocumentContent") ? "export const Markdown=()=>null;" : "export default ()=>null;", loader: "js" })); + }}] + }); + const server = createServer((req, res) => { + res.setHeader("Content-Type", req.url === "/bundle.js" ? "text/javascript" : "text/html"); + res.end(req.url === "/bundle.js" ? result.outputFiles[0].text : '
'); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.route("**/*", route => new URL(route.request().url()).hostname === "127.0.0.1" ? route.continue() : route.abort()); + await page.goto(`http://127.0.0.1:${server.address().port}`); + const findings = page.getByTestId("reports-findings"); + const selectReport = id => page.getByTestId("reports-grid").getByRole("button").filter({hasText:`Report ${id}`}).click(); + await findings.fill("retained draft"); + await selectReport("b"); + await selectReport("a"); + assert.equal(await findings.inputValue(), "retained draft"); + await page.reload(); + await findings.waitFor(); + assert.equal(await findings.inputValue(), "retained draft"); + + await page.evaluate(() => { window.fixture.wait = true; }); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await page.waitForFunction(() => typeof window.fixture.finish === "function"); + await findings.fill("typed while saving"); + await page.evaluate(() => { window.fixture.finish(); }); + await page.waitForFunction(() => document.body.textContent.includes("Saved · rev 2")); + assert.equal(await findings.inputValue(), "typed while saving"); + + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await page.waitForFunction(() => window.fixture.updates === 2); + await selectReport("b"); + await page.evaluate(() => { window.fixture.finish(); }); + await page.waitForTimeout(50); + assert.equal(await findings.inputValue(), ""); + assert.ok((await page.locator("input").evaluateAll(inputs => inputs.map(input => input.value))).includes("Report b")); + + await selectReport("a"); + await findings.fill("must not seal after failure"); + await page.evaluate(() => { window.fixture.wait = false; window.fixture.fail = true; }); + await page.getByTestId("reports-seal").click(); + await page.getByTestId("reports-error").waitFor(); + assert.equal(await page.evaluate(() => window.fixture.seals), 0); + assert.equal(await findings.inputValue(), "must not seal after failure"); + } finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); + } +}); diff --git a/apps/synth_desktop/tests/user_message_ownership.test.mjs b/apps/synth_desktop/tests/user_message_ownership.test.mjs index d23cb3acc..c8e7bda29 100644 --- a/apps/synth_desktop/tests/user_message_ownership.test.mjs +++ b/apps/synth_desktop/tests/user_message_ownership.test.mjs @@ -113,6 +113,23 @@ test("recognized approval lifecycle events retain explicit Synth labels", () => assert.equal(paid.__active__?.[0]?.label, "Paid compute granted"); }); +test("persistent template approval names exact bytes and destination without remembering", () => { + const activity = eventsToLocalActivity([event({ + sequence: 2, eventKind: "approval.requested", payload: { + approvalId: "template-1", kind: "visual_template_persist", templateId: "reviewed.v1", + destination: "/approved/templates/reviewed.v1", packageDigest: "sha256:reviewed", + byteSize: 123, overwrites: true, alwaysSupported: false + } + })], []); + const line = activity.__active__[0]; + assert.equal(line.label, "Save persistent visual template"); + assert.equal(line.approvalKind, "visual_template_persist"); + assert.equal(line.alwaysAllowSupported, false); + for (const text of ["reviewed.v1", "/approved/templates/reviewed.v1", "sha256:reviewed", "123 bytes", "Replaces existing template", "across sessions and restarts"]) { + assert.ok(line.detail.includes(text), text); + } +}); + test("conversation paid-compute auto-approval stays in the journal, not chat", () => { const activity = eventsToLocalActivity([ event({ diff --git a/docs/launch/v0.10.1-release/RELEASE_NOTES.md b/docs/launch/v0.10.1-release/RELEASE_NOTES.md new file mode 100644 index 000000000..7bf3d92e8 --- /dev/null +++ b/docs/launch/v0.10.1-release/RELEASE_NOTES.md @@ -0,0 +1,21 @@ +# Workshop 0.10.1 — candidate + +This patch consolidates reviewed fixes from the v0.10 development branches. +It is not published until the exact packaged archive passes acceptance. +The published v0.10.0 artifacts remain unchanged. + +- Annotation decisions persist and display in insertion order, including tied + timestamps, without rewriting sealed evidence. +- Report edits recover across navigation and reload in the same browser session. + Saving preserves newer edits and their revision base, and a late save does not + navigate backward. Failed saves cannot silently seal an older draft. +- Root renderer failures offer a reload action. +- Source builds and the managed MLX runtime use the same immutable compatibility + revision, including Workshop model aliases, managed model identity and + serialized policy registration. + +Distribution remains ad-hoc signed and **not Apple-notarized**. Build locally +using `scripts/install.sh` and `scripts/workshop.sh build-and-run`; TBLite is not +a production prerequisite. Report draft persistence uses session storage, not a +guarantee of recovery after a full app quit. AI/provider-backed acceptance is +excluded at the release owner's direction, not reported as passed. diff --git a/package-lock.json b/package-lock.json index 7602f2eaa..022a8ffa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "packages/*", "visuals" ], + "dependencies": { + "playwright": "1.62.1" + }, "devDependencies": { "turbo": "^2.10.9" }, @@ -206,7 +209,7 @@ }, "apps/synth_desktop": { "name": "@synth/synth-desktop", - "version": "0.10.0", + "version": "0.10.1", "dependencies": { "@synth/runtime-protocol": "*", "@synth/visuals": "*", @@ -2369,7 +2372,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.62.1" @@ -2388,7 +2390,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -2401,7 +2402,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 14268733d..92398e1be 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,9 @@ "fsevents@2.3.2": true, "fsevents@2.3.3": true }, + "dependencies": { + "playwright": "1.62.1" + }, "devDependencies": { "turbo": "^2.10.9" } diff --git a/packages/workshop-visuals/chrome/useLiveEvalStreams.ts b/packages/workshop-visuals/chrome/useLiveEvalStreams.ts index 947f684cb..e2d73f879 100644 --- a/packages/workshop-visuals/chrome/useLiveEvalStreams.ts +++ b/packages/workshop-visuals/chrome/useLiveEvalStreams.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { LiveEvalEvent } from "../runtime/types.ts"; import { reportVisualDiagnostic, VISUAL_STREAM_CODES } from "../runtime/diagnostics.ts"; import { emptyLiveIngest, ingestLiveEnvelopeBatch } from "../runtime/liveStream.ts"; +import { acceptHostEvidence, type HostLiveEvidence } from "../runtime/hostLiveEvidence.ts"; import { REPLAY_FIRST_RESPONSE_TIMEOUT_MS, REPLAY_PAGE_LIMIT, @@ -18,6 +19,7 @@ export type LiveEvalStreamsView = { ready: boolean; recovered: number; error: string | null; + hostEvidence?: HostLiveEvidence; }; const POLL_INTERVAL_MS = 500; @@ -49,10 +51,11 @@ export function useLiveEvalStreams( const [ready, setReady] = useState(false); const [recovered, setRecovered] = useState(0); const [error, setError] = useState(null); + const [hostEvidence, setHostEvidence] = useState(); const ingest = useRef(emptyLiveIngest()); const clientRef = useRef(client); clientRef.current = client; - const streamKey = client.streams.map((stream) => stream.streamId).join("\n"); + const streamKey = JSON.stringify(client.streams.map(({streamId, pollUrl, sseUrl}) => [streamId, pollUrl, sseUrl])); const { visualId, revision } = identity; useEffect(() => { @@ -62,6 +65,7 @@ export function useLiveEvalStreams( setRecovered(0); setError(null); setClosed(0); + setHostEvidence(undefined); const streams = clientRef.current.streams; if (streams.length === 0) { @@ -72,12 +76,14 @@ export function useLiveEvalStreams( let stopped = false; let answered = false; + let host: HostLiveEvidence | undefined; let timer: number | undefined; const cursors = new Map(streams.map((stream) => [stream.streamId, 0])); const closedStreams = new Set(); const fail = (message: string, code: string) => { if (stopped) return; + stopped = true; // A late sibling response must not erase this failure. setError(message); setState("error"); reportVisualDiagnostic({ @@ -126,6 +132,8 @@ export function useLiveEvalStreams( if (stopped) return; const page = await clientRef.current.poll(stream, after, REPLAY_PAGE_LIMIT); if (stopped) return; + host = acceptHostEvidence(host, page, streams, { visualId, revision }); + setHostEvidence(host); const firstResponse = !answered; answered = true; pending.push(...page.events); @@ -183,5 +191,7 @@ export function useLiveEvalStreams( // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamKey, visualId, revision]); - return { events, state, closed, ready, recovered, error }; + return { events, state, closed, ready: state !== "error" && (hostEvidence?.ready ?? ready), + recovered: hostEvidence?.recovered ?? recovered, + error: state === "error" ? error : hostEvidence ? hostEvidence.error : error, hostEvidence }; } diff --git a/packages/workshop-visuals/components/metrics.v1/Metrics.tsx b/packages/workshop-visuals/components/metrics.v1/Metrics.tsx index ef51a4b77..3779073bd 100644 --- a/packages/workshop-visuals/components/metrics.v1/Metrics.tsx +++ b/packages/workshop-visuals/components/metrics.v1/Metrics.tsx @@ -1,5 +1,6 @@ import type { LiveEvalEvent } from "../../runtime/types.ts"; import { formatMissingNumber } from "../../runtime/liveStream.ts"; +import type { HostLiveEvalProjection } from "../../runtime/replayClient.ts"; const SCALAR_KEYS = [ "reward", @@ -42,8 +43,10 @@ export function reduceMetricsStrip(events: LiveEvalEvent[]): MetricsStrip { }; } -export function Metrics({ events }: { events: LiveEvalEvent[] }) { - const strip = reduceMetricsStrip(events); +export function Metrics({ events, projection }: { events: LiveEvalEvent[]; projection?: HostLiveEvalProjection }) { + const strip = projection + ? { count: projection.event_count, scalarLabel: "Reward", scalarValue: formatMissingNumber(projection.reward) } + : reduceMetricsStrip(events); return (
diff --git a/packages/workshop-visuals/families/analysis/analysis.annotation_workbench.v1/shell.tsx b/packages/workshop-visuals/families/analysis/analysis.annotation_workbench.v1/shell.tsx index 5419f944d..4dbdfaaae 100644 --- a/packages/workshop-visuals/families/analysis/analysis.annotation_workbench.v1/shell.tsx +++ b/packages/workshop-visuals/families/analysis/analysis.annotation_workbench.v1/shell.tsx @@ -1,5 +1,5 @@ import { useVisualState } from "@synth/visuals-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { MetricStrip, VisualChrome } from "../../../chrome/VisualChrome.tsx"; import type { VisualBinding } from "../../../runtime/types.ts"; import craftaxFixture from "../../../fixtures/annotation_workbench_craftax.json"; @@ -22,6 +22,14 @@ type Finding = { }; type Milestone = { id: string; label: string; state: MilestoneState; engineVerified?: boolean }; type SpanRow = { id: string; sequence?: number; title: string; kind?: string }; +type LocalReview = { + reviewId?: string; + findingId?: string | null; + decision: string; + reviewer?: string | null; + rationale?: string | null; + createdAt?: string; +}; type WorkbenchProjection = { schemaVersion?: string; campaign?: { @@ -59,6 +67,7 @@ type WorkbenchProjection = { criteria?: RubricCriterionInput[]; }; findings?: Finding[]; + reviews?: LocalReview[]; taxonomy?: { label: string; count: number }[]; milestones?: Milestone[]; spans?: SpanRow[]; @@ -173,6 +182,14 @@ export function Shell(props: ShellProps) { const [reviewDecision, setReviewDecision] = useState("flag"); const [reviewRationale, setReviewRationale] = useState(""); const [reviewStatus, setReviewStatus] = useState(null); + const [reviews, setReviews] = useState(projection.reviews ?? []); + const reviewHead = useRef(projection.evidenceHead?.digest); + reviewHead.current = projection.evidenceHead?.digest; + + useEffect(() => { + setReviews(projection.reviews ?? []); + setReviewStatus(null); + }, [projection.reviews, projection.evidenceHead?.digest]); const focusedSpan = selectedSpan || selectorKey(findings.find((row) => row.id === selectedFinding)?.target); const citing = useMemo( @@ -513,15 +530,28 @@ export function Shell(props: ShellProps) { return; } setReviewStatus("Saving…"); - void Promise.resolve(props.onReviewFinding({ + const head = projection.evidenceHead?.digest; + void Promise.resolve().then(() => props.onReviewFinding!({ findingId, decision: reviewDecision, rationale: reviewRationale, evidenceHeadDigest: projection.evidenceHead?.digest })).then(() => { + if (reviewHead.current !== head) return; + setReviews((current) => [ + ...current, + { + findingId, + decision: reviewDecision, + reviewer: "workshop", + rationale: reviewRationale, + createdAt: new Date().toISOString() + } + ]); setReviewStatus(`Recorded ${reviewDecision} on ${findingId}`); setReviewRationale(""); }).catch((reason) => { + if (reviewHead.current !== head) return; setReviewStatus(reason instanceof Error ? reason.message : "Review failed"); }); }} @@ -545,8 +575,10 @@ export function Shell(props: ShellProps) {