From 7a4987f4e701ed853ee73e8df0bceac2069403d0 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 04:07:27 -0400 Subject: [PATCH 01/25] Restore scoped document presentation without regressing v0.10 visuals --- .../src-tauri/src/contract/commands.rs | 3 + .../src/contract/desktop_dispatch.rs | 39 + .../src-tauri/src/contract/desktop_tools.json | 686 ++++++++++++++ .../src-tauri/src/contract/specta.rs | 6 +- .../src-tauri/src/documents/commands.rs | 87 ++ .../src-tauri/src/documents/ipc.rs | 102 +++ .../src-tauri/src/documents/mod.rs | 842 ++++++++++++++++++ apps/synth_desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/presentation.rs | 5 + .../src-tauri/src/presentation/document.rs | 398 +++++++++ .../src-tauri/src/presentation/host.rs | 198 ++++ .../src-tauri/src/visuals/models.rs | 1 + .../src-tauri/src/visuals_ipc.rs | 3 + .../renderer/src/components/ReportsPage.tsx | 11 +- .../renderer/src/components/VisualHost.tsx | 2 + .../renderer/src/documents/DocumentChrome.tsx | 171 ++++ .../src/documents/DocumentContent.tsx | 296 ++++++ .../renderer/src/documents/DocumentPane.css | 417 +++++++++ .../renderer/src/documents/DocumentPane.tsx | 204 +++++ .../src/renderer/src/documents/bridge.ts | 84 ++ .../src/renderer/src/documents/highlight.ts | 348 ++++++++ .../src/renderer/src/documents/markdown.ts | 470 ++++++++++ .../renderer/src/documents/useDocumentTabs.ts | 169 ++++ .../src/renderer/src/generated/protocol.ts | 127 +++ .../tests/document_renderer.test.mjs | 173 ++++ .../document.viewer.v1/template.json | 25 + packages/workshop-visuals/runtime/bind.ts | 4 + packages/workshop-visuals/runtime/types.ts | 1 + visuals/tests/binding_kinds.test.mjs | 11 +- visuals/tests/registry.test.mjs | 7 +- 30 files changed, 4887 insertions(+), 4 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/documents/commands.rs create mode 100644 apps/synth_desktop/src-tauri/src/documents/ipc.rs create mode 100644 apps/synth_desktop/src-tauri/src/documents/mod.rs create mode 100644 apps/synth_desktop/src-tauri/src/presentation/document.rs create mode 100644 apps/synth_desktop/src-tauri/src/presentation/host.rs create mode 100644 apps/synth_desktop/src/renderer/src/documents/DocumentChrome.tsx create mode 100644 apps/synth_desktop/src/renderer/src/documents/DocumentContent.tsx create mode 100644 apps/synth_desktop/src/renderer/src/documents/DocumentPane.css create mode 100644 apps/synth_desktop/src/renderer/src/documents/DocumentPane.tsx create mode 100644 apps/synth_desktop/src/renderer/src/documents/bridge.ts create mode 100644 apps/synth_desktop/src/renderer/src/documents/highlight.ts create mode 100644 apps/synth_desktop/src/renderer/src/documents/markdown.ts create mode 100644 apps/synth_desktop/src/renderer/src/documents/useDocumentTabs.ts create mode 100644 apps/synth_desktop/tests/document_renderer.test.mjs create mode 100644 packages/workshop-visuals/families/documents/document.viewer.v1/template.json diff --git a/apps/synth_desktop/src-tauri/src/contract/commands.rs b/apps/synth_desktop/src-tauri/src/contract/commands.rs index 0e19972fa..bebafa9c4 100644 --- a/apps/synth_desktop/src-tauri/src/contract/commands.rs +++ b/apps/synth_desktop/src-tauri/src/contract/commands.rs @@ -52,6 +52,9 @@ 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 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..afdba1210 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,9 @@ pub const NAMES: &[&str] = &[ "product_telemetry_set_consent", "product_telemetry_recent", "product_telemetry_flush_now", + "workspace_read_file", + "workspace_list_dir", + "document_show", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -710,6 +713,9 @@ 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), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4574,3 +4580,36 @@ 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})) + }) +} 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..92cbeb331 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -65003,6 +65003,692 @@ "_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": "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 + }, + "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" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index 8cb71babc..e5bafa143 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -446,6 +446,9 @@ 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, ]) } @@ -603,8 +606,9 @@ 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. assert_eq!( - exported, 351, + exported, 354, "generated bindings must contain the complete desktop command set" ); assert_eq!( 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/lib.rs b/apps/synth_desktop/src-tauri/src/lib.rs index e8c41bd90..26905983b 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; 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/visuals/models.rs b/apps/synth_desktop/src-tauri/src/visuals/models.rs index 54f8e4b8f..b2fb58a0a 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/models.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/models.rs @@ -293,6 +293,7 @@ pub struct VisualQuery { /// visual must never bind to a live query: it would return different rows on /// every render and the page could not state what the reader is looking at. pub const VISUAL_BINDING_KINDS: &[&str] = &[ + "workspace_file", "inline", "trace_v5", "local_cas", diff --git a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs index 1c709f68c..b30deb0d0 100644 --- a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs +++ b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs @@ -795,6 +795,9 @@ async fn dispatch_request( if path.starts_with("/v1/traces") { return dispatch_traces(method, path, json_body, core).await; } + if path == "/v1/documents" || path.starts_with("/v1/documents/") { + return crate::documents::ipc::dispatch_documents(method, path, json_body, core).await; + } if path.starts_with("/v1/analysis") { return dispatch_analysis(method, path, json_body, core).await; } diff --git a/apps/synth_desktop/src/renderer/src/components/ReportsPage.tsx b/apps/synth_desktop/src/renderer/src/components/ReportsPage.tsx index 3ac1f4bc5..b77bc511f 100644 --- a/apps/synth_desktop/src/renderer/src/components/ReportsPage.tsx +++ b/apps/synth_desktop/src/renderer/src/components/ReportsPage.tsx @@ -16,6 +16,7 @@ import type { ReportVisibilityRequest, ResearchLogEntry } from "../bridge"; +import { Markdown } from "../documents/DocumentContent"; import { toPublicError } from "../runtime/publicError"; import { formatVisualAdmissionIdentity } from "../types/landing"; @@ -102,7 +103,15 @@ function CompareStory({ payload }: { payload: Record }) { function ReportEvidence({ block }: { block: ReportBlock }) { if (block.accessState === "missing") return

{MISSING}

