From 185c59507abfa0262c13211b37a1b358bf54c3d5 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 22 Oct 2026 02:32:53 +0000 Subject: [PATCH 1/5] feat(nodelab): author pinned host-admitted workflows --- .../examples/nodelab_host.rs | 187 ++++++++++++ .../src/custom_nodes.rs | 2 + .../src/custom_nodes/authoring.rs | 37 +++ .../src/graph/connection_insert.rs | 7 + .../src/graph/connection_intent.rs | 1 + .../src/graph/session.rs | 16 +- .../src/graph/session_connection_api.rs | 21 +- .../src/graph/session_contract.rs | 22 +- .../src/graph/session_node_api.rs | 45 +++ .../src/graph/session_state.rs | 18 +- .../src/workflow.rs | 1 + .../src/workflow/custom_node_authoring_api.rs | 112 ++++++++ .../src/workflow/service_config.rs | 20 +- .../src/workflow/tests/custom_nodes.rs | 269 ++++++++++++++++++ docs/custom-nodes.md | 48 +++- .../evidence/nodelab-authoring/browser.log.gz | Bin 0 -> 441 bytes .../native-unavailable.log.gz | Bin 0 -> 2190 bytes .../nodelab-authoring/qualification.json | 54 ++++ .../execution-ledger.md | 33 +++ .../issues.md | 4 +- .../plan.md | 58 ++++ scripts/README.md | 17 ++ scripts/check-nodelab-browser.mjs | 172 +++++++++++ src-tauri/src/app_setup.rs | 3 + src-tauri/src/workflow/commands.rs | 34 +++ src/components/workbench/NodeLabPage.svelte | 89 +++++- src/components/workbench/nodeLabController.ts | 86 ++++++ .../workbench/nodeLabPresenters.test.ts | 15 +- src/components/workbench/nodeLabPresenters.ts | 15 +- .../workflow/customNodeAuthoring.test.ts | 143 ++++++++++ src/services/workflow/customNodeAuthoring.ts | 184 ++++++++++++ 31 files changed, 1647 insertions(+), 66 deletions(-) create mode 100644 crates/pantograph-workflow-service/examples/nodelab_host.rs create mode 100644 crates/pantograph-workflow-service/src/custom_nodes/authoring.rs create mode 100644 crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json create mode 100644 scripts/check-nodelab-browser.mjs create mode 100644 src/components/workbench/nodeLabController.ts create mode 100644 src/services/workflow/customNodeAuthoring.test.ts create mode 100644 src/services/workflow/customNodeAuthoring.ts diff --git a/crates/pantograph-workflow-service/examples/nodelab_host.rs b/crates/pantograph-workflow-service/examples/nodelab_host.rs new file mode 100644 index 000000000..c1b4d82c1 --- /dev/null +++ b/crates/pantograph-workflow-service/examples/nodelab_host.rs @@ -0,0 +1,187 @@ +//! Isolated stdio host for NodeLab authoring qualification. No runtime code loader. +//! A verifier supplies the loopback HTTP transport; EOF drops the temporary store. +use async_trait::async_trait; +use node_engine::{Context, ExecutorExtensions, NodeExecutor}; +use pantograph_node_contracts::{ + NodeAuthoringMetadata, NodeCategory, NodeExecutionSemantics, NodeTypeContract, PortContract, + PortRequirement, PortValueType, +}; +use pantograph_workflow_service::{ + custom_nodes::{authoring::CustomNodeDraftRequest, CustomNodeCatalog, TrustedCustomNode}, + *, +}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + io::{BufRead, Write}, + sync::Arc, +}; + +struct Uppercase; +#[async_trait] +impl NodeExecutor for Uppercase { + async fn execute( + &self, + _: &str, + inputs: HashMap, + _: &Context, + _: &ExecutorExtensions, + ) -> node_engine::Result> { + Ok(HashMap::from([( + "output".into(), + json!(inputs["input"] + .as_str() + .ok_or_else(|| node_engine::NodeEngineError::ExecutionFailed( + "string input required".into() + ))? + .to_uppercase()), + )])) + } +} +fn catalog() -> Result, Box> { + let mut output = PortContract::output("output".parse()?, "Output", PortValueType::String); + output.requirement = PortRequirement::Required; + let contract = NodeTypeContract { + node_type: "host-uppercase".parse()?, + category: NodeCategory::Processing, + label: "Host uppercase".into(), + description: "Pure string transform compiled into this isolated Rust host".into(), + inputs: vec![PortContract::input( + "input".parse()?, + "Input", + PortValueType::String, + PortRequirement::Required, + )], + outputs: vec![output], + execution_semantics: NodeExecutionSemantics::Batch, + capability_requirements: vec![], + inference_tasks: vec![], + authoring: NodeAuthoringMetadata::default(), + contract_version: Some("1.0.0".into()), + contract_digest: None, + }; + let digest = |text: &str| format!("blake3:{}", blake3::hash(text.as_bytes())); + Ok(Arc::new(CustomNodeCatalog::try_new(vec![ + TrustedCustomNode::admit( + contract, + digest("nodelab_host.rs uppercase v1"), + digest("isolated Rust authoring host v1"), + Arc::new(Uppercase), + )?, + ])?)) +} +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| WorkflowServiceError::InvalidRequest(e.to_string())) +} +fn encode(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(|e| WorkflowServiceError::Internal(e.to_string())) +} +fn string(args: &Value, name: &str) -> Result { + args[name] + .as_str() + .map(str::to_owned) + .ok_or_else(|| WorkflowServiceError::InvalidRequest(format!("{name} is required"))) +} +async fn dispatch( + service: &WorkflowService, + store: &FileSystemWorkflowGraphStore, + command: &str, + args: Value, +) -> Result { + let edit = || -> Result { + Ok(WorkflowGraphEditSessionGraphRequest { + session_id: string(&args, "executionId")?, + }) + }; + match command { + "get_custom_node_authoring_catalog" => encode(service.custom_node_authoring_catalog()), + "create_custom_node_draft" => encode( + service + .create_custom_node_draft(decode::( + args["request"].clone(), + )?) + .await?, + ), + "get_node_lab_draft" => encode( + service + .workflow_graph_get_edit_session_graph(edit()?) + .await?, + ), + "create_workflow_execution_session" => encode( + service + .workflow_graph_create_edit_session(WorkflowGraphEditSessionCreateRequest { + graph: decode(args["graph"].clone())?, + workflow_id: args["workflowId"].as_str().map(str::to_owned), + }) + .await?, + ), + "update_node_data" => encode( + service + .workflow_graph_update_node_data(WorkflowGraphUpdateNodeDataRequest { + session_id: string(&args, "executionId")?, + node_id: string(&args, "nodeId")?, + data: args["data"].clone(), + }) + .await?, + ), + "save_workflow" => encode( + service + .workflow_graph_save( + store, + WorkflowGraphSaveRequest { + name: string(&args, "name")?, + graph: decode(args["graph"].clone())?, + }, + )? + .path, + ), + "load_workflow" => encode(service.workflow_graph_load( + store, + WorkflowGraphLoadRequest { + path: string(&args, "path")?, + }, + )?), + "remove_execution" => { + service + .workflow_graph_close_edit_session(WorkflowGraphEditSessionCloseRequest { + session_id: edit()?.session_id, + }) + .await?; + Ok(Value::Null) + } + _ => Err(WorkflowServiceError::CapabilityViolation( + "unsupported authoring host command".into(), + )), + } +} +#[tokio::main] +async fn main() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let store = FileSystemWorkflowGraphStore::new(directory.path()); + let service = if std::env::args().any(|arg| arg == "--empty-catalog") { + WorkflowService::new() + } else { + WorkflowService::with_trusted_custom_nodes(catalog()?) + }; + for line in std::io::stdin().lock().lines() { + let result = match serde_json::from_str::(&line?) { + Ok(request) => match request["command"].as_str() { + Some(command) => dispatch(&service, &store, command, request["args"].clone()).await, + None => Err(WorkflowServiceError::InvalidRequest( + "command is required".into(), + )), + }, + Err(error) => Err(WorkflowServiceError::InvalidRequest(error.to_string())), + }; + let response = match result { + Ok(value) => json!({"ok": true, "value": value}), + Err(error) => { + json!({"ok": false, "error": serde_json::from_str::(&error.to_envelope_json())?}) + } + }; + println!("{}", serde_json::to_string(&response)?); + std::io::stdout().flush()?; + } + Ok(()) +} diff --git a/crates/pantograph-workflow-service/src/custom_nodes.rs b/crates/pantograph-workflow-service/src/custom_nodes.rs index a6f8bfca3..90b245b5e 100644 --- a/crates/pantograph-workflow-service/src/custom_nodes.rs +++ b/crates/pantograph-workflow-service/src/custom_nodes.rs @@ -355,3 +355,5 @@ pub(crate) fn validate_payload( #[cfg(test)] pub(crate) mod tests; + +pub mod authoring; diff --git a/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs b/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs new file mode 100644 index 000000000..8dc69b4b2 --- /dev/null +++ b/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs @@ -0,0 +1,37 @@ +//! Read-only host catalog and exactly pinned graph authoring. No execution/loading API. +use super::CustomNodeIdentity; +use serde::{Deserialize, Serialize}; + +/// Version 1 is a deliberately narrow projection of admitted string-port contracts. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CustomNodeAuthoringCatalog { + pub contract_version: u16, + pub nodes: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CustomNodeAuthoringEntry { + pub identity: CustomNodeIdentity, + pub label: String, + pub description: String, + pub trust: CustomNodeAuthoringTrust, + pub inputs: Vec, + pub outputs: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CustomNodeAuthoringTrust { + TrustedInProcess, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CustomNodeAuthoringPort { + pub id: String, + pub label: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CustomNodeDraftRequest { + pub identity: CustomNodeIdentity, +} diff --git a/crates/pantograph-workflow-service/src/graph/connection_insert.rs b/crates/pantograph-workflow-service/src/graph/connection_insert.rs index bd49a8b39..f267b7cd3 100644 --- a/crates/pantograph-workflow-service/src/graph/connection_insert.rs +++ b/crates/pantograph-workflow-service/src/graph/connection_insert.rs @@ -41,6 +41,13 @@ fn resolve_insert_definition<'a>( registry: &'a NodeRegistry, node_type: &str, ) -> Result<&'a NodeDefinition, ConnectionRejection> { + if registry.custom_nodes.get(node_type).is_some() { + return Err(ConnectionRejection { + reason: ConnectionRejectionReason::UnknownInsertNodeType, + message: "custom-node insertion by name is unsupported; add an exactly pinned node and connect its endpoints".into(), + contract_diagnostic: None, + }); + } registry .get_definition(node_type) .ok_or_else(|| ConnectionRejection { diff --git a/crates/pantograph-workflow-service/src/graph/connection_intent.rs b/crates/pantograph-workflow-service/src/graph/connection_intent.rs index 1360f02c0..a0c2caf1e 100644 --- a/crates/pantograph-workflow-service/src/graph/connection_intent.rs +++ b/crates/pantograph-workflow-service/src/graph/connection_intent.rs @@ -506,6 +506,7 @@ pub fn connection_candidates_with_surfaces( let mut insertable_node_types = registry .all_definitions() .into_iter() + .filter(|definition| registry.custom_nodes.get(&definition.node_type).is_none()) .filter_map(|definition| { let mut matching_input_port_ids = definition .inputs diff --git a/crates/pantograph-workflow-service/src/graph/session.rs b/crates/pantograph-workflow-service/src/graph/session.rs index d09eaa2a6..3d940e3bf 100644 --- a/crates/pantograph-workflow-service/src/graph/session.rs +++ b/crates/pantograph-workflow-service/src/graph/session.rs @@ -75,6 +75,7 @@ fn dirty_tasks_from_seed_nodes_unique(graph: &WorkflowGraph, node_ids: &[String] } pub struct GraphSessionStore { + node_registry: Arc, sessions: RwLock>, validation_state: Arc, validation_lifecycle: Arc, @@ -136,6 +137,7 @@ impl GraphSessionStore { dependency_provider: SharedDependencyEnvironmentProvider, ) -> Self { Self { + node_registry: Arc::new(super::registry::NodeRegistry::new()), sessions: RwLock::new(HashMap::new()), validation_state: Arc::new(CurrentInferenceValidationStateStore::new()), validation_lifecycle: Arc::new(WorkflowGraphValidationLifecycleOwner::new()), @@ -146,6 +148,14 @@ impl GraphSessionStore { } } + pub(crate) fn with_node_registry( + mut self, + registry: Arc, + ) -> Self { + self.node_registry = registry; + self + } + pub async fn set_validation_lifecycle_event_sink( &self, event_sink: Option>, @@ -159,7 +169,11 @@ impl GraphSessionStore { workflow_id: Option, ) -> WorkflowGraphEditSessionCreateResponse { let session_id = Uuid::new_v4().to_string(); - let session = Arc::new(Mutex::new(GraphEditSession::new(graph, workflow_id))); + let session = Arc::new(Mutex::new(GraphEditSession::new( + graph, + workflow_id, + self.node_registry.clone(), + ))); let graph_revision = { let state = session.lock().await; state.graph.compute_fingerprint() diff --git a/crates/pantograph-workflow-service/src/graph/session_connection_api.rs b/crates/pantograph-workflow-service/src/graph/session_connection_api.rs index 22a97ae4e..97f717675 100644 --- a/crates/pantograph-workflow-service/src/graph/session_connection_api.rs +++ b/crates/pantograph-workflow-service/src/graph/session_connection_api.rs @@ -13,7 +13,6 @@ use super::super::inference_validation_state::{ WorkflowGraphCurrentConnectionSurfacesStateRequest, }; use super::super::memory_impact::graph_memory_impact_from_graph_change; -use super::super::registry::NodeRegistry; use super::super::session_event::{dirty_tasks_from_seed_nodes, graph_modified_event}; use super::super::session_graph::sync_embedding_emit_metadata_flags; use super::super::session_types::{ @@ -39,10 +38,10 @@ impl GraphSessionStore { request.graph_revision.as_deref(), ) .await?; - let registry = NodeRegistry::new(); + let registry = self.node_registry.as_ref(); connection_candidates_with_surfaces( &graph, - ®istry, + registry, InferenceConnectionSurfaceView::new(&connection_surfaces.surfaces), request.source_anchor, request.graph_revision.as_deref(), @@ -65,10 +64,10 @@ impl GraphSessionStore { let mut state = handle.lock().await; state.touch(); let before_graph = state.graph.clone(); - let registry = NodeRegistry::new(); + let registry = self.node_registry.as_ref(); if let Err(rejection) = commit_connection_with_surfaces( &state.graph, - ®istry, + registry, InferenceConnectionSurfaceView::new(&connection_surfaces.surfaces), &request.graph_revision, &request.source_anchor, @@ -191,10 +190,10 @@ impl GraphSessionStore { let mut state = handle.lock().await; state.touch(); let before_graph = state.graph.clone(); - let registry = NodeRegistry::new(); + let registry = self.node_registry.as_ref(); let (inserted_node, inserted_edge) = match insert_node_and_connect( &state.graph, - ®istry, + registry, &request.graph_revision, &request.source_anchor, &request.node_type, @@ -252,11 +251,11 @@ impl GraphSessionStore { let handle = self.get_session_handle(&request.session_id).await?; let mut state = handle.lock().await; state.touch(); - let registry = NodeRegistry::new(); + let registry = self.node_registry.as_ref(); match preview_node_insert_on_edge( &state.graph, - ®istry, + registry, &request.graph_revision, &request.edge_id, &request.node_type, @@ -283,11 +282,11 @@ impl GraphSessionStore { let mut state = handle.lock().await; state.touch(); let before_graph = state.graph.clone(); - let registry = NodeRegistry::new(); + let registry = self.node_registry.as_ref(); let (inserted_node, incoming_edge, outgoing_edge, bridge) = match insert_node_on_edge( &state.graph, - ®istry, + registry, &request.graph_revision, &request.edge_id, &request.node_type, diff --git a/crates/pantograph-workflow-service/src/graph/session_contract.rs b/crates/pantograph-workflow-service/src/graph/session_contract.rs index ee8d764fc..d62215fc5 100644 --- a/crates/pantograph-workflow-service/src/graph/session_contract.rs +++ b/crates/pantograph-workflow-service/src/graph/session_contract.rs @@ -88,11 +88,28 @@ fn build_graph_session_response( build_graph_session_response_with_state(session_id, graph, workflow_event, None) } +#[cfg(test)] pub(crate) fn build_graph_session_response_with_state( session_id: &str, graph: &super::types::WorkflowGraph, workflow_event: Option, projection: Option<&WorkflowGraphSessionStateProjection>, +) -> WorkflowGraphEditSessionGraphResponse { + build_graph_session_response_with_registry( + session_id, + graph, + &NodeRegistry::new(), + workflow_event, + projection, + ) +} + +pub(crate) fn build_graph_session_response_with_registry( + session_id: &str, + graph: &super::types::WorkflowGraph, + registry: &NodeRegistry, + workflow_event: Option, + projection: Option<&WorkflowGraphSessionStateProjection>, ) -> WorkflowGraphEditSessionGraphResponse { let graph_revision = graph.compute_fingerprint(); WorkflowGraphEditSessionGraphResponse { @@ -106,10 +123,7 @@ pub(crate) fn build_graph_session_response_with_state( workflow_event.as_ref(), projection, )), - graph_diagnostics: validate_workflow_graph_contract_diagnostics( - graph, - &NodeRegistry::new(), - ), + graph_diagnostics: validate_workflow_graph_contract_diagnostics(graph, registry), } } diff --git a/crates/pantograph-workflow-service/src/graph/session_node_api.rs b/crates/pantograph-workflow-service/src/graph/session_node_api.rs index aa63ed567..4f2e3ba19 100644 --- a/crates/pantograph-workflow-service/src/graph/session_node_api.rs +++ b/crates/pantograph-workflow-service/src/graph/session_node_api.rs @@ -40,6 +40,33 @@ impl GraphSessionStore { request.node_id ))); } + let original = state + .graph + .find_node(&request.node_id) + .expect("checked node"); + if self + .node_registry + .custom_nodes + .get(&original.node_type) + .is_some() + || original + .data + .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD) + .is_some() + { + let mut candidate = original.clone(); + merge_node_data(&mut candidate.data, request.data.clone()); + if candidate + .data + .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD) + != original + .data + .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD) + || candidate.data.get("definition") != original.data.get("definition") + { + return Err(WorkflowServiceError::InvalidRequest("custom-node identity/definition cannot be patched; explicitly remove and recreate from the admitted catalog".into())); + } + } state.push_undo_snapshot(); let node = state.graph.find_node_mut(&request.node_id).ok_or_else(|| { WorkflowServiceError::InvalidRequest(format!( @@ -110,6 +137,24 @@ impl GraphSessionStore { let mut state = handle.lock().await; state.touch(); let before_graph = state.graph.clone(); + self.node_registry + .validate_custom_node_identity(&request.node) + .map_err(|error| WorkflowServiceError::InvalidRequest(error.to_string()))?; + if request + .node + .data + .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD) + .is_some() + && self + .node_registry + .custom_nodes + .get(&request.node.node_type) + .is_none() + { + return Err(WorkflowServiceError::InvalidRequest( + "custom-node implementation is unavailable".into(), + )); + } state.push_undo_snapshot(); let node_id = request.node.id.clone(); state.graph.nodes.push(request.node); diff --git a/crates/pantograph-workflow-service/src/graph/session_state.rs b/crates/pantograph-workflow-service/src/graph/session_state.rs index d44bda6b0..f4ff2e920 100644 --- a/crates/pantograph-workflow-service/src/graph/session_state.rs +++ b/crates/pantograph-workflow-service/src/graph/session_state.rs @@ -20,9 +20,10 @@ use super::types::WorkflowGraph; const DEFAULT_MAX_UNDO_SNAPSHOTS: usize = 64; -#[derive(Debug, Clone)] +#[derive(Clone)] pub(super) struct GraphEditSession { pub(super) graph: WorkflowGraph, + node_registry: std::sync::Arc, workflow_id: Option, undo_stack: Vec, redo_stack: Vec, @@ -31,10 +32,15 @@ pub(super) struct GraphEditSession { } impl GraphEditSession { - pub(super) fn new(mut graph: WorkflowGraph, workflow_id: Option) -> Self { + pub(super) fn new( + mut graph: WorkflowGraph, + workflow_id: Option, + node_registry: std::sync::Arc, + ) -> Self { graph = hydrate_embedding_emit_metadata_flags(graph); let mut session = Self { graph, + node_registry, workflow_id: workflow_id.and_then(normalize_optional_session_text), undo_stack: Vec::new(), redo_stack: Vec::new(), @@ -70,6 +76,7 @@ impl GraphEditSession { build_graph_session_response_with_projection( session_id, &self.graph, + &self.node_registry, None, phase6_memory_impact_projection(self.last_memory_impact.clone()), ) @@ -94,6 +101,7 @@ impl GraphEditSession { build_graph_session_response_with_projection( session_id, &self.graph, + &self.node_registry, workflow_event, projection, ) @@ -191,7 +199,7 @@ impl GraphEditSession { pub(super) fn canonicalize_graph(&mut self) { let graph = std::mem::take(&mut self.graph); - self.graph = canonicalize_workflow_graph(graph, &NodeRegistry::new()); + self.graph = canonicalize_workflow_graph(graph, &self.node_registry); self.graph.refresh_derived_graph(); } } @@ -199,12 +207,14 @@ impl GraphEditSession { pub(super) fn build_graph_session_response_with_projection( session_id: &str, graph: &WorkflowGraph, + registry: &NodeRegistry, workflow_event: Option, projection: Option, ) -> WorkflowGraphEditSessionGraphResponse { - super::session_contract::build_graph_session_response_with_state( + super::session_contract::build_graph_session_response_with_registry( session_id, graph, + registry, workflow_event, projection.as_ref(), ) diff --git a/crates/pantograph-workflow-service/src/workflow.rs b/crates/pantograph-workflow-service/src/workflow.rs index 24ee52b89..cf742ae05 100644 --- a/crates/pantograph-workflow-service/src/workflow.rs +++ b/crates/pantograph-workflow-service/src/workflow.rs @@ -1,3 +1,4 @@ +mod custom_node_authoring_api; #[cfg(test)] use async_trait::async_trait; use std::path::PathBuf; diff --git a/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs b/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs new file mode 100644 index 000000000..1992eadd9 --- /dev/null +++ b/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs @@ -0,0 +1,112 @@ +use super::{WorkflowService, WorkflowServiceError}; +use crate::custom_nodes::{authoring::*, CUSTOM_NODE_IDENTITY_FIELD}; +use crate::{GraphEdge, GraphNode, Position, WorkflowGraph, WorkflowGraphEditSessionGraphResponse}; +use serde_json::json; + +impl WorkflowService { + /// Empty means no host registrations; transport failure is a separate outcome. + pub fn custom_node_authoring_catalog(&self) -> CustomNodeAuthoringCatalog { + CustomNodeAuthoringCatalog { + contract_version: 1, + nodes: self + .node_registry + .custom_nodes + .nodes() + .map(|node| { + let ports = |ports: &[pantograph_node_contracts::PortContract]| { + ports + .iter() + .map(|port| CustomNodeAuthoringPort { + id: port.id.as_str().into(), + label: port.label.clone(), + }) + .collect() + }; + CustomNodeAuthoringEntry { + identity: node.identity().clone(), + label: node.contract().label.clone(), + description: node.contract().description.clone(), + trust: CustomNodeAuthoringTrust::TrustedInProcess, + inputs: ports(&node.contract().inputs), + outputs: ports(&node.contract().outputs), + } + }) + .collect(), + } + } + + /// Resolve the user's complete selection before creating any session. Imported pins + /// never pass through this operation, and are never silently updated. + pub async fn create_custom_node_draft( + &self, + request: CustomNodeDraftRequest, + ) -> Result { + let custom = self + .node_registry + .custom_nodes + .get(&request.identity.node_type) + .ok_or_else(|| { + WorkflowServiceError::InvalidRequest( + crate::custom_nodes::CustomNodeError::Unavailable.to_string(), + ) + })?; + if custom.identity() != &request.identity { + return Err(WorkflowServiceError::InvalidRequest( + crate::custom_nodes::CustomNodeError::IdentityMismatch.to_string(), + )); + } + let mut graph = WorkflowGraph { + nodes: vec![GraphNode { + id: "custom".into(), + node_type: request.identity.node_type.clone(), + position: Position { x: 320.0, y: 0.0 }, + data: json!({"label": custom.contract().label, CUSTOM_NODE_IDENTITY_FIELD: custom.identity()}), + }], + edges: vec![], + derived_graph: None, + }; + for (direction, ports, node_type) in [ + ("input", &custom.contract().inputs, "text-input"), + ("output", &custom.contract().outputs, "text-output"), + ] { + let definition = self + .node_registry + .get_definition(node_type) + .ok_or_else(|| { + WorkflowServiceError::Internal("text authoring contract is unavailable".into()) + })?; + for (index, port) in ports.iter().enumerate() { + let id = format!("{direction}-{index}"); + let input = direction == "input"; + graph.nodes.push(GraphNode { + id: id.clone(), + node_type: node_type.into(), + position: Position { + x: if input { 0.0 } else { 640.0 }, + y: index as f64 * 160.0, + }, + data: json!({"label": port.label, "text": "", "definition": definition}), + }); + graph.edges.push(GraphEdge { + id: format!("edge-{id}"), + source: if input { id.clone() } else { "custom".into() }, + source_handle: if input { + "text".into() + } else { + port.id.as_str().into() + }, + target: if input { "custom".into() } else { id }, + target_handle: if input { + port.id.as_str().into() + } else { + "text".into() + }, + }); + } + } + let session = self.graph_session_store.create_session(graph, None).await; + self.graph_session_store + .get_session_graph(&session.session_id) + .await + } +} diff --git a/crates/pantograph-workflow-service/src/workflow/service_config.rs b/crates/pantograph-workflow-service/src/workflow/service_config.rs index 919c487c1..01a5c7a4e 100644 --- a/crates/pantograph-workflow-service/src/workflow/service_config.rs +++ b/crates/pantograph-workflow-service/src/workflow/service_config.rs @@ -92,7 +92,9 @@ impl WorkflowService { runtime_dispatch_assignment_repository: Arc::new(Mutex::new( InMemoryWorkflowRuntimeDispatchAssignmentRepository::new(), )), - graph_session_store: Arc::new(GraphSessionStore::new()), + graph_session_store: Arc::new( + GraphSessionStore::new().with_node_registry(node_registry.clone()), + ), artifact_writer: None, artifact_format_settings: Arc::new(Mutex::new(ArtifactFormatSettings::default())), artifact_format_settings_path: None, @@ -190,7 +192,8 @@ impl WorkflowService { provider: Arc, ) -> Self { self.graph_session_store = Arc::new( - GraphSessionStore::with_inference_interface_facts_provider(provider), + GraphSessionStore::with_inference_interface_facts_provider(provider) + .with_node_registry(self.node_registry.clone()), ); self } @@ -201,11 +204,14 @@ impl WorkflowService { inference_provider: Arc, provider: SharedDependencyEnvironmentProvider, ) -> Self { - self.graph_session_store = Arc::new(GraphSessionStore::with_timeout_and_providers( - std::time::Duration::from_secs(5 * 60), - inference_provider, - provider.clone(), - )); + self.graph_session_store = Arc::new( + GraphSessionStore::with_timeout_and_providers( + std::time::Duration::from_secs(5 * 60), + inference_provider, + provider.clone(), + ) + .with_node_registry(self.node_registry.clone()), + ); self.dependency_readiness_provider = Arc::new(DependencyEnvironmentService::new(provider)); self } diff --git a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs index 6e16c03d5..9226d62a0 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs @@ -362,3 +362,272 @@ async fn custom_nodes_callback_failures_are_terminal_without_publishing_outputs( .is_err()); } } + +async fn authored_draft(service: &WorkflowService) -> crate::WorkflowGraphEditSessionGraphResponse { + let identity = service.custom_node_authoring_catalog().nodes[0] + .identity + .clone(); + service + .create_custom_node_draft(crate::custom_nodes::authoring::CustomNodeDraftRequest { + identity, + }) + .await + .unwrap() +} + +#[tokio::test] +async fn custom_node_authoring_registry_survives_provider_builders_and_undo_redo() { + for mode in 0..3 { + let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new( + Behavior::Uppercase, + )))); + let service = match mode { + 1 => service.with_inference_interface_facts_provider(Arc::new(crate::graph::UnavailableInferenceInterfaceFactsProvider)), + 2 => service.with_graph_session_fact_providers(Arc::new(crate::graph::UnavailableInferenceInterfaceFactsProvider), Arc::new(pantograph_dependency_environment_service::NotImplementedDependencyEnvironmentProvider)), + _ => service, + }; + let draft = authored_draft(&service).await; + assert!( + draft + .graph_diagnostics + .iter() + .all(|d| !d.blocking_submission), + "{:?}", + draft.graph_diagnostics + ); + let pin = draft.graph.nodes[0].data[CUSTOM_NODE_IDENTITY_FIELD].clone(); + let updated = service + .workflow_graph_update_node_data(crate::WorkflowGraphUpdateNodeDataRequest { + session_id: draft.session_id.clone(), + node_id: "input-0".into(), + data: serde_json::json!({"text": "authored"}), + }) + .await + .unwrap(); + assert_eq!( + updated.graph.find_node("input-0").unwrap().data["text"], + "authored" + ); + let request = crate::WorkflowGraphEditSessionGraphRequest { + session_id: draft.session_id, + }; + let undo = service.workflow_graph_undo(request.clone()).await.unwrap(); + let redo = service.workflow_graph_redo(request).await.unwrap(); + for snapshot in [undo, redo] { + assert_eq!( + snapshot.graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD], + pin + ); + assert!(snapshot + .graph_diagnostics + .iter() + .all(|d| !d.blocking_submission)); + } + } +} + +#[tokio::test] +async fn custom_node_authoring_refuses_stale_selection_and_identity_patches_without_mutation() { + let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new( + Behavior::Uppercase, + )))); + let entry = service.custom_node_authoring_catalog().nodes[0].clone(); + let mut stale = entry.identity.clone(); + stale.environment_digest = crate::custom_nodes::tests::digest("changed environment"); + let error = service + .create_custom_node_draft(crate::custom_nodes::authoring::CustomNodeDraftRequest { + identity: stale.clone(), + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("identity does not match")); + assert!( + serde_json::from_value::( + serde_json::json!({"identity": entry.identity, "definition": {}}) + ) + .is_err() + ); + let draft = authored_draft(&service).await; + for patch in [ + serde_json::json!({CUSTOM_NODE_IDENTITY_FIELD: stale}), + serde_json::json!({"definition": {}}), + serde_json::json!({CUSTOM_NODE_IDENTITY_FIELD: null}), + ] { + let error = service + .workflow_graph_update_node_data(crate::WorkflowGraphUpdateNodeDataRequest { + session_id: draft.session_id.clone(), + node_id: "custom".into(), + data: patch, + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("cannot be patched")); + let after = service + .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest { + session_id: draft.session_id.clone(), + }) + .await + .unwrap(); + assert_eq!(after.graph_revision, draft.graph_revision); + assert_eq!(after.graph, draft.graph); + } + let mut unpinned = draft.graph.find_node("custom").unwrap().clone(); + unpinned.id = "bad".into(); + unpinned.data = serde_json::json!({}); + assert!(service + .workflow_graph_add_node(crate::WorkflowGraphAddNodeRequest { + session_id: draft.session_id, + node: unpinned + }) + .await + .unwrap_err() + .to_string() + .contains("identity does not match")); +} + +#[tokio::test] +async fn custom_node_authoring_candidates_and_commit_share_exact_registry() { + let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new( + Behavior::Uppercase, + )))); + let draft = authored_draft(&service).await; + let removed = service + .workflow_graph_remove_edge(crate::WorkflowGraphRemoveEdgeRequest { + session_id: draft.session_id.clone(), + edge_id: "edge-input-0".into(), + }) + .await + .unwrap(); + let source = crate::ConnectionAnchor { + node_id: "input-0".into(), + port_id: "text".into(), + }; + let candidates = service + .workflow_graph_get_connection_candidates( + crate::WorkflowGraphGetConnectionCandidatesRequest { + session_id: draft.session_id.clone(), + source_anchor: source.clone(), + graph_revision: Some(removed.graph_revision.clone()), + }, + ) + .await + .unwrap(); + assert!(candidates + .compatible_nodes + .iter() + .any(|node| node.node_id == "custom")); + assert!(!candidates + .insertable_node_types + .iter() + .any(|node| node.node_type == "custom-uppercase")); + let rejected = service + .workflow_graph_preview_node_insert_on_edge( + crate::WorkflowGraphPreviewNodeInsertOnEdgeRequest { + session_id: draft.session_id.clone(), + edge_id: "edge-output-0".into(), + node_type: "custom-uppercase".into(), + graph_revision: removed.graph_revision.clone(), + }, + ) + .await + .unwrap(); + assert!(!rejected.accepted); + assert!(rejected + .rejection + .unwrap() + .message + .contains("insertion by name is unsupported")); + let committed = service + .workflow_graph_connect(crate::WorkflowGraphConnectRequest { + session_id: draft.session_id.clone(), + source_anchor: source, + target_anchor: crate::ConnectionAnchor { + node_id: "custom".into(), + port_id: "input".into(), + }, + graph_revision: removed.graph_revision, + }) + .await + .unwrap(); + assert!(committed.accepted); + let snapshot = service + .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest { + session_id: draft.session_id, + }) + .await + .unwrap(); + assert!(snapshot + .graph_diagnostics + .iter() + .all(|d| !d.blocking_submission)); +} + +#[tokio::test] +async fn custom_node_authoring_saved_reopen_preserves_pin_and_scheduler_execution() { + let root = tempfile::tempdir().unwrap(); + let catalog = catalog(Arc::new(Executor::new(Behavior::Uppercase))); + let service = WorkflowService::with_trusted_custom_nodes(catalog.clone()); + let draft = authored_draft(&service).await; + let store = FileSystemWorkflowGraphStore::new(root.path()); + let saved = service + .workflow_graph_save( + &store, + crate::WorkflowGraphSaveRequest { + name: "custom-workflow".into(), + graph: draft.graph.clone(), + }, + ) + .unwrap(); + let reopened = WorkflowService::with_trusted_custom_nodes(catalog); + let graph = reopened + .workflow_graph_load( + &store, + crate::WorkflowGraphLoadRequest { + path: saved.path.clone(), + }, + ) + .unwrap() + .graph; + assert_eq!( + graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD], + draft.graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD] + ); + for (service, blocking) in [(&reopened, false), (&WorkflowService::new(), true)] { + let edit = service + .workflow_graph_create_edit_session(crate::WorkflowGraphEditSessionCreateRequest { + graph: graph.clone(), + workflow_id: Some(saved.path.clone()), + }) + .await + .unwrap(); + let snapshot = service + .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest { + session_id: edit.session_id, + }) + .await + .unwrap(); + assert_eq!( + snapshot.graph.find_node("custom").unwrap().data, + graph.find_node("custom").unwrap().data + ); + assert_eq!( + snapshot + .graph_diagnostics + .iter() + .any(|d| d.blocking_submission), + blocking + ); + } + let host = SavedGraphHost { + root: root.path().into(), + }; + let session_id = session(&reopened, &host).await; + let mut request = run_request(&session_id); + request.inputs[0].node_id = "input-0".into(); + request.output_targets.as_mut().unwrap()[0].node_id = "output-0".into(); + let result = reopened + .run_workflow_execution_session(&host, request) + .await + .unwrap(); + assert_eq!(result.outputs[0].value, "MIXED CASE"); +} diff --git a/docs/custom-nodes.md b/docs/custom-nodes.md index 9ed1bcb6e..543552b93 100644 --- a/docs/custom-nodes.md +++ b/docs/custom-nodes.md @@ -82,13 +82,47 @@ adds the `custom_node` non-runtime template; identity adapter version 1 owns thi bounded contract. Coordinated Rust producers/consumers are updated together; older implementations cannot execute that template. -NodeLab authoring, Local Agent and runtime publishing remain unavailable. -Graph edit-session authoring and connection APIs still use built-in discovery; -they are not custom-node authoring surfaces. Before exposing custom nodes there, -NodeLab must install host-authorized registrations and exact graph pins, use the -same registry for edit validation/connection previews, and expose trust, version -mismatch and unavailable implementation outcomes. Publishing arbitrary code -needs its own artifact verification, isolation and lifecycle contract. +## NodeLab graph authoring + +NodeLab authors workflows from implementations already admitted by its host. It +reads `custom_node_authoring_catalog` (wire version 1), a narrow projection of +canonical labels, required string port IDs and complete immutable identities. +The projection's `trusted_in_process` profile carries the trust obligations above; +it is not an installation or execution permission. Default desktop composition +has an empty catalog and says so. Host maintainers register implementations in +Rust before service construction; browser clients cannot register or load code. +The legacy generic palette remains built-in-only. + +`create_custom_node_draft` requires the complete selected identity. The service +resolves exact equality before creating a session, then builds one text source +per required input, the pinned custom node, and one text sink per output. Custom +nodes never receive `node.data.definition`. NodeLab owns a separate edit session, +allows text source edits, displays backend contract diagnostics, and saves/reopens +through existing graph persistence. It provides no execution button; saved graphs +use scheduler sessions. Input text is authored configuration, not tool authority. + +The same admitted registry reaches edit-session canonicalization, diagnostics, +connection candidates and commits, including provider-builder construction. New +custom additions require exact pins. General custom data patches cannot change +identity or definitions. Imported/reopened missing or stale pins remain unchanged +and produce blocking diagnostics; explicit new authoring is the recovery path. +Name-only insert-and-connect/insert-on-edge APIs cannot express a selected complete +identity, so custom insertion there is explicitly unsupported and omitted from +creation suggestions. Adding a pinned node and connecting existing endpoints is +supported. + +Failed create/reopen preserves the current draft. Replacement and page teardown +close owned sessions, including late creation completion. Transport failures during +close retain exact session IDs in the app-scoped NodeLab service. Pending cleanup +is visible on the next NodeLab mount, blocks new creation/reopening and has an +explicit retry action. No automatic graph-session reap is assumed. A process or +host restart ends these ephemeral sessions; saved files retain their exact pins. + +The desktop commands are `get_custom_node_authoring_catalog`, +`create_custom_node_draft` and `get_node_lab_draft`; mutations/persistence reuse +existing commands. Local Agent and runtime publishing remain unavailable or +unsupported. Arbitrary packages still need independently admitted artifact +verification, isolation and lifecycle contracts. A scheduler-managed tool loop must consume this exact admission and invocation identity, plus its own typed tool authority, bounded iterations, cancellation, diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..ec6e6c01c5b6d92e7d5f711e3f8f19252feaee63 GIT binary patch literal 441 zcmV;q0Y?5GiwFP!0000216@+hZWA#OzVB0v_8gpSNQ=~CLE?rGlmkK>9D9;X*ME5I zRZ_%-SAZi=*C%0|Ra&XO`TLpqX6%Uy!gQ@RYJhDeXDRGq7c-%ang zq1*lO-yax;4R{ebWO(AysxD)&m|G!psA1%0Fwv)48n%C+1WXUuU`t$Q~9^t0(A9`Uq|I$JPOuC18OT#7~V=ePt`Bl9#Ul8yz-%1(glPFEQPl5xPbQ`zcft~ j%o+M19VfigHB+k@Vu2r3JKB*bx?kOYkqj1t@9th3OC literal 0 HcmV?d00001 diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..3080c4fbb6a61d170f1146064ebdb28df58b9529 GIT binary patch literal 2190 zcmV;92yyoxiwFP!000021FczYZ``;M{_bC)Z)wq!yxJFeZ6Jr{++Gq~8XqnOihhzQ zidr$2NQI=lyZ-eZzGy9xvYVU>1X^X`laFcp0ZuEM^x$0ii@bC4T&@% z;w&n9+LTdA1;L7o;~5;f=50qC%`J&2NshT7(LK~>eD9moZ#i)1mSHhiZQu9Kyw@R10P zd3V&*ZL(TTsa3CfL1e6uyw@D7WR=cy^olm~932w+GjF(ZFcXL8{!;p=Mk9;m}1vAB~7@vYgTcVr7nN1pjO%J>MuR$)QD^sl$~|1)W%b zTCdlkXe-kzj^e0D+v9s}IDIg7L5X{Y4NS(PwXayaR0-Z%&0e^V{GQkb@bgPMT{%OF z)0qsPD*qx$Js1#!r$F*tMMI@-T_;Q}HC+w|Q$K;aaxf@p$ib%G!xzQdCNGg9inhk3 zELlxC_LMQc*Q8Cd_JV{tlPc!aWg(l%d`YDe_Our(Tno;4tR9W!V4L58O{Ht%!jED} zcSI_8$nPP_j!xg!FACfun>7g1#gtUZoE)Ikbk>3rq__tV!KfJ^d>QsN+RM1Xu{;`> zUS0SyI<5#)6g7FTDp$;57L}mH;>jvBp@S^bQdibV|C9mqrh(}LO>QtffOL%mjf}f{ z8mnVRr0I&F?o~|zS_KzYM!KDcZ*m~GS6qxCJP3!74w2Tg?sP;bN}RE?@Gd*V7%6RC zdicH@v2gQGKjK}u@sY#ErRkk}CwLswC3K{X9$ zw+*-v03q_4!&blT$+e&ElA#=xsB;bkc&od*pgR}|qoy9Uj#8cOY3uxqa66m4If1Jk zq_wjXW|M5gfQ;H|lm}Aik`kONIg0kvNjG^63?bnP+{aDmTGGZP=|D!N2dd}M(hV-p znO-zHea_l3TyoGSkfavhgLV@zOAj%E5^j+~^TKQB5@BarIzu@Dc^YhGitmX7z17Sl zSEn)V6(+OU%v3bUfxZ7=bi6p~fwiQ=N}el@>ouH)em+f6!3vLp(^b0z8kqy2@n@ z1y&C2I2+qxZft_n^T!Zi;XW?WNlUI2CN57D**NOe78 z8)O|oYR@P}(oCNqxSGaG67OlL+&b$;w}t7R?|RT;f7b;i17BXxmRu%4Nertn&Y z<-@4L{KWyN<{t0o>RS)%NPMu1)N1QvlB8`3o9ouyQRV_*-#eb4LbSs#k&R)`les8g z9C6BCP@m@z`l9$e8A0vU&-C2WwZjz7<^0`+wC2?f5*EJL^etkxb(dkA7z5^2^WrN& zK`zyh&)}u#)QASFw!Q$3ak3z&jY-FG?cS?A*wI6=>W4+RHEEn_=MkIU#bb-#fnr*Z0WFH3EHXH{Bjrl#cEVY_3|o8{|Y9> zaSVSbd9UCX3<~&_AmC+8tC%oo*V>Gmx9>lF{3VAX$UooYzyJ0=zrOkW>&N#mpLycT z58Euy)^X478LYuEB%(jWJJ$c8fZPeljez`r3ixpI<@QgQ``uRk{_gg}>wOzZd8LC! z)DP+XZf3~;KR*8PdS@qcUhA$E_Jg{=oBhjwzWi}>bDRHP{0@b^*7=ddKV!w|jKjaT z8ldiXfSs(OI|EJtTzfrxEMi1@36J#l@^ds2`R>!Fn}2J;xBvWCe*NpGckjQvO~;aA zh*lxt1KV#-3}yk8L~l+%{romsCKro0XCVBxChlQFf59V=I67bj#yh=!_f!}}g$bfx zYfh+p7wwUZ7*_zsJu4F@%Z$F0grviF5hDbaV1V<2M3jMdjfQs(;|dOV9TGt(so<<7 z`rQbIv021g7y|ksS0TzG{XLU@qydK0cUgn_?~+EX-IAz8hv37<&$m|*t)e6EqZ&Po zt<)B}xcn3;2o4-FU#|Izz&*`-GH9??^G?D6Do-QO4mCUPewuu5$IVwIUwL5ySAiT^ QCrNVlUq8znCg~dh0L8mLwEzGB literal 0 HcmV?d00001 diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json new file mode 100644 index 000000000..5d259bf2c --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json @@ -0,0 +1,54 @@ +{ + "base": "285c48989a95caef1bcaac92eac9ff3b1be0a9ad", + "environment": { + "os": "Linux x86_64", + "rust": "1.92.0", + "node": "24.12.0", + "chromium": "Chromium 151.0.7922.173 built on Debian GNU/Linux 13 (trixie)", + "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only" + }, + "claims": { + "browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained", + "service_tests": "1068 library +65 integration passed, zero failures/ignored", + "frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included", + "native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim", + "not_claimed": [ + "installed custom nodes in default desktop", + "arbitrary code loading", + "trained model T3", + "GPU", + "inference-backed/effectful tool loops", + "release qualification" + ] + }, + "source_sha256": { + "crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8", + "crates/pantograph-workflow-service/src/custom_nodes.rs": "1d413febbad16689c864e7945191c7bb83079530ece13fda73541178bd86f774", + "crates/pantograph-workflow-service/src/custom_nodes/authoring.rs": "ab2e37ca4a68b846e8f23897891eea064730a1376c8597eb5ce00e1bf5f909ed", + "crates/pantograph-workflow-service/src/graph/connection_insert.rs": "881afe99675e681a2bd1208f69e55f82afa881adec9ead9a6863089dd3a6f2af", + "crates/pantograph-workflow-service/src/graph/connection_intent.rs": "d5832d05d88dd0d2dc10cb367038a0a6d5973e1275fe19e888fb411c13057199", + "crates/pantograph-workflow-service/src/graph/session.rs": "e9248df745093b0ff078691a04036017fed894bdd073d922b0f628920a1efcac", + "crates/pantograph-workflow-service/src/graph/session_connection_api.rs": "f29f061031b4c70f829cfbd603108857ed7d093245ae3cdeb8d63797b9c5fefe", + "crates/pantograph-workflow-service/src/graph/session_contract.rs": "844733c80c5f5a372844637a104e901b9cd46433e1ef73662ff314f4b8e134b0", + "crates/pantograph-workflow-service/src/graph/session_node_api.rs": "61505593be43e6bc58e9c9c8f1b991cfd75949a55da8351278ca45a25015ecd3", + "crates/pantograph-workflow-service/src/graph/session_state.rs": "c19150bbf4b61dd506ad6bb618593c40f59a8fde43f554025eb940e8c75b1d17", + "crates/pantograph-workflow-service/src/workflow.rs": "47e42061802abc91ebe6b3babf8c578116d2e0fecf51d86fa53148cbed613886", + "crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs": "5bf54f01b2db4ec2ffc21b6af41291c695ff91a3b274deb80838c32020b9941d", + "crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513", + "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f", + "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a", + "scripts/check-nodelab-browser.mjs": "30a619d382ddb242c19b50c51ec97fda8a6c20973fe51f94c7c0fcf5658d24d4", + "src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50", + "src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c", + "src/components/workbench/NodeLabPage.svelte": "b9206ed693d2a18f065c547752622cbb48a96a3599d1867d166e45f39fc7424f", + "src/components/workbench/nodeLabController.ts": "3b3806dcf0aedf191fd7ad91ce3ac746a08c6962b1c0b292f54d9ba6e9145716", + "src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e", + "src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075", + "src/services/workflow/customNodeAuthoring.test.ts": "73d776ffb40ddcafcfb0e69f7434f98aa19bd037484a36ccdef7d9eff290d250", + "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08" + }, + "evidence_sha256": { + "browser.log.gz": "39a0549788107cc92c66b172a0810f2f81b40de30458af58e660a67fa1b45287", + "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3" + } +} diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md index 7b4348f1e..e6a6f09de 100644 --- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md +++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md @@ -813,3 +813,36 @@ Accepted stop-string/PR60 and all seed/precision/repair/paused histories remain separate. Controlled package/readiness/dispatch/session facts and untrained CPU models do not qualify live Pumas, pretrained quality, GPU, desktop or native batching/throughput. Full plan acceptance and parent publication remain separate. + +## 2026-10-22 — NodeLab trusted-node authoring + +Continued the admitted CN-02 slice on main `285c4898` after CN-01/README merges. +The [consumer contract](../../custom-nodes.md#nodelab-graph-authoring) owns the +read-only versioned catalog, exact-selection server-built drafts, shared edit +registry, pin-preserving mutations and saved/reopened behavior. Default desktop +has no custom registrations; the separate real Rust qualification host supplies +one compiled pure transform. No code loader, execution UI or tool grant is added. +Independent GPT-6.1 Sol High review accepted after repairing failed replacement +loss, explicit-null diagnostic decoding and close-failure ownership/recovery. + +[Qualification](evidence/nodelab-authoring/qualification.json) retains source +bindings, actual Chromium→Rust→filesystem user-workflow evidence and the native +failure. Service tests pass 1,068 library +65 integration, zero failures/ignored; +740 frontend tests pass including ten focused parser/controller/presenter cases. +Formatting, typecheck, production frontend build, lint, critical/a11y gates, +scheduler-only guard and traceability tests pass. All-target package Clippy +retains only the existing inference dead-field warning. Initial new fixtures used +an unexported provider and a nonexistent response field; corrected against the +actual owning APIs, preserving the assertions. The first browser Enter simulation +omitted character text and did not activate the button; proper CDP key events +then passed the same workflow assertions, with no pointer fallback for that gate. + +Native `cargo check --locked -p pantograph --bin pantograph --no-default-features` +is unavailable at glib-sys because this environment lacks `glib-2.0.pc`; GTK and +WebKit development packages are also absent. No native/desktop startup claim or +persistent access change is made. PR92 `afe33029` remains open with its unique +trained-text source/evidence and missing T3 gate unchanged. CN-03 PR94 integrates +first by parent instruction; CN-02 must then reconcile current main, preserve its +registry/loop validations, rerun combined affected tests and pass exact-head CI +before merge. The local/remote feature branch and worktree are retained pending +that disposition, with source and evidence preserved. diff --git a/docs/plans/domain-architecture-and-multimodal/issues.md b/docs/plans/domain-architecture-and-multimodal/issues.md index 78a0af760..5b5a43ac5 100644 --- a/docs/plans/domain-architecture-and-multimodal/issues.md +++ b/docs/plans/domain-architecture-and-multimodal/issues.md @@ -76,5 +76,5 @@ authorize the historical resident-accounting next-slice text. | ID | Issue | Owner | Disposition / next evidence | | --- | --- | --- | --- | | CN-01 | Palette/callback registration does not establish canonical executable admission or implementation/environment identity. | Custom-node lead | Closed for the bounded trusted string adapter after independent GPT-6.1 Sol High review and 1,129 passing service tests. Exact admission, saved/reopened scheduler execution, cancellation/commit arbitration and inspection share the immutable catalog. Arbitrary executable loading remains outside this contract. | -| CN-02 | NodeLab authoring, Local Agent and runtime publishing remain unavailable. | NodeLab follow-on | Deferred. Consume the [custom-node contract](../../custom-nodes.md): host-authorized registrations and exact graph pins, same registry for edit-session validation/connection previews, trust/version/unavailable outcomes. Untrusted package publishing additionally needs independently admitted artifact verification and isolation. A palette entry cannot enable execution. | -| CN-03 | Scheduler-managed tool-loop exposure needs a safe node invocation contract and explicit tool authority. | Tool-loop follow-on | Deferred. Depend on CN-01 exact identity/admission and scheduler-owned run/attempt/cancellation/result commitment; separately admit typed tool capabilities, bounded iteration and side-effect/idempotency behavior. The string adapter grants no tool capability. Do not restore the retired static loop. | +| CN-02 | NodeLab graph authoring requires the same admission identity and registry as execution. | NodeLab lead | Implemented, verifying: service-owned exact-selection draft creation, NodeLab source editing/save/reopen, shared edit registry and blocking stale/unavailable diagnostics. Default desktop is honestly empty; nonempty host-composed browser flow is qualified separately. Local Agent/code publishing, generic name-only custom insertion and arbitrary loading remain unsupported. See [contract](../../custom-nodes.md#nodelab-graph-authoring). | +| CN-03 | Scheduler-managed tool-loop exposure needs a safe node invocation contract and explicit tool authority. | Tool-loop follow-on | Separate worker task `01a1615c-5be4-74a5-bf4c-c3f3b3136000`. Depend on CN-01 exact identity/admission and scheduler-owned run/attempt/cancellation/result commitment; separately admit typed tool capabilities, bounded iteration and side-effect/idempotency behavior. The string adapter grants no tool capability. Do not restore the retired static loop. | diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md index fdd0a90e6..2a0b0c92a 100644 --- a/docs/plans/domain-architecture-and-multimodal/plan.md +++ b/docs/plans/domain-architecture-and-multimodal/plan.md @@ -298,6 +298,64 @@ Development Proportionality, Commit, Documentation, Architecture/Code Design, Contracts, Security, Concurrency/Resilience and Library/Rust API/Async guidance. The rest of this plan retains its historical standards baseline. +## Admitted NodeLab/edit-session slice (2026-10-22) + +**Admission:** `continue` this Active plan under the delegated unfinished-path +instruction. CN-01 is integrated in `2e517f58`, README PR79 in current main +`285c4898`. Independent GPT-6.1 Sol High design review admits a bounded NodeLab +graph-authoring surface before implementation. This does not author or install +executable code. PR92's unique trained-text source/evidence and missing T3 gate +remain separate and protected; CN-03 has its own worker and integration gate. + +**Outcome and contract:** NodeLab reads a versioned service-owned projection of +host-admitted custom definitions and complete identities. The default desktop +catalog is honestly empty; a composed host supplies nonempty registrations. +Selecting an admitted node creates a separate edit-session draft with one text +source per required input and one text sink per output, derived from host ports. +Users edit source values, inspect backend diagnostics, save and reopen through +existing graph APIs. Saved pins are never silently refreshed. Missing/stale +implementations remain visible and blocking; recovery requires explicit new +authoring. No direct edit-session execution, Local Agent, runtime publishing, +arbitrary loader, new capability grant or static tool loop is enabled. + +The draft is constructed by the service from the exact selected identity, not +from a client-supplied custom definition. The same immutable registry must reach +graph-session construction, provider +builders, canonicalization, response diagnostics and connection candidate/commit +validation. Add requires the exact selected identity; custom data patches cannot +change identity or add dynamic definitions. Imported invalid graphs remain +inspectable without healing. Name-only custom insert-on-edge/insert-and-connect +is explicitly unsupported; use pinned add plus explicit connections. The +legacy generic palette stays built-in-only. CN-03 consumes saved graph pins and +CN-01 scheduler invocation, not NodeLab state or a second execution route. + +**Composition and write set:** registry/catalog owns admitted facts; graph +sessions own authored state, undo and validation; NodeLab owns local interaction +and observes service responses; existing store owns persistence. Stable +interfaces are the versioned read projection and existing pinned graph/mutation +contracts. This avoids duplicating admission or execution in the frontend. +Change service `custom_nodes`, `graph`, graph API/config and related tests; +NodeLab presenters/controller/page and narrow workflow transport projections; +desktop command wiring; a bounded real-host example/browser probe and affected +documentation. No scheduler-loop core, text runtime/readiness, root README or +PR92 edits. Branch `feat/nodelab-trusted-catalog`, worktree +`/workspace/pantograph-nodelab`, base `285c4898`, target `main`. Preserve source, +branches and evidence; parent coordinates serial integration if CN-03 intersects. + +**Acceptance:** focused admission/edit-session refusal tests, real save/reopen +and connection candidate/commit tests, provider-builder registry retention, +frontend decoder/lifecycle tests, and real-browser pointer/keyboard draft → +edit → save → reopen interaction through an injected Rust host. Browser evidence +does not qualify native Tauri startup. Native compile/smoke is attempted only +where available and its actual limits recorded. Required CI, independent source +review and current-main recheck precede merge under the later cleanup authority; +no gate waiver, evidence-weakening rerun or persistent access expansion. Status +`Verifying`; next slice complete independent review, current-main/CI gates and +serial integration. Failed replacement preserves the current draft; failed close +transfers exact session IDs to an app-scoped, user-retryable cleanup owner. Standards baseline `0fc92a2` +continues, adding routed Frontend, TypeScript/Async, Accessibility, IPC, +Persistence, protocol/evolution and GUI verification detail to the CN-01 routes. + ## Standards And Plan Authority Standards source: diff --git a/scripts/README.md b/scripts/README.md index 968852740..15d8c1291 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -257,3 +257,20 @@ it preserves the existing expression-evidence policy rather than proving its runtime value. Native-button rules and reviewed-ignore rules are unchanged. Runtime accessibility and dynamically empty/hidden content still require UI validation. + +## NodeLab authoring browser qualification + +Build `cargo build --locked -p pantograph-workflow-service --example nodelab_host`, +then run `NODELAB_HOST_BINARY= node +scripts/check-nodelab-browser.mjs`. The isolated stdio Rust host registers one +compiled pure transform and owns a temporary workflow store; the verifier exposes +only a temporary loopback transport. It exercises the actual NodeLab component, +strict catalog decoding, keyboard focus/creation, pointer edits, filesystem +save/reopen, pin retention, session replacement cleanup and missing-implementation +blocking. A second real empty host proves the empty catalog and stale reopen; +a deliberately failed transport proves the distinct unavailable state. Browser +arguments select headless Chromium, disposable profile, GPU disabled and the +verification-only no-sandbox mode required by this container. They do not alter +normal startup. This is browser/host evidence, not native Tauri, package, model, +GPU or scheduler tool-loop qualification. EOF and verifier teardown release the +host/store/browser resources. See [custom-node authoring](../docs/custom-nodes.md#nodelab-graph-authoring). diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs new file mode 100644 index 000000000..76cd26290 --- /dev/null +++ b/scripts/check-nodelab-browser.mjs @@ -0,0 +1,172 @@ +/* global document, getComputedStyle */ +// Real NodeLab component -> loopback transport -> isolated Rust service -> filesystem. +// No Tauri startup, installed node, model or GPU claim. Build nodelab_host first. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { mkdtemp, readFile, writeFile, symlink, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { evaluateBrowserFunction } from './cdp-browser-function.mjs'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const fixture = await mkdtemp(path.join(tmpdir(), 'pantograph-nodelab-browser-')); +const hostBinary = process.env.NODELAB_HOST_BINARY; +assert.ok(hostBinary, 'NODELAB_HOST_BINARY must name the built nodelab_host example'); +const hosts = []; +const servers = []; +let browser; +let socket; +const commands = []; +const sessions = []; +try { + await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules')); + await writeFile(path.join(fixture, 'index.html'), '
'); + await writeFile(path.join(fixture, 'main.js'), `import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`); + await build({ configFile: false, root: fixture, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } }); + function rustHost(empty) { + const child = spawn(hostBinary, empty ? ['--empty-catalog'] : [], { stdio: ['pipe', 'pipe', 'inherit'] }); + hosts.push(child); + const lines = createInterface({ input: child.stdout }); + let tail = Promise.resolve(); + return (command, args) => { + const result = tail.then(() => new Promise((resolve, reject) => { + const timeout = setTimeout(() => { lines.removeListener('line', received); reject(new Error('Rust host command timeout')); }, 10000); + const received = line => { clearTimeout(timeout); try { resolve(JSON.parse(line)); } catch (error) { reject(error); } }; + lines.once('line', received); + child.stdin.write(JSON.stringify({ command, args }) + '\n'); + })); + tail = result.catch(() => {}); + return result; + }; + } + const realHost = rustHost(false); + const emptyHost = rustHost(true); + async function startServer(mode) { + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname.startsWith('/api/workflow/commands/')) { + if (request.method !== 'POST') { response.writeHead(405).end(); return; } + let body = ''; for await (const chunk of request) { body += chunk; if (body.length > 100000) throw new Error('request too large'); } + const command = decodeURIComponent(url.pathname.split('/').at(-1)); + const args = JSON.parse(body); + commands.push(command); + const result = mode === 'error' ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args); + if (result.ok && command === 'create_custom_node_draft') sessions.push(result.value.session_id); + response.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' }).end(JSON.stringify(result.ok ? result.value : result.error)); + return; + } + const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); + const file = path.resolve(fixture, 'dist', relative); + if (!file.startsWith(path.resolve(fixture, 'dist') + path.sep)) { response.writeHead(403).end(); return; } + response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : 'text/html'); + response.end(await readFile(file)); + } catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 'internal_error', message: String(error) })); } + }); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return `http://127.0.0.1:${server.address().port}`; + } + const realOrigin = await startServer('real'); + const emptyOrigin = await startServer('empty'); + const errorOrigin = await startServer('error'); + const profile = path.join(fixture, 'profile'); + // This disposable container cannot provide the Chromium user-namespace sandbox. + // These verification flags do not modify the normal application launcher. + browser = spawn(process.env.CHROMIUM_BINARY || 'chromium', ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-background-networking', '--no-first-run', '--no-default-browser-check', '--user-data-dir=' + profile, '--remote-debugging-port=0', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, XDG_CONFIG_HOME: path.join(fixture, 'config'), XDG_CACHE_HOME: path.join(fixture, 'cache') } }); + let diagnostics = ''; browser.stderr.on('data', chunk => { diagnostics = (diagnostics + chunk).slice(-16384); }); + let port; + const deadline = Date.now() + 10000; + while (!port && Date.now() < deadline) { + try { port = (await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; } + catch { await new Promise(resolve => setTimeout(resolve, 50)); } + } + assert.ok(port, 'Chromium startup unavailable: ' + diagnostics); + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(pages.find(page => page.type === 'page').webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); }); + let sequence = 0; const pending = new Map(); + socket.addEventListener('message', event => { + const message = JSON.parse(event.data); const item = pending.get(message.id); if (!item) return; + pending.delete(message.id); clearTimeout(item.timer); + if (message.error) item.reject(new Error(JSON.stringify(message.error))); else item.resolve(message.result); + }); + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timeout')); }, 15000); + pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = (callback, args) => evaluateBrowserFunction(call, callback, args); + const text = () => evaluate(() => document.body.innerText); + async function waitText(expected) { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { if ((await text()).includes(expected)) return; await new Promise(resolve => setTimeout(resolve, 30)); } + assert.fail(`Missing UI text: ${expected}\n${await text()}\nCommands: ${JSON.stringify(commands)}`); + } + async function navigate(origin) { + await call('Page.navigate', { url: `${origin}/?workflowHost=${encodeURIComponent(origin)}` }); + await waitText('NodeLab'); + } + async function clickButton(label) { + const rect = await evaluate(label => { + const button = [...document.querySelectorAll('button')].find(item => item.textContent.trim() === label); + if (!button || button.disabled) throw new Error('Button unavailable: ' + label); + button.scrollIntoView(); const rect = button.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, [label]); + await call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect }); + await call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect }); + } + async function enter(selector, value) { + await evaluate(selector => { const input = document.querySelector(selector); input.focus(); }, [selector]); + await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2, windowsVirtualKeyCode: 65 }); + await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2, windowsVirtualKeyCode: 65 }); + await call('Input.insertText', { text: value }); + } + await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes'); + assert.equal(await evaluate(() => [...document.querySelectorAll('button')].some(button => button.textContent.startsWith('Create '))), false); + await navigate(errorOrigin); await waitText('Qualification host unavailable'); + assert.equal(await evaluate(() => document.querySelector('[role="alert"]')?.textContent), 'Qualification host unavailable'); + await navigate(realOrigin); await waitText('Create Host uppercase draft'); + // Tab/Enter activation proves keyboard access to creation, followed by real pointer actions. + await evaluate(() => document.activeElement?.blur()); + for (let count = 0; count < 10; count++) { + await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }); + await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }); + if (await evaluate(() => document.activeElement?.textContent === 'Create Host uppercase draft')) break; + } + assert.equal(await evaluate(() => document.activeElement?.textContent), 'Create Host uppercase draft'); + assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none'), true); + await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' }); + await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }); + await waitText('Graph contract valid'); + await enter('textarea', 'Saved Mixed case'); await clickButton('Apply input'); await waitText('Input saved in draft'); + await clickButton('Save workflow'); await waitText('Workflow saved'); + const savedPath = await evaluate(() => document.querySelector('code')?.textContent); + assert.ok(savedPath); + const file = (await realHost('load_workflow', { path: savedPath })).value; + assert.equal(file.graph.nodes.find(node => node.node_type === 'text-input').data.text, 'Saved Mixed case'); + const pin = file.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity; + await enter('input', savedPath); await clickButton('Reopen saved workflow'); await waitText('Graph contract valid'); + assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case'); + const reloaded = (await realHost('load_workflow', { path: savedPath })).value; + assert.deepEqual(reloaded.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity, pin); + assert.equal((await realHost('get_node_lab_draft', { executionId: sessions[0] })).error.code, 'session_not_found'); + // Same saved graph opened on a host without its implementation stays visible and blocked. + await emptyHost('save_workflow', { name: 'stale', graph: file.graph }); + await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes'); + await enter('input', '.pantograph/workflows/stale.json'); await clickButton('Reopen saved workflow'); await waitText('Blocks execution:'); + assert.ok((await text()).includes('host-uppercase')); + assert.ok(!commands.some(command => command.includes('run_execution') || command.includes('register') || command.includes('publish'))); + console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2)); +} finally { + socket?.close(); + if (browser && browser.exitCode === null) { browser.kill('SIGTERM'); await new Promise(resolve => { browser.once('exit', resolve); setTimeout(resolve, 2000).unref(); }); } + for (const server of servers) await new Promise(resolve => server.close(resolve)); + for (const host of hosts) { host.stdin.end(); if (host.exitCode === null) await new Promise(resolve => { host.once('exit', resolve); setTimeout(() => { host.kill('SIGTERM'); resolve(); }, 2000).unref(); }); } + await rm(fixture, { recursive: true, force: true }); +} diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs index 2aa8cee7f..10472587b 100644 --- a/src-tauri/src/app_setup.rs +++ b/src-tauri/src/app_setup.rs @@ -461,6 +461,9 @@ pub fn run_app() -> AppStartupResult<()> { crate::llm::reset_recovery_state, // Workflow commands crate::workflow::commands::validate_workflow_connection, + crate::workflow::commands::get_custom_node_authoring_catalog, + crate::workflow::commands::create_custom_node_draft, + crate::workflow::commands::get_node_lab_draft, crate::workflow::commands::get_node_definitions, crate::workflow::commands::get_node_definitions_by_category, crate::workflow::commands::get_node_definition, diff --git a/src-tauri/src/workflow/commands.rs b/src-tauri/src/workflow/commands.rs index 6b2b64548..8b5550b93 100644 --- a/src-tauri/src/workflow/commands.rs +++ b/src-tauri/src/workflow/commands.rs @@ -49,6 +49,40 @@ pub fn validate_workflow_connection(source_type: PortDataType, target_type: Port super::workflow_definition_commands::validate_workflow_connection(source_type, target_type) } +/// Read-only host admissions; no registration or code upload command. +#[command] +pub fn get_custom_node_authoring_catalog( + workflow_service: State<'_, SharedWorkflowService>, +) -> pantograph_workflow_service::custom_nodes::authoring::CustomNodeAuthoringCatalog { + workflow_service.custom_node_authoring_catalog() +} + +#[command] +pub async fn create_custom_node_draft( + request: pantograph_workflow_service::custom_nodes::authoring::CustomNodeDraftRequest, + workflow_service: State<'_, SharedWorkflowService>, +) -> Result { + workflow_service + .create_custom_node_draft(request) + .await + .map_err(|error| error.to_envelope_json()) +} + +#[command] +pub async fn get_node_lab_draft( + execution_id: String, + workflow_service: State<'_, SharedWorkflowService>, +) -> Result { + workflow_service + .workflow_graph_get_edit_session_graph( + pantograph_workflow_service::WorkflowGraphEditSessionGraphRequest { + session_id: execution_id, + }, + ) + .await + .map_err(|error| error.to_envelope_json()) +} + #[command] pub fn get_node_definitions() -> Vec { super::workflow_definition_commands::get_node_definitions() diff --git a/src/components/workbench/NodeLabPage.svelte b/src/components/workbench/NodeLabPage.svelte index ffb8f1863..dea945294 100644 --- a/src/components/workbench/NodeLabPage.svelte +++ b/src/components/workbench/NodeLabPage.svelte @@ -1,23 +1,90 @@ -
-