; if (block.kind === "report.prose.v1" || typeof block.payload.markdown === "string") { - return

{String(block.payload.markdown || "")}

; + // Report prose is markdown and always was; rendering it inside a single + // `

` printed the source — headings, lists and fences as literal + // characters — which is why a sealed report read worse than its own + // draft box. The document pane's renderer is the one markdown reader. + return ( +

+ +
+ ); } if (block.kind === "report.result.v1" && block.payload.schema_version === "craftax.compare-story.v1") { return ; diff --git a/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx b/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx index 4769706c2..16e1fa2ba 100644 --- a/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx +++ b/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx @@ -48,6 +48,7 @@ import { semanticCountsFromRunView } from "../runtime/runProgress/semanticCounts import type { ProgressAgreement } from "../runtime/runProgress/project"; import { DIAGNOSTIC_CODES, reportDiagnostic } from "../runtime/diagnostics"; import { MermaidVisual } from "./MermaidVisual"; +import { DocumentPane, isDocumentArtifact } from "../documents/DocumentPane"; import { SystemsMapVisual } from "./SystemsMapVisual"; import { ChartVisual } from "./ChartVisual"; import { SystemsDynamicVisual } from "./SystemsDynamicVisual"; @@ -1107,6 +1108,7 @@ class VisualErrorBoundary extends Component< } const visualRenderers = new ReactVisualRendererRegistry() + .register({ id: "document", matches: isDocumentArtifact, component: DocumentPane }) .register({ id: "systems-dynamic", matches: (artifact) => artifact.rendererKind === "systems-dynamic", component: SystemsDynamicVisual }) .register({ id: "systems", matches: (artifact) => artifact.rendererKind === "systems", component: SystemsMapVisual }) .register({ id: "chart", matches: (artifact) => artifact.rendererKind === "chart", component: ChartVisual }) diff --git a/apps/synth_desktop/src/renderer/src/documents/DocumentChrome.tsx b/apps/synth_desktop/src/renderer/src/documents/DocumentChrome.tsx new file mode 100644 index 000000000..85dc74853 --- /dev/null +++ b/apps/synth_desktop/src/renderer/src/documents/DocumentChrome.tsx @@ -0,0 +1,171 @@ +/** + * Pane chrome: breadcrumbs, the Open menu, and the folder listing that the + * breadcrumbs and the "+" affordance both land on. + */ + +import { useEffect, useRef, useState } from "react"; +import { openPath, revealItemInDir } from "@tauri-apps/plugin-opener"; + +import { formatBytes, type Breadcrumb, type WorkspaceDirectory } from "./bridge.ts"; + +/** + * The path trail, root first. + * + * Segments come from the host, which computed them from the same canonical + * path it read: the renderer never splits a path itself, because a second path + * helper is a defect even when it is correct. + */ +export function DocumentBreadcrumbs({ + trail, + onOpen +}: { + trail: Breadcrumb[]; + onOpen: (path: string) => void; +}) { + if (!trail.length) return null; + return ( + + ); +} + +/** Open ▾ — the external escape hatch, demoted from the primary action. */ +export function OpenMenu({ path }: { path: string }) { + const [open, setOpen] = useState(false); + const root = useRef(null); + const trigger = useRef(null); + + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + const onPointerDown = (event: PointerEvent) => { + if (root.current && event.target instanceof Node && !root.current.contains(event.target)) setOpen(false); + }; + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("pointerdown", onPointerDown, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("pointerdown", onPointerDown, true); + }; + }, [open]); + + const act = (run: () => void) => { + run(); + setOpen(false); + trigger.current?.focus(); + }; + + return ( +
+ + {open ? ( +
+ + + +
+ ) : null} +
+ ); +} + +/** + * One folder's contents. + * + * Every child is a row. A child that cannot be opened keeps its row and shows + * the host's reason beside it — a folder of binaries reads as a folder of + * binaries, never as an empty folder. + */ +export function DirectoryListing({ + listing, + onOpen +}: { + listing: WorkspaceDirectory; + onOpen: (path: string) => void; +}) { + if (!listing.entries.length) { + return ( +

+ This folder is empty. +

+ ); + } + return ( +
+
    + {listing.entries.map((entry) => ( +
  • + +
  • + ))} +
+ {listing.truncated ? ( +

+ Showing the first {listing.entries.length} entries. Open the folder externally to see the rest. +

+ ) : null} +
+ ); +} diff --git a/apps/synth_desktop/src/renderer/src/documents/DocumentContent.tsx b/apps/synth_desktop/src/renderer/src/documents/DocumentContent.tsx new file mode 100644 index 000000000..5f6bce21e --- /dev/null +++ b/apps/synth_desktop/src/renderer/src/documents/DocumentContent.tsx @@ -0,0 +1,296 @@ +/** + * The rendered body of one workspace document. + * + * Markdown is typeset by default with a View source toggle; every other text + * file is a single highlighted code block. Nothing here is set as HTML: the + * markdown tree and the token list both render 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/packages/workshop-visuals/families/documents/document.viewer.v1/template.json b/packages/workshop-visuals/families/documents/document.viewer.v1/template.json new file mode 100644 index 000000000..ed777819d --- /dev/null +++ b/packages/workshop-visuals/families/documents/document.viewer.v1/template.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "synth.visual-template.v1", + "id": "document.viewer.v1", + "title": "Workspace document", + "genre": "document", + "version": "1.0.0", + "description": "A workspace file in the right panel: markdown typeset by default with a View source toggle, source files syntax highlighted with a language badge and copy, clickable breadcrumbs, and tabs. Host-rendered — like diagram.mermaid.v1 and analysis.chart.v1 it has no shell.tsx, because the pane reads its bytes through a scoped host command rather than through a bound payload.", + "accent": "#F05F22", + "tags": [ + "document", + "markdown", + "workspace" + ], + "inputs": [ + { + "name": "document", + "description": "Absolute path inside a workspace_scope session root. The pane may read this path and no other; the host re-resolves it against the conversation's roots on every read.", + "accepts": [ + "workspace_file" + ], + "required": true, + "schema": "synth.workspace-document.v1" + } + ] +} diff --git a/packages/workshop-visuals/runtime/bind.ts b/packages/workshop-visuals/runtime/bind.ts index af7c6d11e..c59421127 100644 --- a/packages/workshop-visuals/runtime/bind.ts +++ b/packages/workshop-visuals/runtime/bind.ts @@ -133,6 +133,9 @@ async function resolveBinding( if (!ctx.loadRun) throw new Error(`No run loader for input "${bindingInputName(binding) ?? "?"}"`); return dig(await ctx.loadRun(binding.source!), binding.path); } + case "workspace_file": { + throw new Error("workspace_file inputs are read by the scoped host pane, not by bindTemplateSlots"); + } case "optimizer_run": { if (binding.data !== undefined) return dig(binding.data, binding.path); if (!ctx.loadOptimizerRun) { @@ -280,6 +283,7 @@ export function isVisualBindings(value: unknown): value is VisualBindings { } const BINDING_KINDS: readonly string[] = [ + "workspace_file", "inline", "trace_v5", "local_cas", diff --git a/packages/workshop-visuals/runtime/types.ts b/packages/workshop-visuals/runtime/types.ts index b14592c3d..34500ed8a 100644 --- a/packages/workshop-visuals/runtime/types.ts +++ b/packages/workshop-visuals/runtime/types.ts @@ -6,6 +6,7 @@ /** How a template input is fed at runtime. */ export const VISUAL_BINDINGS_SCHEMA_VERSION = "synth.visual-bindings.v1" as const; export type VisualBindingKind = + | "workspace_file" | "inline" | "trace_v5" | "local_cas" diff --git a/visuals/tests/binding_kinds.test.mjs b/visuals/tests/binding_kinds.test.mjs index 9b64f4146..e2ffd7a43 100644 --- a/visuals/tests/binding_kinds.test.mjs +++ b/visuals/tests/binding_kinds.test.mjs @@ -33,7 +33,7 @@ function templateFor(kind) { }; } -test("every advertised binding kind is exercised by bindTemplateSlots", async () => { +test("every generic binding kind is exercised by bindTemplateSlots", async () => { const loaders = { async loadFixture(source) { assert.equal(source, "fixtures/demo.json"); @@ -104,6 +104,15 @@ test("every advertised binding kind is exercised by bindTemplateSlots", async () } }); +test("workspace files cannot be read through a generic template loader", async () => { + const result = await bindTemplateSlots(templateFor("workspace_file"), [{ + input: "payload", kind: "workspace_file", source: "/outside-workspace/secret.txt", + data: { from: "forged-inline-bypass" } + }]); + assert.match(result.errors.join(" "), /scoped host pane/); + assert.equal(result.slots.payload, undefined); +}); + test("trace_v5 and local_cas are distinct loaders, never aliases", async () => { const calls = []; const template = { diff --git a/visuals/tests/registry.test.mjs b/visuals/tests/registry.test.mjs index f6f98d929..94afe12f2 100644 --- a/visuals/tests/registry.test.mjs +++ b/visuals/tests/registry.test.mjs @@ -43,6 +43,7 @@ const EXPECTED_IDS = [ "diagram.mermaid.v1", "diagram.systems.dynamic.v1", "diagram.systems.v1", + "document.viewer.v1", "experiment.overview.v1", "live.annotated_rollouts.v1", "live.container_rollouts.v1", @@ -80,7 +81,11 @@ test("visuals package exposes the registered templates", () => { const { meta, path } = templates.get(id); assert.equal(meta.id, id); assert.equal(meta.schemaVersion, "synth.visual-template.v1"); - if (!id.startsWith("diagram.") && meta.rendererKind !== "chart") { + if (id === "document.viewer.v1") { + const host = readFileSync(join(root, "..", "apps/synth_desktop/src/renderer/src/components/VisualHost.tsx"), "utf8"); + assert.match(host, /matches: isDocumentArtifact, component: DocumentPane/); + assert.deepEqual(declaredInputs(meta).map((input) => input.accepts), [["workspace_file"]]); + } else if (!id.startsWith("diagram.") && meta.rendererKind !== "chart") { assert.ok(existsSync(join(path, "shell.tsx"))); } if (id === "live.container_rollouts.v1") { From 704146dc5d37f49b7d2546d32ab60fce82c40c15 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 04:16:17 -0400 Subject: [PATCH 02/25] Require exact-package human consent for persistent template imports --- .../src-tauri/src/plugins/policy.rs | 1 + .../src-tauri/src/session/approval.rs | 20 +++ .../src-tauri/src/session/mod.rs | 1 + .../src-tauri/src/session/template_persist.rs | 94 ++++++++++++++ .../src-tauri/src/visuals/registry.rs | 11 ++ .../src-tauri/src/visuals/templates.rs | 117 ++++++++++++++++-- .../src-tauri/src/visuals_ipc.rs | 6 + .../src/renderer/src/runtime/sessionView.ts | 10 +- .../src/renderer/src/types/landing.ts | 2 +- .../tests/user_message_ownership.test.mjs | 17 +++ 10 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/session/template_persist.rs 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/session/approval.rs b/apps/synth_desktop/src-tauri/src/session/approval.rs index b33dde995..a03edd08e 100644 --- a/apps/synth_desktop/src-tauri/src/session/approval.rs +++ b/apps/synth_desktop/src-tauri/src/session/approval.rs @@ -200,6 +200,13 @@ pub(crate) enum ApprovalKind { action: String, effect: String, }, + VisualTemplatePersist { + template_id: String, + destination: String, + package_digest: String, + byte_size: u64, + overwrites: bool, + }, PluginLifecycle { plugin_id: String, action: String, @@ -250,6 +257,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 +283,7 @@ impl ApprovalKind { Self::ComputerUse { hazard: true, .. } | Self::PaidCompute { .. } | Self::CredentialAccess { .. } + | Self::VisualTemplatePersist { .. } ) } @@ -322,6 +331,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. @@ -453,6 +463,16 @@ impl ApprovalKind { "effect": effect, "alwaysSupported": false, }), + Self::VisualTemplatePersist { template_id, destination, package_digest, byte_size, overwrites } => json!({ + "approvalId": approval_id, + "kind": self.name(), + "templateId": template_id, + "destination": destination, + "packageDigest": package_digest, + "byteSize": byte_size, + "overwrites": overwrites, + "alwaysSupported": false, + }), Self::PluginLifecycle { plugin_id, action, 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..3cbdaeb04 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/session/template_persist.rs @@ -0,0 +1,94 @@ +//! 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, +} + +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, + } + } +} + +/// 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 } + } + + #[test] + fn consent_binds_every_material_field() { + let original = request(); + let mut changes = vec![original.clone(); 5]; + 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; + 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/visuals/registry.rs b/apps/synth_desktop/src-tauri/src/visuals/registry.rs index 620e4741f..91c3ce116 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/registry.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/registry.rs @@ -869,6 +869,17 @@ impl VisualRegistry { super::templates::import_managed_template(source_path) } + pub async fn import_template_approved( + &self, + app: &tauri::AppHandle, + session_id: Option<&str>, + source_path: &str, + ) -> Result { + let prepared = super::templates::prepare_managed_import(source_path)?; + let consent = crate::session::template_persist::authorize(app, session_id, &prepared.request()?).await?; + prepared.persist(consent) + } + pub async fn mermaid_source(&self, id: String) -> Result { self.visual_source(id).await } diff --git a/apps/synth_desktop/src-tauri/src/visuals/templates.rs b/apps/synth_desktop/src-tauri/src/visuals/templates.rs index 9a8350b38..4fddb38bb 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/templates.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/templates.rs @@ -421,10 +421,60 @@ fn managed_templates_root() -> PathBuf { crate::instance::data_root().join("visuals").join("templates") } -/// Copy one reviewed, networkless HTML visual package into this instance's -/// managed registry. This is intentionally a two-file contract: accepting a -/// directory tree would turn import into an unbounded code and asset loader. +/// Legacy synchronous seam: no broker means no permission to persist code. +/// The approved path prepares exactly two files, obtains consent, then writes +/// those immutable bytes through `PreparedManagedImport::persist`. pub fn import_managed_template(source_path: &str) -> anyhow::Result { + let _ = source_path; + Err(crate::session::template_persist::unapproved()) +} + +pub(crate) struct PreparedManagedImport { + meta: TemplateMeta, + manifest: Vec, + renderer: Vec, + destination: PathBuf, +} + +impl PreparedManagedImport { + pub(crate) fn request(&self) -> anyhow::Result { + if let Ok(meta) = fs::symlink_metadata(&self.destination) { + if !meta.is_dir() || meta.file_type().is_symlink() { + anyhow::bail!("managed template destination must be a real directory"); + } + } + let mut digest = Sha256::new(); + digest.update((self.manifest.len() as u64).to_le_bytes()); + digest.update(&self.manifest); + digest.update((self.renderer.len() as u64).to_le_bytes()); + digest.update(&self.renderer); + Ok(crate::session::template_persist::PersistRequest { + template_id: self.meta.id.clone(), + destination: self.destination.display().to_string(), + package_digest: format!("sha256:{:x}", digest.finalize()), + byte_size: (self.manifest.len() + self.renderer.len()) as u64, + overwrites: self.destination.exists(), + }) + } + + pub(crate) fn persist(mut self, consent: crate::session::template_persist::PersistConsent) -> anyhow::Result { + consent.bind(&self.request()?)?; + fs::create_dir_all(&self.destination)?; + for (name, bytes) in [("template.json", &self.manifest), ("renderer.html", &self.renderer)] { + // Atomic file replacement does not follow an existing file symlink. + let mut file = tempfile::NamedTempFile::new_in(&self.destination)?; + std::io::Write::write_all(&mut file, bytes)?; + file.persist(self.destination.join(name))?; + } + self.meta = load_template_meta(&self.destination)?; + self.meta.path = Some(self.destination.display().to_string()); + self.meta.renderer_path = Some(self.destination.join("renderer.html").display().to_string()); + self.meta.source_kind = Some("managed".into()); + Ok(self.meta) + } +} + +pub(crate) fn prepare_managed_import(source_path: &str) -> anyhow::Result { let source = Path::new(source_path); if !source.is_absolute() { anyhow::bail!("source_path must be an absolute directory"); @@ -448,17 +498,15 @@ pub fn import_managed_template(source_path: &str) -> anyhow::Result MANAGED_TEMPLATE_MAX_BYTES || renderer_bytes.len() as u64 > MANAGED_TEMPLATE_MAX_BYTES { + anyhow::bail!("managed template file exceeds {MANAGED_TEMPLATE_MAX_BYTES} bytes"); + } validate_managed_renderer(&renderer_bytes)?; let destination = managed_templates_root().join(&meta.id); - fs::create_dir_all(&destination)?; - fs::write(destination.join("template.json"), fs::read(&manifest)?)?; - fs::write(destination.join("renderer.html"), renderer_bytes)?; - meta.path = Some(destination.display().to_string()); - meta.renderer_path = Some(destination.join("renderer.html").display().to_string()); - meta.source_kind = Some("managed".into()); - Ok(meta) + Ok(PreparedManagedImport { meta, manifest: manifest_bytes, renderer: renderer_bytes, destination }) } fn validate_managed_renderer(bytes: &[u8]) -> anyhow::Result<()> { @@ -589,8 +637,11 @@ fn discover_template_directories( } fn load_template_meta(path: &Path) -> anyhow::Result { - let raw = fs::read_to_string(path.join("template.json"))?; - let value: Value = serde_json::from_str(&raw)?; + load_template_meta_bytes(path, &fs::read(path.join("template.json"))?) +} + +fn load_template_meta_bytes(path: &Path, raw: &[u8]) -> anyhow::Result { + let value: Value = serde_json::from_slice(raw)?; let id = value .get("id") .and_then(Value::as_str) @@ -710,6 +761,46 @@ fn load_template_meta(path: &Path) -> anyhow::Result { mod tests { use super::*; + #[test] + fn managed_import_prepares_immutable_bytes_without_writing() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("managed.test.v1"); + write_template(&source, "managed.test.v1"); + fs::write(source.join("renderer.html"), "

Reviewed

").unwrap(); + let mut prepared = prepare_managed_import(source.to_str().unwrap()).unwrap(); + prepared.destination = temp.path().join("destination/managed.test.v1"); + let request = prepared.request().unwrap(); + assert!(!prepared.destination.exists()); + assert!(import_managed_template(source.to_str().unwrap()).is_err()); + fs::write(source.join("renderer.html"), "

Replaced while card open

").unwrap(); + fs::write(source.join("template.json"), "{}").unwrap(); + assert_eq!(prepared.renderer, b"

Reviewed

"); + assert_eq!(prepared.request().unwrap(), request); + let manifest = prepared.manifest.clone(); + prepared.manifest.push(b' '); + assert_ne!(prepared.request().unwrap().package_digest, request.package_digest); + assert!(!prepared.destination.exists()); + prepared.manifest = manifest.clone(); + let destination = prepared.destination.clone(); + let meta = prepared.persist(crate::session::template_persist::PersistConsent::for_test(request)).unwrap(); + assert_eq!(fs::read(destination.join("template.json")).unwrap(), manifest); + assert_eq!(fs::read(destination.join("renderer.html")).unwrap(), b"

Reviewed

"); + assert_eq!(meta.template_digest, template_package_digest(&destination).unwrap()); + } + + #[cfg(unix)] + #[test] + fn managed_import_refuses_symlink_destination() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("managed.test.v1"); + write_template(&source, "managed.test.v1"); + fs::write(source.join("renderer.html"), "

Reviewed

").unwrap(); + let mut prepared = prepare_managed_import(source.to_str().unwrap()).unwrap(); + prepared.destination = temp.path().join("destination"); + std::os::unix::fs::symlink(&source, &prepared.destination).unwrap(); + assert!(prepared.request().is_err()); + } + #[test] fn lists_bundled_templates_when_present() { let templates = list_templates(None).unwrap(); diff --git a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs index b30deb0d0..439274305 100644 --- a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs +++ b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs @@ -817,6 +817,12 @@ async fn dispatch_request( if method == "POST" && path.starts_with("/v1/containers/") && path.ends_with("/restart") { return dispatch_container_restart(path, json_body, core, app).await; } + if method == "POST" && path == "/v1/visuals/templates/import" { + let source = json_body.get("sourcePath").or_else(|| json_body.get("source_path")) + .and_then(Value::as_str).context("source_path is required")?; + let session = json_body.get("sessionRef").and_then(Value::as_str); + return Ok(json!({"template": core.visuals().import_template_approved(app, session, source).await?})); + } dispatch(method, path, json_body, core).await } diff --git a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts index 540b560fc..b61206508 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts @@ -1645,6 +1645,7 @@ export function eventsToLocalActivity( const path = typeof payload.path === "string" ? payload.path : undefined; const approvalKind = typeof payload.kind === "string" ? payload.kind : "permission"; const approvalSubject = approvalKind === "paid_compute" ? "Paid compute" + : approvalKind === "visual_template_persist" ? "Persistent visual template" : approvalKind === "credential_access" ? "Credential access" : approvalKind === "sidecar_lifecycle" ? "Sidecar lifecycle" : approvalKind === "container_lifecycle" ? "Container replacement" @@ -1656,6 +1657,7 @@ export function eventsToLocalActivity( switch (event.eventKind) { case "approval.requested": label = approvalKind === "paid_compute" ? "Paid compute approval" + : approvalKind === "visual_template_persist" ? "Save persistent visual template" : approvalKind === "credential_access" ? "Credential access" : approvalKind === "sidecar_lifecycle" ? "Sidecar lifecycle" : approvalKind === "container_lifecycle" ? "Replace container workload" @@ -1700,6 +1702,11 @@ export function eventsToLocalActivity( : []) ].filter((value): value is string => typeof value === "string" && value !== "").join(" · ") : undefined; + const templateDetail = payload.kind === "visual_template_persist" + ? [payload.templateId, payload.destination, payload.packageDigest, + `${payload.byteSize} bytes`, payload.overwrites ? "Replaces existing template" : "Creates new template", + "Renderer code remains available across sessions and restarts"].join(" · ") + : undefined; const pluginDetail = payload.kind === "plugin_lifecycle" ? [ payload.action, @@ -1727,6 +1734,7 @@ export function eventsToLocalActivity( const safeKind = payload.kind === "shell_command" || payload.kind === "file_change" || payload.kind === "permission" || payload.kind === "plugin_lifecycle" || payload.kind === "paid_compute"; const detail = typedDetail + ?? templateDetail ?? computerUseDetail ?? pluginDetail ?? (safeKind && typeof payload.detail === "string" @@ -1740,7 +1748,7 @@ export function eventsToLocalActivity( approvalId: event.eventKind === "approval.requested" ? approvalKey(event) ?? `approval-${event.sequence}` : undefined, - approvalKind: approvalKind === "shell_command" || approvalKind === "paid_compute" || approvalKind === "sidecar_lifecycle" || approvalKind === "container_lifecycle" || approvalKind === "credential_access" || approvalKind === "plugin_lifecycle" || approvalKind === "computer_use" + approvalKind: approvalKind === "shell_command" || approvalKind === "paid_compute" || approvalKind === "sidecar_lifecycle" || approvalKind === "container_lifecycle" || approvalKind === "credential_access" || approvalKind === "plugin_lifecycle" || approvalKind === "visual_template_persist" || approvalKind === "computer_use" ? approvalKind : "permission", approvalPayload: event.eventKind === "approval.requested" && approvalKind === "paid_compute" ? { operation: typeof payload.operation === "string" ? payload.operation : undefined, diff --git a/apps/synth_desktop/src/renderer/src/types/landing.ts b/apps/synth_desktop/src/renderer/src/types/landing.ts index c0fd7f959..3940927df 100644 --- a/apps/synth_desktop/src/renderer/src/types/landing.ts +++ b/apps/synth_desktop/src/renderer/src/types/landing.ts @@ -280,7 +280,7 @@ export type LocalActivityLine = { /** Correlates a pending approval with its durable grant/rejection event. */ approvalId?: string; // Mirrors `ApprovalKind::as_str` in src-tauri/src/session/approval.rs. - approvalKind?: "shell_command" | "paid_compute" | "sidecar_lifecycle" | "container_lifecycle" | "credential_access" | "plugin_lifecycle" | "computer_use" | "permission"; + approvalKind?: "shell_command" | "paid_compute" | "sidecar_lifecycle" | "container_lifecycle" | "credential_access" | "plugin_lifecycle" | "visual_template_persist" | "computer_use" | "permission"; approvalPayload?: { operation?: string; parameters?: Record; 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({ From d2184eebf4e5ebda0d466a13106d800cdd1840e7 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 04:29:51 -0400 Subject: [PATCH 03/25] Restore bounded optimizer snapshot export import and retrieval --- .../src/adapters/mcp/operations/optimizers.rs | 12 +- .../src-tauri/src/optimizers/mod.rs | 2 + .../src-tauri/src/optimizers/service.rs | 78 ++++ .../src-tauri/src/optimizers/snapshot.rs | 419 ++++++++++++++++++ .../src-tauri/src/optimizers/terminal.rs | 8 + .../src-tauri/src/storage/content_store.rs | 2 +- .../src-tauri/src/storage/migrations.rs | 20 + .../src-tauri/src/visuals_ipc.rs | 12 + 8 files changed, 550 insertions(+), 3 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/optimizers/snapshot.rs 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/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/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/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/migrations.rs b/apps/synth_desktop/src-tauri/src/storage/migrations.rs index 593e17bbf..77d931001 100644 --- a/apps/synth_desktop/src-tauri/src/storage/migrations.rs +++ b/apps/synth_desktop/src-tauri/src/storage/migrations.rs @@ -79,6 +79,7 @@ const MIGRATIONS: &[&str] = &[ MIGRATION_74, MIGRATION_75, MIGRATION_76, + MIGRATION_77, ]; const MIGRATION_70: &str = r#" @@ -246,6 +247,7 @@ CREATE TABLE IF NOT EXISTS optimizer_evidence_amendments ( "#; const REQUIRED_TABLES: &[(&str, &str)] = &[ + ("optimizer_snapshots", MIGRATION_77), ("optimizer_terminal_manifests", MIGRATION_23), ("secret_refs", MIGRATION_25), ("credential_locators", CREDENTIAL_LOCATORS_TABLE_DDL), @@ -5771,6 +5773,24 @@ CREATE TABLE visual_corpus_details ( ); "#; +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/visuals_ipc.rs b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs index 439274305..0ed51e747 100644 --- a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs +++ b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs @@ -4003,6 +4003,18 @@ pub(crate) async fn dispatch_optimizer( ) -> Result { let optimizers = core.optimizers(); match (method, path) { + ("POST", "/v1/optimizers/snapshots/import") => { + let receipt = optimizers.import_snapshot(serde_json::from_value(body)?).await?; + Ok(json!({"receipt": receipt})) + } + ("GET", path) if path.starts_with("/v1/optimizers/snapshots/") => { + optimizers.get_snapshot(path.trim_start_matches("/v1/optimizers/snapshots/").to_owned()).await + } + ("POST", path) if path.starts_with("/v1/optimizers/runs/") && path.ends_with("/snapshot") => { + let id = path.trim_start_matches("/v1/optimizers/runs/").trim_end_matches("/snapshot"); + let receipt = optimizers.export_snapshot(id.to_owned()).await?; + Ok(json!({"receipt": receipt})) + } ("GET", "/v1/optimizers/algorithms") => { Ok(json!({ "algorithms": optimizers.list_algorithms() })) } From bd5589c6cdb4d8ae4da3a13a48bdc5640efc4eb0 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 04:50:43 -0400 Subject: [PATCH 04/25] Restore approved user-template authoring and live TSX rendering --- .../src-tauri/src/contract/commands.rs | 4 + .../src/contract/desktop_dispatch.rs | 52 ++ .../src-tauri/src/contract/desktop_tools.json | 644 ++++++++++++++++++ .../src-tauri/src/contract/specta.rs | 7 +- .../src-tauri/src/session/approval.rs | 4 +- .../src-tauri/src/session/template_persist.rs | 7 +- .../src-tauri/src/visuals/mod.rs | 1 + .../src-tauri/src/visuals/templates.rs | 137 +++- .../src-tauri/src/visuals/user_templates.rs | 131 ++++ .../src/renderer/src/bridge/types.ts | 4 + .../renderer/src/components/VisualHost.tsx | 37 +- .../src/renderer/src/generated/protocol.ts | 4 + .../src/renderer/src/runtime/desktopBridge.ts | 4 + .../src/renderer/src/runtime/sessionView.ts | 2 +- .../tests/playwright/user-template.spec.ts | 32 + packages/workshop-visuals/registry/index.ts | 22 +- 16 files changed, 1059 insertions(+), 33 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/visuals/user_templates.rs create mode 100644 apps/synth_desktop/tests/playwright/user-template.spec.ts diff --git a/apps/synth_desktop/src-tauri/src/contract/commands.rs b/apps/synth_desktop/src-tauri/src/contract/commands.rs index bebafa9c4..7f8d0e99e 100644 --- a/apps/synth_desktop/src-tauri/src/contract/commands.rs +++ b/apps/synth_desktop/src-tauri/src/contract/commands.rs @@ -55,6 +55,10 @@ impl Commands { 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 afdba1210..93b7b7e60 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -357,6 +357,10 @@ pub const NAMES: &[&str] = &[ "workspace_read_file", "workspace_list_dir", "document_show", + "visuals_template_shell_source", + "visuals_template_save", + "visuals_template_create", + "visuals_template_validate", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -716,6 +720,10 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "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), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4613,3 +4621,47 @@ fn operation_353(app: &tauri::AppHandle, args: Value) -> Reply<'_> { 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})) + }) +} 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 92cbeb331..5105ad22b 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -65689,6 +65689,650 @@ "_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" + } + ] + }, + "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" + } + ] + }, + "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" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index e5bafa143..a8ed7a145 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -449,6 +449,10 @@ pub fn builder() -> Builder { 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, ]) } @@ -607,8 +611,9 @@ mod tests { // 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. assert_eq!( - exported, 354, + exported, 358, "generated bindings must contain the complete desktop command set" ); assert_eq!( diff --git a/apps/synth_desktop/src-tauri/src/session/approval.rs b/apps/synth_desktop/src-tauri/src/session/approval.rs index a03edd08e..496b71114 100644 --- a/apps/synth_desktop/src-tauri/src/session/approval.rs +++ b/apps/synth_desktop/src-tauri/src/session/approval.rs @@ -206,6 +206,7 @@ pub(crate) enum ApprovalKind { package_digest: String, byte_size: u64, overwrites: bool, + source_kind: String, }, PluginLifecycle { plugin_id: String, @@ -463,12 +464,13 @@ impl ApprovalKind { "effect": effect, "alwaysSupported": false, }), - Self::VisualTemplatePersist { template_id, destination, package_digest, byte_size, overwrites } => json!({ + 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, diff --git a/apps/synth_desktop/src-tauri/src/session/template_persist.rs b/apps/synth_desktop/src-tauri/src/session/template_persist.rs index 3cbdaeb04..a5dec1a81 100644 --- a/apps/synth_desktop/src-tauri/src/session/template_persist.rs +++ b/apps/synth_desktop/src-tauri/src/session/template_persist.rs @@ -11,6 +11,7 @@ pub(crate) struct PersistRequest { pub package_digest: String, pub byte_size: u64, pub overwrites: bool, + pub source_kind: String, } impl PersistRequest { @@ -21,6 +22,7 @@ impl PersistRequest { package_digest: self.package_digest.clone(), byte_size: self.byte_size, overwrites: self.overwrites, + source_kind: self.source_kind.clone(), } } } @@ -65,18 +67,19 @@ mod tests { 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 } + 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(); 5]; + 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()); } diff --git a/apps/synth_desktop/src-tauri/src/visuals/mod.rs b/apps/synth_desktop/src-tauri/src/visuals/mod.rs index ba28e34b9..40351042c 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/mod.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/mod.rs @@ -21,6 +21,7 @@ pub mod snapshot; pub mod sourced; pub mod systems; mod templates; +pub mod user_templates; /// The repository's `visuals/` root, so tests can load the same fixtures the /// binding resolver reads. diff --git a/apps/synth_desktop/src-tauri/src/visuals/templates.rs b/apps/synth_desktop/src-tauri/src/visuals/templates.rs index 4fddb38bb..84a8b75a0 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/templates.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/templates.rs @@ -280,15 +280,10 @@ fn resolve_template_inner(visuals_root: &Path, id: &str) -> anyhow::Result PathBuf { crate::instance::data_root().join("visuals").join("templates") } +pub(super) fn user_template_path(id: &str) -> anyhow::Result { + let root = managed_templates_root(); + let path = root.join(id); + if path.parent() != Some(root.as_path()) || path.file_name().and_then(|name| name.to_str()) != Some(id) { + anyhow::bail!("invalid user visual template id"); + } + Ok(path) +} + +fn checked_template_file(path: &Path) -> anyhow::Result { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + Ok(meta) if meta.file_type().is_symlink() || !meta.is_file() => anyhow::bail!("template requires a regular file: {}", path.display()), + Ok(meta) if meta.len() > MANAGED_TEMPLATE_MAX_BYTES => anyhow::bail!("template exceeds size limit"), + Ok(_) => Ok(true), + } +} + +pub(super) fn instance_template(path: &Path) -> anyhow::Result> { + let dir = match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + other => other?, + }; + if dir.file_type().is_symlink() || !dir.is_dir() { anyhow::bail!("template directory must not be a symlink"); } + if !checked_template_file(&path.join("template.json"))? { return Ok(None); } + let html = checked_template_file(&path.join("renderer.html"))?; + let tsx = checked_template_file(&path.join("shell.tsx"))?; + if html && tsx { anyhow::bail!("template cannot contain both renderer.html and shell.tsx"); } + if !html && !tsx { return Ok(None); } + let mut meta = load_template_meta(path)?; + meta.path = Some(path.display().to_string()); + if tsx { + meta.source_kind = Some("user".into()); + meta.renderer_kind = Some("template".into()); + meta.shell_path = Some(path.join("shell.tsx").display().to_string()); + } else { + meta.source_kind = Some("managed".into()); + meta.renderer_path = Some(path.join("renderer.html").display().to_string()); + } + Ok(Some(meta)) +} + +pub(super) fn prepare_user_save(id: &str, manifest: &str, source: &str) -> anyhow::Result { + let destination = user_template_path(id)?; + if destination.join("renderer.html").exists() { anyhow::bail!("cannot replace an HTML package with a TSX template"); } + if let Ok(existing) = resolve_template(id) { + if existing.source_kind.as_deref() != Some("user") { anyhow::bail!("cannot overwrite a bundled template; fork under a new id"); } + } + if manifest.len() as u64 > MANAGED_TEMPLATE_MAX_BYTES || source.len() > 256 * 1024 { anyhow::bail!("user template exceeds size limit"); } + let meta = decode_template_meta(&destination, manifest.as_bytes(), String::new())?; + Ok(PreparedManagedImport { meta, manifest: manifest.as_bytes().to_vec(), renderer: source.as_bytes().to_vec(), destination, renderer_file: "shell.tsx", source_kind: "user" }) +} + /// Legacy synchronous seam: no broker means no permission to persist code. /// The approved path prepares exactly two files, obtains consent, then writes /// those immutable bytes through `PreparedManagedImport::persist`. @@ -434,16 +474,23 @@ pub(crate) struct PreparedManagedImport { manifest: Vec, renderer: Vec, destination: PathBuf, + renderer_file: &'static str, + source_kind: &'static str, } impl PreparedManagedImport { pub(crate) fn request(&self) -> anyhow::Result { + let other_file = if self.source_kind == "user" { "renderer.html" } else { "shell.tsx" }; + if checked_template_file(&self.destination.join(other_file))? { + anyhow::bail!("template cannot change renderer tier during approval"); + } if let Ok(meta) = fs::symlink_metadata(&self.destination) { if !meta.is_dir() || meta.file_type().is_symlink() { anyhow::bail!("managed template destination must be a real directory"); } } let mut digest = Sha256::new(); + digest.update(self.renderer_file.as_bytes()); digest.update((self.manifest.len() as u64).to_le_bytes()); digest.update(&self.manifest); digest.update((self.renderer.len() as u64).to_le_bytes()); @@ -454,13 +501,14 @@ impl PreparedManagedImport { package_digest: format!("sha256:{:x}", digest.finalize()), byte_size: (self.manifest.len() + self.renderer.len()) as u64, overwrites: self.destination.exists(), + source_kind: self.source_kind.into(), }) } pub(crate) fn persist(mut self, consent: crate::session::template_persist::PersistConsent) -> anyhow::Result { consent.bind(&self.request()?)?; fs::create_dir_all(&self.destination)?; - for (name, bytes) in [("template.json", &self.manifest), ("renderer.html", &self.renderer)] { + for (name, bytes) in [("template.json", &self.manifest), (self.renderer_file, &self.renderer)] { // Atomic file replacement does not follow an existing file symlink. let mut file = tempfile::NamedTempFile::new_in(&self.destination)?; std::io::Write::write_all(&mut file, bytes)?; @@ -468,8 +516,13 @@ impl PreparedManagedImport { } self.meta = load_template_meta(&self.destination)?; self.meta.path = Some(self.destination.display().to_string()); - self.meta.renderer_path = Some(self.destination.join("renderer.html").display().to_string()); - self.meta.source_kind = Some("managed".into()); + if self.source_kind == "managed" { + self.meta.renderer_path = Some(self.destination.join(self.renderer_file).display().to_string()); + } else { + self.meta.renderer_kind = Some("template".into()); + self.meta.shell_path = Some(self.destination.join(self.renderer_file).display().to_string()); + } + self.meta.source_kind = Some(self.source_kind.into()); Ok(self.meta) } } @@ -506,7 +559,7 @@ pub(crate) fn prepare_managed_import(source_path: &str) -> anyhow::Result anyhow::Result<()> { @@ -641,6 +694,10 @@ fn load_template_meta(path: &Path) -> anyhow::Result { } fn load_template_meta_bytes(path: &Path, raw: &[u8]) -> anyhow::Result { + decode_template_meta(path, raw, template_package_digest(path)?) +} + +fn decode_template_meta(path: &Path, raw: &[u8], template_digest: String) -> anyhow::Result { let value: Value = serde_json::from_slice(raw)?; let id = value .get("id") @@ -704,7 +761,7 @@ fn load_template_meta_bytes(path: &Path, raw: &[u8]) -> anyhow::Result anyhow::Result Result { + let path = templates::user_template_path(id)?; + let meta = + templates::instance_template(&path)?.context("user template is incomplete or missing")?; + if meta.source_kind.as_deref() != Some("user") { + bail!("only user TSX templates expose shell source"); + } + let bytes = fs::read(path.join("shell.tsx"))?; + if bytes.len() > 256 * 1024 { + bail!("user template exceeds 256 KiB renderer limit"); + } + String::from_utf8(bytes).context("user template source must be UTF-8") +} + +async fn save( + app: &tauri::AppHandle, + session: &str, + id: &str, + manifest: &str, + source: &str, +) -> Result { + let prepared = templates::prepare_user_save(id, manifest, source)?; + let consent = template_persist::authorize(app, Some(session), &prepared.request()?).await?; + prepared.persist(consent) +} + +#[tauri::command] +#[specta::specta] +pub fn visuals_template_shell_source(template_id: String) -> Result { + shell_source(&template_id).map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn visuals_template_save( + app: tauri::AppHandle, + session_id: String, + template_id: String, + manifest: String, + source: String, +) -> Result { + save(&app, &session_id, &template_id, &manifest, &source) + .await + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub async fn visuals_template_create( + app: tauri::AppHandle, + session_id: String, + template_id: String, + from_template_id: String, + title: Option, +) -> Result { + let result: Result = async { + let destination = templates::user_template_path(&template_id)?; + if fs::symlink_metadata(&destination).is_ok() { + bail!("fork requires an unused template id; use save to update an existing template"); + } + if template_id == from_template_id { + bail!("fork requires a new template id"); + } + let origin = templates::resolve_template(&from_template_id)?; + let path = origin + .path + .as_deref() + .context("origin template has no source directory")?; + let source = fs::read_to_string( + origin + .shell_path + .as_deref() + .context("only TSX templates can be forked")?, + )?; + let mut manifest: Value = + serde_json::from_slice(&fs::read(std::path::Path::new(path).join("template.json"))?)?; + manifest["id"] = json!(template_id); + manifest["rendererKind"] = json!("template"); + manifest["forkedFrom"] = json!({"templateId": from_template_id, "version": origin.version}); + if let Some(title) = title { + manifest["title"] = json!(title); + } + save( + &app, + &session_id, + &template_id, + &serde_json::to_string_pretty(&manifest)?, + &source, + ) + .await + } + .await; + result.map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub fn visuals_template_validate(template_id: String) -> Result { + let result = templates::user_template_path(&template_id) + .and_then(|path| { + templates::instance_template(&path)? + .context("template requires template.json and shell.tsx") + }) + .and_then(|meta| { + if meta.source_kind.as_deref() != Some("user") { + bail!("not a user TSX template"); + } + Ok(meta) + }); + let (ok, source_kind, findings) = match result { + Ok(meta) => (true, meta.source_kind, vec![]), + Err(error) => ( + false, + None, + vec![json!({"code":"user_template_unavailable", "message":error.to_string()})], + ), + }; + Ok(OpaqueJson( + json!({"schemaVersion":"synth.user-template-validation.v1", "id":template_id, + "path":templates::user_template_path(&template_id).ok().map(|path| path.display().to_string()), + "ok":ok, "sourceKind":source_kind, "findings":findings, + "sourceScan":"Import allowlist and forbidden-token validation run in the visual pane; structural validation is not code approval."}), + )) +} diff --git a/apps/synth_desktop/src/renderer/src/bridge/types.ts b/apps/synth_desktop/src/renderer/src/bridge/types.ts index 21ebecc84..d2f975984 100644 --- a/apps/synth_desktop/src/renderer/src/bridge/types.ts +++ b/apps/synth_desktop/src/renderer/src/bridge/types.ts @@ -625,6 +625,10 @@ export type VisualTemplateMeta = TemplateMeta; export type VisualsBridge = { listTemplates(genre?: string | null): Promise; getTemplate(templateId: string): Promise; + templateShellSource?(templateId: string): Promise; + saveTemplate?(sessionId: string, templateId: string, manifest: string, source: string): Promise; + createTemplate?(sessionId: string, templateId: string, fromTemplateId: string, title?: string | null): Promise; + validateTemplate?(templateId: string): Promise; list(query?: { status?: string; sessionId?: string; diff --git a/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx b/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx index 16e1fa2ba..59e50830a 100644 --- a/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx +++ b/apps/synth_desktop/src/renderer/src/components/VisualHost.tsx @@ -22,6 +22,7 @@ import { propsFromBindings, replayStreamsFromBindings, resolveTemplate, + registerRuntimeTemplate, visualExtensions, resolveVisualBindings, selectObservationSurface, @@ -350,6 +351,19 @@ function TemplateVisualHost({ artifact }: { artifact: ArtifactRef }) { }>>([]); const [analysisFindings, setAnalysisFindings] = useState([]); const [analysisCampaigns, setAnalysisCampaigns] = useState([]); + const [userTemplateDigest, setUserTemplateDigest] = useState(null); + const [templateReload, setTemplateReload] = useState(0); + const [templateCatalogEpoch, setTemplateCatalogEpoch] = useState(0); + useEffect(() => { + if (!userTemplateDigest || !artifact.templateId || !bridges.visuals) return; + let cancelled = false; + const timer = window.setInterval(() => { + void bridges.visuals!.getTemplate(artifact.templateId!).then(meta => { + if (!cancelled && meta.templateDigest !== userTemplateDigest) setTemplateReload(value => value + 1); + }).catch(reason => { if (!cancelled) setShell(() => sourcedInvalidShell(publicError(reason))); }); + }, 1000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [artifact.templateId, userTemplateDigest]); const visualIdentity = useMemo( () => ({ @@ -525,12 +539,25 @@ function TemplateVisualHost({ artifact }: { artifact: ArtifactRef }) { } return; } - if (isSourcedTemplate(templateId)) { + const nativeTemplate = typeof bridges.visuals?.getTemplate === "function" ? await bridges.visuals.getTemplate(templateId) : null; + const userAuthored = nativeTemplate?.sourceKind === "user"; + if (cancelled) return; + if (userAuthored) { + registerRuntimeTemplate(nativeTemplate); + setUserTemplateDigest(nativeTemplate.templateDigest ?? null); + setTemplateCatalogEpoch(value => value + 1); + } else setUserTemplateDigest(null); + if (isSourcedTemplate(templateId) || userAuthored) { const visualId = artifact.visualId ?? artifact.id; let source = ""; try { - const asset = await bridges.visuals?.content?.(visualId); - if (asset?.base64) source = decodeBase64Utf8(asset.base64); + if (userAuthored) { + if (!bridges.visuals?.templateShellSource) throw new Error("User-template source requires a native host"); + source = await bridges.visuals.templateShellSource(templateId); + } else { + const asset = await bridges.visuals?.content?.(visualId); + if (asset?.base64) source = decodeBase64Utf8(asset.base64); + } } catch (reason) { if (!cancelled) setShell(() => sourcedInvalidShell(publicError(reason))); return; @@ -577,7 +604,7 @@ function TemplateVisualHost({ artifact }: { artifact: ArtifactRef }) { }); }); return () => { cancelled = true; }; - }, [artifact.templateId, artifact.visualId, artifact.id, artifact.contentDigest, artifact.revision, visualIdentity]); + }, [artifact.templateId, artifact.visualId, artifact.id, artifact.contentDigest, artifact.revision, visualIdentity, templateReload]); useEffect(() => { const controller=new AbortController(); @@ -610,7 +637,7 @@ function TemplateVisualHost({ artifact }: { artifact: ArtifactRef }) { message,details:{templateId:artifact.templateId ?? null}}); }); return ()=>controller.abort(); - },[artifact.id,artifact.revision,artifact.templateId,bindingsSignature,asyncBindings.length,visualIdentity]); + },[artifact.id,artifact.revision,artifact.templateId,bindingsSignature,asyncBindings.length,visualIdentity,templateCatalogEpoch]); /* * The optimizer stream is read through the shared `RunProgressSubscription` * store, not a private loop here. One run can be open in the transcript card, diff --git a/apps/synth_desktop/src/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index 0f390a686..eefb30b43 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -718,6 +718,10 @@ export const commands = { * the agent shows and a document the reader clicks arrive by one path. */ documentShow: (sessionId: string, path: string) => typedError(__TAURI_INVOKE("document_show", { sessionId, path })), + visualsTemplateShellSource: (templateId: string) => typedError(__TAURI_INVOKE("visuals_template_shell_source", { templateId })), + visualsTemplateSave: (sessionId: string, templateId: string, manifest: string, source: string) => typedError(__TAURI_INVOKE("visuals_template_save", { sessionId, templateId, manifest, source })), + visualsTemplateCreate: (sessionId: string, templateId: string, fromTemplateId: string, title: string | null) => typedError(__TAURI_INVOKE("visuals_template_create", { sessionId, templateId, fromTemplateId, title })), + visualsTemplateValidate: (templateId: string) => typedError(__TAURI_INVOKE("visuals_template_validate", { templateId })), }; /* Types */ diff --git a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts index e5871e5d8..340f7cb4f 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts @@ -881,6 +881,10 @@ window.synthWorkspaceScope ??= isTauri window.synthVisuals ??= { listTemplates: (genre) => fromGenerated(spectaCommands.visualsTemplatesList(genre ?? null)), getTemplate: (templateId) => fromGenerated(spectaCommands.visualsTemplatesGet(templateId)), + templateShellSource: (templateId) => fromGenerated(spectaCommands.visualsTemplateShellSource(templateId)), + saveTemplate: (sessionId, templateId, manifest, source) => fromGenerated(spectaCommands.visualsTemplateSave(sessionId, templateId, manifest, source)), + createTemplate: (sessionId, templateId, fromTemplateId, title) => fromGenerated(spectaCommands.visualsTemplateCreate(sessionId, templateId, fromTemplateId, title ?? null)), + validateTemplate: (templateId) => fromGenerated(spectaCommands.visualsTemplateValidate(templateId)), list: (query) => fromGenerated(spectaCommands.visualsList(wire(query ?? null))), get: (visualId) => fromGenerated(spectaCommands.visualsGet(visualId)), engine: (visualId, request) => fromGenerated(spectaCommands.visualsEngine(visualId, request)) as Promise>, diff --git a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts index b61206508..02dc2a610 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts @@ -1703,7 +1703,7 @@ export function eventsToLocalActivity( ].filter((value): value is string => typeof value === "string" && value !== "").join(" · ") : undefined; const templateDetail = payload.kind === "visual_template_persist" - ? [payload.templateId, payload.destination, payload.packageDigest, + ? [payload.templateId, payload.sourceKind === "user" ? "TSX template" : "Sandboxed HTML template", payload.destination, payload.packageDigest, `${payload.byteSize} bytes`, payload.overwrites ? "Replaces existing template" : "Creates new template", "Renderer code remains available across sessions and restarts"].join(" · ") : undefined; 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/packages/workshop-visuals/registry/index.ts b/packages/workshop-visuals/registry/index.ts index 7742770b2..9fd34a74c 100644 --- a/packages/workshop-visuals/registry/index.ts +++ b/packages/workshop-visuals/registry/index.ts @@ -69,6 +69,24 @@ overlay(internalManifests, "templates-internal"); const ORDERED_ENTRIES = [...BY_ID.values()].sort((left, right) => left.meta.id.localeCompare(right.meta.id)); const INTERNAL_IDS = new Set(Object.values(internalManifests).map((meta) => meta.id)); +const RUNTIME_TEMPLATES = new Map(); + +/** Native registry metadata, never a shell import path supplied by a template. */ +export function registerRuntimeTemplate(meta: Record): void { + if (meta.sourceKind !== "user" || typeof meta.id !== "string" || meta.schemaVersion !== "synth.visual-template.v1") { + throw new Error("Invalid user-template metadata"); + } + if (BY_ID.has(meta.id)) throw new Error(`User template cannot shadow bundled template ${meta.id}`); + const inputs = meta.inputs ?? meta.slots; + if (!Array.isArray(inputs) || inputs.some(input => !input || typeof input !== "object" || typeof input.name !== "string")) { + throw new Error("User template has invalid input declarations"); + } + RUNTIME_TEMPLATES.set(meta.id, { + ...meta, title: String(meta.title ?? meta.id), genre: String(meta.genre ?? "custom"), + version: String(meta.version ?? ""), description: String(meta.description ?? ""), + shell: "shell.tsx", root: String(meta.path ?? ""), inputs, slots: inputs, + } as VisualTemplate); +} type ShellModule = { Shell: (props: Record) => unknown; @@ -96,12 +114,12 @@ function withDistribution(entry: RegistryEntry): VisualTemplate { } export function listTemplates(): VisualTemplate[] { - return ORDERED_ENTRIES.map(withDistribution); + return [...ORDERED_ENTRIES.map(withDistribution), ...RUNTIME_TEMPLATES.values()]; } export function resolveTemplate(id: string): VisualTemplate | undefined { const entry = BY_ID.get(id); - if (!entry) return undefined; + if (!entry) return RUNTIME_TEMPLATES.get(id); return withDistribution(entry); } From 7a812601e5bc810d0ddd9b3ca625dbfc53cf1da3 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 04:58:02 -0400 Subject: [PATCH 05/25] Restore live approval inspection and human-only digest resolution --- .../src-tauri/src/contract/commands.rs | 2 + .../src/contract/desktop_dispatch.rs | 26 ++++ .../src-tauri/src/contract/desktop_policy.rs | 3 + .../src-tauri/src/contract/desktop_tools.json | 143 ++++++++++++++++++ .../src-tauri/src/contract/specta.rs | 5 +- .../src-tauri/src/session/approval.rs | 51 +++++++ .../src/session/approval_inspection.rs | 135 +++++++++++++++++ .../src/renderer/src/generated/protocol.ts | 25 +++ 8 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 apps/synth_desktop/src-tauri/src/session/approval_inspection.rs diff --git a/apps/synth_desktop/src-tauri/src/contract/commands.rs b/apps/synth_desktop/src-tauri/src/contract/commands.rs index 7f8d0e99e..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"; 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 93b7b7e60..f553c0d14 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -361,6 +361,8 @@ pub const NAMES: &[&str] = &[ "visuals_template_save", "visuals_template_create", "visuals_template_validate", + "approvals_pending", + "approvals_approve_digest", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -724,6 +726,8 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "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), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4665,3 +4669,25 @@ fn operation_357(app: &tauri::AppHandle, args: Value) -> Reply<'_> { 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})) + }) +} 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..3fe32d404 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,7 @@ pub fn human_surface(name: &str) -> Option<&'static str> { "context_mcp_group_update" | "desktop_state_commit" | "codex_approval_resolve" + | "approvals_approve_digest" | "workspace_scope_approve_request" | "workspace_scope_deny_request" | "desktop_permissions_update" @@ -62,6 +63,8 @@ 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("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 5105ad22b..eed3ec20e 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -66333,6 +66333,149 @@ "_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" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index a8ed7a145..1a1d8441d 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -453,6 +453,8 @@ pub fn builder() -> Builder { 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, ]) } @@ -612,8 +614,9 @@ mod tests { // 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. assert_eq!( - exported, 358, + exported, 360, "generated bindings must contain the complete desktop command set" ); assert_eq!( diff --git a/apps/synth_desktop/src-tauri/src/session/approval.rs b/apps/synth_desktop/src-tauri/src/session/approval.rs index 496b71114..64bf7bcce 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>>; @@ -1849,6 +1852,54 @@ 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()); } + } + 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..38ec7eed8 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs @@ -0,0 +1,135 @@ +//! 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 { + 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/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index eefb30b43..868c0e0ba 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -722,6 +722,13 @@ export const commands = { visualsTemplateSave: (sessionId: string, templateId: string, manifest: string, source: string) => typedError(__TAURI_INVOKE("visuals_template_save", { sessionId, templateId, manifest, source })), visualsTemplateCreate: (sessionId: string, templateId: string, fromTemplateId: string, title: string | null) => typedError(__TAURI_INVOKE("visuals_template_create", { sessionId, templateId, fromTemplateId, title })), visualsTemplateValidate: (templateId: string) => typedError(__TAURI_INVOKE("visuals_template_validate", { templateId })), + approvalsPending: () => typedError(__TAURI_INVOKE("approvals_pending")), + /** + * 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. + */ + approvalsApproveDigest: (request: ApproveDigestRequest) => typedError(__TAURI_INVOKE("approvals_approve_digest", { request })), }; /* Types */ @@ -872,6 +879,16 @@ export type AppEvent = { createdAt: string, }; +export type ApproveDigestOutcome = { + approvalId: string, + alreadySettled: boolean, + executionSpecDigest: string, +}; + +export type ApproveDigestRequest = { + executionSpecDigest: string, +}; + export type ArtifactMutationReceipt = { operation: string, artifactId: string, @@ -3467,6 +3484,14 @@ export type PaidComputeAutoApprovalSettings = { providers: string[], }; +export type PendingApprovalView = { + approvalId: string, + sessionId: string, + kind: string, + requiresHuman: boolean, + preparationDigest: string | null, +}; + export type PendingGrantSummary = { requestId: string, secretId: string, From 0099cb6bcc8c0c2aad8af060b89c9e7abaf7f888 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:03:55 -0400 Subject: [PATCH 06/25] Bind approval clicks to the proposal digest shown to the operator --- .../src-tauri/src/contract/desktop_tools.json | 10 +++++ .../src-tauri/src/eval_driver.rs | 1 + apps/synth_desktop/src-tauri/src/lib.rs | 2 +- .../src-tauri/src/session/approval.rs | 22 +++++++++++ .../src/session/approval_inspection.rs | 30 ++++++++++++++ .../src-tauri/src/session/codex/manager.rs | 2 +- .../src-tauri/src/session/codex/proto.rs | 2 + .../src-tauri/src/session/codex/tests.rs | 2 + .../src/renderer/src/bridge/types.ts | 2 +- .../src/components/ChatTranscript.tsx | 12 +++--- .../components/OperatorTrainingApproval.tsx | 6 +-- .../src/renderer/src/generated/protocol.ts | 1 + .../renderer/src/hooks/useAppController.ts | 2 +- .../synth_desktop/src/renderer/src/routes.tsx | 2 +- .../src/renderer/src/runtime/desktopBridge.ts | 2 +- .../src/renderer/src/runtime/sessionView.ts | 1 + .../src/renderer/src/types/landing.ts | 1 + .../playwright/annotation-paid-card.spec.ts | 39 +++++++++++++++++-- 18 files changed, 120 insertions(+), 19 deletions(-) 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 eed3ec20e..233a96f16 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -59343,6 +59343,16 @@ }, "decision": { "type": "string" + }, + "approvalDigest": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] } }, "required": [ 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 26905983b..936b9ba4a 100644 --- a/apps/synth_desktop/src-tauri/src/lib.rs +++ b/apps/synth_desktop/src-tauri/src/lib.rs @@ -5363,7 +5363,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/session/approval.rs b/apps/synth_desktop/src-tauri/src/session/approval.rs index 64bf7bcce..5d452c584 100644 --- a/apps/synth_desktop/src-tauri/src/session/approval.rs +++ b/apps/synth_desktop/src-tauri/src/session/approval.rs @@ -434,6 +434,7 @@ impl ApprovalKind { "timeoutSeconds": timeout_seconds, "credentialNames": credential_names, "preparationDigest": preparation_digest, + "approvalDigest": preparation_digest, "alwaysSupported": false, }), Self::SidecarLifecycle { sidecar, action } => json!({ @@ -1900,6 +1901,27 @@ mod tests { 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 index 38ec7eed8..596d720a3 100644 --- a/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs +++ b/apps/synth_desktop/src-tauri/src/session/approval_inspection.rs @@ -24,6 +24,36 @@ fn preparation_digest(kind: &ApprovalKind) -> Option<&str> { } 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 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..8c2f184bd 100644 --- a/apps/synth_desktop/src-tauri/src/session/codex/manager.rs +++ b/apps/synth_desktop/src-tauri/src/session/codex/manager.rs @@ -1454,7 +1454,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..a7458a065 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)] 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..85f55afe5 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 @@ -2018,6 +2019,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/renderer/src/bridge/types.ts b/apps/synth_desktop/src/renderer/src/bridge/types.ts index d2f975984..f69498719 100644 --- a/apps/synth_desktop/src/renderer/src/bridge/types.ts +++ b/apps/synth_desktop/src/renderer/src/bridge/types.ts @@ -525,7 +525,7 @@ export type CodexBridge = { listThreadItems?(sessionId: string, threadId: string, cursor?: string, limit?: number): Promise; /** Mid-turn user input via Codex `turn/steer`. Optional on browser fixtures without a native runtime. */ steerTurn?(sessionId: string, text: string): Promise; - resolveApproval(sessionId: string, approvalId: string, decision: "once" | "always" | "reject" | "remember-locator" | "register-source"): Promise; + resolveApproval(sessionId: string, approvalId: string, decision: "once" | "always" | "reject" | "remember-locator" | "register-source", approvalDigest?: string): Promise; close(sessionId: string): Promise; onEvent(listener: (event: CodexEvent) => void): () => void; }; diff --git a/apps/synth_desktop/src/renderer/src/components/ChatTranscript.tsx b/apps/synth_desktop/src/renderer/src/components/ChatTranscript.tsx index 2d5d07dfd..fa8980e25 100644 --- a/apps/synth_desktop/src/renderer/src/components/ChatTranscript.tsx +++ b/apps/synth_desktop/src/renderer/src/components/ChatTranscript.tsx @@ -32,7 +32,7 @@ type Props = { onOpenContainer?: (id: string | null) => void; onOpenReport?: (id: string) => void; onOpenRun?: (run: OptimizerRunRecord) => void; - onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source") => void; + onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source", approvalDigest?: string) => void; onAlwaysAllow?: (approvalId: string) => void; onReject?: (approvalId: string) => void; running?: boolean; @@ -225,7 +225,7 @@ function ActivityLine({ onToggleVisual?: () => void; containerOpen?: boolean; onToggleContainer?: () => void; - onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source") => void; + onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source", approvalDigest?: string) => void; onAlwaysAllow?: (approvalId: string) => void; onReject?: (approvalId: string) => void; live?: boolean; @@ -474,7 +474,7 @@ function ActivityLine({ export function PaidComputeApprovalModal({ line, onApprove, onReject }: { line: LocalActivityLine; - onApprove?: (approvalId: string) => void; + onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source", approvalDigest?: string) => void; onReject?: (approvalId: string) => void; }) { const payload = line.approvalPayload; @@ -520,7 +520,7 @@ export function PaidComputeApprovalModal({ line, onApprove, onReject }: { window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [line.approvalId, onReject]); - return
+ return
Paid compute
@@ -564,7 +564,7 @@ export function PaidComputeApprovalModal({ line, onApprove, onReject }: { : null}
- +
; @@ -572,7 +572,7 @@ export function PaidComputeApprovalModal({ line, onApprove, onReject }: { function CredentialAccessApprovalModal({ line, onApprove, onReject }: { line: LocalActivityLine; - onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source") => void; + onApprove?: (approvalId: string, decision?: "remember-locator" | "register-source", approvalDigest?: string) => void; onReject?: (approvalId: string) => void; }) { const payload = line.approvalPayload; diff --git a/apps/synth_desktop/src/renderer/src/components/OperatorTrainingApproval.tsx b/apps/synth_desktop/src/renderer/src/components/OperatorTrainingApproval.tsx index dc0265c1e..a732caedf 100644 --- a/apps/synth_desktop/src/renderer/src/components/OperatorTrainingApproval.tsx +++ b/apps/synth_desktop/src/renderer/src/components/OperatorTrainingApproval.tsx @@ -27,16 +27,16 @@ export function OperatorTrainingApproval({ eventsBySession, onError }: { .filter(line => line.kind === "approval" && line.approvalKind === "paid_compute" && line.approvalId) .map(line => ({ sessionId, line })); })[0], [eventsBySession, operatorEvents]); - const resolve = async (approvalId: string, decision: "once" | "reject") => { + const resolve = async (approvalId: string, decision: "once" | "reject", approvalDigest?: string) => { if (!pending || settling) return; setSettling(true); try { if (!bridges.codex) throw new Error("Native approval service is unavailable"); - await bridges.codex.resolveApproval(pending.sessionId, approvalId, decision); + await bridges.codex.resolveApproval(pending.sessionId, approvalId, decision, approvalDigest); } catch (error) { onError(error instanceof Error ? error.message : "Approval could not be resolved"); } finally { setSettling(false); } }; return pending ? void resolve(id, "once")} onReject={id => void resolve(id, "reject")} /> : null; + onApprove={(id, _decision, digest) => void resolve(id, "once", digest)} onReject={id => void resolve(id, "reject")} /> : null; } diff --git a/apps/synth_desktop/src/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index 868c0e0ba..dfebe1adc 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -1094,6 +1094,7 @@ export type CodexApprovalDecisionRequest = { sessionId: string, approvalId: string, decision: string, + approvalDigest?: string | null, }; export type CodexSessionInfo = { diff --git a/apps/synth_desktop/src/renderer/src/hooks/useAppController.ts b/apps/synth_desktop/src/renderer/src/hooks/useAppController.ts index ef118459e..34040af61 100644 --- a/apps/synth_desktop/src/renderer/src/hooks/useAppController.ts +++ b/apps/synth_desktop/src/renderer/src/hooks/useAppController.ts @@ -2034,7 +2034,7 @@ export function useAppController() { }; settlingApprovalIdsRef.current.add(approvalId); try { - await nativeCodex.resolveApproval(activeSessionId, approvalId, decision); + await nativeCodex.resolveApproval(activeSessionId, approvalId, decision, typeof payload.approvalDigest === "string" ? payload.approvalDigest : undefined); // The durable native settlement event is authoritative, but the RPC // reply is also a settlement receipt. Publish a local equivalent so a // dropped/reordered event cannot leave a live approval modal behind. diff --git a/apps/synth_desktop/src/renderer/src/routes.tsx b/apps/synth_desktop/src/renderer/src/routes.tsx index d700e370b..5536623f6 100644 --- a/apps/synth_desktop/src/renderer/src/routes.tsx +++ b/apps/synth_desktop/src/renderer/src/routes.tsx @@ -620,7 +620,7 @@ export function MainRoutes(props: MainRoutesProps): ReactNode { onOpenArtifact={openArtifactInDock} openContainerId={openContainer?.id ?? null} onOpenContainer={(id) => void toggleContainer(id)} - onApprove={(approvalId, decision) => void controlActive("approve", { approvalId, decision })} + onApprove={(approvalId, decision, approvalDigest) => void controlActive("approve", { approvalId, decision, approvalDigest })} onAlwaysAllow={(approvalId) => void controlActive("approve", { approvalId, decision: "always" }) } diff --git a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts index 340f7cb4f..8c4742073 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts @@ -855,7 +855,7 @@ window.synthWorkspaceScope ??= isTauri fromGenerated(spectaCommands.codexThreadItemsList(wire({ sessionId, threadId, cursor: cursor ?? null, limit: limit ?? null }))), steerTurn: (sessionId, text) => fromGenerated(spectaCommands.codexTurnSteer({ sessionId, text })), - resolveApproval: (sessionId, approvalId, decision) => fromGenerated(spectaCommands.codexApprovalResolve({ sessionId, approvalId, decision })), + resolveApproval: (sessionId, approvalId, decision, approvalDigest) => fromGenerated(spectaCommands.codexApprovalResolve({ sessionId, approvalId, decision, approvalDigest: approvalDigest ?? null })), close: (sessionId) => fromGenerated(spectaCommands.codexSessionClose({ sessionId })), onEvent(listener) { let disposed = false; diff --git a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts index 02dc2a610..afb99ebc2 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/sessionView.ts @@ -1751,6 +1751,7 @@ export function eventsToLocalActivity( approvalKind: approvalKind === "shell_command" || approvalKind === "paid_compute" || approvalKind === "sidecar_lifecycle" || approvalKind === "container_lifecycle" || approvalKind === "credential_access" || approvalKind === "plugin_lifecycle" || approvalKind === "visual_template_persist" || approvalKind === "computer_use" ? approvalKind : "permission", approvalPayload: event.eventKind === "approval.requested" && approvalKind === "paid_compute" ? { + approvalDigest: typeof payload.approvalDigest === "string" ? payload.approvalDigest : typeof payload.preparationDigest === "string" ? payload.preparationDigest : undefined, operation: typeof payload.operation === "string" ? payload.operation : undefined, parameters: payload.parameters && typeof payload.parameters === "object" && !Array.isArray(payload.parameters) ? payload.parameters as Record : undefined, estimatedCostUsdMicros: typeof payload.estimatedCostUsdMicros === "number" ? payload.estimatedCostUsdMicros : undefined, diff --git a/apps/synth_desktop/src/renderer/src/types/landing.ts b/apps/synth_desktop/src/renderer/src/types/landing.ts index 3940927df..d0f9a2c9d 100644 --- a/apps/synth_desktop/src/renderer/src/types/landing.ts +++ b/apps/synth_desktop/src/renderer/src/types/landing.ts @@ -282,6 +282,7 @@ export type LocalActivityLine = { // Mirrors `ApprovalKind::as_str` in src-tauri/src/session/approval.rs. approvalKind?: "shell_command" | "paid_compute" | "sidecar_lifecycle" | "container_lifecycle" | "credential_access" | "plugin_lifecycle" | "visual_template_persist" | "computer_use" | "permission"; approvalPayload?: { + approvalDigest?: string; operation?: string; parameters?: Record; estimatedCostUsdMicros?: number; 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"] + ]); +}); From 6a53eede699597d556b6d2ce90e1d045e562b5c3 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:18:55 -0400 Subject: [PATCH 07/25] Serialize configuration mutations and atomically preserve settings --- .../src-tauri/src/synth_config.rs | 502 ++++++++++++------ 1 file changed, 332 insertions(+), 170 deletions(-) diff --git a/apps/synth_desktop/src-tauri/src/synth_config.rs b/apps/synth_desktop/src-tauri/src/synth_config.rs index 362fe682b..cd4cea727 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config.rs @@ -348,7 +348,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 +469,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 +688,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 +860,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 +1073,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 +1391,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 +1454,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 +1471,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 +1809,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( From 93fb986ddb9fe1afb6a39bd28cad3f9ebf437529 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:27:01 -0400 Subject: [PATCH 08/25] Restore project grant persistence and reject discovery symlink escapes --- apps/synth_desktop/src-tauri/src/lib.rs | 1 + .../src/optimizers/workspace_recipe.rs | 79 ++++- .../src-tauri/src/project_sources.rs | 254 ++++++++++++++++ .../src-tauri/src/synth_config.rs | 7 + .../src/synth_config/project_sources.rs | 278 ++++++++++++++++++ 5 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/project_sources.rs create mode 100644 apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs diff --git a/apps/synth_desktop/src-tauri/src/lib.rs b/apps/synth_desktop/src-tauri/src/lib.rs index 936b9ba4a..16297e954 100644 --- a/apps/synth_desktop/src-tauri/src/lib.rs +++ b/apps/synth_desktop/src-tauri/src/lib.rs @@ -52,6 +52,7 @@ mod model_catalog; mod optimizers; mod platform; mod plugins; +mod project_sources; pub mod presentation; pub mod recovery; mod reports; 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..08e3986a5 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs @@ -798,22 +798,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 +839,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 +847,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 @@ -2027,6 +2038,42 @@ mod tests { use super::*; use tempfile::tempdir; + #[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"); 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..1d7aece49 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -0,0 +1,254 @@ +//! 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}, +}; + +#[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 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> { + 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) +} + +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/synth_config.rs b/apps/synth_desktop/src-tauri/src/synth_config.rs index cd4cea727..090b3ce32 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config.rs @@ -7,6 +7,13 @@ use std::{ path::{Path, PathBuf}, }; +#[path = "synth_config/project_sources.rs"] +mod project_sources; +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"; 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..a66b26e6c --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs @@ -0,0 +1,278 @@ +//! 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()) +} + +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)) +} + +pub fn forget_project_source(path: &str) -> Result { + forget_at(&config_path(), path) +} + +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)) +} + +fn mutate_at( + path: &Path, + edit: impl FnOnce(&mut Vec), +) -> 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; + + 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))); + } + } +} From f9960a2a0e23e313b391743a4e64cfc84ba41e8f Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:36:46 -0400 Subject: [PATCH 09/25] Restore native project source inspection and picker commands --- .../src/contract/desktop_dispatch.rs | 52 ++ .../src-tauri/src/contract/desktop_policy.rs | 5 + .../src-tauri/src/contract/desktop_tools.json | 651 ++++++++++++++++++ .../src-tauri/src/contract/specta.rs | 7 +- .../src/optimizers/workspace_recipe.rs | 42 +- .../src-tauri/src/project_sources.rs | 30 + .../src-tauri/src/project_sources/commands.rs | 46 ++ .../src/project_sources/inspection.rs | 207 ++++++ .../src/renderer/src/generated/protocol.ts | 34 + 9 files changed, 1070 insertions(+), 4 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/project_sources/commands.rs create mode 100644 apps/synth_desktop/src-tauri/src/project_sources/inspection.rs 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 f553c0d14..00bfd802b 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -363,6 +363,10 @@ pub const NAMES: &[&str] = &[ "visuals_template_validate", "approvals_pending", "approvals_approve_digest", + "project_sources_get", + "project_sources_refresh", + "project_source_add", + "project_source_remove", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -728,6 +732,10 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "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), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4691,3 +4699,47 @@ fn operation_359(app: &tauri::AppHandle, args: Value) -> Reply<'_> { 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(), 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(serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).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 3fe32d404..bc33b5a1e 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs @@ -22,6 +22,8 @@ pub fn human_surface(name: &str) -> Option<&'static str> { | "desktop_state_commit" | "codex_approval_resolve" | "approvals_approve_digest" + | "project_source_add" + | "project_source_remove" | "workspace_scope_approve_request" | "workspace_scope_deny_request" | "desktop_permissions_update" @@ -65,6 +67,9 @@ mod tests { 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("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 233a96f16..f81477739 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -66486,6 +66486,657 @@ "_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" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index 1a1d8441d..c77602455 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -455,6 +455,10 @@ pub fn builder() -> Builder { 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, ]) } @@ -615,8 +619,9 @@ mod tests { // 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. assert_eq!( - exported, 360, + exported, 364, "generated bindings must contain the complete desktop command set" ); assert_eq!( 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 08e3986a5..ce620eb54 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) } @@ -2038,6 +2049,31 @@ 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() { diff --git a/apps/synth_desktop/src-tauri/src/project_sources.rs b/apps/synth_desktop/src-tauri/src/project_sources.rs index 1d7aece49..89a88f719 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -10,6 +10,36 @@ use std::{ path::{Path, PathBuf}, }; +pub mod commands; +mod inspection; +pub use inspection::{catalog, ProjectSourceCatalog}; + +fn admit_picked_root(path: &str, containers: bool, recipes: bool) -> Result { + 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") + ); + } + synth_config::merge_project_source(ProjectSourceEntry { + path: root.display().to_string(), + containers, + recipes, + })?; + catalog() +} + +fn remove_root(path: &str) -> Result { + synth_config::forget_project_source(path)?; + catalog() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Capability { Containers, 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..b41991d71 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs @@ -0,0 +1,46 @@ +//! Human admission is performed by a native folder picker, not an agent path. +use super::{admit_picked_root, catalog, remove_root, ProjectSourceCatalog}; +use crate::error::AppError; +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, + 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(&path, containers, recipes) + .map(Some) + .map_err(AppError::from) +} + +#[tauri::command] +#[specta::specta] +pub fn project_source_remove(path: String) -> Result { + remove_root(&path).map_err(AppError::from) +} 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/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index dfebe1adc..c303bb3e2 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -729,6 +729,14 @@ export const commands = { * legacy alreadySettled field is false, not a promise of durable idempotence. */ approvalsApproveDigest: (request: ApproveDigestRequest) => typedError(__TAURI_INVOKE("approvals_approve_digest", { request })), + projectSourcesGet: () => typedError(__TAURI_INVOKE("project_sources_get")), + projectSourcesRefresh: () => typedError(__TAURI_INVOKE("project_sources_refresh")), + projectSourceAdd: (containers: boolean, recipes: boolean) => typedError<{ + configPath: string, + sources: ProjectSourceRow[], + implicitRoots: ProjectSourceRow[], +} | null, AppError_Serialize>(__TAURI_INVOKE("project_source_add", { containers, recipes })), + projectSourceRemove: (path: string) => typedError(__TAURI_INVOKE("project_source_remove", { path })), }; /* Types */ @@ -3554,6 +3562,30 @@ export type PluginStatus = { detail?: string | null, }; +export type ProjectSourceCatalog = { + configPath: string, + sources: ProjectSourceRow[], + implicitRoots: ProjectSourceRow[], +}; + +export type ProjectSourceInspection = { + path: string, + status: string, + code: string | null, + message: string | null, + containers: string[], + recipes: string[], +}; + +export type ProjectSourceRow = { + path: string, + containers: boolean, + recipes: boolean, + origin: RootOrigin, + inspection: ProjectSourceInspection, + lastScannedAt: string | null, +}; + export type ProviderUsePolicy = { operations: string[], models: string[], @@ -3986,6 +4018,8 @@ export type RolloutEvidenceEntry = { */ export type RolloutEvidenceState = "open" | "sealed_complete" | "sealed_partial" | "aborted" | "missing"; +export type RootOrigin = "configured" | "environment"; + export type RunCollection = "candidates" | "rollouts" | "evaluations" | "metric_points" | "proposer_calls" | "artifacts" | "evidence_refs"; export type RunCollectionFilter = { From 3bced3917825a6db04cf2f7253c4a56d0ffbfd3d Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:48:25 -0400 Subject: [PATCH 10/25] Persist project source requests and journal human decisions --- .../src/contract/desktop_dispatch.rs | 43 +- .../src-tauri/src/contract/desktop_policy.rs | 3 + .../src-tauri/src/contract/desktop_tools.json | 369 ++++++++++++++++++ .../src-tauri/src/contract/specta.rs | 6 +- .../src-tauri/src/project_sources.rs | 34 +- .../src-tauri/src/project_sources/commands.rs | 50 ++- .../src-tauri/src/project_sources/requests.rs | 251 ++++++++++++ .../src-tauri/src/storage/migrations.rs | 46 +++ .../src/renderer/src/generated/protocol.ts | 26 ++ 9 files changed, 819 insertions(+), 9 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/project_sources/requests.rs 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 00bfd802b..de5b7cf66 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -367,6 +367,9 @@ pub const NAMES: &[&str] = &[ "project_sources_refresh", "project_source_add", "project_source_remove", + "project_source_request", + "project_source_requests_list", + "project_source_deny", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -736,6 +739,9 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "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), _ => Box::pin(async { anyhow::bail!("unknown desktop operation") }), } } @@ -4728,7 +4734,7 @@ fn operation_362(app: &tauri::AppHandle, args: Value) -> Reply<'_> { // 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(), 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:?}")))?; + 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})) }) } @@ -4739,7 +4745,40 @@ fn operation_363(app: &tauri::AppHandle, args: Value) -> Reply<'_> { // 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(serde_json::from_value(args.get("path").cloned().unwrap_or(Value::Null)).context("invalid path")?).map_err(|error| anyhow::anyhow!(format!("{error:?}")))?; + 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})) }) } 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 bc33b5a1e..47715c9bf 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs @@ -24,6 +24,7 @@ pub fn human_surface(name: &str) -> Option<&'static str> { | "approvals_approve_digest" | "project_source_add" | "project_source_remove" + | "project_source_deny" | "workspace_scope_approve_request" | "workspace_scope_deny_request" | "desktop_permissions_update" @@ -70,6 +71,8 @@ mod tests { 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("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 f81477739..4fcd9cae0 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -67137,6 +67137,375 @@ "_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" + } } ] } diff --git a/apps/synth_desktop/src-tauri/src/contract/specta.rs b/apps/synth_desktop/src-tauri/src/contract/specta.rs index c77602455..67115f646 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -459,6 +459,9 @@ pub fn builder() -> Builder { 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, ]) } @@ -620,8 +623,9 @@ mod tests { // 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. assert_eq!( - exported, 364, + exported, 367, "generated bindings must contain the complete desktop command set" ); assert_eq!( diff --git a/apps/synth_desktop/src-tauri/src/project_sources.rs b/apps/synth_desktop/src-tauri/src/project_sources.rs index 89a88f719..e42a04255 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -12,9 +12,16 @@ use std::{ pub mod commands; mod inspection; +pub mod requests; pub use inspection::{catalog, ProjectSourceCatalog}; -fn admit_picked_root(path: &str, containers: bool, recipes: bool) -> Result { +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"); } @@ -32,11 +39,29 @@ fn admit_picked_root(path: &str, containers: bool, recipes: bool) -> Result Result { +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?; catalog() } @@ -110,7 +135,10 @@ fn validate_root(root: &Path, home: Option<&Path>) -> Result<()> { "/bin", "/sbin", ]; - if home == Some(root) || broad.iter().any(|path| root == Path::new(path)) { + 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(()) diff --git a/apps/synth_desktop/src-tauri/src/project_sources/commands.rs b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs index b41991d71..736f87745 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources/commands.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs @@ -1,6 +1,10 @@ //! Human admission is performed by a native folder picker, not an agent path. +use super::requests::{self, ProjectSourceRequest, ProjectSourceRequestInput}; use super::{admit_picked_root, catalog, remove_root, ProjectSourceCatalog}; +use crate::core_runtime::CoreRuntime; use crate::error::AppError; +use std::sync::Arc; +use tauri::State; use tauri_plugin_dialog::DialogExt; #[tauri::command] @@ -21,6 +25,7 @@ pub fn project_sources_refresh() -> Result { #[specta::specta] pub async fn project_source_add( app: tauri::AppHandle, + core: State<'_, Arc>, containers: bool, recipes: bool, ) -> Result, AppError> { @@ -34,13 +39,52 @@ pub async fn project_source_add( let Some(path) = receiver.await.map_err(AppError::from)? else { return Ok(None); }; - admit_picked_root(&path, containers, recipes) + admit_picked_root(core.storage().database(), &path, containers, recipes) + .await .map(Some) .map_err(AppError::from) } #[tauri::command] #[specta::specta] -pub fn project_source_remove(path: String) -> Result { - remove_root(&path).map_err(AppError::from) +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) } 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..d8b0d8ad0 --- /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(super) 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/storage/migrations.rs b/apps/synth_desktop/src-tauri/src/storage/migrations.rs index 77d931001..912c4a15f 100644 --- a/apps/synth_desktop/src-tauri/src/storage/migrations.rs +++ b/apps/synth_desktop/src-tauri/src/storage/migrations.rs @@ -80,6 +80,7 @@ const MIGRATIONS: &[&str] = &[ MIGRATION_75, MIGRATION_76, MIGRATION_77, + MIGRATION_78, ]; const MIGRATION_70: &str = r#" @@ -247,6 +248,7 @@ 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), @@ -3767,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 @@ -5773,6 +5795,30 @@ 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, diff --git a/apps/synth_desktop/src/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index c303bb3e2..5d9daac24 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -737,6 +737,9 @@ export const commands = { implicitRoots: ProjectSourceRow[], } | null, AppError_Serialize>(__TAURI_INVOKE("project_source_add", { containers, recipes })), projectSourceRemove: (path: string) => typedError(__TAURI_INVOKE("project_source_remove", { path })), + projectSourceRequest: (request: ProjectSourceRequestInput) => typedError(__TAURI_INVOKE("project_source_request", { request })), + projectSourceRequestsList: (sessionId: string | null) => typedError(__TAURI_INVOKE("project_source_requests_list", { sessionId })), + projectSourceDeny: (requestId: string) => typedError(__TAURI_INVOKE("project_source_deny", { requestId })), }; /* Types */ @@ -3577,6 +3580,29 @@ export type ProjectSourceInspection = { recipes: string[], }; +export type ProjectSourceRequest = { + id: string, + sessionId: string | null, + requestedPath: string, + canonicalPath: string, + reason: string, + containers: boolean, + recipes: boolean, + attachToConversation: boolean, + status: string, + createdAt: string, + resolvedAt: string | null, +}; + +export type ProjectSourceRequestInput = { + sessionId: string | null, + path: string, + reason: string, + containers: boolean, + recipes: boolean, + attachToConversation?: boolean, +}; + export type ProjectSourceRow = { path: string, containers: boolean, From f6f483831d023ae5e6770be5b306a22fa011b0df Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 05:57:35 -0400 Subject: [PATCH 11/25] Bind source approvals to native picker and compensate failed grants --- .../src/contract/desktop_dispatch.rs | 13 + .../src-tauri/src/contract/desktop_policy.rs | 2 + .../src-tauri/src/contract/desktop_tools.json | 368 ++++++++++++++++++ .../src-tauri/src/contract/specta.rs | 4 +- .../src-tauri/src/project_sources.rs | 18 +- .../src-tauri/src/project_sources/approval.rs | 252 ++++++++++++ .../src-tauri/src/project_sources/commands.rs | 34 +- .../src-tauri/src/synth_config.rs | 3 + .../src/synth_config/project_sources.rs | 101 ++++- .../src/renderer/src/generated/protocol.ts | 15 + 10 files changed, 800 insertions(+), 10 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/project_sources/approval.rs 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 de5b7cf66..8a8c5d4ac 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_dispatch.rs @@ -370,6 +370,7 @@ pub const NAMES: &[&str] = &[ "project_source_request", "project_source_requests_list", "project_source_deny", + "project_source_approve", ]; type Reply<'a> = std::pin::Pin> + Send + 'a>>; @@ -742,6 +743,7 @@ pub fn invoke<'a>(app: &'a tauri::AppHandle, name: &str, args: Value) -> Reply<' "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") }), } } @@ -4782,3 +4784,14 @@ fn operation_366(app: &tauri::AppHandle, args: Value) -> Reply<'_> { 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 47715c9bf..c885fb4bf 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_policy.rs @@ -25,6 +25,7 @@ pub fn human_surface(name: &str) -> Option<&'static str> { | "project_source_add" | "project_source_remove" | "project_source_deny" + | "project_source_approve" | "workspace_scope_approve_request" | "workspace_scope_deny_request" | "desktop_permissions_update" @@ -73,6 +74,7 @@ mod tests { 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 4fcd9cae0..90350a33f 100644 --- a/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json +++ b/apps/synth_desktop/src-tauri/src/contract/desktop_tools.json @@ -67506,6 +67506,374 @@ "_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 67115f646..d9ce89789 100644 --- a/apps/synth_desktop/src-tauri/src/contract/specta.rs +++ b/apps/synth_desktop/src-tauri/src/contract/specta.rs @@ -462,6 +462,7 @@ pub fn builder() -> Builder { 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, ]) } @@ -624,8 +625,9 @@ mod tests { // 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, 367, + exported, 368, "generated bindings must contain the complete desktop command set" ); assert_eq!( diff --git a/apps/synth_desktop/src-tauri/src/project_sources.rs b/apps/synth_desktop/src-tauri/src/project_sources.rs index e42a04255..b020f34ab 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -10,9 +10,11 @@ use std::{ 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( @@ -34,16 +36,16 @@ async fn admit_picked_root( inspection.message.as_deref().unwrap_or("invalid source") ); } - synth_config::merge_project_source(ProjectSourceEntry { + let change = synth_config::begin_project_source_grant(ProjectSourceEntry { path: root.display().to_string(), containers, recipes, })?; - requests::audit(db, "project_source.approved", serde_json::json!({ + 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?; + })).await { return Err(compensate(change, error)); } catalog() } @@ -61,10 +63,18 @@ async fn remove_root( "project_source.removed", serde_json::json!({ "path": path.trim() }), ) - .await?; + .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, 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 index 736f87745..7a99e4207 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources/commands.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources/commands.rs @@ -1,8 +1,11 @@ //! Human admission is performed by a native folder picker, not an agent path. use super::requests::{self, ProjectSourceRequest, ProjectSourceRequestInput}; -use super::{admit_picked_root, catalog, remove_root, ProjectSourceCatalog}; +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; @@ -88,3 +91,32 @@ pub async fn project_source_deny( .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/synth_config.rs b/apps/synth_desktop/src-tauri/src/synth_config.rs index 090b3ce32..a474186eb 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config.rs @@ -9,6 +9,9 @@ use std::{ #[path = "synth_config/project_sources.rs"] mod project_sources; +#[cfg(test)] +pub(crate) use project_sources::begin_project_source_grant_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, 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 index a66b26e6c..040da286a 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs @@ -38,7 +38,10 @@ pub fn merge_project_source(entry: ProjectSourceEntry) -> Result Result { - mutate_at(path, |entries| entries.push(entry)) + mutate_at(path, |entries| { + entries.push(entry); + Ok(()) + }) } pub fn forget_project_source(path: &str) -> Result { @@ -48,16 +51,86 @@ pub fn forget_project_source(path: &str) -> Result { 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)) + 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), + edit: impl FnOnce(&mut Vec) -> Result<()>, ) -> Result { let entries = mutate_config(path, |document| { let mut entries = entries_from_document(document)?; - edit(&mut entries); + edit(&mut entries)?; let entries = normalize(entries)?; let root = document .as_table_mut() @@ -171,6 +244,26 @@ 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(), diff --git a/apps/synth_desktop/src/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index 5d9daac24..774e89746 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -740,6 +740,13 @@ export const commands = { projectSourceRequest: (request: ProjectSourceRequestInput) => typedError(__TAURI_INVOKE("project_source_request", { request })), projectSourceRequestsList: (sessionId: string | null) => typedError(__TAURI_INVOKE("project_source_requests_list", { sessionId })), projectSourceDeny: (requestId: string) => typedError(__TAURI_INVOKE("project_source_deny", { requestId })), + projectSourceApprove: (requestId: string) => typedError<{ + request: ProjectSourceRequest, + source: ProjectSourceRow, + catalog: ProjectSourceCatalog, + scope: ConversationWorkspaceScope | null, + attachmentError: string | null, +} | null, AppError_Serialize>(__TAURI_INVOKE("project_source_approve", { requestId })), }; /* Types */ @@ -3565,6 +3572,14 @@ export type PluginStatus = { detail?: string | null, }; +export type ProjectSourceApproval = { + request: ProjectSourceRequest, + source: ProjectSourceRow, + catalog: ProjectSourceCatalog, + scope: ConversationWorkspaceScope | null, + attachmentError: string | null, +}; + export type ProjectSourceCatalog = { configPath: string, sources: ProjectSourceRow[], From 1d9277fb50782180145fa4bcd008dbf19b2a3171 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 06:07:45 -0400 Subject: [PATCH 12/25] Restore workspace source controls and pending approval UX --- .../src/renderer/src/bridge/types.ts | 10 ++ .../src/components/ProjectSourcesSettings.css | 9 ++ .../src/components/ProjectSourcesSettings.tsx | 97 +++++++++++++++++++ .../renderer/src/components/SettingsPage.tsx | 7 ++ apps/synth_desktop/src/renderer/src/env.d.ts | 2 + .../synth_desktop/src/renderer/src/routes.tsx | 2 +- .../src/renderer/src/runtime/desktopBridge.ts | 18 ++++ .../project-sources-settings.spec.ts | 91 +++++++++++++++++ 8 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.css create mode 100644 apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.tsx create mode 100644 apps/synth_desktop/tests/playwright/project-sources-settings.spec.ts diff --git a/apps/synth_desktop/src/renderer/src/bridge/types.ts b/apps/synth_desktop/src/renderer/src/bridge/types.ts index f69498719..d6c592bff 100644 --- a/apps/synth_desktop/src/renderer/src/bridge/types.ts +++ b/apps/synth_desktop/src/renderer/src/bridge/types.ts @@ -445,6 +445,16 @@ export type SynthConfigBridge = { }): Promise; }; +export type ProjectSourcesBridge = { + get(): Promise; + refresh(): Promise; + add(containers: boolean, recipes: boolean): Promise; + remove(path: string): Promise; + requests(sessionId?: string | null): Promise; + approve(requestId: string): Promise; + deny(requestId: string): Promise; +}; + export type CodexSessionStart = { sessionId: string; workspace: string; diff --git a/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.css b/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.css new file mode 100644 index 000000000..82b9814f9 --- /dev/null +++ b/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.css @@ -0,0 +1,9 @@ +.project-sources { display: grid; gap: 12px; padding: 18px; } +.project-sources h3, .project-sources h4, .project-sources p { margin: 0; } +.project-sources code { overflow-wrap: anywhere; } +.project-source-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.project-source-actions label { display: inline-flex; align-items: center; gap: 6px; } +.project-source-row { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 12px 0; border-top: 1px solid var(--border-subtle, #4444); } +.project-source-row > div { min-width: 0; display: grid; gap: 4px; } +.project-source-row button { flex-shrink: 0; } +.project-source-request { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--border-subtle, #4444); border-radius: 8px; } diff --git a/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.tsx b/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.tsx new file mode 100644 index 000000000..498291e6e --- /dev/null +++ b/apps/synth_desktop/src/renderer/src/components/ProjectSourcesSettings.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ProjectSourceCatalog, ProjectSourceRequest, ProjectSourceRow } from "../generated/protocol"; +import { bridges } from "../runtime/desktopBridge"; +import { publicError } from "../runtime/publicError"; +import "./ProjectSourcesSettings.css"; + +function SourceRow({ row, busy, remove }: { row: ProjectSourceRow; busy: boolean; remove?: (path: string) => void }) { + const counts = [row.containers ? `${row.inspection.containers.length} container(s)` : null, row.recipes ? `${row.inspection.recipes.length} recipe(s)` : null].filter(Boolean).join(" · "); + return
+
{row.path}

+ {row.containers ? "Containers " : ""}{row.recipes ? "Recipes " : ""} + · {row.origin === "configured" ? "Approved" : "Launcher environment"} +

{row.inspection.status === "valid" + ? `Last successful scan: ${counts}` + : row.inspection.message ?? row.inspection.status}

+ {remove ? : null} +
; +} + +export function ProjectSourcesSettings() { + const [catalog, setCatalog] = useState(null); + const [requests, setRequests] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [containers, setContainers] = useState(true); + const [recipes, setRecipes] = useState(true); + const active = useRef(false); + const mounted = useRef(true); + const bridge = () => { + if (!bridges.projectSources) throw new Error("Project source controls are unavailable"); + return bridges.projectSources; + }; + const reload = useCallback(async () => { + const [next, pending] = await Promise.all([bridge().refresh(), bridge().requests()]); + if (mounted.current) { setCatalog(next); setRequests(pending.filter((request) => request.status === "pending")); } + }, []); + const run = useCallback(async (action: () => Promise, quiet = false) => { + if (active.current) return; + active.current = true; + if (mounted.current) { setBusy(true); if (!quiet) { setError(null); setNotice(null); } } + try { await action(); } + catch (reason) { if (mounted.current) setError(publicError(reason)); } + finally { active.current = false; if (mounted.current) setBusy(false); } + }, []); + useEffect(() => { + mounted.current = true; + void run(reload); + const timer = window.setInterval(() => void run(reload, true), 5000); + return () => { mounted.current = false; window.clearInterval(timer); }; + }, [reload, run]); + const add = () => run(async () => { + const next = await bridge().add(containers, recipes); + if (next && mounted.current) { setCatalog(next); setNotice("Project source approved."); } + }); + const remove = (path: string) => void run(async () => { + try { const next = await bridge().remove(path); if (mounted.current) setCatalog(next); } + catch (reason) { await reload().catch(() => undefined); throw reason; } // Revocation may have succeeded before an audit failure. + }); + const approve = (id: string) => void run(async () => { + try { + const result = await bridge().approve(id); + if (!result) return; // Native picker cancellation grants nothing. + if (mounted.current) { + setCatalog(result.catalog); + setNotice(result.attachmentError ?? "Project source approved."); + } + await reload(); + } catch (reason) { await reload().catch(() => undefined); throw reason; } + }); + const deny = (id: string) => void run(async () => { await bridge().deny(id); await reload(); }); + return
+

Project sources

+

Approve folders containing container and optimizer recipe declarations. These grants are separate from conversation file access; execution approvals still apply.

+
+ + + + +
+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + {catalog?.sources.map((row) => )} + {catalog && !catalog.sources.length ?

No approved project source. Add a repository folder or review an agent request below.

: null} + {catalog?.implicitRoots.length ?

Launcher-managed sources

These permissions come from the launcher environment. Removing an approved row above does not remove an environment grant.

+ {catalog.implicitRoots.map((row) => )}
: null} +

Pending source requests

+ {requests === null ?

{busy ? "Loading source requests…" : "Source requests are unavailable."}

: !requests.length ?

No pending source requests.

: requests.map((request) =>
+ {request.canonicalPath}

{request.reason}

+

Requested: {request.containers ? "containers " : ""}{request.recipes ? "recipes" : ""}. + {request.attachToConversation ? ` Also attach with read/write access to conversation ${request.sessionId}.` : " No conversation file access requested."}

+

Approval requires selecting this exact folder, not its parent.

+
+
)} + {catalog?.configPath ? {catalog.configPath} : null} +
; +} diff --git a/apps/synth_desktop/src/renderer/src/components/SettingsPage.tsx b/apps/synth_desktop/src/renderer/src/components/SettingsPage.tsx index 39ab9e1ad..e3155853a 100644 --- a/apps/synth_desktop/src/renderer/src/components/SettingsPage.tsx +++ b/apps/synth_desktop/src/renderer/src/components/SettingsPage.tsx @@ -31,6 +31,8 @@ import { bridges } from "../runtime/desktopBridge"; import { ChatgptCodexSubscriptionCard } from "./ChatgptCodexSubscriptionCard"; import { ContextSettings } from "./ContextSettings"; import { SecretsSettings } from "./SecretsSettings"; +import { ProjectSourcesSettings } from "./ProjectSourcesSettings"; +import { WorkspaceAccessSettings } from "./WorkspaceAccessSettings"; import { CapabilityManifest } from "./CapabilityManifest"; import { PluginVisibilitySettings } from "./PluginVisibilitySettings"; @@ -129,6 +131,7 @@ function IconChevronLeft() { const SECTIONS = [ { id: "general", label: "General", icon: IconSliders }, { id: "context", label: "Context", icon: IconContext }, + { id: "workspace", label: "Workspace", icon: IconContext }, { id: "models", label: "Models", icon: IconChip }, { id: "inference", label: "Inference", icon: IconGauge }, { id: "voice", label: "Voice", icon: IconMic }, @@ -555,6 +558,10 @@ export function SettingsPage({
) : null} + {section === "workspace" ?
+ + +
: null} {section === "context" ?
} />
: null} {section === "inference" ? (
diff --git a/apps/synth_desktop/src/renderer/src/env.d.ts b/apps/synth_desktop/src/renderer/src/env.d.ts index 6309a72ff..dfa80dd73 100644 --- a/apps/synth_desktop/src/renderer/src/env.d.ts +++ b/apps/synth_desktop/src/renderer/src/env.d.ts @@ -28,6 +28,7 @@ import type { SkillsBridge, SynthAccountBridge, SynthConfigBridge, + ProjectSourcesBridge, TariffsBridge, TerminalBridge, UpdatesBridge, @@ -63,6 +64,7 @@ declare global { synthSkills?: SkillsBridge; synthContext?: ContextBridge; synthConfig?: SynthConfigBridge; + synthProjectSources?: ProjectSourcesBridge; synthWorkspaceScope?: WorkspaceScopeBridge; synthAccount?: SynthAccountBridge; synthCodex?: CodexBridge; diff --git a/apps/synth_desktop/src/renderer/src/routes.tsx b/apps/synth_desktop/src/renderer/src/routes.tsx index 5536623f6..e54288920 100644 --- a/apps/synth_desktop/src/renderer/src/routes.tsx +++ b/apps/synth_desktop/src/renderer/src/routes.tsx @@ -60,7 +60,7 @@ export type MainView = | { kind: "chat"; chatId: string } | { kind: "sync"; sessionId: string } | { kind: "async"; sessionId: string } - | { kind: "settings"; section?: "general" | "models" | "inference" | "context" | "voice" | "plugins" | "account" | "secrets" | "about" } + | { kind: "settings"; section?: "general" | "models" | "inference" | "context" | "workspace" | "voice" | "plugins" | "account" | "secrets" | "about" } | { kind: "connectors" } | { kind: "inventory" } | { kind: "inference" } diff --git a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts index 8c4742073..b46991789 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/desktopBridge.ts @@ -654,6 +654,21 @@ window.synthConfig ??= isTauri }), updateDesktopPermissions: async () => { throw new Error("Desktop permission settings require Synth Desktop"); } }; +// Source admission has its own boundary; it never reuses file attachments. +if (!window.synthProjectSources) { + const requireDesktop = () => { + if (!isDesktopApp()) throw new Error("Project sources require Synth Desktop"); + }; + window.synthProjectSources = { + get: async () => { requireDesktop(); return fromGenerated(spectaCommands.projectSourcesGet()); }, + refresh: async () => { requireDesktop(); return fromGenerated(spectaCommands.projectSourcesRefresh()); }, + add: async (containers, recipes) => { requireDesktop(); return fromGenerated(spectaCommands.projectSourceAdd(containers, recipes)); }, + remove: async (path) => { requireDesktop(); return fromGenerated(spectaCommands.projectSourceRemove(path)); }, + requests: async (sessionId = null) => { requireDesktop(); return fromGenerated(spectaCommands.projectSourceRequestsList(sessionId)); }, + approve: async (requestId) => { requireDesktop(); return fromGenerated(spectaCommands.projectSourceApprove(requestId)); }, + deny: async (requestId) => { requireDesktop(); return fromGenerated(spectaCommands.projectSourceDeny(requestId)); } + }; +} window.synthWorkspaceScope ??= isTauri ? { get: (sessionId) => fromGenerated(spectaCommands.workspaceScopeGet(sessionId)), @@ -1201,6 +1216,9 @@ window.synthWorkspaceScope ??= isTauri /** Quarantined window.synth* accessors — import these instead of reading window. */ export const bridges = { + get projectSources() { + return window.synthProjectSources; + }, get desktop() { return window.synthDesktop; }, 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([]); +}); From f3d24b51ad7e95c10ccbf717a7780fa2fb514bb7 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 06:25:38 -0400 Subject: [PATCH 13/25] Enforce executable source grants through discovery and queued launch --- .../src/optimizers/container_eval.rs | 1 + .../src/optimizers/container_lifecycle.rs | 89 +++++++++++++++++-- .../src-tauri/src/optimizers/manager.rs | 48 +++++++++- .../src-tauri/src/optimizers/recipes.rs | 38 ++++++++ .../src/optimizers/workspace_recipe.rs | 44 +++++---- .../src-tauri/src/project_sources.rs | 21 +++++ .../src-tauri/src/project_sources/requests.rs | 2 +- .../src-tauri/src/synth_config.rs | 2 + .../src/synth_config/project_sources.rs | 4 +- 9 files changed, 220 insertions(+), 29 deletions(-) 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/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/workspace_recipe.rs b/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs index ce620eb54..86ebbe9b3 100644 --- a/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs +++ b/apps/synth_desktop/src-tauri/src/optimizers/workspace_recipe.rs @@ -704,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)? { @@ -729,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" )), } } @@ -742,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); @@ -973,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 { @@ -2182,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"); @@ -2248,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) = @@ -2258,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/project_sources.rs b/apps/synth_desktop/src-tauri/src/project_sources.rs index b020f34ab..0c482cabc 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources.rs @@ -155,6 +155,16 @@ fn validate_root(root: &Path, home: Option<&Path>) -> Result<()> { } 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 { @@ -170,6 +180,17 @@ pub fn resolve_roots(capability: Capability) -> Result> { .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( diff --git a/apps/synth_desktop/src-tauri/src/project_sources/requests.rs b/apps/synth_desktop/src-tauri/src/project_sources/requests.rs index d8b0d8ad0..2fae8ae41 100644 --- a/apps/synth_desktop/src-tauri/src/project_sources/requests.rs +++ b/apps/synth_desktop/src-tauri/src/project_sources/requests.rs @@ -7,7 +7,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -pub(super) static RESOLUTION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +pub(crate) static RESOLUTION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub(super) async fn audit( db: &Arc, diff --git a/apps/synth_desktop/src-tauri/src/synth_config.rs b/apps/synth_desktop/src-tauri/src/synth_config.rs index a474186eb..666497757 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config.rs @@ -11,6 +11,8 @@ use std::{ 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, 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 index 040da286a..ce8cda23a 100644 --- a/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs +++ b/apps/synth_desktop/src-tauri/src/synth_config/project_sources.rs @@ -26,7 +26,7 @@ pub fn project_source_settings() -> Result { settings_at(&config_path()) } -fn settings_at(path: &Path) -> Result { +pub(crate) fn settings_at(path: &Path) -> Result { Ok(ProjectSourceSettings { config_path: path.display().to_string(), entries: entries_from_document(&read_toml(path)?)?, @@ -48,7 +48,7 @@ pub fn forget_project_source(path: &str) -> Result { forget_at(&config_path(), path) } -fn forget_at(config: &Path, path: &str) -> Result { +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| { From 9ae29825ed170b8147fd7582ace7be1be6a5c353 Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 06:42:03 -0400 Subject: [PATCH 14/25] Restore host stream receipts and evidence-based visual readiness --- .../src-tauri/src/contract/desktop_tools.json | 490 +++++++- apps/synth_desktop/src-tauri/src/lib.rs | 173 ++- .../src-tauri/src/stream_fold.rs | 1006 +++++++++++++++ .../src-tauri/src/visuals/live_eval.rs | 90 ++ .../src-tauri/src/visuals/mod.rs | 3 +- .../src-tauri/src/visuals/stream_receipt.rs | 1103 +++++++++++++++++ .../src/visuals/stream_receipt/tests.rs | 125 ++ .../src-tauri/src/visuals/templates.rs | 4 + .../src-tauri/src/visuals_ipc.rs | 29 +- .../src/renderer/src/generated/protocol.ts | 355 +++++- .../live.intern_acceptance.v1/template.json | 18 + .../live.container_rollouts.v1/template.json | 18 + .../live.eval_stream.v1/template.json | 18 + .../live.harbor_eval.v1/template.json | 18 + .../workshop-visuals/runtime/replayClient.ts | 69 +- packages/workshop-visuals/runtime/types.ts | 1 + visuals/tests/live_stream_contract.test.mjs | 15 + 17 files changed, 3480 insertions(+), 55 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/stream_fold.rs create mode 100644 apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs create mode 100644 apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs 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 90350a33f..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": [ { @@ -31661,10 +32085,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -31906,10 +32330,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "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" @@ -34972,10 +35396,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -35227,10 +35651,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -35830,10 +36254,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -65413,10 +65837,10 @@ "const": "failed" }, { - "const": "draft" + "const": "live" }, { - "const": "live" + "const": "draft" }, { "const": "saved" @@ -65972,6 +66396,13 @@ } ] }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, "requireTerminal": { "anyOf": [ { @@ -66260,6 +66691,13 @@ } ] }, + "minimumTransportEnvelopeCount": { + "anyOf": [ + { + "type": "number" + } + ] + }, "requireTerminal": { "anyOf": [ { diff --git a/apps/synth_desktop/src-tauri/src/lib.rs b/apps/synth_desktop/src-tauri/src/lib.rs index 16297e954..a1dca8729 100644 --- a/apps/synth_desktop/src-tauri/src/lib.rs +++ b/apps/synth_desktop/src-tauri/src/lib.rs @@ -62,6 +62,7 @@ mod services; mod session; mod skills; pub mod storage; +pub mod stream_fold; mod synth_config; mod tariffs; mod telemetry; @@ -2870,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) @@ -2892,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, @@ -2932,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)", @@ -2940,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)) @@ -2971,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 { @@ -2995,24 +3108,68 @@ 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 projection = + visuals::live_eval::observed_projection(&visual.id, visual.current_revision, None) + .transpose() + .map_err(AppError::from)? + .map(|projection| visuals::live_eval::projection_view(&projection)) + .transpose() + .map_err(AppError::from)? + .map(contract::specta::OpaqueJson); + 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: outcome.evidence_truncated, + receipt: visuals::stream_receipt::receipt( + &visual.id, + visual.current_revision, + &receipt_streams, + ), + }) } 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)) } } 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..a5516d881 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/stream_fold.rs @@ -0,0 +1,1006 @@ +//! 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* — the parts a renderer cannot do without. It keeps no gap scan +//! and no conflict ledger: those are evidence accounting, they are read by the +//! readiness gate and by agents, and a second implementation of them is a +//! second answer to a question that must have one. +//! +//! The mirror is pinned to this module by a golden capture over every +//! checked-in fixture — `visuals/fixtures/live_fold_golden.json`, regenerated +//! by `visuals/tests/live_fold_golden_gen.mjs` — asserted from both sides. A +//! mirror is honest exactly as long as something checks it. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +// =========================================================================== +// 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/visuals/live_eval.rs b/apps/synth_desktop/src-tauri/src/visuals/live_eval.rs index 1ca3f63bf..ef4d05bc0 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/live_eval.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/live_eval.rs @@ -460,6 +460,96 @@ pub fn live_eval_bind_metadata( Ok(bind) } +/// The projection schema a sealed live-eval view carries. +pub const LIVE_EVAL_PROJECTION_SCHEMA: &str = "synth.live-eval-projection.v1"; + +/// The evidence prefix the host holds for one declared stream. +#[derive(Clone, Debug)] +pub struct ObservedEvidence { + /// Distinct non-control envelopes, in arrival order. + pub events: Vec, + /// Retention stopped short of the run — see the bound in + /// `stream_receipt`; the prefix is a lower bound, not the run. + pub truncated: bool, +} + +/// Record the envelopes one poll of one declared stream delivered. +/// +/// The poll seam does not come through here — it retains from the fold +/// verdicts it already holds, in the lock it already holds. This is the door +/// for a caller that has envelopes and no poll, and it lands in the same store +/// with the same fold, so the two cannot disagree. +pub fn record_live_evidence(visual_id: &str, revision: i64, stream_id: &str, envelopes: &[Value]) { + super::stream_receipt::record_evidence(visual_id, revision, stream_id, envelopes); +} + +/// The evidence prefix the host observed for one declared stream, if any. +/// +/// `None` means this process has recorded no evidence for that stream at that +/// revision — which is the difference between "the stream carried nothing" and +/// "nobody ever opened this visual", and the seal's refusal says which. +pub fn observed_stream_evidence( + visual_id: &str, + revision: i64, + stream_id: &str, +) -> Option { + let (events, truncated) = + super::stream_receipt::observed_evidence(visual_id, revision, stream_id)?; + Some(ObservedEvidence { events, truncated }) +} + +/// The live-eval projection over everything this host has observed, folded at +/// an optional cutoff. +/// +/// The seam that serves this is the poll seam, and it serves it from evidence +/// that was already being retained for the seal. Nothing new is held to answer +/// it: the projection is a read of the prefix, not a second copy of it. +/// +/// `None` means this process observed nothing for that visual and revision, +/// which is the honest answer for a pane no reviewer ever rendered. +pub fn observed_projection( + visual_id: &str, + revision: i64, + cutoff: Option<&crate::stream_fold::CursorVector>, +) -> Option> { + let (events, _) = super::stream_receipt::observed_evidence_log(visual_id, revision)?; + if events.is_empty() { return None; } + Some(crate::stream_fold::project_live_eval(&events, cutoff)) +} + + +/// The sealed projection over one stream's evidence. +/// +/// The derived values only: `events` is dropped and replaced by +/// `event_count`, because the evidence itself is already frozen into the +/// binding beside this and a sealed bundle that carries every envelope twice +/// is twice the upload for nothing. The frozen runtime renders these literal +/// values and folds nothing. +pub fn seal_projection(events: &[Value]) -> Result { + projection_view(&crate::stream_fold::project_live_eval(events, None)?) +} + +/// The derived view of a folded projection: the shape a seal freezes and the +/// shape the poll seam serves. +/// +/// One shape for both on purpose. The pane, the review capture and the sealed +/// bundle read the same object, so a number that appears in a review cannot +/// differ from the number the seal carries — which is the whole premise the +/// system rests on, and was previously guaranteed by nothing. +pub fn projection_view(projection: &crate::stream_fold::LiveEvalProjection) -> Result { + let mut value = serde_json::to_value(projection)?; + let object = value + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("live eval projection must serialize to an object"))?; + let count = object + .remove("events") + .and_then(|events| events.as_array().map(Vec::len)) + .unwrap_or(0); + object.insert("event_count".into(), json!(count)); + object.insert("schema_version".into(), json!(LIVE_EVAL_PROJECTION_SCHEMA)); + Ok(value) +} + fn stream_path(source: &str) -> String { let without_query = source.split(['?', '#']).next().unwrap_or(source); if let Some(rest) = without_query.split("://").nth(1) { diff --git a/apps/synth_desktop/src-tauri/src/visuals/mod.rs b/apps/synth_desktop/src-tauri/src/visuals/mod.rs index 40351042c..095fce681 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/mod.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/mod.rs @@ -8,7 +8,8 @@ pub mod cache_gc; pub use mermaid::RENDERER_VERSION as RENDITION_RENDERER_VERSION; pub mod chart_data; pub mod charts; -mod live_eval; +pub(crate) mod live_eval; +pub mod stream_receipt; pub mod mermaid; mod models; mod registry; diff --git a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs new file mode 100644 index 000000000..79fc33e1d --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs @@ -0,0 +1,1103 @@ +//! Host-observed receipt for a visual's declared live streams. +//! +//! Every renderer poll of a live stream lands in `visual_stream_poll`, which +//! already holds the bytes, the declared bindings, the visual id and the +//! revision. That makes this the one place a stream's behaviour can be recorded +//! without asking the thing under test to describe itself: the receipt is not +//! renderer-reported and not agent-authored, so an agent that reads it is +//! reading the transport, not its own narration of the transport. +//! +//! # What this is not +//! +//! This is **not** the live-eval fold. It does not project, it does not keep +//! envelope bodies, and it answers no question about what the visual should +//! draw. It keeps the small amount of bookkeeping the poll seam can observe +//! honestly — identity, sequence, kind, cursor, latency — so that "declared ten +//! streams and opened none" and "opened fine, received only control envelopes" +//! stop being the same empty pane. When the fold moves into Rust wholesale, the +//! fold subsumes this bookkeeping; until then this seam is the only server-side +//! observation there is. +//! +//! # Where the rules live now +//! +//! Identity, scope, the control predicate, dedupe, conflict detection and the +//! sequence-gap scan are [`crate::stream_fold`]. This module keeps only what +//! the *poll seam* can observe that a fold cannot: which declared stream a +//! page came back on, how long it took to answer, whether it closed, and what +//! failed. Everything else here is that fold, read. +//! +//! Two rules were written here and in the TypeScript ingest independently, in +//! the same afternoon, and arrived at the same answer; both now have one home: +//! +//! 1. **Control envelopes keep their sequence numbers.** A gap is a claim about +//! the producer's sequence space and control records occupy that space. +//! 2. **`control: true` is honored** alongside control kinds. +//! +//! The one real divergence — `last_sequence` per *declared stream* and +//! advanced by control here, `lastSequenceByScope` and evidence-only in +//! TypeScript — is **resolved in favour of evidence-only**. A stream carrying +//! nothing but sequenced heartbeats has not advanced its evidence, and this +//! receipt exists precisely so that a gate cannot be told otherwise. The gap +//! scan still counts those heartbeats; the high-water mark does not. See +//! `stream_fold.rs` rule 4. + +use super::models::canonicalize_bindings; +use crate::stream_fold::{self, FoldLimits, LiveFold}; +use crate::visuals_ipc::RenderedVisualObservation; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Instant; + +/// One identity delivered twice with two different bodies. As above. +pub use crate::stream_fold::EnvelopeConflict as StreamConflict; +/// A hole in a producer's sequence space. Defined by the fold; re-exported so +/// a reader of the receipt does not have to know where the scan lives. +pub use crate::stream_fold::SequenceGap as StreamGap; + +/// Receipt envelope version. A reader that does not know this string should +/// refuse the receipt rather than guess at its fields. +pub const VISUAL_STREAM_RECEIPT_SCHEMA: &str = "synth.visual-stream-receipt.v1"; + +/// Bookkeeping bounds for one visual's fold. +/// +/// The receipt is a live, unbounded-lifetime observation of a stream that may +/// carry hundreds of thousands of envelopes, so the bookkeeping is bounded and +/// says when it stopped being complete. A truncated receipt reports lower +/// bounds; it never reports a smaller number as if it were the whole count. +/// Envelope bodies are never retained: they carry model output and rollout +/// payloads, and a receipt is identifiers and counts. +const RECEIPT_FOLD_LIMITS: FoldLimits = FoldLimits { + max_identities: 50_000, + max_sequences_per_scope: 50_000, + max_defects: 64, + retain_events: false, +}; + +/// Evidence bodies retained per stream before the store reports lower bounds. +/// +/// A live stream has an unbounded lifetime and frame envelopes are not small, +/// so retention is bounded and says when it stopped being complete. A seal +/// over a truncated prefix is still a seal over real, replayable evidence — +/// it just says so, rather than presenting a prefix as the whole run. +const MAX_RETAINED_EVIDENCE: usize = 20_000; + +/// Bytes of evidence retained per stream. +/// +/// A sealed bundle carries its evidence twice — once in `data.json` and once +/// inlined into `index.html` — against a 64 MiB hosted-viewer limit, and a +/// Craftax frame envelope is not small. A prefix that seals is worth more than +/// a whole run that cannot be shared, so retention stops here and says so. +const MAX_RETAINED_BYTES: usize = 8 * 1024 * 1024; + +/// The transport lifecycle, as the host observed it. +/// +/// The same six states the renderer's `TransportState` names, read from the +/// poll seam rather than from renderer state. The mapping is exact for `idle`, +/// `declared` and `terminal`; `replaying` here means "a poll was issued and has +/// not answered yet", and `error` is the last observation rather than a resting +/// state — a poll that fails and then succeeds reports `live` with a non-zero +/// `pollFailures`, because the transport did in fact recover and a gate that +/// blocked on the memory of a recovered failure would block honest runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum StreamTransportState { + /// No stream is declared. Nothing is pending and nothing is wrong. + Idle, + /// Streams are declared and the host has issued no poll for them. + Declared, + /// A poll is outstanding and no page has come back yet. + Replaying, + /// At least one page arrived and some declared stream is still open. + Live, + /// Every declared stream reported a closed cursor. + Terminal, + /// The most recent observation was a refusal or a transport failure. + Error, +} + +impl Default for StreamTransportState { + /// A visual nobody declared a stream for is idle, not broken. + fn default() -> Self { + Self::Idle + } +} + +impl StreamTransportState { + fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Declared => "declared", + Self::Replaying => "replaying", + Self::Live => "live", + Self::Terminal => "terminal", + Self::Error => "error", + } + } +} + +/// Why the last poll of one stream failed, kept whole. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct StreamPollFailure { + /// A `diagnostics::codes` constant, so the failure joins its remediation. + pub code: String, + pub message: String, + #[specta(type = Option)] + pub status: Option, + pub retryable: bool, + pub observed_at: String, +} + +/// Envelopes delivered under one `kind`, so an all-heartbeat stream is legible. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct StreamKindCount { + pub kind: String, + #[specta(type = specta_typescript::Number)] + pub count: u64, + pub control: bool, +} + +/// One declared stream, as the host saw it behave. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct StreamReceiptStream { + /// The renderer's `streamId`: the declared `source`, or the poll URL when + /// the binding declares no source. Derived from the same bindings the + /// renderer reads, so the two agree by construction. + pub stream_id: String, + /// The declared durable poll authority. Replay works from this alone. + pub declared_source: String, + /// The declared incremental transport, when the binding names one. + pub sse_source: Option, + #[specta(type = specta_typescript::Number)] + pub poll_attempts: u64, + #[specta(type = specta_typescript::Number)] + pub poll_responses: u64, + #[specta(type = specta_typescript::Number)] + pub poll_failures: u64, + /// Milliseconds from the first poll issued to the first page returned. + /// `null` while a declared stream has never answered. + #[specta(type = Option)] + pub first_response_latency_ms: Option, + /// Highest numeric sequence delivered on this stream. `null` when the + /// producer sequences with non-numeric strings, which is legitimate — the + /// multiplexed Craftax fixture does exactly that — and is not a defect. + #[specta(type = Option)] + pub last_sequence: Option, + /// The producer's own cursor, passed through rather than recomputed. + #[specta(type = Option)] + pub cursor_next: Option, + /// Envelopes handed to the renderer, duplicates included: what the + /// transport delivered, before any fold has an opinion about it. + #[specta(type = specta_typescript::Number)] + pub envelope_count: u64, + /// Envelopes with a distinct identity: what a fold would keep. + #[specta(type = specta_typescript::Number)] + pub distinct_envelope_count: u64, + pub closed: bool, + pub last_failure: Option, +} + +/// What the host observed of one visual's declared streams. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct StreamReceipt { + pub schema_version: String, + pub visual_id: String, + #[specta(type = specta_typescript::Number)] + pub revision: i64, + pub state: StreamTransportState, + /// Milliseconds the host has held the reported state. A visual resting in + /// `declared` for a minute is the failure this number exists to name. + #[specta(type = specta_typescript::Number)] + pub time_in_state_ms: u64, + /// False when the host has recorded no poll at all for this visual and + /// revision. A browser preview polls with raw `fetch` and never reaches + /// this seam, so `observed: false` reads as "not shown in Desktop" — which + /// is the right answer for a pane no reviewer ever rendered. + pub observed: bool, + /// Whether the host ever saw this visual advance past `declared`. Distinct + /// from `state`: a stream that answered once and then failed has left + /// `declared`, and one that never answered has not. + pub ever_left_declared: bool, + #[specta(type = specta_typescript::Number)] + pub declared_stream_count: u64, + /// Declared streams that returned at least one page. + #[specta(type = specta_typescript::Number)] + pub responding_stream_count: u64, + #[specta(type = specta_typescript::Number)] + pub closed_stream_count: u64, + /// Declared `live_sse` bindings carrying no `poll_url`. The renderer cannot + /// replay these at all, so they are declared and unreachable rather than + /// declared and quiet. + pub streams_missing_transport: Vec, + pub streams: Vec, + pub gaps: Vec, + pub conflicts: Vec, + /// A `stream.subscribed` control envelope was delivered. The same signal + /// the renderer's ingest folds into `ready`. + pub ready: bool, + /// Distinct non-control envelopes accepted across every declared stream: + /// the evidence a fold would have to work with. + #[specta(type = specta_typescript::Number)] + pub recovered: u64, + #[specta(type = specta_typescript::Number)] + pub envelope_count: u64, + /// Envelopes that are not heartbeats, pings or subscription notices. + /// A stream can be perfectly healthy on every other field and still have + /// carried no evidence at all; this is the field that says so. + #[specta(type = specta_typescript::Number)] + pub non_control_envelope_count: u64, + pub envelopes_by_kind: Vec, + /// Set once bookkeeping hit its bound. Dedupe, gaps and conflicts become + /// lower bounds from that point; the counts of delivered envelopes do not. + pub tracking_truncated: bool, + pub first_observed_at: Option, + pub last_observed_at: Option, +} + +/// A live stream the visual's bindings declare, read the way the renderer's +/// `replayStreamsFromBindings` reads it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeclaredStream { + pub stream_id: String, + pub poll_url: String, + pub sse_url: Option, +} + +/// Declared streams plus the ones that cannot be replayed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeclaredStreams { + pub streams: Vec, + /// Declared `live_sse` bindings with no durable poll authority, named by + /// their `source` so the report identifies which binding is unreachable. + pub missing_transport: Vec, +} + +/// Read the declared live streams out of a visual's bindings. +/// +/// One authority decides what a visual declared, and this reads the same +/// canonical envelope `declared_poll_urls` reads. Identity matches the +/// renderer's `streamId` rule — declared `source`, falling back to the poll URL +/// — so the receipt and the pane name the same stream. +pub fn declared_streams(bindings: &Value) -> DeclaredStreams { + let Ok(canonical) = canonicalize_bindings(bindings) else { + return DeclaredStreams::default(); + }; + let mut declared = DeclaredStreams::default(); + let Some(slots) = canonical + .value + .get("inputs") + .or_else(|| canonical.value.get("slots")) + .and_then(Value::as_array) + else { + return declared; + }; + for (index, slot) in slots.iter().enumerate() { + if slot.get("kind").and_then(Value::as_str) != Some("live_sse") { + continue; + } + let source = slot + .get("source") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + match slot + .get("poll_url") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + Some(poll_url) => declared.streams.push(DeclaredStream { + stream_id: source.unwrap_or(poll_url).to_string(), + poll_url: poll_url.to_string(), + sse_url: source.map(str::to_string), + }), + None => declared + .missing_transport + .push(source.map(str::to_string).unwrap_or_else(|| { + let input = slot + .get("input") + .or_else(|| slot.get("slot")) + .and_then(Value::as_str) + .unwrap_or("stream"); + format!("{input}[{index}]") + })), + } + } + declared +} + +/// What a recorded page changed, for the caller that has to report it. +#[derive(Clone, Debug, Default)] +pub struct PollOutcome { + /// Gaps observed for the first time by this page. Emitted as + /// `STREAM_REPLAY_GAP` by the caller, once per gap rather than once per + /// poll: a 500 ms loop over a permanent hole would otherwise be a + /// diagnostic every 500 ms forever. + pub new_gaps: Vec, + /// Conflicts observed for the first time by this page. + pub new_conflicts: Vec, + pub state: StreamTransportState, + /// Retention of this stream's evidence bodies has stopped short of the + /// run, so anything folded from the prefix is a lower bound. + pub evidence_truncated: bool, +} + +impl PollOutcome { + /// The state name, for a diagnostic detail bag. + pub fn state_str(&self) -> &'static str { + self.state.as_str() + } +} + +#[derive(Debug)] +struct StreamState { + stream_id: String, + declared_source: String, + sse_source: Option, + poll_attempts: u64, + poll_responses: u64, + poll_failures: u64, + first_attempt_at: Option, + first_response_latency_ms: Option, + last_sequence: Option, + cursor_next: Option, + envelope_count: u64, + distinct_envelope_count: u64, + closed: bool, + last_failure: Option, +} + +impl StreamState { + fn new(declared: &DeclaredStream) -> Self { + Self { + stream_id: declared.stream_id.clone(), + declared_source: declared.poll_url.clone(), + sse_source: declared.sse_url.clone(), + poll_attempts: 0, + poll_responses: 0, + poll_failures: 0, + first_attempt_at: None, + first_response_latency_ms: None, + last_sequence: None, + cursor_next: None, + envelope_count: 0, + distinct_envelope_count: 0, + closed: false, + last_failure: None, + } + } + + fn view(&self) -> StreamReceiptStream { + StreamReceiptStream { + stream_id: self.stream_id.clone(), + declared_source: self.declared_source.clone(), + sse_source: self.sse_source.clone(), + poll_attempts: self.poll_attempts, + poll_responses: self.poll_responses, + poll_failures: self.poll_failures, + first_response_latency_ms: self.first_response_latency_ms, + last_sequence: self.last_sequence, + cursor_next: self.cursor_next, + envelope_count: self.envelope_count, + distinct_envelope_count: self.distinct_envelope_count, + closed: self.closed, + last_failure: self.last_failure.clone(), + } + } +} + +#[derive(Debug)] +struct VisualState { + revision: i64, + /// Declared stream ids in binding order. The renderer resets its ingest + /// when this changes; so does this, for the same reason. + stream_key: DeclaredStreams, + state: StreamTransportState, + state_since: Instant, + ever_left_declared: bool, + first_observed_at: String, + last_observed_at: String, + streams: BTreeMap, + /// The envelope accounting, in its one home. Everything this module used + /// to keep by hand — identities, per-scope sequence spaces, gaps, + /// conflicts, kind counts, `ready`, the truncation flag and the delivered + /// ordinal — is this. + fold: LiveFold, + /// Accepted evidence bodies in arrival order across every declared stream, + /// each tagged with the declared stream it came back on. + /// + /// The receipt reads none of this: a receipt is identifiers and counts, and + /// that promise has not changed. It is here because the seal and the + /// projection need replayable bodies and this is the one seam every polled + /// envelope passes through — the same key, the same fold, the same + /// revision lifetime, so keeping it in a second process-global bought two + /// answers to one question and a second lock on the poll path. + /// + /// One arrival order, not one per stream: the renderer folds every + /// declared stream into one ingest, so a projection served from here has + /// to be able to answer in the order the pane saw. + evidence: Vec<(String, Value)>, + /// Per-stream retention accounting, so the bound stays per stream and a + /// quiet stream is never charged for a loud one. + evidence_books: BTreeMap, + observed: bool, + failed_last: bool, +} + +/// What one declared stream's retained prefix has cost, and whether it stopped +/// being the whole run. +#[derive(Debug, Default)] +struct EvidenceBook { + kept: usize, + bytes: usize, + truncated: bool, +} + +impl VisualState { + fn new(revision: i64, declared: &DeclaredStreams) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + revision, + stream_key: declared.clone(), + state: if declared.streams.is_empty() { + StreamTransportState::Idle + } else { + StreamTransportState::Declared + }, + state_since: Instant::now(), + ever_left_declared: false, + first_observed_at: now.clone(), + last_observed_at: now, + streams: BTreeMap::new(), + fold: LiveFold::new(RECEIPT_FOLD_LIMITS), + evidence: Vec::new(), + evidence_books: BTreeMap::new(), + observed: false, + failed_last: false, + } + } + + /// Retain one accepted evidence body, inside the bound. + /// + /// Returns false once the bound is reached, so a caller stops walking the + /// rest of the batch instead of asking the same refused question per + /// envelope. The prefix is then a lower bound on the run and says so. + fn retain_evidence(&mut self, stream_id: &str, envelope: &Value) -> bool { + let size = serde_json::to_string(envelope) + .map(|text| text.len()) + .unwrap_or(0); + let book = self + .evidence_books + .entry(stream_id.to_string()) + .or_default(); + if book.truncated || book.kept >= MAX_RETAINED_EVIDENCE + || book.bytes.saturating_add(size) > MAX_RETAINED_BYTES + { + book.truncated = true; + return false; + } + book.bytes += size; + book.kept += 1; + self.evidence + .push((stream_id.to_string(), stream_fold::normalize_identity(envelope))); + true + } + + fn stream_mut(&mut self, declared: &DeclaredStream) -> &mut StreamState { + self.streams + .entry(declared.poll_url.clone()) + .or_insert_with(|| StreamState::new(declared)) + } + + fn enter(&mut self, next: StreamTransportState) { + if next != StreamTransportState::Idle && next != StreamTransportState::Declared { + self.ever_left_declared = true; + } + if self.state != next { + self.state = next; + self.state_since = Instant::now(); + } + } + + /// Derive the state from what the host has observed, not from what the + /// renderer says about itself. + fn recompute(&mut self, declared: &DeclaredStreams) { + let next = if declared.streams.is_empty() { + StreamTransportState::Idle + } else if self.failed_last { + StreamTransportState::Error + } else if self + .streams + .values() + .all(|stream| stream.poll_attempts == 0) + { + StreamTransportState::Declared + } else if self + .streams + .values() + .all(|stream| stream.poll_responses == 0) + { + StreamTransportState::Replaying + } else if declared + .streams + .iter() + .all(|stream| self.stream_closed(&stream.poll_url)) + { + StreamTransportState::Terminal + } else { + StreamTransportState::Live + }; + self.enter(next); + } + + fn stream_closed(&self, poll_url: &str) -> bool { + self.streams + .get(poll_url) + .is_some_and(|stream| stream.closed) + } + + fn touch(&mut self) { + self.last_observed_at = chrono::Utc::now().to_rfc3339(); + self.observed = true; + } +} + +/// Everything this host observed about one visual, in one place. +/// +/// Three stores used to hold this: what the pane reported after it rendered, +/// what the poll seam saw of the transport, and the envelope bodies the seal +/// replays. Same key, same seam, same process lifetime, three globals — and so +/// three answers to "what happened to this visual", each with its own lock and +/// its own reset rule. +/// +/// They are one store now and three responsibilities still, because the +/// promises differ and blurring them would cost more than the duplication did: +/// +/// * [`RenderedVisualObservation`] is what only the DOM can know — rendered +/// frames, semantic events — and is therefore renderer-reported. It is kept +/// apart from the transport observation precisely so a gate can tell the two +/// apart. +/// * [`VisualState`] is what the host itself saw at the poll seam. Nothing in +/// it is reported by the thing under test. +/// * The evidence prefix inside it is bodies, bounded, for the seal. The +/// receipt still retains none of its own. +#[derive(Debug, Default)] +struct VisualObservation { + /// Renderer-reported, replaced whole on each report and never revision + /// reset: a report carries its own `rendered_revision` and the gate reads + /// it there. + rendered: Option, + /// Host-observed at the poll seam, reset when the revision or the declared + /// stream set changes. + transport: Option, +} + +/// Process-global observation store, keyed by visual id. +/// +/// An observation of a running process, not a durable record: it must not +/// survive a restart claiming a stream was seen that this process never saw. +static VISUAL_OBSERVATIONS: OnceLock>> = OnceLock::new(); + +/// Take the store lock, recovering the map if some other caller panicked while +/// holding it. +/// +/// A poisoned lock here means an unrelated panic, not a broken map: nothing in +/// this module can leave the bookkeeping half-updated across an unwind, because +/// every mutation completes inside one call. Refusing to observe a stream +/// because of someone else's panic would turn a receipt into a second failure +/// report about itself. +fn store() -> std::sync::MutexGuard<'static, BTreeMap> { + VISUAL_OBSERVATIONS + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .unwrap_or_else(PoisonError::into_inner) +} + +// --------------------------------------------------------------------------- +// Responsibility 1: what the pane reported after it rendered. +// --------------------------------------------------------------------------- + +/// Record what the renderer saw of its own DOM. +/// +/// Deliberately not merged into the receipt: this is the one observation the +/// host cannot make for itself, and a gate that could not tell it apart from a +/// host observation would be reading the thing under test's own account of +/// itself without knowing. +pub fn record_rendered(observation: RenderedVisualObservation) { + let visual_id = observation.visual_id.clone(); + store().entry(visual_id).or_default().rendered = Some(observation); +} + +/// The last rendered observation for a visual, if the pane ever reported one. +pub fn rendered(visual_id: &str) -> Option { + store().get(visual_id)?.rendered.clone() +} + +fn entry<'a>( + store: &'a mut BTreeMap, + visual_id: &str, + revision: i64, + declared: &DeclaredStreams, +) -> &'a mut VisualState { + let observation = store.entry(visual_id.to_string()).or_default(); + let stale = observation.transport.as_ref().is_some_and(|state| { + state.revision != revision + || state.stream_key != *declared + }); + if stale { + // A revision or a re-binding replaces the stream set. Carrying the old + // observation forward would let a previous revision's evidence answer + // for this one. The rendered report beside it is untouched: it carries + // its own revision and answers a different question. + observation.transport = None; + } + observation + .transport + .get_or_insert_with(|| VisualState::new(revision, declared)) +} + +/// The transport observation, for a caller that must not create or reset one. +/// +/// A read never resets: `observed_evidence` asking about a revision this host +/// never polled must answer "no", not erase the revision it did poll. +fn read<'a>( + store: &'a BTreeMap, + visual_id: &str, + revision: i64, +) -> Option<&'a VisualState> { + store + .get(visual_id)? + .transport + .as_ref() + .filter(|state| state.revision == revision) +} + +fn newer(store: &BTreeMap, visual_id: &str, revision: i64) -> bool { + store.get(visual_id).and_then(|value| value.transport.as_ref()) + .is_some_and(|state| state.revision > revision) +} + +/// The evidence-side entry: revision scoped, and blind to the declared set. +/// +/// Recording evidence is not an observation of a declared stream — a caller +/// may hand over envelopes for a stream this host never polled — so this one +/// resets on the revision alone, which is exactly what the evidence store next +/// door did before the two became one. +fn evidence_entry<'a>( + store: &'a mut BTreeMap, + visual_id: &str, + revision: i64, +) -> &'a mut VisualState { + let observation = store.entry(visual_id.to_string()).or_default(); + if observation + .transport + .as_ref() + .is_some_and(|state| state.revision != revision) + { + observation.transport = None; + } + observation + .transport + .get_or_insert_with(|| VisualState::new(revision, &DeclaredStreams::default())) +} + +/// Record that a poll was issued, before its answer is known. +/// +/// Without this the host could never report `replaying`: a stream that is being +/// asked and a stream nobody asked would both read as `declared`. +pub fn record_poll_attempt( + visual_id: &str, + revision: i64, + declared: &DeclaredStreams, + poll_url: &str, +) { + let mut store = store(); + if newer(&store, visual_id, revision) { return; } + let state = entry(&mut store, visual_id, revision, declared); + let Some(stream) = declared + .streams + .iter() + .find(|stream| stream.poll_url == poll_url) + .cloned() + else { + return; + }; + let entry = state.stream_mut(&stream); + entry.poll_attempts += 1; + if entry.first_attempt_at.is_none() { + entry.first_attempt_at = Some(Instant::now()); + } + state.recompute(declared); +} + +/// Record a page the host actually fetched. +pub fn record_poll_page( + visual_id: &str, + revision: i64, + declared: &DeclaredStreams, + poll_url: &str, + page: &Value, +) -> PollOutcome { + let mut store = store(); + if newer(&store, visual_id, revision) { return PollOutcome::default(); } + let state = entry(&mut store, visual_id, revision, declared); + state.touch(); + state.failed_last = false; + let Some(stream) = declared + .streams + .iter() + .find(|stream| stream.poll_url == poll_url) + .cloned() + else { + state.recompute(declared); + return PollOutcome { + state: state.state, + ..PollOutcome::default() + }; + }; + + let events = page_events(page); + let cursor = page_cursor(page); + { + let entry = state.stream_mut(&stream); + entry.poll_responses += 1; + entry.closed |= cursor.closed; + if let Some(next) = cursor.next { + entry.cursor_next = Some(next); + } + if entry.first_response_latency_ms.is_none() { + entry.first_response_latency_ms = Some( + entry + .first_attempt_at + .map(|at| at.elapsed().as_millis() as u64) + .unwrap_or_default(), + ); + } + } + + // One fold decides what these envelopes are; this loop only writes down + // which declared stream they came back on. Every rule the loop used to + // re-implement — identity, scope, control, dedupe, conflict, the gap scan + // — is `stream_fold`, and the seam cannot drift from the renderer's fold + // because there is no second implementation to drift from. + let batch = state.fold.accept_batch(events.iter()); + // The bodies go into the same entry, from the same verdicts. The receipt + // keeps none of them — it is identifiers and counts, and every field it + // reports below is a count or an identifier. What the retention buys is + // the seal: this is the only seam every polled envelope passes through, so + // it is the only place a live-eval seal can get replayable evidence + // without a caller being asked to remember to attach it. A required key + // nobody wrote is how sealing came to be dead for every live visual; + // nothing here has to be remembered. + let mut retaining = true; + for (step, envelope) in batch.steps.iter().zip(events.iter()) { + { + let entry = state.stream_mut(&stream); + entry.envelope_count += 1; + if step.verdict.accepted() { + entry.distinct_envelope_count += 1; + } + } + // Rule 4: the evidence high-water mark is evidence-only. A duplicate + // was never accepted and a control record is not evidence, so neither + // advances it — a stream carrying nothing but sequenced heartbeats has + // made no progress and this number must not say it has. + if step.verdict == stream_fold::FoldVerdict::Evidence { + if let Some(sequence) = step.sequence { + let entry = state.stream_mut(&stream); + entry.last_sequence = Some( + entry + .last_sequence + .map_or(sequence, |last| last.max(sequence)), + ); + } + if retaining { + retaining = state.retain_evidence(&stream.stream_id, envelope); + } + } + } + + state.recompute(declared); + // The response projection combines every stream, so its truncation flag + // must not become false merely because a quieter stream answered next. + let evidence_truncated = state.evidence_books.values().any(|book| book.truncated); + PollOutcome { + new_gaps: batch.new_gaps, + new_conflicts: batch.new_conflicts, + state: state.state, + evidence_truncated, + } +} + +/// Record a poll the host could not complete, or refused to issue. +/// +/// A refusal — an undeclared URL — names no declared stream, so it is recorded +/// against the visual and not invented as an eleventh stream. +pub fn record_poll_failure( + visual_id: &str, + revision: i64, + declared: &DeclaredStreams, + poll_url: &str, + failure: StreamPollFailure, +) { + let mut store = store(); + if newer(&store, visual_id, revision) { return; } + let state = entry(&mut store, visual_id, revision, declared); + state.touch(); + state.failed_last = true; + if let Some(stream) = declared + .streams + .iter() + .find(|stream| stream.poll_url == poll_url) + .cloned() + { + let entry = state.stream_mut(&stream); + entry.poll_failures += 1; + entry.last_failure = Some(failure); + } + state.recompute(declared); +} + +/// The receipt for a visual, from whatever the host has observed so far. +/// +/// A visual the host has never polled still gets a receipt: `observed: false` +/// with its declared streams listed, which is the difference between "ten +/// streams declared and none opened" and "no streams declared at all". +pub fn receipt(visual_id: &str, revision: i64, declared: &DeclaredStreams) -> StreamReceipt { + let mut store = store(); + // Looking up an old revision must not erase a newer process observation. + let mut absent = VisualState::new(revision, declared); + let state = if newer(&store, visual_id, revision) { &mut absent } + else { entry(&mut store, visual_id, revision, declared) }; + let streams: Vec = declared + .streams + .iter() + .map(|stream| { + state + .streams + .get(&stream.poll_url) + .map(StreamState::view) + .unwrap_or_else(|| StreamState::new(stream).view()) + }) + .collect(); + StreamReceipt { + schema_version: VISUAL_STREAM_RECEIPT_SCHEMA.to_string(), + visual_id: visual_id.to_string(), + revision, + state: state.state, + time_in_state_ms: state.state_since.elapsed().as_millis() as u64, + observed: state.observed, + ever_left_declared: state.ever_left_declared, + declared_stream_count: declared.streams.len() as u64, + responding_stream_count: streams + .iter() + .filter(|stream| stream.poll_responses > 0) + .count() as u64, + closed_stream_count: streams.iter().filter(|stream| stream.closed).count() as u64, + streams_missing_transport: declared.missing_transport.clone(), + streams, + gaps: state.fold.gaps().to_vec(), + conflicts: state.fold.conflicts().to_vec(), + ready: state.fold.ready(), + recovered: state.fold.evidence_count(), + envelope_count: state.fold.delivered(), + non_control_envelope_count: state.fold.delivered_non_control(), + envelopes_by_kind: state + .fold + .kinds() + .map(|(kind, count, control)| StreamKindCount { + kind: kind.to_string(), + count, + control, + }) + .collect(), + tracking_truncated: state.fold.truncated(), + first_observed_at: state.observed.then(|| state.first_observed_at.clone()), + last_observed_at: state.observed.then(|| state.last_observed_at.clone()), + } +} + +// --------------------------------------------------------------------------- +// Responsibility 3: the evidence prefix the seal and the projection replay. +// --------------------------------------------------------------------------- + +/// Record the envelopes one delivery of one declared stream carried. +/// +/// The poll seam does not call this — it retains from the fold verdicts it +/// already has, inside the lock it already holds. This is the door for a +/// caller that has envelopes and no poll: it folds them through the same +/// per-visual fold, so the two paths cannot disagree about what a duplicate is. +/// +/// `stream_id` is the renderer's stream identity — the declared `source`, +/// falling back to the poll URL — which is what [`declared_streams`] computes +/// and what the seal resolves from the same binding. The two agree by +/// construction rather than by convention. +/// +/// Duplicates, replays and control envelopes are the fold's business and are +/// dropped here; only accepted evidence is retained. +pub fn record_evidence(visual_id: &str, revision: i64, stream_id: &str, envelopes: &[Value]) { + if envelopes.is_empty() { + return; + } + let mut store = store(); + if newer(&store, visual_id, revision) { return; } + let state = evidence_entry(&mut store, visual_id, revision); + let batch = state.fold.accept_batch(envelopes.iter()); + let mut retaining = true; + for (step, envelope) in batch.steps.iter().zip(envelopes.iter()) { + if step.verdict != stream_fold::FoldVerdict::Evidence { + continue; + } + if !retaining { + break; + } + retaining = state.retain_evidence(stream_id, envelope); + } +} + +/// The evidence prefix this host observed for one declared stream. +/// +/// `None` means this process has recorded no evidence for that stream at that +/// revision — which is the difference between "the stream carried nothing" and +/// "nobody ever opened this visual", and the seal's refusal says which. +/// +/// Returns the retained bodies in arrival order and whether retention stopped +/// short of the run. +pub fn observed_evidence( + visual_id: &str, + revision: i64, + stream_id: &str, +) -> Option<(Vec, bool)> { + let store = store(); + let state = read(&store, visual_id, revision)?; + let events: Vec = state + .evidence + .iter() + .filter(|(stream, _)| stream == stream_id) + .map(|(_, envelope)| envelope.clone()) + .collect(); + if events.is_empty() { + return None; + } + let truncated = state + .evidence_books + .get(stream_id) + .is_some_and(|book| book.truncated); + Some((events, truncated)) +} + +/// Every retained envelope for a visual, in the order the host received them. +/// +/// Across streams, not per stream: the renderer folds every declared stream +/// into one ingest, so a projection served from here answers in the order the +/// pane saw. `truncated` is true when any stream's retention stopped short. +pub fn observed_evidence_log(visual_id: &str, revision: i64) -> Option<(Vec, bool)> { + let store = store(); + let state = read(&store, visual_id, revision)?; + let truncated = state.evidence_books.values().any(|book| book.truncated); + Some(( + state + .evidence + .iter() + .map(|(_, envelope)| envelope.clone()) + .collect(), + truncated, + )) +} + + +/// The three page shapes producers emit, read the way `parseReplayPage` reads +/// them. A bare array is one closed page: the only reading that cannot silently +/// drop rows. +pub fn page_events(page: &Value) -> &[Value] { + if let Some(rows) = page.as_array() { + return rows; + } + page.pointer("/page/events") + .or_else(|| page.get("events")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +/// The producer's own cursor, passed through rather than recomputed. +/// +/// Every field is optional because a producer may omit it, and an omitted +/// field must reach the renderer as omitted: a cursor is never derived from a +/// sequence number here, because the multiplexed Craftax fixture sequences +/// with opaque strings and a recomputed cursor there walks a stream that does +/// not exist. The renderer's `parseReplayPage` owns the fallbacks, in one +/// place, and this hands it the same three page shapes it already reads. +#[derive(Clone, Copy, Debug, Default, Serialize, specta::Type)] +pub struct PageCursor { + #[specta(type = Option)] + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, + #[specta(type = Option)] + #[serde(skip_serializing_if = "Option::is_none")] + pub high_water: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_more: Option, + /// A bare array is one closed page: the only reading that cannot silently + /// drop rows, and the one field this reader does decide. + pub closed: bool, +} + +/// Read a page's cursor, in the three shapes producers emit. +pub fn page_cursor(page: &Value) -> PageCursor { + if page.is_array() { + // A bare array is one closed page with nothing after it, which is what + // `parseReplayPage` has always made of it. + return PageCursor { + has_more: Some(false), + closed: true, + ..PageCursor::default() + }; + } + let number = |pointer: &str| match page.pointer(pointer) { + Some(Value::Number(value)) => value.as_i64(), + Some(Value::String(text)) => text.trim().parse::().ok(), + _ => None, + }; + PageCursor { + next: number("/cursor/next"), + high_water: number("/cursor/high_water"), + has_more: page.pointer("/cursor/has_more").and_then(Value::as_bool), + closed: page + .pointer("/cursor/closed") + .and_then(Value::as_bool) + .unwrap_or(false), + } +} + +/// A host receipt complements, and never substitutes for, screenshot/DOM proof. +pub fn certification(receipt: &StreamReceipt, minimum_evidence: u64) -> anyhow::Result { + let failure = if !receipt.observed { + Some(("visual_observation_unavailable", "Show this revision in Desktop so the host can observe its streams", true)) + } else if !receipt.streams_missing_transport.is_empty() + || receipt.responding_stream_count != receipt.declared_stream_count + || !matches!(receipt.state, StreamTransportState::Live | StreamTransportState::Terminal) { + Some(("visual_stream_unsettled", "Every declared stream must have a working poll authority and respond before certification", true)) + } else if receipt.tracking_truncated { + Some(("visual_stream_tracking_truncated", "The host exhausted its accounting bound; this receipt cannot prove complete stream integrity", false)) + } else if !receipt.gaps.is_empty() { + Some(("stream_replay_gap", "Replay the missing producer history before certifying this revision", true)) + } else if !receipt.conflicts.is_empty() { + Some(("visual_stream_conflict", "The same producer identity carried conflicting bodies; repair the producer before certification", false)) + } else if receipt.recovered < minimum_evidence { + Some(("visual_stream_no_evidence", "The host has not observed enough distinct non-control evidence for this template", true)) + } else { None }; + if let Some((code, message, retryable)) = failure { + return Err(crate::error::StructuredFailure::new(code, message, message) + .retryable(retryable).with_details(serde_json::json!({ + "receipt": receipt, "minimumTransportEnvelopeCount": minimum_evidence, + })).into()); + } + Ok(serde_json::to_value(receipt)?) +} + +#[cfg(test)] +mod tests; diff --git a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs new file mode 100644 index 000000000..e8dac9024 --- /dev/null +++ b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs @@ -0,0 +1,125 @@ +use super::*; +use serde_json::json; + +fn declared() -> DeclaredStreams { + DeclaredStreams { streams: vec![DeclaredStream { stream_id: "fixture".into(), poll_url: "http://127.0.0.1/events".into(), sse_url: None }], missing_transport: vec![] } +} +fn id() -> String { uuid::Uuid::new_v4().to_string() } +fn poll(id: &str, revision: i64, events: Value) -> StreamReceipt { + let streams = declared(); + record_poll_attempt(id, revision, &streams, &streams.streams[0].poll_url); + record_poll_page(id, revision, &streams, &streams.streams[0].poll_url, &events); + receipt(id, revision, &streams) +} + +#[test] +fn folded_lanes_control_gaps_and_conflicts_remain_distinct() { + let mut fold = LiveFold::default(); + let events = vec![json!({"rollout_id":"a","sequence":1,"kind":"frame"}), + json!({"rollout_id":"b","sequence":1,"kind":"frame"}), + json!({"rollout_id":"a","sequence":2,"kind":"heartbeat"}), + json!({"rollout_id":"a","sequence":3,"kind":"frame"})]; + fold.accept_batch(&events); + fold.accept_batch(&events); + assert_eq!(fold.evidence_count(), 3); + assert_eq!(fold.delivered(), 8); + assert!(fold.gaps().is_empty()); + fold.accept(&json!({"rollout_id":"a","sequence":5,"kind":"heartbeat"})); + assert_eq!(fold.last_sequence("a"), Some(3)); + assert_eq!(fold.gaps().len(), 1); + fold.accept(&json!({"rollout_id":"a","sequence":4,"kind":"frame"})); + assert!(fold.gaps().is_empty()); + fold.accept(&json!({"rollout_id":"a","sequence":3,"kind":"frame","payload":{"changed":true}})); + assert_eq!(fold.conflicts().len(), 1); +} + +#[test] +fn accounting_limit_does_not_invent_distinct_evidence() { + let mut fold = LiveFold::new(FoldLimits { max_identities: 1, ..FoldLimits::default() }); + fold.accept(&json!({"sequence":1,"kind":"frame"})); + for _ in 0..3 { fold.accept(&json!({"sequence":2,"kind":"frame"})); } + assert_eq!(fold.evidence_count(), 1); + assert_eq!(fold.distinct(), 1); + assert_eq!(fold.delivered(), 4); + assert!(fold.truncated()); +} + +#[test] +fn certification_requires_observed_distinct_evidence_from_every_stream() { + let id = id(); + assert!(certification(&receipt(&id, 1, &declared()), 1).is_err()); + let controls = poll(&id, 1, json!([{"sequence":1,"kind":"heartbeat"}])); + assert!(certification(&controls, 1).is_err()); + assert!(crate::visuals::live_eval::observed_projection(&id, 1, None).is_none()); + let valid = poll(&id, 1, json!([{"sequence":2,"kind":"frame"}])); + assert!(certification(&valid, 1).is_ok()); + assert!(certification(&valid, 2).is_err()); + let replay = poll(&id, 1, json!([{"sequence":2,"kind":"frame"}])); + assert!(certification(&replay, 2).is_err()); + let conflict = poll(&id, 1, json!([{"sequence":2,"kind":"frame","payload":1}])); + assert!(certification(&conflict, 1).is_err()); + let mut missing = valid.clone(); + missing.declared_stream_count += 1; + assert!(certification(&missing, 1).is_err()); + missing = valid.clone(); + missing.streams_missing_transport.push("missing".into()); + assert!(certification(&missing, 1).is_err()); + missing = valid; + missing.tracking_truncated = true; + assert!(certification(&missing, 1).is_err()); +} + +#[test] +fn old_revision_reads_and_late_pages_do_not_erase_newer_evidence() { + let id = id(); + let page = json!([{"sequence":1,"kind":"frame","payload":{"rollout_id":"lane-a"}}]); + poll(&id, 2, page.clone()); + assert!(!receipt(&id, 1, &declared()).observed); + poll(&id, 1, page); + let evidence = observed_evidence(&id, 2, "fixture").unwrap().0; + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0]["rollout_id"], "lane-a"); + assert_eq!(receipt(&id, 2, &declared()).recovered, 1); + let mut rebound = declared(); + rebound.streams[0].poll_url = "http://127.0.0.1/replacement".into(); + assert!(!receipt(&id, 2, &rebound).observed); + assert!(observed_evidence(&id, 2, "fixture").is_none()); +} + +#[test] +fn retained_evidence_stays_a_prefix_after_byte_limit() { + let mut state = VisualState::new(1, &declared()); + assert!(state.retain_evidence("fixture", &json!({"kind":"frame"}))); + assert!(!state.retain_evidence("fixture", &json!({"payload":"x".repeat(MAX_RETAINED_BYTES)}))); + assert!(!state.retain_evidence("fixture", &json!({"kind":"verifier"}))); + assert_eq!(state.evidence.len(), 1); + assert!(state.evidence_books["fixture"].truncated); +} + +#[test] +fn projection_truncation_survives_a_quiet_stream_response() { + let id = id(); + poll(&id, 1, json!([{"sequence":1,"kind":"frame"}])); + { + let mut map = store(); + let state = entry(&mut map, &id, 1, &declared()); + state.evidence_books.entry("another-stream".into()).or_default().truncated = true; + } + let streams = declared(); + let outcome = record_poll_page(&id, 1, &streams, &streams.streams[0].poll_url, &json!([])); + assert!(outcome.evidence_truncated); +} + +#[test] +fn projection_preserves_missing_usage_and_opaque_cutoff() { + let mut fold = LiveFold::retaining(); + fold.accept(&json!({"stream_id":"a","sequence":"opaque-a","kind":"frame"})); + fold.accept(&json!({"stream_id":"a","sequence":"opaque-b","kind":"verifier","payload":{"reward.txt":0.5}})); + let cutoff = stream_fold::CursorVector::new([("a".into(), 1)]); + let prefix = stream_fold::project_live_eval(fold.events(), Some(&cutoff)).unwrap(); + assert!(prefix.has_live_frames); + assert_eq!(prefix.reward, None); + assert_eq!(prefix.usage, None); + assert_eq!(stream_fold::project_live_eval(fold.events(), None).unwrap().reward, Some(0.5)); + assert!(stream_fold::project_live_eval(&[json!({"payload":{"capability_blob":"private"}})], None).is_err()); +} diff --git a/apps/synth_desktop/src-tauri/src/visuals/templates.rs b/apps/synth_desktop/src-tauri/src/visuals/templates.rs index 84a8b75a0..8b3e1261f 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/templates.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/templates.rs @@ -54,6 +54,10 @@ pub struct TemplateReadinessContract { #[serde(default)] #[specta(type = specta_typescript::Number)] pub minimum_semantic_event_count: u64, + /// Distinct non-control transport envelopes required from the host receipt. + #[serde(default)] + #[specta(type = specta_typescript::Number)] + pub minimum_transport_envelope_count: u64, #[serde(default)] pub require_terminal: bool, /// Which evidence affordances this surface actually offers, out of diff --git a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs index 0ed51e747..79cbe7824 100644 --- a/apps/synth_desktop/src-tauri/src/visuals_ipc.rs +++ b/apps/synth_desktop/src-tauri/src/visuals_ipc.rs @@ -371,9 +371,6 @@ pub struct RenderedVisualObservation { pub observed_at: String, } -static RENDERED_OBSERVATIONS: OnceLock>> = - OnceLock::new(); - /// The data root this server was spawned with. Review capture writes PNGs to /// caller-named paths, and this is the boundary those paths must stay inside. static VISUALS_DATA_ROOT: OnceLock = OnceLock::new(); @@ -390,21 +387,12 @@ pub fn record_rendered_observation(observation: RenderedVisualObservation) -> Re { anyhow::bail!("rendered visual observation requires bindings and transport authority"); } - RENDERED_OBSERVATIONS - .get_or_init(|| Mutex::new(BTreeMap::new())) - .lock() - .map_err(|_| anyhow::anyhow!("rendered observation store is unavailable"))? - .insert(observation.visual_id.clone(), observation); + crate::visuals::stream_receipt::record_rendered(observation); Ok(()) } pub(crate) fn rendered_observation(visual_id: &str) -> Result { - RENDERED_OBSERVATIONS - .get_or_init(|| Mutex::new(BTreeMap::new())) - .lock() - .map_err(|_| anyhow::anyhow!("rendered observation store is unavailable"))? - .get(visual_id) - .cloned() + crate::visuals::stream_receipt::rendered(visual_id) .with_context(|| format!("no rendered observation is available for visual {visual_id}")) } @@ -3578,6 +3566,8 @@ pub async fn dispatch(method: &str, path: &str, body: Value, core: &CoreRuntime) let certification_identity = registry.certification_identity(id.to_string()).await?; Ok(json!({ "visual": visual, + "streamReceipt": crate::visuals::stream_receipt::receipt(id, visual.current_revision, + &crate::visuals::stream_receipt::declared_streams(&visual.bindings)), "template": template, "certificationIdentity": certification_identity, "annotations": annotations, @@ -3837,6 +3827,14 @@ pub async fn dispatch(method: &str, path: &str, body: Value, core: &CoreRuntime) bindings_digest.as_deref(), &certification_identity, )?; + let declared_streams = crate::visuals::stream_receipt::declared_streams(¤t.bindings); + let stream_certification = if declared_streams.streams.is_empty() && declared_streams.missing_transport.is_empty() { + Value::Null + } else { + let receipt = crate::visuals::stream_receipt::receipt(id, revision, &declared_streams); + crate::visuals::stream_receipt::certification(&receipt, template.observation_contract.as_ref() + .map(|contract| contract.readiness.minimum_transport_envelope_count).unwrap_or(0))? + }; if let Some(kind) = match current.renderer_kind { crate::visuals::RendererKind::Systems => Some(crate::visuals::systems::SystemsKind::Static), crate::visuals::RendererKind::SystemsDynamic => Some(crate::visuals::systems::SystemsKind::Dynamic), @@ -3884,6 +3882,7 @@ pub async fn dispatch(method: &str, path: &str, body: Value, core: &CoreRuntime) "certificationIdentity": certification_identity, "reviewCount": current_reviews.len(), "certifiedBy": receipts, + "streamReceipt": stream_certification, "supersededReviewCount": current_reviews.len() - receipts.len(), "readyAt": chrono::Utc::now().to_rfc3339(), }), @@ -6964,6 +6963,7 @@ mod tests { minimum_rollout_count: 1, minimum_rendered_frame_count: 1, minimum_semantic_event_count: 1, + minimum_transport_envelope_count: 1, require_terminal: true, authoring_affordances: None, }, @@ -7141,6 +7141,7 @@ mod tests { minimum_rollout_count: 0, minimum_rendered_frame_count: 0, minimum_semantic_event_count: 1, + minimum_transport_envelope_count: 1, require_terminal: true, authoring_affordances: None, }, diff --git a/apps/synth_desktop/src/renderer/src/generated/protocol.ts b/apps/synth_desktop/src/renderer/src/generated/protocol.ts index 774e89746..c880765d6 100644 --- a/apps/synth_desktop/src/renderer/src/generated/protocol.ts +++ b/apps/synth_desktop/src/renderer/src/generated/protocol.ts @@ -289,11 +289,15 @@ export const commands = { visualSubscriptionReady: (request: VisualReadyRequest) => typedError(__TAURI_INVOKE("visual_subscription_ready", { request })), /** * 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`]. */ - visualStreamPoll: (request: VisualStreamPollRequest) => typedError(__TAURI_INVOKE("visual_stream_poll", { request })), + visualStreamPoll: (request: VisualStreamPollRequest) => typedError(__TAURI_INVOKE("visual_stream_poll", { request })), visualMediaRead: (request: VisualMediaReadRequest) => typedError(__TAURI_INVOKE("visual_media_read", { request })), /** Record a renderer diagnostic. Returns as soon as it is queued. */ diagnosticsReport: (request: DiagnosticReportRequest) => typedError(__TAURI_INVOKE("diagnostics_report", { request })), @@ -1586,6 +1590,19 @@ export type EnvImportRequest = { destinationScope: string | null, }; +/** + * 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. + */ +export type EnvelopeConflict = { + identity: string, + scope: string, + message: string, +}; + /** * The immutable, revision-addressed evaluation aggregate shared verbatim by * chat, experiment, and workbench surfaces. Consumers may format this value; @@ -3489,6 +3506,60 @@ export type OptimizerUsageSummary = { extra?: unknown, }; +/** + * The producer's own cursor, passed through rather than recomputed. + * + * Every field is optional because a producer may omit it, and an omitted + * field must reach the renderer as omitted: a cursor is never derived from a + * sequence number here, because the multiplexed Craftax fixture sequences + * with opaque strings and a recomputed cursor there walks a stream that does + * not exist. The renderer's `parseReplayPage` owns the fallbacks, in one + * place, and this hands it the same three page shapes it already reads. + */ +export type PageCursor = PageCursor_Serialize | PageCursor_Deserialize; + +/** + * The producer's own cursor, passed through rather than recomputed. + * + * Every field is optional because a producer may omit it, and an omitted + * field must reach the renderer as omitted: a cursor is never derived from a + * sequence number here, because the multiplexed Craftax fixture sequences + * with opaque strings and a recomputed cursor there walks a stream that does + * not exist. The renderer's `parseReplayPage` owns the fallbacks, in one + * place, and this hands it the same three page shapes it already reads. + */ +export type PageCursor_Deserialize = { + next: number | null, + high_water: number | null, + has_more: boolean | null, + /** + * A bare array is one closed page: the only reading that cannot silently + * drop rows, and the one field this reader does decide. + */ + closed: boolean, +}; + +/** + * The producer's own cursor, passed through rather than recomputed. + * + * Every field is optional because a producer may omit it, and an omitted + * field must reach the renderer as omitted: a cursor is never derived from a + * sequence number here, because the multiplexed Craftax fixture sequences + * with opaque strings and a recomputed cursor there walks a stream that does + * not exist. The renderer's `parseReplayPage` owns the fallbacks, in one + * place, and this hands it the same three page shapes it already reads. + */ +export type PageCursor_Serialize = { + next?: number | null, + high_water?: number | null, + has_more?: boolean | null, + /** + * A bare array is one closed page: the only reading that cannot silently + * drop rows, and the one field this reader does decide. + */ + closed: boolean, +}; + /** * User-owned paid-compute auto-approval, stored only in Workshop config. * @@ -4434,6 +4505,17 @@ export type SecretsProxyStatus = { running: boolean, }; +/** + * A hole in one scope's sequence space, reported as the two envelopes that + * bracket it rather than as a rendered sentence. + */ +export type SequenceGap = { + /** Producer lane, as [`envelope_scope`] derives it. */ + scope: string, + after: number, + before: number, +}; + export type SftProjection = { workItems: WorkItem[], phase: RunPhase | null, @@ -4517,6 +4599,151 @@ export type Status = { expiresAt: string | null, }; +/** Envelopes delivered under one `kind`, so an all-heartbeat stream is legible. */ +export type StreamKindCount = { + kind: string, + count: number, + control: boolean, +}; + +/** Why the last poll of one stream failed, kept whole. */ +export type StreamPollFailure = { + /** A `diagnostics::codes` constant, so the failure joins its remediation. */ + code: string, + message: string, + status: number | null, + retryable: boolean, + observedAt: string, +}; + +/** What the host observed of one visual's declared streams. */ +export type StreamReceipt = { + schemaVersion: string, + visualId: string, + revision: number, + state: StreamTransportState, + /** + * Milliseconds the host has held the reported state. A visual resting in + * `declared` for a minute is the failure this number exists to name. + */ + timeInStateMs: number, + /** + * False when the host has recorded no poll at all for this visual and + * revision. A browser preview polls with raw `fetch` and never reaches + * this seam, so `observed: false` reads as "not shown in Desktop" — which + * is the right answer for a pane no reviewer ever rendered. + */ + observed: boolean, + /** + * Whether the host ever saw this visual advance past `declared`. Distinct + * from `state`: a stream that answered once and then failed has left + * `declared`, and one that never answered has not. + */ + everLeftDeclared: boolean, + declaredStreamCount: number, + /** Declared streams that returned at least one page. */ + respondingStreamCount: number, + closedStreamCount: number, + /** + * Declared `live_sse` bindings carrying no `poll_url`. The renderer cannot + * replay these at all, so they are declared and unreachable rather than + * declared and quiet. + */ + streamsMissingTransport: string[], + streams: StreamReceiptStream[], + gaps: SequenceGap[], + conflicts: EnvelopeConflict[], + /** + * A `stream.subscribed` control envelope was delivered. The same signal + * the renderer's ingest folds into `ready`. + */ + ready: boolean, + /** + * Distinct non-control envelopes accepted across every declared stream: + * the evidence a fold would have to work with. + */ + recovered: number, + envelopeCount: number, + /** + * Envelopes that are not heartbeats, pings or subscription notices. + * A stream can be perfectly healthy on every other field and still have + * carried no evidence at all; this is the field that says so. + */ + nonControlEnvelopeCount: number, + envelopesByKind: StreamKindCount[], + /** + * Set once bookkeeping hit its bound. Dedupe, gaps and conflicts become + * lower bounds from that point; the counts of delivered envelopes do not. + */ + trackingTruncated: boolean, + firstObservedAt: string | null, + lastObservedAt: string | null, +}; + +/** One declared stream, as the host saw it behave. */ +export type StreamReceiptStream = { + /** + * The renderer's `streamId`: the declared `source`, or the poll URL when + * the binding declares no source. Derived from the same bindings the + * renderer reads, so the two agree by construction. + */ + streamId: string, + /** The declared durable poll authority. Replay works from this alone. */ + declaredSource: string, + /** The declared incremental transport, when the binding names one. */ + sseSource: string | null, + pollAttempts: number, + pollResponses: number, + pollFailures: number, + /** + * Milliseconds from the first poll issued to the first page returned. + * `null` while a declared stream has never answered. + */ + firstResponseLatencyMs: number | null, + /** + * Highest numeric sequence delivered on this stream. `null` when the + * producer sequences with non-numeric strings, which is legitimate — the + * multiplexed Craftax fixture does exactly that — and is not a defect. + */ + lastSequence: number | null, + /** The producer's own cursor, passed through rather than recomputed. */ + cursorNext: number | null, + /** + * Envelopes handed to the renderer, duplicates included: what the + * transport delivered, before any fold has an opinion about it. + */ + envelopeCount: number, + /** Envelopes with a distinct identity: what a fold would keep. */ + distinctEnvelopeCount: number, + closed: boolean, + lastFailure: StreamPollFailure | null, +}; + +/** + * The transport lifecycle, as the host observed it. + * + * The same six states the renderer's `TransportState` names, read from the + * poll seam rather than from renderer state. The mapping is exact for `idle`, + * `declared` and `terminal`; `replaying` here means "a poll was issued and has + * not answered yet", and `error` is the last observation rather than a resting + * state — a poll that fails and then succeeds reports `live` with a non-zero + * `pollFailures`, because the transport did in fact recover and a gate that + * blocked on the memory of a recovered failure would block honest runs. + */ +export type StreamTransportState = +/** No stream is declared. Nothing is pending and nothing is wrong. */ +"idle" | +/** Streams are declared and the host has issued no poll for them. */ +"declared" | +/** A poll is outstanding and no page has come back yet. */ +"replaying" | +/** At least one page arrived and some declared stream is still open. */ +"live" | +/** Every declared stream reported a closed cursor. */ +"terminal" | +/** The most recent observation was a refusal or a transport failure. */ +"error"; + /** * One catalog entry as the renderer receives it — the same numbers the * estimator prices with, so Settings can never drift from billing. @@ -4605,6 +4832,8 @@ export type TemplateReadinessContract = { minimumRolloutCount?: number, minimumRenderedFrameCount?: number, minimumSemanticEventCount?: number, + /** Distinct non-control transport envelopes required from the host receipt. */ + minimumTransportEnvelopeCount?: number, requireTerminal?: boolean, /** * Which evidence affordances this surface actually offers, out of @@ -5093,6 +5322,124 @@ export type VisualStreamPollRequest = { limit: number, }; +/** + * 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. + */ +export type VisualStreamPollResult = VisualStreamPollResult_Serialize | VisualStreamPollResult_Deserialize; + +/** + * 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. + */ +export type VisualStreamPollResult_Deserialize = { + schemaVersion: string, + /** The producer's envelopes for this page, exactly as they arrived. */ + events: unknown, + /** The producer's own cursor, passed through rather than recomputed. */ + cursor: PageCursor_Deserialize, + /** + * `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: unknown | null, + /** + * The retained evidence prefix stopped short of the run, so the + * projection is a lower bound rather than the whole eval. + */ + evidenceTruncated: boolean, + /** + * The host's own account of the transport. Not renderer-reported and not + * agent-authored: an agent reading this is reading the transport. + */ + receipt: StreamReceipt, +}; + +/** + * 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. + */ +export type VisualStreamPollResult_Serialize = { + schemaVersion: string, + /** The producer's envelopes for this page, exactly as they arrived. */ + events: unknown, + /** The producer's own cursor, passed through rather than recomputed. */ + cursor: PageCursor_Serialize, + /** + * `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: unknown | null, + /** + * The retained evidence prefix stopped short of the run, so the + * projection is a lower bound rather than the whole eval. + */ + evidenceTruncated: boolean, + /** + * The host's own account of the transport. Not renderer-reported and not + * agent-authored: an agent reading this is reading the transport. + */ + receipt: StreamReceipt, +}; + export type VisualUpdateRequest = { title: string | null, bindings: unknown, diff --git a/packages/workshop-visuals/families/compatibility/live.intern_acceptance.v1/template.json b/packages/workshop-visuals/families/compatibility/live.intern_acceptance.v1/template.json index 596e97e5e..127ffb301 100644 --- a/packages/workshop-visuals/families/compatibility/live.intern_acceptance.v1/template.json +++ b/packages/workshop-visuals/families/compatibility/live.intern_acceptance.v1/template.json @@ -14,6 +14,24 @@ "sync", "async" ], + "observationContract": { + "schemaVersion": "synth.visual-observation-contract.v1", + "readiness": { + "rejectTransportStates": [ + "idle", + "declared", + "replaying", + "error", + "connecting", + "reconnecting" + ], + "minimumRolloutCount": 0, + "minimumRenderedFrameCount": 0, + "minimumSemanticEventCount": 1, + "minimumTransportEnvelopeCount": 1, + "requireTerminal": false + } + }, "inputs": [ { "name": "acceptance", diff --git a/packages/workshop-visuals/families/first_class_example_containers/live.container_rollouts.v1/template.json b/packages/workshop-visuals/families/first_class_example_containers/live.container_rollouts.v1/template.json index a06cff14c..e70f4b85e 100644 --- a/packages/workshop-visuals/families/first_class_example_containers/live.container_rollouts.v1/template.json +++ b/packages/workshop-visuals/families/first_class_example_containers/live.container_rollouts.v1/template.json @@ -15,6 +15,24 @@ "rollout", "craftax" ], + "observationContract": { + "schemaVersion": "synth.visual-observation-contract.v1", + "readiness": { + "rejectTransportStates": [ + "idle", + "declared", + "replaying", + "error", + "connecting", + "reconnecting" + ], + "minimumRolloutCount": 1, + "minimumRenderedFrameCount": 0, + "minimumSemanticEventCount": 1, + "minimumTransportEnvelopeCount": 1, + "requireTerminal": false + } + }, "inputs": [ { "name": "stream", diff --git a/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/template.json b/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/template.json index 23a4f7586..f440863bb 100644 --- a/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/template.json +++ b/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/template.json @@ -8,6 +8,24 @@ "accent": "#FF5C00", "shell": "shell.tsx", "tags": ["live", "sse", "eval", "acceptance"], + "observationContract": { + "schemaVersion": "synth.visual-observation-contract.v1", + "readiness": { + "rejectTransportStates": [ + "idle", + "declared", + "replaying", + "error", + "connecting", + "reconnecting" + ], + "minimumRolloutCount": 0, + "minimumRenderedFrameCount": 0, + "minimumSemanticEventCount": 1, + "minimumTransportEnvelopeCount": 1, + "requireTerminal": false + } + }, "inputs": [ { "name": "stream", diff --git a/packages/workshop-visuals/families/first_class_example_containers/live.harbor_eval.v1/template.json b/packages/workshop-visuals/families/first_class_example_containers/live.harbor_eval.v1/template.json index ce949cb57..4a05b5996 100644 --- a/packages/workshop-visuals/families/first_class_example_containers/live.harbor_eval.v1/template.json +++ b/packages/workshop-visuals/families/first_class_example_containers/live.harbor_eval.v1/template.json @@ -15,6 +15,24 @@ "job", "rollout" ], + "observationContract": { + "schemaVersion": "synth.visual-observation-contract.v1", + "readiness": { + "rejectTransportStates": [ + "idle", + "declared", + "replaying", + "error", + "connecting", + "reconnecting" + ], + "minimumRolloutCount": 0, + "minimumRenderedFrameCount": 0, + "minimumSemanticEventCount": 1, + "minimumTransportEnvelopeCount": 1, + "requireTerminal": false + } + }, "inputs": [ { "name": "stream", diff --git a/packages/workshop-visuals/runtime/replayClient.ts b/packages/workshop-visuals/runtime/replayClient.ts index 854a1bd4d..df76ff90a 100644 --- a/packages/workshop-visuals/runtime/replayClient.ts +++ b/packages/workshop-visuals/runtime/replayClient.ts @@ -36,11 +36,56 @@ export type ReplayCursor = { closed: boolean; }; +/** + * The host's fold of everything it has observed for this visual, in the shape + * a seal freezes (`synth.live-eval-projection.v1`). + * + * Derived values only: `event_count` stands in for the envelopes, which travel + * beside it as `ReplayPage.events` rather than being sent twice. A host + * without Rust supplies none of this and the template folds locally, which is + * what browser preview and fixture replay do. + */ +export type HostLiveEvalProjection = { + schema_version: string; + kinds: string[]; + has_live_frames: boolean; + has_reward_txt: boolean; + reward: number | null; + usage: { + prompt_tokens: number | null; + completion_tokens: number | null; + total_tokens: number | null; + cost_usd: number | null; + } | null; + event_count: number; +}; + export type ReplayPage = { events: LiveEnvelope[]; cursor: ReplayCursor; + /** + * What the host folded, when the host is Workshop. Absent in browser + * preview and fixture replay, where the template folds for itself — so a + * reader treats this as the authoritative answer when it is there and as + * nothing at all when it is not. + */ + projection?: HostLiveEvalProjection; + /** The host's own account of the transport, when the host keeps one. */ + receipt?: unknown; + /** + * The host's retained evidence stopped short of the run, so `projection` is + * a lower bound rather than the whole eval. + */ + evidenceTruncated?: boolean; }; +/** + * Envelope version of a Workshop poll answer. A body carrying this string + * brings the host's fold with it; anything else is a producer page and is + * folded by the reader. + */ +export const HOST_POLL_SCHEMA = "synth.visual-stream-poll.v1"; + export type ReplayClient = { /** Declared streams, in binding order. Never inferred from a prop bag. */ streams: ReplayStream[]; @@ -78,13 +123,23 @@ export const REPLAY_PAGE_LIMIT_MAX = 1_000; type RawPage = | LiveEnvelope[] | { + schemaVersion?: string; events?: LiveEnvelope[]; page?: { events?: LiveEnvelope[] }; cursor?: { next?: number; high_water?: number; has_more?: boolean; closed?: boolean }; + projection?: HostLiveEvalProjection | null; + receipt?: unknown; + evidenceTruncated?: boolean; }; /** - * Normalize the three page shapes producers emit today. + * Normalize the page shapes this client can be handed. + * + * Four, and only one of them is new: Workshop's own answer, which wraps the + * producer's envelopes and cursor beside the fold the host already performed. + * It is read by the same two fields as a producer page on purpose — the host + * passes the producer's cursor through rather than recomputing it — so the + * only thing the wrapper adds here is the projection and the receipt. * * COMPAT: a bare array has no cursor, so it is treated as one closed page — * that is the only reading which cannot silently drop rows. Remove the array @@ -114,7 +169,17 @@ export function parseReplayPage(body: unknown, after: number): ReplayPage { highWater, hasMore: page.cursor?.has_more ?? (highWater != null && next < highWater), closed: page.cursor?.closed ?? false - } + }, + // Carried only when the host actually folded. `projection: null` is the + // host saying it observed nothing for this visual, which is not the same + // claim as a host that folds nothing at all, and neither is an empty fold. + ...(page.schemaVersion === HOST_POLL_SCHEMA + ? { + ...(page.projection ? { projection: page.projection } : {}), + ...(page.receipt !== undefined ? { receipt: page.receipt } : {}), + evidenceTruncated: page.evidenceTruncated === true + } + : {}) }; } diff --git a/packages/workshop-visuals/runtime/types.ts b/packages/workshop-visuals/runtime/types.ts index 34500ed8a..b7192e9ce 100644 --- a/packages/workshop-visuals/runtime/types.ts +++ b/packages/workshop-visuals/runtime/types.ts @@ -146,6 +146,7 @@ export type VisualTemplateMeta = { minimumRolloutCount?: number; minimumRenderedFrameCount?: number; minimumSemanticEventCount?: number; + minimumTransportEnvelopeCount?: number; requireTerminal?: boolean; }; }; diff --git a/visuals/tests/live_stream_contract.test.mjs b/visuals/tests/live_stream_contract.test.mjs index 8c64b21a4..ff75e9e7e 100644 --- a/visuals/tests/live_stream_contract.test.mjs +++ b/visuals/tests/live_stream_contract.test.mjs @@ -17,6 +17,21 @@ import { const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +test("host replay answers preserve projection, receipt and truncation without inventing provider authority", async () => { + const { parseReplayPage, HOST_POLL_SCHEMA } = await import("../runtime/replayClient.ts"); + const projection = { schema_version: "synth.live-eval-projection.v1", kinds: ["frame"], + has_live_frames: true, has_reward_txt: false, reward: null, usage: null, event_count: 1 }; + const body = { schemaVersion: HOST_POLL_SCHEMA, events: [{ sequence: "opaque" }], + cursor: { next: 6, closed: true }, projection, receipt: { recovered: 1 }, evidenceTruncated: true }; + const page = parseReplayPage(body, 5); + assert.deepEqual(page.projection, projection); + assert.deepEqual(page.receipt, { recovered: 1 }); + assert.equal(page.evidenceTruncated, true); + assert.equal(page.cursor.next, 6); + assert.equal(parseReplayPage({ ...body, schemaVersion: "producer.v1" }, 5).projection, undefined); + assert.equal(parseReplayPage({ ...body, projection: null }, 5).projection, undefined); +}); + test("live.harbor_eval.v1 binds slot stream, not jobs", () => { const meta = JSON.parse( readFileSync(join(root, "families/first_class_example_containers/live.harbor_eval.v1/template.json"), "utf8"), From 547549922ef98a10a05207d22646b0afdd5f100b Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 06:57:14 -0400 Subject: [PATCH 15/25] Freeze observed live evidence into offline visual artifacts --- .../src-tauri/src/storage/live_spool.rs | 56 +- .../src-tauri/src/visuals/artifacts.rs | 60 +- .../src-tauri/src/visuals/frozen_runtime.js | 137 ++++- .../src-tauri/src/visuals/mod.rs | 1 + .../src-tauri/src/visuals/registry.rs | 2 +- .../src-tauri/src/visuals/seal_evidence.rs | 542 ++++++++++++++++++ .../src/visuals/seal_evidence/tests.rs | 99 ++++ .../tests/playwright/frozen-artifact.spec.ts | 22 + 8 files changed, 827 insertions(+), 92 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/visuals/seal_evidence.rs create mode 100644 apps/synth_desktop/src-tauri/src/visuals/seal_evidence/tests.rs create mode 100644 apps/synth_desktop/tests/playwright/frozen-artifact.spec.ts 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/visuals/artifacts.rs b/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs index 9174e3c1d..be74a7c66 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,18 @@ 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, + }); + } scan_forbidden(&data, "$")?; let data_bytes = canonical_json(&data)?; let runtime_digest = hex_sha256(FROZEN_RUNTIME.as_bytes()); @@ -922,43 +934,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 { @@ -1184,7 +1164,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()) diff --git a/apps/synth_desktop/src-tauri/src/visuals/frozen_runtime.js b/apps/synth_desktop/src-tauri/src/visuals/frozen_runtime.js index 066f93e38..134d8e63b 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/frozen_runtime.js +++ b/apps/synth_desktop/src-tauri/src/visuals/frozen_runtime.js @@ -1,3 +1,17 @@ +// The sealed viewer. It renders projections; it does not compute them. +// +// This file used to locate a projection by scanning `data.bindings` for a +// known schema version, and for a live-eval visual there was nothing to find: +// it printed the raw envelope JSON into a `
`. That made the viewer a
+// third implementation of a projection that has one home in Rust, and it made
+// every sealed bundle depend on the viewer still agreeing with that home.
+//
+// The seal now names its own views. `data.projection.views[]` carries either
+// an inline `data` (the live-eval fold's output, frozen at export time) or a
+// `ref` — a JSON Pointer to a projection the template's own resolver already
+// placed inside the sealed document. Either way the projection is *in* the
+// bundle, so a seal keeps rendering after the plugin, the user template or the
+// build that produced it is gone. Everything below is presentation.
 (() => {
   const data = JSON.parse(document.getElementById("synth-artifact-data").textContent);
   const root = document.getElementById("app");
@@ -12,15 +26,116 @@
   const section = document.createElement("section");
   section.className = "visual";
   root.append(header, section);
-  const projection = window.SynthRolloutInspector
-    && window.SynthRolloutInspector.extractProjection(data.bindings);
-  if (data.template_id === "trace.rollout_inspector.v1" && projection) {
-    window.SynthRolloutInspector.mount(section, {
-      traces: [{ label: data.title, projection }],
-    });
-    return;
-  }
-  const pre = document.createElement("pre");
-  pre.textContent = JSON.stringify(data.bindings, null, 2);
-  section.append(pre);
+
+  const MISSING = "—";
+  const TRACE_SCHEMA = "synth.trace-projection.rollout-inspector.v1";
+  const LIVE_EVAL_SCHEMA = "synth.live-eval-projection.v1";
+
+  // RFC 6901. The only indirection in this file, and it stays inside the
+  // sealed document: a pointer names where a projection already is, never
+  // where one might be recomputed from.
+  function deref(document_, pointer) {
+    if (typeof pointer !== "string" || pointer === "") return null;
+    let node = document_;
+    for (const raw of pointer.split("/").slice(1)) {
+      const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
+      if (node == null || typeof node !== "object") return null;
+      node = Array.isArray(node) ? node[Number(key)] : node[key];
+    }
+    return node == null ? null : node;
+  }
+
+  function block(parent) {
+    const node = document.createElement("div");
+    parent.append(node);
+    return node;
+  }
+
+  function definitions(parent, rows) {
+    const list = document.createElement("dl");
+    list.className = "inspector";
+    for (const [label, value] of rows) {
+      const term = document.createElement("dt");
+      term.textContent = label;
+      const detail = document.createElement("dd");
+      detail.textContent = value === null || value === undefined || value === "" ? MISSING : String(value);
+      list.append(term, detail);
+    }
+    parent.append(list);
+  }
+
+  // A tally of a sealed array of strings. Presentation, not a fold: the array
+  // is one entry per projected envelope and was written by the Rust fold.
+  function tally(values) {
+    const counts = new Map();
+    for (const value of values || []) counts.set(value, (counts.get(value) || 0) + 1);
+    return [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
+  }
+
+  function renderLiveEval(parent, projection) {
+    const usage = projection.usage || {};
+    const cutoff = projection.cutoff || {};
+    const streams = Object.keys(cutoff);
+    definitions(parent, [
+      ["projected envelopes", projection.event_count],
+      ["live frames", projection.has_live_frames ? "yes" : "no"],
+      ["reward.txt emitted", projection.has_reward_txt ? "yes" : "no"],
+      ["reward", projection.reward],
+      ["input tokens", usage.prompt_tokens],
+      ["output tokens", usage.completion_tokens],
+      ["total tokens", usage.total_tokens],
+      ["cost (usd)", usage.cost_usd],
+      ["cutoff", streams.length ? streams.map((id) => `${id}:${cutoff[id]}`).join(" · ") : "whole prefix"],
+    ]);
+    const kinds = tally(projection.kinds);
+    if (kinds.length) {
+      const list = document.createElement("ul");
+      for (const [kind, count] of kinds) {
+        const row = document.createElement("li");
+        row.textContent = `${kind} · ${count}`;
+        list.append(row);
+      }
+      const heading = document.createElement("h2");
+      heading.textContent = "Envelope kinds";
+      parent.append(heading, list);
+    }
+  }
+
+  function renderView(view, projection) {
+    const parent = block(section);
+    if (view.schema_version === TRACE_SCHEMA && window.SynthRolloutInspector) {
+      window.SynthRolloutInspector.mount(parent, {
+        traces: [{ label: data.title, projection }],
+        emptyMessage: MISSING,
+      });
+      return true;
+    }
+    if (view.schema_version === LIVE_EVAL_SCHEMA) {
+      renderLiveEval(parent, projection);
+      return true;
+    }
+    // A schema this build does not know still renders its own values rather
+    // than nothing: the projection is sealed, so there is something to show.
+    const pre = document.createElement("pre");
+    pre.textContent = JSON.stringify(projection, null, 2);
+    parent.append(pre);
+    return true;
+  }
+
+  const views = data.projection && Array.isArray(data.projection.views) ? data.projection.views : [];
+  let rendered = false;
+  for (const view of views) {
+    if (!view || typeof view !== "object") continue;
+    const projection = view.data !== undefined && view.data !== null ? view.data : deref(data, view.ref);
+    if (!projection) continue;
+    rendered = renderView(view, projection) || rendered;
+  }
+
+  // The fallback the seal has always had, for a bundle that names no view:
+  // the bindings it was sealed with, verbatim.
+  if (!rendered) {
+    const pre = document.createElement("pre");
+    pre.textContent = JSON.stringify(data.bindings, null, 2);
+    section.append(pre);
+  }
 })();
diff --git a/apps/synth_desktop/src-tauri/src/visuals/mod.rs b/apps/synth_desktop/src-tauri/src/visuals/mod.rs
index 095fce681..1e07de918 100644
--- a/apps/synth_desktop/src-tauri/src/visuals/mod.rs
+++ b/apps/synth_desktop/src-tauri/src/visuals/mod.rs
@@ -1,6 +1,7 @@
 //! Local Visual Registry: durable visual instances, revisions, and template catalog.
 
 mod artifacts;
+mod seal_evidence;
 mod backfill;
 pub mod cache_gc;
 
diff --git a/apps/synth_desktop/src-tauri/src/visuals/registry.rs b/apps/synth_desktop/src-tauri/src/visuals/registry.rs
index 91c3ce116..2fc04e4fd 100644
--- a/apps/synth_desktop/src-tauri/src/visuals/registry.rs
+++ b/apps/synth_desktop/src-tauri/src/visuals/registry.rs
@@ -1733,7 +1733,7 @@ fn validate_svg_bytes(bytes: &[u8]) -> Result<()> {
     Ok(())
 }
 
-const CHART_DEFAULT_PROJECTION: &str = "rollout-inspector";
+pub(super) const CHART_DEFAULT_PROJECTION: &str = "rollout-inspector";
 
 /// What a chart sees when it binds an optimizer run: the record — whose
 /// `summary.records` is the per-trial ledger — beside the typed result.
diff --git a/apps/synth_desktop/src-tauri/src/visuals/seal_evidence.rs b/apps/synth_desktop/src-tauri/src/visuals/seal_evidence.rs
new file mode 100644
index 000000000..d0fb3165a
--- /dev/null
+++ b/apps/synth_desktop/src-tauri/src/visuals/seal_evidence.rs
@@ -0,0 +1,542 @@
+//! Freeze live and Trace V5 bindings into self-contained, provenance-bearing evidence.
+use anyhow::{anyhow, bail, Context, Result};
+use serde_json::{json, Map, Value};
+use std::collections::BTreeMap;
+use super::artifacts::hex_sha256;
+
+struct SealEvidence<'a> {
+    visual_id: &'a str,
+    revision: i64,
+    content: &'a crate::storage::ContentStore,
+    traces: BTreeMap,
+}
+
+/// The trace projection one `trace_v5` descriptor names: the sealed archive
+/// digest it points at, and the consumer projection it wants derived from it.
+type TraceRequest = (String, String);
+
+/// A Trace V5 projection document the seal froze into a binding, and the
+/// provenance a verifier reads to know which archive it came from.
+struct ResolvedTraceEvidence {
+    /// The projection payload, verbatim. It is self-describing — it carries
+    /// its own `schema_version` — which is what lets
+    /// [`locate_sealed_projections`] name it as a view without a second
+    /// registry of where projections live.
+    payload: Value,
+    /// The `sha256:`-qualified digest of the sealed archive it was derived
+    /// from, as the resolver normalised it.
+    trace_digest: String,
+    /// The format the archive's own manifest declared.
+    projection_schema: String,
+    /// The digest of the projection payload, as the trace tooling computed it.
+    /// A verifier re-deriving the projection compares this, not the bindings.
+    payload_digest: String,
+}
+
+/// Replayable evidence for one live binding, and where the seal found it.
+struct ResolvedEvidence {
+    /// The evidence bodies. Empty for the opaque descriptor snapshot below,
+    /// which is not an envelope log and cannot be projected.
+    envelopes: Vec,
+    /// The verbatim value to freeze into the binding's `data`.
+    data: Value,
+    /// `descriptor` (an inline `snapshot`), `spool` (a CAS digest named on the
+    /// binding) or `host_observation` (what Desktop polled). Recorded on the
+    /// binding so a verifier reads how the evidence was obtained rather than
+    /// inferring it.
+    origin: &'static str,
+    spool_digest: Option,
+    truncated: bool,
+}
+
+/// The identity a declared live stream is recorded and resolved under.
+///
+/// The same rule `stream_receipt::declared_streams` applies — declared
+/// `source`, falling back to the poll URL — so the evidence the host recorded
+/// while polling and the evidence the seal asks for are the same key by
+/// construction, not by two functions agreeing.
+fn binding_stream_id(object: &Map) -> Option {
+    for key in ["source", "poll_url", "pollUrl"] {
+        if let Some(value) = object
+            .get(key)
+            .and_then(Value::as_str)
+            .filter(|value| !value.is_empty())
+        {
+            return Some(value.to_string());
+        }
+    }
+    None
+}
+
+/// The archive digest and projection kind one `trace_v5` descriptor names.
+///
+/// `projection` is the key a chart panel writes; `schema` is the key the trace
+/// pane stamps when it creates the inspector visual. Both name the same thing —
+/// which consumer projection to derive — so both are read here rather than one
+/// being privileged and the other silently defaulted. The strip mirrors
+/// `data.rs::projection_consumer_kind`, which does the same in the other
+/// direction for the cache key.
+fn trace_binding_request(object: &Map) -> Option {
+    let source = object
+        .get("source")
+        .and_then(Value::as_str)
+        .filter(|value| !value.is_empty())?;
+    let kind = object
+        .get("projection")
+        .and_then(Value::as_str)
+        .filter(|value| !value.is_empty())
+        .map(str::to_string)
+        .or_else(|| {
+            object
+                .get("schema")
+                .and_then(Value::as_str)
+                .and_then(|schema| schema.strip_prefix("synth.trace-projection."))
+                .and_then(|rest| rest.strip_suffix(".v1"))
+                .map(str::to_string)
+        })
+        .unwrap_or_else(|| super::registry::CHART_DEFAULT_PROJECTION.to_string());
+    Some((source.to_string(), kind))
+}
+
+/// Every distinct Trace V5 projection a bindings tree asks for.
+///
+/// Two descriptors naming the same archive and the same projection resolve
+/// once; two naming different projections of one archive resolve twice, which
+/// is what the key being a pair buys.
+fn trace_binding_requests(bindings: &Value) -> Vec {
+    fn walk(value: &Value, out: &mut Vec) {
+        match value {
+            Value::Object(object) => {
+                if object.get("kind").and_then(Value::as_str) == Some("trace_v5") {
+                    if let Some(request) = trace_binding_request(object) {
+                        if !out.contains(&request) {
+                            out.push(request);
+                        }
+                    }
+                }
+                for child in object.values() {
+                    walk(child, out);
+                }
+            }
+            Value::Array(items) => {
+                for child in items {
+                    walk(child, out);
+                }
+            }
+            _ => {}
+        }
+    }
+    let mut out = Vec::new();
+    walk(bindings, &mut out);
+    out
+}
+
+/// Freeze one `trace_v5` binding into the projection it names.
+///
+/// The defect this closes is the live-eval one in the other major visual
+/// class. A `trace.rollout_inspector.v1` seal carried `{kind: "trace_v5",
+/// source: }` verbatim: a pointer into a content-addressed store the
+/// reader does not have. The bundle was reproducible only on the machine that
+/// wrote it, and the frozen viewer — having nothing to render — printed the
+/// bindings into a `
`.
+///
+/// There is deliberately no caller-supplied rung here, unlike the live ladder.
+/// `visuals_ipc` already refuses an MCP caller's projection bytes outright and
+/// re-resolves from the local inventory; a seal that accepted them would
+/// reopen that door in the one place whose whole product is a receipt. The
+/// single rung is the trusted local Trace V5 inventory, which requires nothing
+/// of any caller — the property the live ladder was rebuilt around.
+fn resolve_trace_evidence<'a>(
+    object: &Map,
+    evidence: &'a SealEvidence<'_>,
+) -> Result<&'a ResolvedTraceEvidence> {
+    let input = super::descriptor_input_name(&Value::Object(object.clone()))
+        .unwrap_or_else(|_| "projection".to_string());
+    let Some(request) = trace_binding_request(object) else {
+        bail!(
+            "trace input \"{input}\" is bound as trace_v5 but names no `source`, so the seal has \
+             no sealed archive to derive its projection from. Bind the Trace V5 digest with \
+             visual_bind_data_source before sealing."
+        );
+    };
+    evidence.traces.get(&request).ok_or_else(|| {
+        anyhow!(
+            "trace input \"{input}\" names Trace V5 archive {} but the seal found no replayable \
+             evidence for it: this host holds no trusted, self-contained bundle for that digest, \
+             so the {} projection cannot be derived. Import the sealed Trace V5 bundle on this \
+             machine before sealing visual {} revision {}.",
+            request.0,
+            request.1,
+            evidence.visual_id,
+            evidence.revision,
+        )
+    })
+}
+
+/// Find replayable evidence for one `live_sse` binding, or say what is missing.
+///
+/// The ladder is ordered so that the rung requiring nothing of a caller is the
+/// one that normally answers. A required key nothing produces is how this path
+/// came to be dead code; a host observation nobody has to remember to attach
+/// cannot fail the same way.
+fn resolve_live_evidence(
+    object: &mut Map,
+    evidence: &SealEvidence<'_>,
+) -> Result {
+    let input = super::descriptor_input_name(&Value::Object(object.clone()))
+        .unwrap_or_else(|_| super::LIVE_EVAL_INPUT.to_string());
+    let stream_id = binding_stream_id(object);
+
+    // 1. An inline snapshot on the descriptor. Kept because a caller that
+    //    genuinely holds the evidence should not be refused, and because a
+    //    snapshot may be any shape a template renders — it is frozen verbatim
+    //    and, not being an envelope log, yields no projection.
+    if let Some(snapshot) = object.remove("snapshot") {
+        let envelopes = snapshot_envelopes(&snapshot);
+        return Ok(ResolvedEvidence {
+            data: snapshot,
+            envelopes,
+            origin: "descriptor",
+            spool_digest: None,
+            truncated: false,
+        });
+    }
+
+    // 2. A CAS spool named on the descriptor. `storage/live_spool.rs` persists
+    //    raw envelopes for exactly this after-the-fact replay, and a digest
+    //    survives the engine, the process and the machine.
+    let declared_digest = ["spool_digest", "spoolDigest"]
+        .iter()
+        .find_map(|key| object.get(*key).and_then(Value::as_str))
+        .map(str::to_string);
+    if let Some(digest) = declared_digest {
+        let spool = crate::storage::load_live_spool(evidence.content, &digest)
+            .with_context(|| format!("sealing live input \"{input}\" from spool {digest}"))?;
+        return Ok(ResolvedEvidence {
+            data: json!({ "events": spool.envelopes.clone() }),
+            envelopes: spool.envelopes,
+            origin: "spool",
+            spool_digest: Some(spool.digest),
+            truncated: false,
+        });
+    }
+
+    // 3. What this host actually polled. Nothing had to be attached for this
+    //    to be here, which is the point.
+    if let Some(stream_id) = stream_id.as_deref() {
+        if let Some(observed) = super::live_eval::observed_stream_evidence(
+            evidence.visual_id,
+            evidence.revision,
+            stream_id,
+        ) {
+            let spool = crate::storage::persist_live_envelopes(
+                evidence.content,
+                Some(stream_id),
+                None,
+                observed.events.clone(),
+            )
+            .with_context(|| format!("spooling observed evidence for live input \"{input}\""))?;
+            return Ok(ResolvedEvidence {
+                data: json!({ "events": spool.envelopes.clone() }),
+                envelopes: spool.envelopes,
+                origin: "host_observation",
+                spool_digest: Some(spool.digest),
+                truncated: observed.truncated,
+            });
+        }
+    }
+
+    // Naming the stream and the three ways to supply it, because "live SSE
+    // binding has no frozen snapshot" named a key no caller could write and
+    // sent every reader looking for a producer that did not exist.
+    bail!(
+        "live input \"{input}\" declares stream {} but the seal found no replayable evidence for it: \
+         this host recorded no envelopes for visual {} revision {}, the binding names no \
+         spool_digest, and it carries no inline snapshot. Open the visual in Desktop so the \
+         declared stream is polled, or bind a {} digest before sealing.",
+        stream_id.as_deref().unwrap_or(""),
+        evidence.visual_id,
+        evidence.revision,
+        crate::storage::LIVE_SPOOL_SCHEMA,
+    )
+}
+
+/// The envelope log inside a descriptor snapshot, if it is one.
+///
+/// A snapshot may be any shape a template renders. Only the two shapes that
+/// *are* an ordered envelope log are projected; anything else is frozen
+/// verbatim and carries no projection, which is honest rather than a guess.
+fn snapshot_envelopes(snapshot: &Value) -> Vec {
+    if let Some(rows) = snapshot.as_array() {
+        return rows.clone();
+    }
+    snapshot
+        .get("events")
+        .and_then(Value::as_array)
+        .cloned()
+        .unwrap_or_default()
+}
+
+/// Freeze a visual's bindings into evidence, and collect the projections a
+/// sealed viewer renders.
+///
+/// Returns the frozen bindings and one view per live stream. The views are the
+/// point: a sealed bundle that stores raw bindings and a runtime that re-folds
+/// them stores a *promise* that the fold will still exist and still agree.
+/// Storing the fold's output instead is what makes a seal survive the plugin,
+/// the template and the build that produced it.
+fn freeze_bindings(mut value: Value, evidence: &SealEvidence<'_>) -> Result<(Value, Vec)> {
+    fn walk(value: &mut Value, evidence: &SealEvidence<'_>, views: &mut Vec) -> Result<()> {
+        match value {
+            Value::Object(object) => {
+                let mut froze_evidence = false;
+                if object.get("kind").and_then(Value::as_str) == Some("live_sse") {
+                    froze_evidence = true;
+                    let stream_id = binding_stream_id(object);
+                    let input = super::descriptor_input_name(&Value::Object(object.clone())).ok();
+                    let resolved = resolve_live_evidence(object, evidence)?;
+                    object.insert("kind".into(), Value::String("inline".into()));
+                    object.insert("data".into(), resolved.data);
+                    object.remove("source");
+                    object.remove("poll_url");
+                    object.remove("pollUrl");
+                    object.remove("spool_digest");
+                    object.remove("spoolDigest");
+                    // Absent, not null, for the descriptor snapshot path: a
+                    // key that is always present would re-digest every seal
+                    // that already worked, and a verbatim snapshot has no
+                    // provenance to report beyond having been supplied.
+                    if resolved.origin != "descriptor" {
+                        let mut provenance = Map::new();
+                        provenance.insert("origin".into(), json!(resolved.origin));
+                        // The stream's *digest*, never its URL. A sealed
+                        // bundle that names a loopback engine points at a
+                        // machine the reader does not have and leaks the
+                        // topology of one they do; the digest still tells a
+                        // verifier holding the bindings that this evidence
+                        // came from that stream and not another.
+                        provenance.insert(
+                            "stream_digest".into(),
+                            stream_id
+                                .as_deref()
+                                .map(|id| json!(hex_sha256(id.as_bytes())))
+                                .unwrap_or(Value::Null),
+                        );
+                        provenance.insert("envelope_count".into(), json!(resolved.envelopes.len()));
+                        provenance.insert("truncated".into(), json!(resolved.truncated));
+                        if let Some(digest) = &resolved.spool_digest {
+                            provenance.insert("spool_digest".into(), json!(digest));
+                            provenance.insert(
+                                "spool_schema".into(),
+                                json!(crate::storage::LIVE_SPOOL_SCHEMA),
+                            );
+                        }
+                        object.insert("evidence".into(), Value::Object(provenance));
+                    }
+                    if !resolved.envelopes.is_empty() {
+                        let mut view = Map::new();
+                        if let Some(input) = input {
+                            view.insert("input".into(), json!(input));
+                        }
+                        let projection = super::live_eval::seal_projection(&resolved.envelopes)?;
+                        view.insert(
+                            "schema_version".into(),
+                            projection
+                                .get("schema_version")
+                                .cloned()
+                                .unwrap_or(Value::Null),
+                        );
+                        view.insert("data".into(), projection);
+                        views.push(Value::Object(view));
+                    }
+                }
+                if object.get("kind").and_then(Value::as_str) == Some("trace_v5") {
+                    froze_evidence = true;
+                    let projection_kind = trace_binding_request(object)
+                        .map(|request| request.1)
+                        .unwrap_or_default();
+                    let resolved = resolve_trace_evidence(object, evidence)?;
+                    // A projection that does not say what it is would be
+                    // frozen, sealed, and then silently unrenderable: the
+                    // viewer and `locate_sealed_projections` both key on the
+                    // document's own `schema_version`, and a document without
+                    // one falls through to the `
` this change exists to
+                    // remove. Refusing here is the difference between a seal
+                    // that carries evidence and one that promises it.
+                    let declared = resolved
+                        .payload
+                        .get("schema_version")
+                        .and_then(Value::as_str)
+                        .unwrap_or_default();
+                    if !declared.starts_with("synth.trace-projection.") {
+                        bail!(
+                            "Trace V5 archive {} resolved a {} projection whose document declares \
+                             schema_version {:?}; a sealed projection must declare its own \
+                             synth.trace-projection.* schema or no reader can render it.",
+                            resolved.trace_digest,
+                            resolved.projection_schema,
+                            declared,
+                        );
+                    }
+                    object.insert("kind".into(), Value::String("inline".into()));
+                    object.insert("data".into(), resolved.payload.clone());
+                    // The archive digest moves into `evidence` rather than
+                    // staying in `source`: a frozen binding whose `source`
+                    // still named a CAS entry is exactly the pointer this
+                    // change removes, and a reader must not be able to mistake
+                    // one for a thing they can fetch.
+                    object.remove("source");
+                    object.remove("projection");
+                    object.insert(
+                        "evidence".into(),
+                        json!({
+                            "origin": "trace_inventory",
+                            "trace_digest": resolved.trace_digest,
+                            "projection_kind": projection_kind,
+                            "projection_schema": resolved.projection_schema,
+                            "payload_digest": resolved.payload_digest,
+                        }),
+                    );
+                    // No view is pushed. The projection now *is* the binding's
+                    // document, so `locate_sealed_projections` names it by
+                    // pointer — one mechanism, and a bundle that does not carry
+                    // a megabyte-scale projection twice.
+                }
+                // Frozen evidence is producer data, not a binding tree. An
+                // envelope whose payload happens to describe a `live_sse`
+                // binding — an eval streaming a visual's own configuration —
+                // would otherwise be "frozen" a second time and fail the seal.
+                for (key, child) in object.iter_mut() {
+                    if froze_evidence && key.as_str() == "data" {
+                        continue;
+                    }
+                    walk(child, evidence, views)?;
+                }
+            }
+            Value::Array(items) => {
+                for child in items {
+                    walk(child, evidence, views)?;
+                }
+            }
+            _ => {}
+        }
+        Ok(())
+    }
+    let mut views = Vec::new();
+    walk(&mut value, evidence, &mut views)?;
+    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, views));
+    }
+    Ok((value, views))
+}
+
+/// Whether this descriptor is one the seal froze producer evidence into.
+///
+/// One predicate, because three passes over the frozen bindings — the
+/// limitations report, the projection locator and the redaction scan — all have
+/// to tell the host's own binding *metadata* from the producer bytes underneath
+/// it, and three spellings of that test would drift into three different
+/// answers about the same document.
+///
+/// A descriptor snapshot supplied inline by a caller is deliberately *not*
+/// this: it writes no `evidence` block (so that seals of that shape keep their
+/// digests), and it is author-supplied rather than host-observed, so it stays
+/// under the stricter reading everywhere.
+fn frozen_evidence_descriptor(object: &Map) -> bool {
+    object.get("kind").and_then(Value::as_str) == Some("inline")
+        && object.get("evidence").is_some_and(Value::is_object)
+}
+
+/// Projection documents a template's own resolver already placed in the
+/// bindings, named by JSON Pointer rather than copied.
+///
+/// The trace inspector's projection is computed upstream and rides inside the
+/// bindings today. The runtime used to *find* it by scanning every binding for
+/// a known `schema_version` — a locator in the viewer, which is the thing item
+/// 3 removes. Naming its location at seal time moves that knowledge into the
+/// sealed document, where it is pinned, and costs a pointer rather than a
+/// second copy of a projection that can run to megabytes.
+///
+/// A frozen evidence document is a candidate, never a haystack. A `trace_v5`
+/// binding's frozen `data` *is* the projection, so it is checked; a live
+/// stream's frozen `data` is a hundred thousand producer envelopes, and one of
+/// them may legitimately quote a `synth.trace-projection.*` document — an
+/// optimizer event carrying a proposer's trace does exactly that. Searching
+/// inside producer bytes would name that envelope as the seal's own view and
+/// point the rollout inspector at it.
+pub(super) fn locate_sealed_projections(bindings: &Value) -> Vec {
+    fn escape(key: &str) -> String {
+        key.replace('~', "~0").replace('/', "~1")
+    }
+    fn projection_schema(value: &Value) -> Option<&str> {
+        value
+            .get("schema_version")
+            .and_then(Value::as_str)
+            .filter(|schema| schema.starts_with("synth.trace-projection."))
+    }
+    fn walk(value: &Value, pointer: &str, out: &mut Vec) {
+        match value {
+            Value::Object(object) => {
+                if let Some(schema) = projection_schema(value) {
+                    out.push(json!({
+                        "schema_version": schema,
+                        "ref": format!("/bindings{pointer}"),
+                    }));
+                    return;
+                }
+                let frozen = frozen_evidence_descriptor(object);
+                for (key, child) in object {
+                    if frozen && key.as_str() == "data" {
+                        if let Some(schema) = projection_schema(child) {
+                            out.push(json!({
+                                "schema_version": schema,
+                                "ref": format!("/bindings{pointer}/data"),
+                            }));
+                        }
+                        continue;
+                    }
+                    walk(child, &format!("{pointer}/{}", escape(key)), out);
+                }
+            }
+            Value::Array(items) => {
+                for (index, child) in items.iter().enumerate() {
+                    walk(child, &format!("{pointer}/{index}"), out);
+                }
+            }
+            _ => {}
+        }
+    }
+    let mut out = Vec::new();
+    walk(bindings, "", &mut out);
+    out
+}
+
+
+pub(super) async fn freeze(
+    registry: &super::VisualRegistry, value: Value, visual_id: &str, revision: i64,
+) -> Result<(Value, Vec)> {
+    let data = crate::data::DataStore::new(registry.db.clone(), registry.content.clone());
+    let mut traces = BTreeMap::new();
+    for request in trace_binding_requests(&value) {
+        let projection = data.resolve_trace_projection(request.0.clone(), request.1.clone()).await
+            .with_context(|| format!("sealing visual {visual_id} revision {revision}: import the trusted Trace V5 bundle {} before sealing its {} projection", request.0, request.1))?;
+        traces.insert(request, ResolvedTraceEvidence {
+            payload: projection.payload, trace_digest: projection.trace_digest,
+            projection_schema: projection.projection_schema, payload_digest: projection.payload_digest,
+        });
+    }
+    freeze_bindings(value, &SealEvidence { visual_id, revision, content: ®istry.content, traces })
+}
+
+#[cfg(test)]
+pub(super) fn freeze_fixture(value: Value, content: &crate::storage::ContentStore, visual_id: &str, revision: i64) -> Result<(Value, Vec)> {
+    freeze_bindings(value, &SealEvidence { visual_id, revision, content, traces: BTreeMap::new() })
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/apps/synth_desktop/src-tauri/src/visuals/seal_evidence/tests.rs b/apps/synth_desktop/src-tauri/src/visuals/seal_evidence/tests.rs
new file mode 100644
index 000000000..db54fedf1
--- /dev/null
+++ b/apps/synth_desktop/src-tauri/src/visuals/seal_evidence/tests.rs
@@ -0,0 +1,99 @@
+use super::*;
+use crate::storage::{ContentStore, persist_live_envelopes};
+
+fn binding() -> Value { json!({"slots":[{"input":"stream","kind":"live_sse","source":"http://127.0.0.1/stream","poll_url":"http://127.0.0.1/events"}]}) }
+fn events() -> Vec { vec![json!({"rollout_id":"a","event_id":"1","kind":"frame"}), json!({"rollout_id":"b","event_id":"1","kind":"verifier","payload":{"reward.txt":0.75}})] }
+
+#[test]
+fn host_observation_freezes_without_caller_snapshot_and_preserves_lanes() {
+    let dir = tempfile::tempdir().unwrap();
+    let store = ContentStore::new(dir.path());
+    let id = uuid::Uuid::new_v4().to_string();
+    let input = binding();
+    let declared = crate::visuals::stream_receipt::declared_streams(&input);
+    crate::visuals::stream_receipt::record_poll_page(&id, 1, &declared, "http://127.0.0.1/events", &json!(events()));
+    let (frozen, views) = freeze_fixture(input, &store, &id, 1).unwrap();
+    let slot = &frozen["inputs"][0];
+    assert_eq!(slot["kind"], "inline");
+    assert!(slot.get("source").is_none());
+    assert_eq!(slot["data"]["events"].as_array().unwrap().len(), 2);
+    assert_eq!(slot["evidence"]["origin"], "host_observation");
+    assert_eq!(views[0]["data"]["event_count"], 2);
+    assert_eq!(views[0]["data"]["reward"], 0.75);
+    assert!(views[0]["data"]["usage"].is_null());
+    assert!(!serde_json::to_string(&frozen).unwrap().contains("127.0.0.1"));
+    assert!(freeze_fixture(binding(), &store, &id, 2).is_err());
+}
+
+#[test]
+fn spool_freezes_after_host_observation_is_gone_and_conflicts_refuse() {
+    let dir = tempfile::tempdir().unwrap();
+    let store = ContentStore::new(dir.path());
+    let spool = persist_live_envelopes(&store, Some("http://127.0.0.1/stream"), None, events()).unwrap();
+    let mut input = binding();
+    input["slots"][0]["spool_digest"] = json!(spool.digest);
+    let (frozen, views) = freeze_fixture(input, &store, "never-observed", 1).unwrap();
+    assert_eq!(frozen["inputs"][0]["evidence"]["origin"], "spool");
+    assert_eq!(views[0]["data"]["event_count"], 2);
+    let bad = vec![json!({"rollout_id":"a","event_id":"1","payload":1}), json!({"rollout_id":"a","event_id":"1","payload":2})];
+    assert!(persist_live_envelopes(&store, None, None, bad).is_err());
+}
+
+#[test]
+fn producer_binding_shaped_payload_is_not_recursively_frozen() {
+    let dir = tempfile::tempdir().unwrap();
+    let store = ContentStore::new(dir.path());
+    let mut input = binding();
+    input["slots"][0]["snapshot"] = json!({"events":[{"kind":"frame","payload":{"kind":"live_sse","source":"quoted-description"}}]});
+    let (frozen, _) = freeze_fixture(input, &store, "fixture", 1).unwrap();
+    assert_eq!(frozen["inputs"][0]["data"]["events"][0]["payload"]["kind"], "live_sse");
+}
+
+#[test]
+fn trace_evidence_must_come_from_inventory_not_inline_claims() {
+    let dir = tempfile::tempdir().unwrap();
+    let store = ContentStore::new(dir.path());
+    let input = json!({"slots":[{"input":"projection","kind":"trace_v5","source":"sha256:abc","snapshot":{"invented":true}}]});
+    assert!(freeze_fixture(input, &store, "fixture", 1).is_err());
+    let request = ("sha256:abc".into(), "rollout-inspector".into());
+    let evidence = SealEvidence { visual_id: "fixture", revision: 1, content: &store,
+        traces: BTreeMap::from([(request, ResolvedTraceEvidence { payload: json!({"schema_version":"synth.trace-projection.rollout-inspector.v1","rollouts":[]}),
+            trace_digest:"sha256:abc".into(), projection_schema:"trace_v5".into(), payload_digest:"abc".into() })]) };
+    let input = json!({"slots":[{"input":"projection","kind":"trace_v5","source":"sha256:abc"}]});
+    let (frozen, views) = freeze_bindings(input, &evidence).unwrap();
+    assert!(views.is_empty());
+    assert_eq!(frozen["inputs"][0]["evidence"]["origin"], "trace_inventory");
+    assert_eq!(locate_sealed_projections(&frozen)[0]["ref"], "/bindings/inputs/0/data");
+}
+
+#[tokio::test]
+async fn registry_seal_freezes_real_host_observation_and_reopens_offline() {
+    let dir = tempfile::tempdir().unwrap();
+    let storage = crate::storage::Storage::open(dir.path()).unwrap();
+    let registry = crate::visuals::VisualRegistry::new(storage.database().clone(),
+        crate::storage::EventJournal::new(storage.database().clone()), ContentStore::new(storage.content_root()));
+    let request = serde_json::from_value(json!({"templateId":"live.eval_stream.v1", "title":"Offline live evidence",
+        "bindings":binding(), "metadata":{"qualityGate":{"ready":true,"revision":1}}})).unwrap();
+    let (visual, _) = registry.create(request).await.unwrap();
+    let identity = registry.certification_identity(visual.id.clone()).await.unwrap();
+    let target = visual.id.clone();
+    registry.db.run(move |conn| {
+        conn.execute("UPDATE visuals SET metadata_json=json_set(metadata_json,'$.qualityGate.certificationIdentity',json(?1)) WHERE id=?2",
+            rusqlite::params![identity.to_string(),target])?;
+        Ok(())
+    }).await.unwrap();
+    let declared = crate::visuals::stream_receipt::declared_streams(&visual.bindings);
+    crate::visuals::stream_receipt::record_poll_page(&visual.id, 1, &declared, "http://127.0.0.1/events", &json!(events()));
+    let (seal, _) = registry.seal(visual.id.clone(), 1).await.unwrap();
+    let bundle = registry.get_seal(seal.receipt_digest.clone()).await.unwrap();
+    assert_eq!(bundle.data["projection"]["views"][0]["data"]["reward"], 0.75);
+    assert_eq!(bundle.data["bindings"]["inputs"][0]["evidence"]["origin"], "host_observation");
+    assert!(!bundle.index_html.contains("127.0.0.1"));
+    assert!(bundle.index_html.contains("projected envelopes"));
+    let reopened = crate::visuals::VisualRegistry::new(storage.database().clone(),
+        crate::storage::EventJournal::new(storage.database().clone()), ContentStore::new(storage.content_root()));
+    assert_eq!(reopened.get_seal(seal.receipt_digest).await.unwrap().data, bundle.data);
+    if let Some(path) = std::env::var_os("WORKSHOP_TEST_SEAL_EXPORT") {
+        std::fs::write(path, bundle.index_html).unwrap();
+    }
+}
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 });
+});

From 99b397fc5a77c03f8969f50b2dc61949bdcad9d9 Mon Sep 17 00:00:00 2001
From: Josh Purtell 
Date: Thu, 10 Sep 2026 07:01:12 -0400
Subject: [PATCH 16/25] Verify cached Trace projection archive bytes before
 replay

---
 apps/synth_desktop/src-tauri/src/data.rs | 55 +++++++++++++++++++++---
 1 file changed, 48 insertions(+), 7 deletions(-)

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()

From 8398310930a4a3c8ab8b778257781ca0203eb9d3 Mon Sep 17 00:00:00 2001
From: Josh Purtell 
Date: Thu, 10 Sep 2026 07:11:56 -0400
Subject: [PATCH 17/25] Render atomic host live evidence without stale response
 rollback

---
 apps/synth_desktop/src-tauri/src/lib.rs       | 22 +++----
 .../src-tauri/src/visuals/stream_receipt.rs   | 16 +++++
 .../src/visuals/stream_receipt/tests.rs       | 25 ++++++++
 .../playwright/host-live-evidence.spec.ts     | 60 +++++++++++++++++++
 .../chrome/useLiveEvalStreams.ts              | 14 ++++-
 .../components/metrics.v1/Metrics.tsx         |  7 ++-
 .../live.eval_stream.v1/shell.tsx             |  4 +-
 .../live.harbor_eval.v1/shell.tsx             |  7 ++-
 .../runtime/hostLiveEvidence.ts               | 55 +++++++++++++++++
 visuals/tests/host_live_evidence.test.mjs     | 42 +++++++++++++
 10 files changed, 230 insertions(+), 22 deletions(-)
 create mode 100644 apps/synth_desktop/tests/playwright/host-live-evidence.spec.ts
 create mode 100644 packages/workshop-visuals/runtime/hostLiveEvidence.ts
 create mode 100644 visuals/tests/host_live_evidence.test.mjs

diff --git a/apps/synth_desktop/src-tauri/src/lib.rs b/apps/synth_desktop/src-tauri/src/lib.rs
index a1dca8729..d09ef935b 100644
--- a/apps/synth_desktop/src-tauri/src/lib.rs
+++ b/apps/synth_desktop/src-tauri/src/lib.rs
@@ -3118,14 +3118,12 @@ async fn visual_stream_poll(
             // 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 projection =
-                visuals::live_eval::observed_projection(&visual.id, visual.current_revision, None)
-                    .transpose()
-                    .map_err(AppError::from)?
-                    .map(|projection| visuals::live_eval::projection_view(&projection))
-                    .transpose()
-                    .map_err(AppError::from)?
-                    .map(contract::specta::OpaqueJson);
+            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(
@@ -3133,12 +3131,8 @@ async fn visual_stream_poll(
                 )),
                 cursor: visuals::stream_receipt::page_cursor(&page),
                 projection,
-                evidence_truncated: outcome.evidence_truncated,
-                receipt: visuals::stream_receipt::receipt(
-                    &visual.id,
-                    visual.current_revision,
-                    &receipt_streams,
-                ),
+                evidence_truncated,
+                receipt,
             })
         }
         Err(error) => {
diff --git a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs
index 79fc33e1d..8ddbca0de 100644
--- a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs
+++ b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt.rs
@@ -872,6 +872,10 @@ pub fn receipt(visual_id: &str, revision: i64, declared: &DeclaredStreams) -> St
     let mut absent = VisualState::new(revision, declared);
     let state = if newer(&store, visual_id, revision) { &mut absent }
         else { entry(&mut store, visual_id, revision, declared) };
+    receipt_from_state(visual_id, revision, declared, state)
+}
+
+fn receipt_from_state(visual_id: &str, revision: i64, declared: &DeclaredStreams, state: &VisualState) -> StreamReceipt {
     let streams: Vec = declared
         .streams
         .iter()
@@ -920,6 +924,18 @@ pub fn receipt(visual_id: &str, revision: i64, declared: &DeclaredStreams) -> St
     }
 }
 
+/// Capture accounting and retained bytes under one lock. The projection is
+/// computed afterwards, without allowing a newer receipt to label older bytes.
+pub fn evidence_snapshot(visual_id: &str, revision: i64, declared: &DeclaredStreams) -> (StreamReceipt, Vec, bool) {
+    let mut store = store();
+    let mut absent = VisualState::new(revision, declared);
+    let state = if newer(&store, visual_id, revision) { &mut absent }
+        else { entry(&mut store, visual_id, revision, declared) };
+    (receipt_from_state(visual_id, revision, declared, state),
+        state.evidence.iter().map(|(_, envelope)| envelope.clone()).collect(),
+        state.evidence_books.values().any(|book| book.truncated))
+}
+
 // ---------------------------------------------------------------------------
 // Responsibility 3: the evidence prefix the seal and the projection replay.
 // ---------------------------------------------------------------------------
diff --git a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs
index e8dac9024..b63f1ef0d 100644
--- a/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs
+++ b/apps/synth_desktop/src-tauri/src/visuals/stream_receipt/tests.rs
@@ -12,6 +12,31 @@ fn poll(id: &str, revision: i64, events: Value) -> StreamReceipt {
     receipt(id, revision, &streams)
 }
 
+#[test]
+fn concurrent_poll_snapshots_pair_receipt_with_the_exact_retained_prefix() {
+    let id = id();
+    let writer_id = id.clone();
+    let writer = std::thread::spawn(move || {
+        for sequence in 1..=100 {
+            poll(&writer_id, 1, json!([{"sequence":sequence,"kind":"frame"}]));
+            std::thread::yield_now();
+        }
+    });
+    for _ in 0..100 {
+        let (receipt, events, truncated) = evidence_snapshot(&id, 1, &declared());
+        assert!(!truncated);
+        assert_eq!(receipt.recovered, events.len() as u64);
+        assert_eq!(receipt.streams[0].poll_responses, events.len() as u64);
+        std::thread::yield_now();
+    }
+    writer.join().unwrap();
+    let (receipt, events, truncated) = evidence_snapshot(&id, 1, &declared());
+    assert!(!truncated);
+    assert_eq!(receipt.recovered, 100);
+    assert_eq!(events.len(), 100);
+    assert_eq!(events.last().unwrap()["sequence"], 100);
+}
+
 #[test]
 fn folded_lanes_control_gaps_and_conflicts_remain_distinct() {
     let mut fold = LiveFold::default();
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/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/first_class_example_containers/live.eval_stream.v1/shell.tsx b/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/shell.tsx index 4474ff5f8..f70e6983f 100644 --- a/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/shell.tsx +++ b/packages/workshop-visuals/families/first_class_example_containers/live.eval_stream.v1/shell.tsx @@ -42,7 +42,7 @@ export function Shell(props: ShellProps) { [declaredStreamCount, stream.events] ); - const { events, state, error } = useLiveEvalStream({ + const { events, state, error, hostEvidence } = useLiveEvalStream({ replay: props.replay, fixtureEvents, visualId: props.visualId, @@ -60,7 +60,7 @@ export function Shell(props: ShellProps) { testId="visual-live-eval-stream" footer="live.eval_stream.v1" > - + 0 || Boolean(stream.events); - const { events, state, error, ready } = useLiveEvalStream({ + const { events, state, error, ready, hostEvidence } = useLiveEvalStream({ replay: props.replay, fixtureEvents, replayMs: stream.replay_ms, @@ -152,7 +152,10 @@ export function Shell(props: ShellProps) { selectedEventIndex != null && selectedEventIndex < visibleEvents.length ? visibleEvents[selectedEventIndex] : visibleEvents.at(-1); - const projection = projectLiveEval(visibleEvents); + // The host projection covers its whole observed prefix. A historical + // selection still needs the specialized view of that selected event cut. + const projection = eventCutoff == null && hostEvidence?.projection + ? hostEvidence.projection : projectLiveEval(visibleEvents); const trials = useMemo(() => foldTrials(visibleEvents), [visibleEvents]); const skills = useMemo(() => harborSkillProgress(visibleEvents), [visibleEvents]); const status = [...visibleEvents].reverse().find((event) => event.kind === "status"); diff --git a/packages/workshop-visuals/runtime/hostLiveEvidence.ts b/packages/workshop-visuals/runtime/hostLiveEvidence.ts new file mode 100644 index 000000000..98ac75b51 --- /dev/null +++ b/packages/workshop-visuals/runtime/hostLiveEvidence.ts @@ -0,0 +1,55 @@ +import type { HostLiveEvalProjection, ReplayPage, ReplayStream } from "./replayClient.ts"; + +export type HostLiveEvidence = { + projection?: HostLiveEvalProjection; + responseCount: number; + recovered: number; + ready: boolean; + truncated: boolean; + error: string | null; +}; + +/** A poll receipt describes the whole visual, not only the responding stream. + * Its total response count orders concurrent snapshots without wall-clock time. + * Never add together whole-visual projections from individual poll answers. */ +export function acceptHostEvidence( + previous: HostLiveEvidence | undefined, + page: ReplayPage, + streams: ReplayStream[], + identity: { visualId?: string | null; revision?: number | null } +): HostLiveEvidence | undefined { + if (page.receipt === undefined) { + if (previous) throw new Error("Host replay stopped supplying its evidence receipt"); + return undefined; // Browser/fixture transport; never a silent host downgrade. + } + const receipt = page.receipt as Record | null; + if (!receipt || receipt.schemaVersion !== "synth.visual-stream-receipt.v1" || + receipt.visualId !== identity.visualId || receipt.revision !== identity.revision) { + throw new Error("Host stream receipt does not match this visual revision"); + } + const rows = receipt.streams as { streamId: string; declaredSource: string; pollResponses: number }[]; + if (!Array.isArray(rows) || rows.length !== streams.length || + streams.some(stream => !rows.some(row => row.streamId === stream.streamId && row.declaredSource === stream.pollUrl))) { + throw new Error("Host stream receipt does not match the declared transports"); + } + const count = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0; + if (!rows.every(row => count(row.pollResponses)) || !count(receipt.recovered)) { + throw new Error("Host stream receipt has invalid evidence accounting"); + } + const responseCount = rows.reduce((total, row) => total + row.pollResponses, 0); + if (previous && responseCount <= previous.responseCount) return previous; + if (page.projection && page.projection.schema_version !== "synth.live-eval-projection.v1") { + throw new Error("Unsupported host live projection schema"); + } + const truncated = page.evidenceTruncated === true || receipt.trackingTruncated === true; + const conflicts = Array.isArray(receipt.conflicts) ? receipt.conflicts.length : 0; + const gaps = Array.isArray(receipt.gaps) ? receipt.gaps.length : 0; + const missing = Array.isArray(receipt.streamsMissingTransport) ? receipt.streamsMissingTransport.length : 0; + const error = truncated ? "Host evidence is truncated; displayed values cover only the retained prefix" + : conflicts ? "Host observed conflicting replay envelopes" + : gaps ? "Host observed an evidence gap" + : missing ? "Declared streams are missing poll transport" : null; + return { projection: page.projection, responseCount, recovered: receipt.recovered, + ready: receipt.ready === true && receipt.respondingStreamCount === receipt.declaredStreamCount && !error, + truncated, error }; +} diff --git a/visuals/tests/host_live_evidence.test.mjs b/visuals/tests/host_live_evidence.test.mjs new file mode 100644 index 000000000..46aa029cd --- /dev/null +++ b/visuals/tests/host_live_evidence.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { acceptHostEvidence } from "../runtime/hostLiveEvidence.ts"; + +const streams = [{ streamId: "a", pollUrl: "/a" }, { streamId: "b", pollUrl: "/b" }]; +const identity = { visualId: "visual", revision: 2 }; +function page(counts, reward = null) { + return { events: [], cursor: {next: 0, hasMore: false, closed: true}, + projection: {schema_version:"synth.live-eval-projection.v1", kinds:[], event_count:counts.reduce((a,b)=>a+b), + reward, usage:null, has_live_frames:false, has_reward_txt:false}, + receipt: {schemaVersion:"synth.visual-stream-receipt.v1", ...identity, ready:true, + declaredStreamCount:2, respondingStreamCount:counts.filter(Boolean).length, + recovered:counts.reduce((a,b)=>a+b), gaps:[], conflicts:[], streamsMissingTransport:[], + streams:streams.map((stream,i)=>({streamId:stream.streamId,declaredSource:stream.pollUrl,pollResponses:counts[i]}))}}; +} +test("whole-visual host snapshots never sum or roll back on response reordering", () => { + const newer = acceptHostEvidence(undefined, page([1,1], .75), streams, identity); + assert.equal(newer.ready, true); + assert.equal(newer.recovered, 2); + assert.equal(acceptHostEvidence(newer, page([1,0], .1), streams, identity), newer); + assert.equal(acceptHostEvidence(newer, page([1,1], .2), streams, identity), newer); + assert.equal(acceptHostEvidence(newer, page([2,1], null), streams, identity).projection.reward, null); +}); +test("partial streams, gaps, conflicts and truncation do not claim readiness", () => { + assert.equal(acceptHostEvidence(undefined,page([1,0]),streams,identity).ready,false); + for (const issue of ["gaps","conflicts"]) { + const input=page([1,1]); input.receipt[issue]=[{}]; + const result=acceptHostEvidence(undefined,input,streams,identity); + assert.equal(result.ready,false); assert.ok(result.error); + } + const input=page([1,1]); input.evidenceTruncated=true; + const result=acceptHostEvidence(undefined,input,streams,identity); + assert.equal(result.ready,false); assert.equal(result.truncated,true); + assert.match(result.error,/retained prefix/); +}); +test("receipt identity and transport changes refuse; browser pages have no host claims", () => { + assert.throws(()=>acceptHostEvidence(undefined,page([1,1]),streams,{...identity,revision:3}),/revision/); + assert.throws(()=>acceptHostEvidence(undefined,page([1,1]),[{...streams[0],pollUrl:"/changed"},streams[1]],identity),/transports/); + assert.equal(acceptHostEvidence(undefined,{events:[],cursor:{}},streams,identity),undefined); + const prior = acceptHostEvidence(undefined,page([1,1]),streams,identity); + assert.throws(()=>acceptHostEvidence(prior,{events:[],cursor:{}},streams,identity),/stopped supplying/); +}); From a27465905d9a295a1faf5f2c89393f903865081d Mon Sep 17 00:00:00 2001 From: Josh Purtell Date: Thu, 10 Sep 2026 07:33:13 -0400 Subject: [PATCH 18/25] Embed certified user source and verify captured Trace offline seals --- .../src-tauri/src/visuals/artifacts.rs | 33 ++++++- .../src-tauri/src/visuals/mod.rs | 1 + .../src/visuals/seal_evidence/tests.rs | 36 +++++++ .../src-tauri/src/visuals/seal_template.rs | 96 +++++++++++++++++++ .../playwright/frozen-trace-artifact.spec.ts | 25 +++++ 5 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 apps/synth_desktop/src-tauri/src/visuals/seal_template.rs create mode 100644 apps/synth_desktop/tests/playwright/frozen-trace-artifact.spec.ts diff --git a/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs b/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs index be74a7c66..1b587e2da 100644 --- a/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs +++ b/apps/synth_desktop/src-tauri/src/visuals/artifacts.rs @@ -201,6 +201,10 @@ impl VisualRegistry { "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()); @@ -974,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", @@ -996,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}"); } @@ -1022,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
"# @@ -1198,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":"