Node Editor

-
- {nodeLabUnavailableMessage()} -
-
+
+

NodeLab

+

{nodeLabTrustMessage()}

+
{#each statusRows as row (row.label)}
-
{row.label}
+
{row.label}
{row.value}
{/each}
+ {#if view.error}{/if} +

{view.busy ? 'Working…' : view.notice}

+ {#if view.pendingCleanup} + + + {/if} +

Admitted nodes

+ + {#if view.catalog?.length === 0} +

This host has no admitted custom nodes. A host maintainer must register a trusted implementation before creating a new draft.

+ {:else if view.catalog} +
    + {#each view.catalog as entry (entry.identity.node_type)} +
  • +

    {entry.label}

    +

    {entry.description}

    +

    {entry.inputs.length} string inputs · {entry.outputs.length} string outputs · Trusted in-process

    +
    + Implementation identity +
    {JSON.stringify(entry.identity, null, 2)}
    +
    + +
  • + {/each} +
+ {/if} +
{ event.preventDefault(); void controller.reopen(path); }}> + + +
+ {#if view.savedPath}

Saved: {view.savedPath}

{/if} + {#if view.draft} +
+

Workflow draft

+

{view.draft.graph.nodes.length} nodes · {view.draft.graph.edges.length} connections. Saving preserves the selected implementation identity. Run saved workflows from Scheduler.

+ {#if view.draft.graph_diagnostics.length} +
    + {#each view.draft.graph_diagnostics as diagnostic} +
  • {diagnostic.blocking_submission ? 'Blocks execution: ' : ''}{diagnostic.message}
  • + {/each} +
+ {:else}

Graph contract valid

{/if} + {#each view.draft.graph.nodes.filter(node => node.node_type === 'text-input') as node (node.id)} +
{ event.preventDefault(); const input = new FormData(event.currentTarget).get('text'); if (typeof input === 'string') void controller.update(node, input); }}> + + +
+ {/each} +
{ event.preventDefault(); void controller.save(name); }}> + + +
+
+ {/if}
diff --git a/src/components/workbench/nodeLabController.ts b/src/components/workbench/nodeLabController.ts new file mode 100644 index 000000000..122609ce6 --- /dev/null +++ b/src/components/workbench/nodeLabController.ts @@ -0,0 +1,86 @@ +import { NodeLabService, type CustomNodeAuthoringEntry, type NodeLabDraft } from '../../services/workflow/customNodeAuthoring.ts'; +import type { GraphNode } from '../../services/workflow/types.ts'; + +export interface NodeLabView { + catalog: CustomNodeAuthoringEntry[] | null; + draft: NodeLabDraft | null; + savedPath: string; + busy: boolean; + error: string | null; + notice: string; + pendingCleanup: number; +} +/** One page owns one separate edit session and observes all in-flight completion. */ +export class NodeLabController { + private disposed = false; + private operation: Promise | null = null; + private readonly ownedSessions = new Set(); + cleanupError: unknown = null; + readonly view: NodeLabView = { catalog: null, draft: null, savedPath: '', busy: false, error: null, notice: '', pendingCleanup: 0 }; + private readonly service: NodeLabService; + private readonly changed: (view: NodeLabView) => void; + constructor(service: NodeLabService, changed: (view: NodeLabView) => void) { this.service = service; this.changed = changed; this.view.pendingCleanup = service.pendingCleanupCount; } + private publish(): void { if (!this.disposed) this.changed({ ...this.view }); } + private perform(action: () => Promise): Promise { + if (this.disposed || this.operation) return Promise.resolve(); + this.view.busy = true; this.view.error = null; this.view.notice = ''; this.publish(); + const operation = (async () => { + try { await action(); } + catch (error) { this.view.error = error instanceof Error ? error.message : 'NodeLab operation failed'; } + finally { this.view.busy = false; this.operation = null; this.view.pendingCleanup = this.service.pendingCleanupCount; this.publish(); } + })(); + this.operation = operation; + return operation; + } + retryCleanup(): Promise { return this.perform(() => this.service.retryCleanup()); } + loadCatalog(): Promise { + return this.perform(async () => { this.view.catalog = await this.service.catalog(); }); + } + private async replace(create: () => Promise): Promise { + const next = await create(); + this.ownedSessions.add(next.session_id); + if (this.view.draft) { + try { + await this.service.close(this.view.draft.session_id); + this.ownedSessions.delete(this.view.draft.session_id); + } catch (error) { + this.service.retainActiveSession(this.view.draft.session_id); + try { await this.service.close(next.session_id); this.ownedSessions.delete(next.session_id); } + catch (cleanupError) { this.cleanupError = cleanupError; throw new AggregateError([error, cleanupError], 'Draft replacement and cleanup failed; previous draft retained'); } + throw error; + } + } + this.view.draft = next; + this.view.savedPath = ''; + } + create(entry: CustomNodeAuthoringEntry): Promise { + return this.perform(() => this.replace(() => this.service.create(entry.identity))); + } + reopen(path: string): Promise { + return this.perform(async () => { await this.replace(() => this.service.reopen(path)); this.view.savedPath = path; }); + } + update(node: GraphNode, text: string): Promise { + return this.perform(async () => { + if (!this.view.draft) throw new Error('No NodeLab draft'); + this.view.draft = await this.service.update(this.view.draft.session_id, node, text); + this.view.notice = 'Input saved in draft'; + }); + } + save(name: string): Promise { + return this.perform(async () => { + if (!this.view.draft) throw new Error('No NodeLab draft'); + const result = await this.service.save(this.view.draft.session_id, name); + this.view.draft = result.draft; this.view.savedPath = result.path; this.view.notice = 'Workflow saved'; + }); + } + async dispose(): Promise { + this.disposed = true; + await this.operation; + for (const id of this.ownedSessions) { + try { + await this.service.close(id); this.ownedSessions.delete(id); + if (this.view.draft?.session_id === id) this.view.draft = null; + } catch (error) { this.cleanupError = error; } + } + } +} diff --git a/src/components/workbench/nodeLabPresenters.test.ts b/src/components/workbench/nodeLabPresenters.test.ts index a83fd6967..c279c61d0 100644 --- a/src/components/workbench/nodeLabPresenters.test.ts +++ b/src/components/workbench/nodeLabPresenters.test.ts @@ -1,16 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { buildNodeLabStatusRows, nodeLabTrustMessage } from './nodeLabPresenters.ts'; -import { - buildNodeLabStatusRows, - nodeLabUnavailableMessage, -} from './nodeLabPresenters.ts'; - -test('node lab presenters expose a disabled state without authoring affordances', () => { - assert.equal(nodeLabUnavailableMessage(), 'Node authoring is unavailable in this build'); +test('NodeLab distinguishes admitted graph authoring from unsupported code publishing', () => { + assert.match(nodeLabTrustMessage(), /trusted in-process/); + assert.match(nodeLabTrustMessage(), /cannot install or publish code/); assert.deepEqual(buildNodeLabStatusRows(), [ - { label: 'Authoring API', value: 'Unavailable' }, + { label: 'Graph authoring', value: 'Host-admitted nodes' }, { label: 'Local Agent', value: 'Unavailable' }, - { label: 'Runtime Publishing', value: 'Unavailable' }, + { label: 'Runtime Publishing', value: 'Unsupported' }, ]); }); diff --git a/src/components/workbench/nodeLabPresenters.ts b/src/components/workbench/nodeLabPresenters.ts index 530bb2dd5..abea6cb53 100644 --- a/src/components/workbench/nodeLabPresenters.ts +++ b/src/components/workbench/nodeLabPresenters.ts @@ -1,16 +1,11 @@ -export interface NodeLabStatusRow { - label: string; - value: string; -} - +export interface NodeLabStatusRow { label: string; value: string } export function buildNodeLabStatusRows(): NodeLabStatusRow[] { return [ - { label: 'Authoring API', value: 'Unavailable' }, + { label: 'Graph authoring', value: 'Host-admitted nodes' }, { label: 'Local Agent', value: 'Unavailable' }, - { label: 'Runtime Publishing', value: 'Unavailable' }, + { label: 'Runtime Publishing', value: 'Unsupported' }, ]; } - -export function nodeLabUnavailableMessage(): string { - return 'Node authoring is unavailable in this build'; +export function nodeLabTrustMessage(): string { + return 'These nodes are supplied by this host and run as trusted in-process code. NodeLab creates workflows; it cannot install or publish code.'; } diff --git a/src/services/workflow/customNodeAuthoring.test.ts b/src/services/workflow/customNodeAuthoring.test.ts new file mode 100644 index 000000000..88d976738 --- /dev/null +++ b/src/services/workflow/customNodeAuthoring.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NodeLabService, parseCustomNodeCatalog, parseNodeLabDraft } from './customNodeAuthoring.ts'; +import { NodeLabController } from '../../components/workbench/nodeLabController.ts'; + +const identity = { node_type: 'custom-uppercase', contract_version: '1.0.0', contract_digest: `contract-blake3:${'c'.repeat(64)}`, implementation_digest: `blake3:${'a'.repeat(64)}`, environment_digest: `blake3:${'b'.repeat(64)}`, adapter_version: 1 }; +const entry = { identity, label: 'Uppercase', description: 'Host transform', trust: 'trusted_in_process', inputs: [{ id: 'input', label: 'Input' }], outputs: [{ id: 'output', label: 'Output' }] }; +const catalog = () => ({ contract_version: 1, nodes: [structuredClone(entry)] }); +const snapshot = (id = 'edit-1') => ({ session_id: id, graph_revision: 'revision-1', graph: { nodes: [{ id: 'source', node_type: 'text-input', position: { x: 0, y: 0 }, data: { text: 'draft' } }], edges: [] }, graph_diagnostics: [] }); + +test('catalog decoder requires complete v1 identity, trust, ports and extra-field policy', () => { + assert.equal(parseCustomNodeCatalog(catalog())[0].identity.environment_digest, identity.environment_digest); + assert.deepEqual(parseCustomNodeCatalog({ contract_version: 1, nodes: [] }), []); + const invalid: unknown[] = [null, [], { ...catalog(), contract_version: 2 }, { ...catalog(), extra: true }, { ...catalog(), nodes: {} }]; + for (const patch of [ + { trust: 'sandbox' }, { inputs: [] }, { outputs: [{ id: 'output', label: 1 }] }, + { identity: { ...identity, adapter_version: 2 } }, { identity: { ...identity, implementation_digest: 'unverified' } }, + { identity: { ...identity, extra: true } }, { identity: { ...identity, contract_digest: 'bad' } }, { identity: { ...identity, contract_version: 'not-semver' } }, { identity: { ...identity, node_type: 'bad type' } }, { inputs: [{ id: 'x', label: 'X' }, { id: 'x', label: 'Again' }] }, + ]) invalid.push({ contract_version: 1, nodes: [{ ...entry, ...patch }] }); + for (const value of invalid) assert.throws(() => parseCustomNodeCatalog(value)); + assert.throws(() => parseCustomNodeCatalog({ contract_version: 1, nodes: [entry, entry] })); +}); + +test('draft decoder refuses malformed graph and diagnostics before exposing state', () => { + assert.equal(parseNodeLabDraft(snapshot()).graph.nodes[0].data.text, 'draft'); + for (const value of [{ ...snapshot(), graph: { nodes: [{}], edges: [] } }, { ...snapshot(), graph_diagnostics: [{ message: 'invalid' }] }, { ...snapshot(), session_id: '' }, { ...snapshot(), graph_diagnostics: null }]) assert.throws(() => parseNodeLabDraft(value)); +}); + +test('service forwards exact selection and refuses changed-session response', async () => { + const calls: [string, Record | undefined][] = []; + const service = new NodeLabService(async (command, args) => { calls.push([command, args]); return snapshot(); }); + await service.create(identity); + assert.deepEqual(calls[0], ['create_custom_node_draft', { request: { identity } }]); + await assert.rejects(service.snapshot('other-session'), /correlation mismatch/); +}); + +test('invalid newly created response closes its session instead of leaking it', async () => { + const closed: string[] = []; + const service = new NodeLabService(async (command, args) => { + if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; } + return { session_id: 'created', graph: 'broken' }; + }); + await assert.rejects(service.create(identity), /Invalid NodeLab text/); + assert.deepEqual(closed, ['created']); +}); + +test('controller distinguishes empty catalog from failure and closes superseded drafts', async () => { + let fail = false; + let counter = 0; + const closed: string[] = []; + const service = new NodeLabService(async (command, args) => { + if (command === 'get_custom_node_authoring_catalog') { + if (fail) throw new Error('host unavailable'); + return { contract_version: 1, nodes: [] }; + } + if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; } + return snapshot(`edit-${++counter}`); + }); + const controller = new NodeLabController(service, () => {}); + await controller.loadCatalog(); assert.deepEqual(controller.view.catalog, []); assert.equal(controller.view.error, null); + fail = true; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable'); + const selected = parseCustomNodeCatalog(catalog())[0]; + await controller.create(selected); await controller.create(selected); + assert.deepEqual(closed, ['edit-1']); + await controller.dispose(); assert.deepEqual(closed, ['edit-1', 'edit-2']); +}); + +test('dispose observes in-flight creation, prevents obsolete publication and releases the late session', async () => { + let complete!: (value: unknown) => void; + const pending = new Promise(resolve => { complete = resolve; }); + const closed: string[] = []; + let published = 0; + const service = new NodeLabService(async (command, args) => { + if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; } + return pending; + }); + const controller = new NodeLabController(service, () => { published++; }); + const create = controller.create(parseCustomNodeCatalog(catalog())[0]); + const before = published; + const disposed = controller.dispose(); + complete(snapshot('late')); + await Promise.all([create, disposed]); + assert.equal(published, before); assert.deepEqual(closed, ['late']); assert.equal(controller.cleanupError, null); +}); + +test('close failure compensates the new session and retains the old draft', async () => { + let created = 0; + const closed: unknown[] = []; + const service = new NodeLabService(async (command, args) => { + if (command === 'remove_execution') { + closed.push(args?.executionId); + if (args?.executionId === 'edit-1') throw new Error('close refused'); + return null; + } + return snapshot(`edit-${++created}`); + }); + const controller = new NodeLabController(service, () => {}); + const selected = parseCustomNodeCatalog(catalog())[0]; + await controller.create(selected); await controller.create(selected); + assert.equal(created, 2); assert.equal(controller.view.error, 'close refused'); + assert.equal(controller.view.draft?.session_id, 'edit-1'); + assert.deepEqual(closed, ['edit-1', 'edit-2']); + await controller.dispose(); assert.match(String(controller.cleanupError), /close refused/); +}); + +test('failed stale selection or reopen retains current edited draft and save path', async () => { + let refuse = false; + const closed: unknown[] = []; + const service = new NodeLabService(async (command, args) => { + if (command === 'remove_execution') { closed.push(args?.executionId); return null; } + if (refuse) throw new Error('selection/path unavailable'); + return snapshot('current'); + }); + const controller = new NodeLabController(service, () => {}); + const selected = parseCustomNodeCatalog(catalog())[0]; + await controller.create(selected); + controller.view.savedPath = 'existing.json'; + const before = structuredClone(controller.view.draft); + refuse = true; + await controller.create(selected); + assert.deepEqual(controller.view.draft, before); assert.equal(controller.view.savedPath, 'existing.json'); + await controller.reopen('missing.json'); + assert.deepEqual(controller.view.draft, before); assert.equal(controller.view.savedPath, 'existing.json'); + assert.deepEqual(closed, []); + await controller.dispose(); assert.deepEqual(closed, ['current']); +}); + +test('app service retains failed malformed-response cleanup across page owners until explicit recovery', async () => { + let closeFails = true; + const service = new NodeLabService(async command => { + if (command === 'remove_execution') { if (closeFails) throw new Error('offline'); return null; } + return { session_id: 'known-created', graph_revision: 'revision', graph: null }; + }); + await assert.rejects(service.create(identity), /offline/); + assert.equal(service.pendingCleanupCount, 1); + const nextPage = new NodeLabController(service, () => {}); + assert.equal(nextPage.view.pendingCleanup, 1); + await assert.rejects(service.create(identity), /Close previous/); + closeFails = false; + await nextPage.retryCleanup(); + assert.equal(nextPage.view.pendingCleanup, 0); assert.equal(service.pendingCleanupCount, 0); + await nextPage.dispose(); +}); diff --git a/src/services/workflow/customNodeAuthoring.ts b/src/services/workflow/customNodeAuthoring.ts new file mode 100644 index 000000000..995ea61e0 --- /dev/null +++ b/src/services/workflow/customNodeAuthoring.ts @@ -0,0 +1,184 @@ +// Wire authority: custom_nodes/authoring.rs (catalog v1) and graph/session_contract.rs. +import type { GraphNode, WorkflowGraph } from './types.ts'; +import { invokeWorkflowCommand } from './workflowServiceErrors.ts'; + +export interface CustomNodeIdentity { + node_type: string; + contract_version: string; + contract_digest: string; + implementation_digest: string; + environment_digest: string; + adapter_version: number; +} +export interface CustomNodeAuthoringEntry { + identity: CustomNodeIdentity; + label: string; + description: string; + trust: 'trusted_in_process'; + inputs: { id: string; label: string }[]; + outputs: { id: string; label: string }[]; +} +export interface NodeLabDraft { + session_id: string; + graph_revision: string; + graph: WorkflowGraph; + graph_diagnostics: { message: string; blocking_submission: boolean }[]; +} +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid NodeLab object'); + return value as Record; +} +function exact(value: unknown, keys: string[]): Record { + const item = record(value); + if (Object.keys(item).length !== keys.length || keys.some(key => !(key in item))) throw new Error('Invalid NodeLab fields'); + return item; +} +function text(value: unknown): string { + if (typeof value !== 'string') throw new Error('Invalid NodeLab text'); + return value; +} +function identifier(value: unknown): string { + const id = text(value); + if (!id.trim()) throw new Error('Invalid NodeLab identifier'); + return id; +} +function contractId(value: unknown): string { + const id = identifier(value); + // NodeTypeId/PortId grammar in pantograph-node-contracts; do not normalize pins. + if (id.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(id)) throw new Error('Invalid custom-node contract identifier'); + return id; +} +export function parseCustomNodeIdentity(value: unknown): CustomNodeIdentity { + const item = exact(value, ['node_type', 'contract_version', 'contract_digest', 'implementation_digest', 'environment_digest', 'adapter_version']); + if (item.adapter_version !== 1) throw new Error('Unsupported custom-node adapter version'); + for (const key of ['implementation_digest', 'environment_digest']) { + if (!/^blake3:[0-9a-f]{64}$/.test(text(item[key]))) throw new Error('Invalid custom-node attestation'); + } + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(text(item.contract_version)) || !/^contract-blake3:[0-9a-f]{64}$/.test(text(item.contract_digest))) throw new Error('Invalid custom-node contract identity'); + return { + node_type: contractId(item.node_type), contract_version: text(item.contract_version), + contract_digest: identifier(item.contract_digest), implementation_digest: text(item.implementation_digest), + environment_digest: text(item.environment_digest), adapter_version: 1, + }; +} +export function parseCustomNodeCatalog(value: unknown): CustomNodeAuthoringEntry[] { + const catalog = exact(value, ['contract_version', 'nodes']); + if (catalog.contract_version !== 1) throw new Error('Unsupported NodeLab catalog version'); + if (!Array.isArray(catalog.nodes)) throw new Error('Invalid NodeLab catalog'); + const seen = new Set(); + return catalog.nodes.map(value => { + const item = exact(value, ['identity', 'label', 'description', 'trust', 'inputs', 'outputs']); + const identity = parseCustomNodeIdentity(item.identity); + if (seen.has(identity.node_type)) throw new Error('Duplicate NodeLab type'); + seen.add(identity.node_type); + if (item.trust !== 'trusted_in_process') throw new Error('Unsupported custom-node trust profile'); + const ports = (value: unknown) => { + if (!Array.isArray(value) || value.length < 1 || value.length > 8) throw new Error('Invalid NodeLab ports'); + const seen = new Set(); + return value.map(value => { + const port = exact(value, ['id', 'label']); + const id = contractId(port.id); + if (id.startsWith('_')) throw new Error('Invalid reserved custom-node port'); + if (seen.has(id)) throw new Error('Duplicate NodeLab port'); + seen.add(id); + return { id, label: text(port.label) }; + }); + }; + return { identity, label: text(item.label), description: text(item.description), trust: 'trusted_in_process', inputs: ports(item.inputs), outputs: ports(item.outputs) }; + }); +} +function parseGraph(value: unknown): WorkflowGraph { + const graph = record(value); + if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) throw new Error('Invalid NodeLab graph'); + const nodes = graph.nodes.map(value => { + const node = record(value); + const position = record(node.position); + if (typeof position.x !== 'number' || !Number.isFinite(position.x) || typeof position.y !== 'number' || !Number.isFinite(position.y)) throw new Error('Invalid NodeLab position'); + return { id: identifier(node.id), node_type: identifier(node.node_type), position: { x: position.x, y: position.y }, data: record(node.data) }; + }); + const edges = graph.edges.map(value => { + const edge = record(value); + return { id: identifier(edge.id), source: identifier(edge.source), source_handle: identifier(edge.source_handle), target: identifier(edge.target), target_handle: identifier(edge.target_handle) }; + }); + return { nodes, edges }; +} +export function parseNodeLabDraft(value: unknown): NodeLabDraft { + const draft = record(value); + const diagnostics = draft.graph_diagnostics === undefined ? [] : draft.graph_diagnostics; + if (!Array.isArray(diagnostics)) throw new Error('Invalid NodeLab diagnostics'); + return { + session_id: identifier(draft.session_id), graph_revision: identifier(draft.graph_revision), graph: parseGraph(draft.graph), + graph_diagnostics: diagnostics.map(value => { + const item = record(value); + if (typeof item.blocking_submission !== 'boolean') throw new Error('Invalid NodeLab diagnostic'); + return { message: text(item.message), blocking_submission: item.blocking_submission }; + }), + }; +} + +export class NodeLabService { + // App-scoped recovery ownership for transport/close failures, never an execution registry. + private readonly pendingCloses = new Set(); + get pendingCleanupCount(): number { return this.pendingCloses.size; } + // A failed replacement keeps the old draft under its still-active controller. + retainActiveSession(id: string): void { this.pendingCloses.delete(id); } + async retryCleanup(): Promise { + const errors: unknown[] = []; + for (const id of [...this.pendingCloses]) { try { await this.close(id); } catch (error) { errors.push(error); } } + if (errors.length) throw new AggregateError(errors, 'Some NodeLab sessions could not be closed'); + } + private requireCleanupResolved(): void { + if (this.pendingCloses.size) throw new Error('Close previous NodeLab sessions before creating or reopening a draft'); + } + private readonly invoke: (command: string, args?: Record) => Promise; + constructor(invoke: (command: string, args?: Record) => Promise = invokeWorkflowCommand) { this.invoke = invoke; } + async catalog(): Promise { + return parseCustomNodeCatalog(await this.invoke('get_custom_node_authoring_catalog')); + } + private async ownedDraft(response: unknown): Promise { + const id = identifier(record(response).session_id); + try { return parseNodeLabDraft(response); } + catch (error) { await this.close(id); throw error; } + } + async create(identity: CustomNodeIdentity): Promise { + this.requireCleanupResolved(); + return this.ownedDraft(await this.invoke('create_custom_node_draft', { request: { identity } })); + } + async reopen(path: string): Promise { + this.requireCleanupResolved(); + const saved = record(await this.invoke('load_workflow', { path })); + const graph = parseGraph(saved.graph); + const session = record(await this.invoke('create_workflow_execution_session', { graph, workflowId: path })); + const id = identifier(session.session_id); + try { return await this.snapshot(id); } + catch (error) { await this.close(id); throw error; } + } + async snapshot(id: string): Promise { + const draft = parseNodeLabDraft(await this.invoke('get_node_lab_draft', { executionId: id })); + if (draft.session_id !== id) throw new Error('NodeLab session correlation mismatch'); + return draft; + } + async update(id: string, node: GraphNode, value: string): Promise { + // Source values are authored text; custom identities/definitions never use this path. + if (node.node_type !== 'text-input') throw new Error('NodeLab only edits text sources'); + const draft = parseNodeLabDraft(await this.invoke('update_node_data', { executionId: id, nodeId: node.id, data: { text: value } })); + if (draft.session_id !== id) throw new Error('NodeLab session correlation mismatch'); + return draft; + } + async save(id: string, name: string): Promise<{ draft: NodeLabDraft; path: string }> { + const draft = await this.snapshot(id); + const path = identifier(await this.invoke('save_workflow', { name, graph: draft.graph })); + return { draft, path }; + } + async close(id: string): Promise { + try { await this.invoke('remove_execution', { executionId: id }); this.pendingCloses.delete(id); } + catch (error) { + // Already absent is a terminal close outcome; other failures retain exact ownership. + if (error && typeof error === 'object' && 'code' in error && error.code === 'session_not_found') { this.pendingCloses.delete(id); return; } + this.pendingCloses.add(id); throw error; + } + } +} + +// Workbench lifetime owner: failed closes survive NodeLab page navigation. +export const nodeLabService = new NodeLabService(); From d503ed305448e9e36b8a54f2bc393d263803fe58 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 22 Oct 2026 02:35:34 +0000 Subject: [PATCH 2/5] fix(nodelab): key projected graph diagnostics --- .../evidence/nodelab-authoring/browser.log.gz | Bin 441 -> 441 bytes .../nodelab-authoring/qualification.json | 4 ++-- src/components/workbench/NodeLabPage.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz index ec6e6c01c5b6d92e7d5f711e3f8f19252feaee63..685a06aa6d63b9419d3576a7161c5a2a3d99454b 100644 GIT binary patch delta 318 zcmV-E0m1&c1Gxi`8GqCi)FUAERv{<{gg7|%B$=-N@Yt)QhzqX(N1m=v!Z@oGslNI9 znfYeyi3-AWt*+Dn+e*$-*uySnNIBYS3dNw5>Ppsu1@=O>`_tdwFbr4VMdXm-nMbR- zjKN}Vh0LLbk(fdMwJ)%6SOdZjjJYfImTEkFU*YV(G>A00p+$Nok}skmr2 z<1~2|l1$fsc|qx(q#SKEgiUIsy;4_aYfImTEkFU*YV(@ApB+FS$Nok}skmr2 z<1~2|{view.draft.graph.nodes.length} nodes · {view.draft.graph.edges.length} connections. Saving preserves the selected implementation identity. Run saved workflows from Scheduler.

{#if view.draft.graph_diagnostics.length}
    - {#each view.draft.graph_diagnostics as diagnostic} + {#each view.draft.graph_diagnostics as diagnostic, index (index)}
  • {diagnostic.blocking_submission ? 'Blocks execution: ' : ''}{diagnostic.message}
  • {/each}
From 802544a0ed5f523d2ce7a54f7859b8ed0187e63f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 22 Oct 2026 02:40:26 +0000 Subject: [PATCH 3/5] test(nodelab): qualify browser with application styles --- .../evidence/nodelab-authoring/browser.log.gz | Bin 441 -> 584 bytes .../nodelab-authoring/qualification.json | 7 ++++--- .../execution-ledger.md | 2 ++ scripts/check-nodelab-browser.mjs | 8 ++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz index 685a06aa6d63b9419d3576a7161c5a2a3d99454b..07f9f0f9027ca4ed6844bfd1846afd03d511f9e3 100644 GIT binary patch literal 584 zcmV-O0=NAiiwFP!0000218q~wZWBQeyzf`E%mpNSY-8+zPbdL#LW&R(LWslcPRHBv zyqKPG>>w`u0v!3ed6E-ABr_#0#Js8t73JsyCw8FM_!J{E6ROoRTtd& zzSj6o-?R@BYU8lE{qYT^)4A9(#UjJ6{?MogkjJb5AGdFVMZdP**~Q`H5Og{^UNMsQI*VYwvZ{8OXyz&1c+vmKhJida#s16dcx9 zXEiw%>Y7|7r(po@72oov~L)nIst>}MB4%5bU4mV{8k1!JV<4X~0UF9<)*Bor3=uu#K83RC;Z0#FL9)LcXLLs%Zncz- z(p*d^C$K?t3?+=Ry0zpCgz<4dC^;dN zF%5dTEV06HCiWZ^H>`7rfpz7|?^K-*!3SUwYQuI2ug))OPE9y?7etgDr7clE{zhP`rbe zXQhS}jBnxT&#$xb<_rx2%~s8fh35*d-rs+E__8*4>VW|^uhsPn>w2X&XKPE}hAlt= z)@t*HyB{4uAIJViMya@HH{&#U7UXN!qVyTH1N5n!!04h~-1m!_IZvNrF0j+&YDQ?` zC>Q~9^t0(A9`Uq|I$JPOuE>^cc#V@RF`2 z-MVN-(e&c@c3zZx0+coN2h>)gFuap^o~mE8J*3FGdF4a3qzecUSPE_9aRIjt@9dEeVj diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json index 75b65155d..1d75ffd22 100644 --- a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json +++ b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json @@ -5,7 +5,8 @@ "rust": "1.92.0", "node": "24.12.0", "chromium": "Chromium 151.0.7922.173 built on Debian GNU/Linux 13 (trixie)", - "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only" + "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only", + "application_styles": true }, "claims": { "browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained", @@ -37,7 +38,7 @@ "crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513", "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f", "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a", - "scripts/check-nodelab-browser.mjs": "30a619d382ddb242c19b50c51ec97fda8a6c20973fe51f94c7c0fcf5658d24d4", + "scripts/check-nodelab-browser.mjs": "91505dbfa49defa836a246fcff7317bc6dae795fd257da1ee8747146cebe3357", "src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50", "src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c", "src/components/workbench/NodeLabPage.svelte": "ab23063b86e78e3dc919602df48d027f0e736bee678b8e5609eedc41d0da25d2", @@ -48,7 +49,7 @@ "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08" }, "evidence_sha256": { - "browser.log.gz": "6a1c002a0a7a2aa962249b01f31b98f5f33e5643f43472cb07b390d8d75c0f74", + "browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e", "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3" } } diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md index e6a6f09de..1f3defa2e 100644 --- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md +++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md @@ -836,6 +836,8 @@ an unexported provider and a nonexistent response field; corrected against the actual owning APIs, preserving the assertions. The first browser Enter simulation omitted character text and did not activate the button; proper CDP key events then passed the same workflow assertions, with no pointer fallback for that gate. +Full lint required keys on stateless diagnostic rows; repaired. Final browser +evidence loads the actual application stylesheet and checks nonzero focus outline. Native `cargo check --locked -p pantograph --bin pantograph --no-default-features` is unavailable at glib-sys because this environment lacks `glib-2.0.pc`; GTK and diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs index 76cd26290..76e5496a5 100644 --- a/scripts/check-nodelab-browser.mjs +++ b/scripts/check-nodelab-browser.mjs @@ -26,8 +26,8 @@ const sessions = []; try { await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules')); await writeFile(path.join(fixture, 'index.html'), '
'); - await writeFile(path.join(fixture, 'main.js'), `import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`); - await build({ configFile: false, root: fixture, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } }); + await writeFile(path.join(fixture, 'main.js'), `import ${JSON.stringify(path.join(repo, 'src/styles.css'))}; import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`); + await build({ configFile: false, root: fixture, css: { postcss: repo }, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } }); function rustHost(empty) { const child = spawn(hostBinary, empty ? ['--empty-catalog'] : [], { stdio: ['pipe', 'pipe', 'inherit'] }); hosts.push(child); @@ -64,7 +64,7 @@ try { const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); const file = path.resolve(fixture, 'dist', relative); if (!file.startsWith(path.resolve(fixture, 'dist') + path.sep)) { response.writeHead(403).end(); return; } - response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : 'text/html'); + response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'); response.end(await readFile(file)); } catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 'internal_error', message: String(error) })); } }); @@ -140,7 +140,7 @@ try { if (await evaluate(() => document.activeElement?.textContent === 'Create Host uppercase draft')) break; } assert.equal(await evaluate(() => document.activeElement?.textContent), 'Create Host uppercase draft'); - assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none'), true); + assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none' && parseFloat(getComputedStyle(document.activeElement).outlineWidth) > 0), true); await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' }); await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }); await waitText('Graph contract valid'); From ff4e4de05d884f05338e70745db2400df0134926 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 22 Oct 2026 04:10:18 +0000 Subject: [PATCH 4/5] fix(nodelab): hide stale catalog choices after refresh failure --- .../nodelab-authoring/browser-refresh.log.gz | Bin 0 -> 617 bytes .../catalog-refresh-red.log.gz | Bin 0 -> 1146 bytes .../nodelab-authoring/qualification.json | 15 ++++++---- .../execution-ledger.md | 18 ++++++++++++ scripts/check-nodelab-browser.mjs | 27 ++++++++++++++++-- src/components/workbench/nodeLabController.ts | 6 +++- .../workflow/customNodeAuthoring.test.ts | 17 +++++++---- 7 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/catalog-refresh-red.log.gz diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..391ed64af588415c6df5b84da9b76f650b9e8068 GIT binary patch literal 617 zcmV-v0+#(BiwFP!0000219ekPZxb;Pz3;CWg$qcDleA3=IiXgF3x^7U5JCuf*OTlt z{=#FY35W}S0gn8-{u0JX0|mt1{5Wr{_HU;fajCZRW&Y7kvt{FRPBUJ6H3wKe;CpH9M)?>b*X72J$dh^9eVfCF4O@4;B(c!C^gh zR+D2v-Zj<7K!US?F6JF{^2#=>2E)V6KD!uFhNUK35<;Dhd?!E(?m#*guW{D~oeXp$ zShowbH?Se5)}kz4SNK>O1Nn>z&`=(;-rzW4i0C2rDU?+YFAGZxlK0zjMt9`lR!i$B z=3+uwfeo7DRl;a>hXxp#YxB+q{(bF8PRl%D*<`ku zZQZ-h7x>_o75>CSQ8_r+GF2G66`a`I)J(*y+>IB0S5%36d% zf=l0|B|36!hp?F6P;U%LE=EUiR+g=D8k~c}R+od5Y73^Tl2~qybAVif`IAN=s6u0? zAX4NG<+c#{PS}0OQJPLhi&{{>{6WYDvV9rXu96`+qxhp_fK1~uFraZ<=1k{81wQpH z_bAJ<{pAfehb6CUtl4twXqu+UWOOl4XPv0EfERQaeP^9c_$G$tu=(&1y&FCm`&cOR zG|bW3X8Qq6dR6(2v9L^nMFQK|MK=B)wqyt=!)zWwTGBW&ZG^Z;=5pnV%36al#Z4+AxuDjh5Gr5DH?=&K~92WHy+%_PomF4 zxs_#r$f)}rW)fx}(=JG(+i$NmlQBCnC6HmI7j_9sE&`1EC#Nxmpt9t0jr0P(*%_=9 zdlb_wf8*1A(Ot`?J5EN)=bTcy@C1+^!#v z7R~J+TRO)xQ~7_zqQ3VxG;Zp{*LcL&Ij+ZV!Ix4mOKZ?Bt}(669`HFJT`5&}Occ4N zkEq?ZkQe?0BwsUa^nPn9_YL(5G-);*#@XUZmWyG@v^ViZKL}+3&*6WcLW@N<9=$vI zXKt_a$Re>7-b(>Fli*`DQ^R1j#6IS9Txa9)c$!_jY<~#*2cbDU-c_r5UcUx;k)}Y5 z loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained", + "browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained; latest repaired-source run in browser-refresh.log.gz; original browser.log.gz retained as baseline evidence", "service_tests": "1068 library +65 integration passed, zero failures/ignored", "frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included", "native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim", @@ -20,7 +20,8 @@ "GPU", "inference-backed/effectful tool loops", "release qualification" - ] + ], + "catalog_refresh": "failed refresh after successful load removes stale choices while preserving draft and saved path; focused red on baseline 802544a0, green after repair; same mounted real-browser flow passed" }, "source_sha256": { "crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8", @@ -38,18 +39,20 @@ "crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513", "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f", "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a", - "scripts/check-nodelab-browser.mjs": "91505dbfa49defa836a246fcff7317bc6dae795fd257da1ee8747146cebe3357", + "scripts/check-nodelab-browser.mjs": "82f884c2fb04394168c06ba09b0432345aad059e67bc54efdc281a759b68c88f", "src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50", "src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c", "src/components/workbench/NodeLabPage.svelte": "ab23063b86e78e3dc919602df48d027f0e736bee678b8e5609eedc41d0da25d2", - "src/components/workbench/nodeLabController.ts": "3b3806dcf0aedf191fd7ad91ce3ac746a08c6962b1c0b292f54d9ba6e9145716", + "src/components/workbench/nodeLabController.ts": "0389ac2397e253926162195a1b41bddf1e07568f65afd568e1673b4fe44c0d1f", "src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e", "src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075", - "src/services/workflow/customNodeAuthoring.test.ts": "73d776ffb40ddcafcfb0e69f7434f98aa19bd037484a36ccdef7d9eff290d250", + "src/services/workflow/customNodeAuthoring.test.ts": "97aa21f8487984832d03efac4f81d694b214ed065087099aca4a9b87433083dd", "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08" }, "evidence_sha256": { "browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e", - "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3" + "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3", + "catalog-refresh-red.log.gz": "c849e1ead56e2b00b0f7fc3c7e04a4cf867a99fe176476aa9b0d9658613bf720", + "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2" } } diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md index 1f3defa2e..9cae9a29c 100644 --- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md +++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md @@ -848,3 +848,21 @@ first by parent instruction; CN-02 must then reconcile current main, preserve it registry/loop validations, rerun combined affected tests and pass exact-head CI before merge. The local/remote feature branch and worktree are retained pending that disposition, with source and evidence preserved. + +While dependency integration was pending, a reachable catalog-refresh failure +left earlier Create choices visible. The focused regression failed on `802544a0` +and passes after clearing/publishing the catalog before transport; authored draft +and saved path remain unchanged. The same mounted real-browser flow passes +failed refresh and recovery. Its first probe inspected old-session closure before +Reopen completed; bounded observation now waits for replacement completion and +retains the original closure assertion. Original browser/native receipts remain; +the qualification manifest binds the repaired source and additional receipts. +No backend, scheduler or audit behavior changed. This repair remains local until +the required PR94 integration/current-main qualification. + +Hosted PR95 source/build/native-IPC checks passed on `802544a0`; the required +audit failed with an actual advisory-bulk HTTP 503. PR94 likewise remains blocked +on audit service failures. No audit retries or gate changes were requested here. +Local native provisioning inspection stopped when `apt-cache policy` was denied +reading `/etc/apt/apt.conf.d/80-applied-apt-retries`; no package installation or +privileged retry was attempted. This does not qualify native startup/interaction. diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs index 76e5496a5..55ae40d26 100644 --- a/scripts/check-nodelab-browser.mjs +++ b/scripts/check-nodelab-browser.mjs @@ -23,6 +23,7 @@ let browser; let socket; const commands = []; const sessions = []; +let failRealCatalog = false; try { await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules')); await writeFile(path.join(fixture, 'index.html'), '
'); @@ -56,7 +57,7 @@ try { const command = decodeURIComponent(url.pathname.split('/').at(-1)); const args = JSON.parse(body); commands.push(command); - const result = mode === 'error' ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args); + const result = (mode === 'error' || (mode === 'real' && failRealCatalog && command === 'get_custom_node_authoring_catalog')) ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args); if (result.ok && command === 'create_custom_node_draft') sessions.push(result.value.session_id); response.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' }).end(JSON.stringify(result.ok ? result.value : result.error)); return; @@ -151,18 +152,38 @@ try { const file = (await realHost('load_workflow', { path: savedPath })).value; assert.equal(file.graph.nodes.find(node => node.node_type === 'text-input').data.text, 'Saved Mixed case'); const pin = file.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity; - await enter('input', savedPath); await clickButton('Reopen saved workflow'); await waitText('Graph contract valid'); + await enter('input', savedPath); await clickButton('Reopen saved workflow'); + const reopenDeadline = Date.now() + 10000; + while ((await realHost('get_node_lab_draft', { executionId: sessions[0] })).ok && Date.now() < reopenDeadline) { + await new Promise(resolve => setTimeout(resolve, 30)); + } + while (await evaluate(() => document.querySelector('[aria-label="NodeLab authoring"]')?.getAttribute('aria-busy') === 'true')) { + assert.ok(Date.now() < reopenDeadline, 'Reopen must finish before inspecting the replacement'); + await new Promise(resolve => setTimeout(resolve, 30)); + } + await waitText('Graph contract valid'); assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case'); const reloaded = (await realHost('load_workflow', { path: savedPath })).value; assert.deepEqual(reloaded.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity, pin); assert.equal((await realHost('get_node_lab_draft', { executionId: sessions[0] })).error.code, 'session_not_found'); + // A failed refresh cannot advertise stale implementations or discard the authored draft. + failRealCatalog = true; + await clickButton('Refresh catalog'); await waitText('Qualification host unavailable'); + assert.equal(await evaluate(() => [...document.querySelectorAll('button')].some(button => button.textContent.startsWith('Create '))), false); + assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case'); + assert.equal(await evaluate(() => document.querySelector('code')?.textContent), savedPath); + assert.equal((await text()).includes('This host has no admitted custom nodes'), false); + failRealCatalog = false; + await clickButton('Refresh catalog'); await waitText('Create Host uppercase draft'); + assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case'); + assert.equal(await evaluate(() => document.querySelector('code')?.textContent), savedPath); // Same saved graph opened on a host without its implementation stays visible and blocked. await emptyHost('save_workflow', { name: 'stale', graph: file.graph }); await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes'); await enter('input', '.pantograph/workflows/stale.json'); await clickButton('Reopen saved workflow'); await waitText('Blocks execution:'); assert.ok((await text()).includes('host-uppercase')); assert.ok(!commands.some(command => command.includes('run_execution') || command.includes('register') || command.includes('publish'))); - console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2)); + console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking', 'failed catalog refresh hides stale choices and preserves draft'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2)); } finally { socket?.close(); if (browser && browser.exitCode === null) { browser.kill('SIGTERM'); await new Promise(resolve => { browser.once('exit', resolve); setTimeout(resolve, 2000).unref(); }); } diff --git a/src/components/workbench/nodeLabController.ts b/src/components/workbench/nodeLabController.ts index 122609ce6..575872ffb 100644 --- a/src/components/workbench/nodeLabController.ts +++ b/src/components/workbench/nodeLabController.ts @@ -34,7 +34,11 @@ export class NodeLabController { } retryCleanup(): Promise { return this.perform(() => this.service.retryCleanup()); } loadCatalog(): Promise { - return this.perform(async () => { this.view.catalog = await this.service.catalog(); }); + return this.perform(async () => { + this.view.catalog = null; + this.publish(); + this.view.catalog = await this.service.catalog(); + }); } private async replace(create: () => Promise): Promise { const next = await create(); diff --git a/src/services/workflow/customNodeAuthoring.test.ts b/src/services/workflow/customNodeAuthoring.test.ts index 88d976738..30a1f1ace 100644 --- a/src/services/workflow/customNodeAuthoring.test.ts +++ b/src/services/workflow/customNodeAuthoring.test.ts @@ -45,22 +45,29 @@ test('invalid newly created response closes its session instead of leaking it', }); test('controller distinguishes empty catalog from failure and closes superseded drafts', async () => { - let fail = false; + let mode: 'empty' | 'full' | 'failed' = 'empty'; let counter = 0; const closed: string[] = []; const service = new NodeLabService(async (command, args) => { if (command === 'get_custom_node_authoring_catalog') { - if (fail) throw new Error('host unavailable'); - return { contract_version: 1, nodes: [] }; + if (mode === 'failed') throw new Error('host unavailable'); + return mode === 'empty' ? { contract_version: 1, nodes: [] } : catalog(); } if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; } return snapshot(`edit-${++counter}`); }); const controller = new NodeLabController(service, () => {}); await controller.loadCatalog(); assert.deepEqual(controller.view.catalog, []); assert.equal(controller.view.error, null); - fail = true; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable'); + mode = 'full'; await controller.loadCatalog(); assert.equal(controller.view.catalog?.length, 1); const selected = parseCustomNodeCatalog(catalog())[0]; - await controller.create(selected); await controller.create(selected); + await controller.create(selected); + controller.view.savedPath = 'saved-current.json'; + const before = structuredClone(controller.view.draft); + mode = 'failed'; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable'); + assert.equal(controller.view.catalog, null, 'failed refresh must not advertise stale creation choices'); + assert.deepEqual(controller.view.draft, before, 'catalog failure must preserve the current draft'); + assert.equal(controller.view.savedPath, 'saved-current.json'); + await controller.create(selected); assert.deepEqual(closed, ['edit-1']); await controller.dispose(); assert.deepEqual(closed, ['edit-1', 'edit-2']); }); From b4854d8d62bb58ef2917694ffcd8355733c252d7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 22 Oct 2026 04:28:19 +0000 Subject: [PATCH 5/5] test(nodelab): qualify edit sessions with integrated loop admission --- .github/workflows/quality-gates.yml | 6 ++ .../src/workflow/tests/custom_nodes.rs | 90 ++++++++++++++++++ docs/custom-nodes.md | 8 ++ .../nodelab-authoring/browser-combined.log.gz | Bin 0 -> 618 bytes .../nodelab-authoring/ci-discovery.log.gz | Bin 0 -> 1531 bytes .../nodelab-authoring/qualification.json | 38 ++++++-- .../nodelab-authoring/service-combined.log.gz | Bin 0 -> 20631 bytes .../execution-ledger.md | 41 +++++++- .../plan.md | 14 +++ 9 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 4b002a144..815184f09 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -434,6 +434,12 @@ jobs: python3 -c 'import pathlib, sys; names = [line for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if "tool_loop" in line and line.endswith(": test")]; print("Tool-loop tests discovered:", len(names)); sys.exit(0 if len(names) == 15 else 1)' "$RUNNER_TEMP/tool-loop-tests.list" cargo test --locked -p pantograph-workflow-service --lib tool_loop + - name: Run exact-head custom-node authoring and admission regression suite + run: | + cargo test --locked -p pantograph-workflow-service --lib workflow::tests::custom_nodes:: -- --list > "$RUNNER_TEMP/custom-node-tests.list" + python3 -c 'import pathlib, sys; names = [line for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line.startswith("workflow::tests::custom_nodes::") and line.endswith(": test")]; print("Custom-node tests discovered:", len(names)); sys.exit(0 if len(names) == 10 else 1)' "$RUNNER_TEMP/custom-node-tests.list" + cargo test --locked -p pantograph-workflow-service --lib workflow::tests::custom_nodes:: + - name: Run complete session execution regression suite run: | cargo test -p pantograph-workflow-service --lib workflow::tests::session_execution:: -- --list > "$RUNNER_TEMP/session-execution-tests.list" diff --git a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs index 9226d62a0..76584515d 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs @@ -631,3 +631,93 @@ async fn custom_node_authoring_saved_reopen_preserves_pin_and_scheduler_executio .unwrap(); assert_eq!(result.outputs[0].value, "MIXED CASE"); } + +#[tokio::test] +async fn custom_node_edit_sessions_preserve_integrated_loop_admission() { + use crate::tool_loops::tests::{catalog as loop_catalog, Executor as LoopExecutor, Mode}; + use crate::tool_loops::TOOL_LOOP_IDENTITY_FIELD; + + let controller = LoopExecutor::new(Mode::RoundTrip); + let tool = LoopExecutor::new(Mode::Uppercase); + let catalog = loop_catalog(controller.clone(), tool.clone(), 3); + let mut graph = custom_graph(catalog.get("loop-controller").unwrap().identity()); + let node = graph.find_node_mut("custom").unwrap(); + node.node_type = "tool-loop".into(); + node.data = serde_json::json!({TOOL_LOOP_IDENTITY_FIELD: catalog.tool_loop_identity("pure-loop").unwrap()}); + graph.edges[0].target_handle = "prompt".into(); + graph.edges[1].source_handle = "response".into(); + let root = tempfile::tempdir().unwrap(); + let store = FileSystemWorkflowGraphStore::new(root.path()); + let service = WorkflowService::with_trusted_custom_nodes(catalog.clone()); + let saved = service + .workflow_graph_save( + &store, + crate::WorkflowGraphSaveRequest { + name: "integrated-loop".into(), + graph, + }, + ) + .unwrap(); + let graph = service + .workflow_graph_load( + &store, + crate::WorkflowGraphLoadRequest { + path: saved.path.clone(), + }, + ) + .unwrap() + .graph; + let pin = graph.find_node("custom").unwrap().data[TOOL_LOOP_IDENTITY_FIELD].clone(); + let changed = loop_catalog(controller.clone(), tool.clone(), 4); + for (service, blocking) in [ + (service, false), + (WorkflowService::with_trusted_custom_nodes(changed), true), + (WorkflowService::new(), true), + ] { + let edit = service + .workflow_graph_create_edit_session(crate::WorkflowGraphEditSessionCreateRequest { + graph: graph.clone(), + workflow_id: Some(saved.path.clone()), + }) + .await + .unwrap(); + let request = crate::WorkflowGraphEditSessionGraphRequest { + session_id: edit.session_id.clone(), + }; + let snapshot = service + .workflow_graph_get_edit_session_graph(request.clone()) + .await + .unwrap(); + assert_eq!( + snapshot.graph.find_node("custom").unwrap().data[TOOL_LOOP_IDENTITY_FIELD], + pin + ); + assert_eq!( + snapshot + .graph_diagnostics + .iter() + .any(|d| d.blocking_submission), + blocking, + "{:?}", + snapshot.graph_diagnostics + ); + let mut invalid = snapshot.graph.find_node("custom").unwrap().clone(); + invalid.id = "invalid-new-loop".into(); + invalid.data[TOOL_LOOP_IDENTITY_FIELD]["max_turns"] = serde_json::json!(5); + assert!(service + .workflow_graph_add_node(crate::WorkflowGraphAddNodeRequest { + session_id: edit.session_id, + node: invalid, + }) + .await + .is_err()); + let after = service + .workflow_graph_get_edit_session_graph(request) + .await + .unwrap(); + assert_eq!(after.graph_revision, snapshot.graph_revision); + assert_eq!(after.graph, snapshot.graph); + } + assert!(controller.calls.lock().is_empty()); + assert!(tool.calls.lock().is_empty()); +} diff --git a/docs/custom-nodes.md b/docs/custom-nodes.md index 6eebe7fe5..20ce7362f 100644 --- a/docs/custom-nodes.md +++ b/docs/custom-nodes.md @@ -132,5 +132,13 @@ retired. Inference-backed controllers, side-effectful tools, external workers, Wasm, Python, package discovery, automatic updates, streaming and inference ports require follow-on admission; none are inferred from a palette entry. +NodeLab's creation catalog projects individual CN-01 registrations, not loop +templates or tool grants. Existing host-pinned loop graphs can be reopened using +the same registry; their pins remain unchanged and unavailable/stale grants are +blocking. A future loop-authoring surface must select a complete host-admitted +`ToolLoopIdentity` and produce exactly one prompt binding under the +[CN-03 contract](tool-loops.md); optional authoring ports and new grants remain +unsupported. No scheduler or loop authority is owned by NodeLab. + See [ADR-018](adr/ADR-018-trusted-custom-node-adapter.md) and the [admitted implementation slice](plans/domain-architecture-and-multimodal/plan.md#admitted-custom-node-adapter-slice-2026-10-21). diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..a137fef0cf1f89dac4cd954458a0a0685ec949cc GIT binary patch literal 618 zcmV-w0+szAiwFP!0000219el&ZWBQeyzf`E%mpNS94B!eKA}X23x^1S5JCve?zFuf z&x`39#}3McUw|WDmoK5mjv;{9+thSbch&T+C?l*+)UldCTgV#n3z&xlF$Gh)Ou;Lq znw+#}ft_5}^F<0bjEOCAoKj@qGNkVNLHupci!DKR(u}rbZ@XH?>)g<%=Qw^f)i(hgzERwP0iY;y_0@E#4 z*i?0r^0n%-v+&P%peCoVyny@fU$@NJ7CIJa$JNZ3c_%Tte1A=&n-@o_V+NQWsgt9> zxaaEZKio)k^XikkR@Xv>>8Uz7{*!xnpr(hFTfNt(&Ojc{)af}lUnJu}SPo_qM8RP_ zbykyOK|VCq$3TL!fG*|@bn?nJEC$2f%{IFjQii!ETM|N@j(jIT3T{W*7cX(s2AvFa zB3QQzv^TIKrq-e?URQWu8Uy)^3D8g;vtHpiVTkA<_bHS`53dVL3zGNSa7MT1;aW@U zDCS~9S%DRr{Z+zfb%zERnQQaT2L65RNKkoVa?zqgpLY}Pr>m2;Hgrp=*;A@z(R+wQ zc5*H~d+5o?RE`Gl3yk1c^-(!$8FrQh$vwXdf87g#BeDl9ThjMX~w|1 zP9Q&J^*aO~fJLtj>mj_ndRuF1!OqFR%iJq`qf4Ty=|hIkMO&JaR5W+ZPnj3L%Jl;P E0G*>FyZ`_I literal 0 HcmV?d00001 diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..f81bcbc8b299786b4c6a46ee848ef39efcc048ea GIT binary patch literal 1531 zcmVTU%F3A=ljOhHlcizR zYCbn&iGSvLK6hFdG>oEg)c%81tk_0sLnRl=Nq0?+a7-#o-Ad4mDXnD23d)7ejI10+ za=8S`oCK`IW-GycAq+s7T~lrF0IUon@#rQU05efgmOEi6Hc%D9Nv&vQ#0LB}JS(N6 zOmQrv5X=fonRBA7TE;Dyy#=4~Qd%1jjrbELv(&4#(wB-_#|lBYuL|tPiOAqps=eZZ zQ|8@D8*IN5Nj>;)%rjv=b+Am>f)!?i^;Sk=4{}qJRLo#!6l)O1u4L7PfeXe9_?AWu zzyd`-m-N8F4>hVeThkWiS=5#V!=f6FCUx@7RhUl)-V|43t)7> zh5@$z1%gyGFXVFN+J}&IrLBmT;57HxP^UGwo@7~&iVdD@{3otf43QJueJSA+@9+rP zqjB=3HtRn4B{nK)rOlpAgZOr$>VoE2S%t8rL9<0z={B-p2c;bhv(am8;K*}f!Fi!C zk5Lchmql%DBd@V9xPl>Sam3M4eQjV_sn-2?U`~LygQ=6H2o`SKcvI$0wuy`A$79(B z=88#c=Zj!)wQQm`*Jc@lG={A8Rq)X*LEDSZ2we%aBlaP zAOr|ZT1v%g=WtDfAdqnp+aXani8nBE<+mZ1pnyc8r({Y}p|VxU%-Y^k29Aoz@lxv{ zG)z7G*KQpqhnl$L5N^HmCL!BZSiQJ{_~^E<++mO|EJox$h^Th3MXHa{U1yFqU@GQ9 zHQ5Gt>D|5h>PB2Io?34pI`~6w(V$QyM1DGAwKW;}-Mw z;AsWXumw{eMok~CHrJrF$yQ7)#bGjQ({}A5v7fF6klmVm8tBk-p`lo=_WG_}f7PGs zICUTUD?^W^ZnjtnTF!1cC#JpF7+*4@V8uDfr6{;1iwYEMDHc!3!ZGA;hhJbM3obS? z6AJ>55{2qyh(WuP5HcFQCRvcIok*3Vh(cr&YqCk59%4Njr?NA~Hh%zxCMWOjCpTU)P&edkMNe@30iffaW1A-=p1 zG!&2{%-RPv=G)fRzr=@c`Mqe-)cE>|ASi&a`0itH{Yhw9Q7pJP{j?w_p_UPyZ{K=_ zY|ruPlxRiLYb+pV=(m!cZWddgV}2piQ?f*e#0*Aeq!|{N$s4Icih-1ZNEZYNJeP$a zAHC8Qv>pohr^ak2_B8lzHl97RaWZ-DRX`%>dR-Jb2@(S#1zSkJFyrX_#d&&`J4AHjyO4L?{8VSD(oZ#ZlJ!`~pThaLf2(wenSP;)#OhYC_Li<0^Z9a=!If61lN zrv6zXyZHEfHobU>Hl}d#HWART@UN+$D2>zc>?K;eyUvr}n!9h!-B;e+O~)56zoO=D zy5HPA@)gYeY3b1~VjfQ4|Au5B=YwxChVHfR{mlR2(w loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained; latest repaired-source run in browser-refresh.log.gz; original browser.log.gz retained as baseline evidence", - "service_tests": "1068 library +65 integration passed, zero failures/ignored", + "browser_user_workflow": "passed on combined source: actual styled NodeLab -> loopback -> rebuilt Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; exact pin retained; missing implementation blocking; failed catalog refresh/recovery preserves draft/path. Latest receipt browser-combined.log.gz", + "service_tests": "1084 library +65 integration passed, zero failures/ignored; both custom-node and pure-loop suites included", "frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included", "native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim", "not_claimed": [ @@ -21,11 +21,12 @@ "inference-backed/effectful tool loops", "release qualification" ], - "catalog_refresh": "failed refresh after successful load removes stale choices while preserving draft and saved path; focused red on baseline 802544a0, green after repair; same mounted real-browser flow passed" + "catalog_refresh": "focused red on 802544a0, green after repair; same mounted combined-source browser flow removes stale choices and preserves draft and saved path", + "hosted_test_discovery": "local exact CI commands discovered and passed 15 tool_loop tests and 10 workflow::tests::custom_nodes:: tests; original loop gate unchanged" }, "source_sha256": { "crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8", - "crates/pantograph-workflow-service/src/custom_nodes.rs": "1d413febbad16689c864e7945191c7bb83079530ece13fda73541178bd86f774", + "crates/pantograph-workflow-service/src/custom_nodes.rs": "93b016626ce454091fe6092807442184556f90b6f4ae65125af9c12a1e9c52aa", "crates/pantograph-workflow-service/src/custom_nodes/authoring.rs": "ab2e37ca4a68b846e8f23897891eea064730a1376c8597eb5ce00e1bf5f909ed", "crates/pantograph-workflow-service/src/graph/connection_insert.rs": "881afe99675e681a2bd1208f69e55f82afa881adec9ead9a6863089dd3a6f2af", "crates/pantograph-workflow-service/src/graph/connection_intent.rs": "d5832d05d88dd0d2dc10cb367038a0a6d5973e1275fe19e888fb411c13057199", @@ -37,8 +38,8 @@ "crates/pantograph-workflow-service/src/workflow.rs": "47e42061802abc91ebe6b3babf8c578116d2e0fecf51d86fa53148cbed613886", "crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs": "5bf54f01b2db4ec2ffc21b6af41291c695ff91a3b274deb80838c32020b9941d", "crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513", - "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f", - "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a", + "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "39ea2a0a726a4a93e6199f6cf72d22914db7d43e4edc8d750178acc5084c55ec", + "scripts/README.md": "a1b99bbc7b161a5842b49b4a97e08c8042121a2b034848c35c53e22e3dbd881c", "scripts/check-nodelab-browser.mjs": "82f884c2fb04394168c06ba09b0432345aad059e67bc54efdc281a759b68c88f", "src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50", "src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c", @@ -47,12 +48,31 @@ "src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e", "src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075", "src/services/workflow/customNodeAuthoring.test.ts": "97aa21f8487984832d03efac4f81d694b214ed065087099aca4a9b87433083dd", - "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08" + "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08", + ".github/workflows/quality-gates.yml": "030e50d32426e0abaa17990e683e67a29b9cdb704a2a146908c78817e5369d2f", + "crates/pantograph-workflow-service/src/graph/registry.rs": "ad7cfd660e85e264a0c8f9e2ddd78fa3d4fd3074ee2275a13ee33c3f998434c7", + "crates/pantograph-workflow-service/src/tool_loops.rs": "3dee015aefb679de08564a02ed4175d902c9836c520c1b0de072cc5a036aaa09", + "crates/pantograph-workflow-service/src/scheduler/tool_loop.rs": "3af80db2ce8f4a621e2e1778e1c4dede603068fc12457c8a550d752d9a0bf362" }, "evidence_sha256": { "browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e", "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3", "catalog-refresh-red.log.gz": "c849e1ead56e2b00b0f7fc3c7e04a4cf867a99fe176476aa9b0d9658613bf720", - "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2" + "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2", + "browser-combined.log.gz": "a8ef7d653ecfa27ad28d01cf055ee112c4c83b03ee1d3b251e25671b37e7d598", + "service-combined.log.gz": "6cd931f20b55b2fc5bfd846b74e7ac0dfbcd4d61e4d175da0228192ec7374029", + "ci-discovery.log.gz": "272c0cad07fac982061e125acd110df89918e2147a478a2f30b9051fed19223a" + }, + "integrated_dependency": { + "pull_request": 94, + "head": "fcb2d7f618f32000ed1992569d7eabf3c77cf873", + "merge": "46aad5841ca8f2185e639da44f73474af045bc13", + "production_loop_core": "unchanged from merged main; NodeLab consumes the same immutable registry without authoring loop grants" + }, + "historical_evidence": { + "browser.log.gz": "802544a0ed5f523d2ce7a54f7859b8ed0187e63f; initial styled flow", + "browser-refresh.log.gz": "ff4e4de05d884f05338e70745db2400df0134926; refresh repair", + "catalog-refresh-red.log.gz": "regression failed on 802544a0 before repair", + "native-unavailable.log.gz": "original missing-GLib failure; retained unchanged" } } diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..ca5b09ba36c795fed8ef60905417d7326d6131d4 GIT binary patch literal 20631 zcmV(sK<&RDiwFP!000021I>L|k1NS?-uwKD1`Jqt0L^md%r0Ai0YQKP8xUl@2{Nb1 zN|JTESVh&+efnBI-Wh8~t*n(-7tm;iO=jd085!#r?@^H@+2!|_bK=steEH)ZonE7h z`{Qq4{-~nj;wt}dQ5L`a5xckKzkfLuQRT|-lI-jX zmz~^qWpVnhbg4U4ELowxU+b(cUF`q#^-G?;96$U7&QYCKUw!p1eDjB%x&M8d z9KU|KxXi(Hc-7{Kdup#@qI% z@@qJ|Pv3t3xx^p8{BxbbNH2AkRPcL$rk=$-LC;PvfBNn{FJ4Q3lK zPSNS=zM~Ppi`}ul_Q}F-ockJWfvbUtjvzyB*`QG8xeY|}8?YA%a>nbXGEIRPbRZ$#O(cwL*u6gZ$ zTxWjUo&V~+uBOwx!QD92rN0{G0X}*y51Ie0Ja{>ADDu>mTPz^+Uksnawv>lFJJi{0 zmcM62aSJ+ke>V>-1eBV;QgA|U3j&3!@DH-x*CeYpnYkVC@D`=@x()Gz-lEjs!ty{{ z?EfPw*Ny4L2b@~^VfeAZc891uT%#hE-@;W`K+u2SveG{;o(W@5_x0_7m;Z3i3oqWX zj20Z;La+kKGxIHM>^u? zX(xKf3GT~1O-@P0E1Wy_&E0apA^ty-n}1+DjX(RjmxsT&BIm(M&%g5rN=lV0?^g%H z1qXr&CuOyKVqs2)DzBpSkbAueo}2kY;{Jv4N}A~1AK|1ML&pr?_ty}PY)w zy>v?A3FF7Nq=ZYRJ6LCBeTO^iKuyVzx9FisG91*iPx*M@Q{*L2nns|19^xXu;{%x{ zmuq!6*Hv9Odc2rw>Cei4;uVeCy?Q*la4;PbOcNe-81R@^*TXe0D;&%Z3lo?--=p&~ z2IS>iexgrM|Mv1HO-t6R^J2=NX$q40;5onxEtsFzgp!lL`kp^O?jQvGXL0U-R~_P{ zJf(T*4*D}t<0ouJm?nsMkRi0;yw4(7{Qr6RKWQ)gY2+6@oVW%d0!)ZITqkLj_$`TU zsP&xEItHe@`xEqAj*m}+2I_sd!?|BOJa{-+yY=!17t}-27ULWg6TdmvkGPhf zS9+@;x&4IsGcOpDya2+wViFa)V3I`iUUXj6Vd^QUDrNPN!q$0Nf|tE)e9k7u!Th?c z3s1>`%mUGTs=WmB--nId>}XMv7H>vh3uj8&Ej{!g7~&-Iucw#Hr*hJLKslw?h#6ll zagmo^urn7N^E!+Dqd##wWnK9Vq_Fvd+!cQxJlFVLIHq*P>0wtFuP8RMSCH_%K z(;K`1F1q1e@Gl@a0%-kfR~Vg6?(SD8%;2~DR3vvV2ub_2=QoE#A8siM@e{6&%yCd* z&G&%VTw3}UzppXheb0tw3-09>dVJ4^x&?1lkCBy0FxYsitV z9P*bbjr_ZC{!kUqS_}Re3dTvAytzY~=dbmBTgqY$&=JymfJ+{{a06bQvOdCwDcP

gQo|i z;6b&Pre4(5)R~SupFu*tIlB|0E$iP4-YXHg_<0vglr3^J;ra_C_AiZ@t<6nJ;wR<>rs*$&eM2CRT1o`d;L;)~NQc zS<8wPqJz&jnf;^$=J(ti$REZB*ZCVbVM=nNz~ypZz)pr>L{fUS>_dYC+bs_@yTF=s zD^w>d^7|d%C(@=jTW0(H!&x2OHG1;~t=G?rvg3f6ObO+nAJi4zJoazR$&09qc;cFb z_)l9hiwJ+&P-EV(FHZeqePl2GCAlANb=4A=UQkp_j=n-h+$m2}&w|!LY+^j_y+}X{ zWpg!gb4b=1{efwc$p{{FyoQx4GB2P5F)@jU#dO#S{5h$;AbW#f@qSM~&?W8vJ2;XY zg@s|i{bju8Wt470K+FN)z|>r3V*+it4)fH=GWwnBYw6T!mE8S;@;d~0hc436;YH`> z(EX+(2Pg8@bH^x$iEQ{wcutDjpr-wpf@MpcZJ@NCfHKLJJFkNV@8v4Kr^nICjqs6I z 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 CXg`_& literal 0 HcmV?d00001 diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md index c01ec945f..074037ad1 100644 --- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md +++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md @@ -920,8 +920,45 @@ No backend, scheduler or audit behavior changed. This repair remains local until the required PR94 integration/current-main qualification. Hosted PR95 source/build/native-IPC checks passed on `802544a0`; the required -audit failed with an actual advisory-bulk HTTP 503. PR94 likewise remains blocked -on audit service failures. No audit retries or gate changes were requested here. +audit failed with an actual advisory-bulk HTTP 503. PR94 was likewise blocked +on audit service failures at that stage. No audit retries or gate changes were requested here. Local native provisioning inspection stopped when `apt-cache policy` was denied reading `/etc/apt/apt.conf.d/80-applied-apt-retries`; no package installation or privileged retry was attempted. This does not qualify native startup/interaction. + + +## 2026-10-22 — Serial CN-02/CN-03 reconciliation + +PR94 passed its required exact-head checks and merged as `46aad584`. A normal +merge (`0acae31f`) incorporates that current main into the retained NodeLab branch; +only plan/issue conflicts required resolution, preserving both admitted slices. +CN-03 production registry/loop/scheduler code remains unchanged. The NodeLab +[handoff](../../custom-nodes.md#nodelab-graph-authoring) distinguishes individual +CN-01 authoring from future host-pinned loop-template authoring: no UI tool grants, +optional loop ports or second execution route are introduced. + +The combined edit-session regression saves/reopens an exact loop pin, accepts its +admitted catalog, preserves it with blocking diagnostics under changed/missing +catalogs, and refuses invalid new pins without snapshot/revision mutation or +callback invocation. Hosted coverage now explicitly discovers/runs all ten +custom-node workflow tests. The new regression is named outside the existing +`tool_loop` substring filter; that original fifteen-test discovery/execution gate +is unchanged. Local exact commands discover and pass both 15 and 10 tests. + +The [combined qualification](evidence/nodelab-authoring/qualification.json) binds +28 source files and retains seven receipts, including fresh 1,084 library +65 +integration tests (1,149 total, zero failed/ignored), both CI discovery/run filters +and styled Chromium → rebuilt Rust host → filesystem interaction. All eight +browser claims pass, including failed catalog refresh/recovery without losing +saved draft/path. The final frontend suite passes 740 tests; formatting, +typecheck, full/critical/a11y lint, production frontend build, scheduler-only guard +and 28 traceability tests pass. Original browser, refresh-red and missing-GLib +receipts remain preserved. Final package all-target Clippy passes with only the +existing inference dead-field warning. Local native startup/interaction remains unqualified; +the recorded APT read denial was not retried or bypassed. + +At this qualification stage, PR94 post-merge workflows and PR95 final independent +review/fresh exact-head CI/current-main rechecks still gate normal integration. +No old-head audit retry, dependency suppression, lockfile change, text-readiness +edit or branch deletion was performed. PR92's unique trained-text source/evidence +and missing T3 gate remain separate. diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md index 782bb67f8..96591c6cd 100644 --- a/docs/plans/domain-architecture-and-multimodal/plan.md +++ b/docs/plans/domain-architecture-and-multimodal/plan.md @@ -360,6 +360,20 @@ transfers exact session IDs to an app-scoped, user-retryable cleanup owner. Stan continues, adding routed Frontend, TypeScript/Async, Accessibility, IPC, Persistence, protocol/evolution and GUI verification detail to the CN-01 routes. +**Serial reconciliation (2026-10-22):** CN-03 PR94 is integrated as `46aad584`. +Continue CN-02 on that current-main base with the same production contract. +Preserve both admitted slices and CN-03 source unchanged. Add one edit-session +integration regression for a saved/reopened exact loop pin: its admitted catalog +accepts it, changed/missing catalogs retain it with blocking diagnostics, and an +invalid new pin is rejected without mutation. This tests the shared-registry +handoff rather than adding loop authoring or execution. Rerun both service suites, +frontend/static gates and the real-host browser flow; bind fresh combined-source +evidence, obtain independent review, then pass fresh exact-head CI and current-main +rechecks. PR94 post-merge CI must also pass before PR95 merges normally. +Add explicit hosted discovery/execution of all ten custom-node service tests; +keep CN-03's existing fifteen-test loop gate unchanged and name the new +edit-session regression outside that substring filter. + ## Admitted CN-03 pure tool-loop slice (2026-10-22) **Authority and status:** The user authorized unfinished Pantograph paths and