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/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 218a0a4db..28449f848 100644 --- a/crates/pantograph-workflow-service/src/custom_nodes.rs +++ b/crates/pantograph-workflow-service/src/custom_nodes.rs @@ -409,3 +409,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..76584515d 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,362 @@ 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"); +} + +#[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 5bc44d073..20ce7362f 100644 --- a/docs/custom-nodes.md +++ b/docs/custom-nodes.md @@ -82,13 +82,47 @@ retains the `custom_node` non-runtime template and adds `tool_loop`; identity ad 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. The [scheduler-managed pure tool-loop adapter](tool-loops.md) consumes this exact admission and invocation identity plus separate host tool grants. It @@ -98,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 000000000..a137fef0c Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz differ 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 000000000..391ed64af Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz differ 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 000000000..07f9f0f90 Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz differ diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/catalog-refresh-red.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/catalog-refresh-red.log.gz new file mode 100644 index 000000000..2629229d6 Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/catalog-refresh-red.log.gz differ 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 000000000..f81bcbc8b Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz differ 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 000000000..3080c4fbb Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz differ 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..51fb69cfd --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json @@ -0,0 +1,78 @@ +{ + "base": "46aad5841ca8f2185e639da44f73474af045bc13", + "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", + "application_styles": true + }, + "claims": { + "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": [ + "installed custom nodes in default desktop", + "arbitrary code loading", + "trained model T3", + "GPU", + "inference-backed/effectful tool loops", + "release qualification" + ], + "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": "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", + "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": "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", + "src/components/workbench/NodeLabPage.svelte": "ab23063b86e78e3dc919602df48d027f0e736bee678b8e5609eedc41d0da25d2", + "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": "97aa21f8487984832d03efac4f81d694b214ed065087099aca4a9b87433083dd", + "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-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 000000000..ca5b09ba3 Binary files /dev/null and b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz differ diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md index 8f10f1359..074037ad1 100644 --- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md +++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md @@ -872,3 +872,93 @@ 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. +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 +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. + +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 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/issues.md b/docs/plans/domain-architecture-and-multimodal/issues.md index ca920fe4b..045fe03c2 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-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 explicit authority and bounded progress. | Tool-loop owner | Verifying the [pure-controller slice](../../tool-loops.md): exact CN-01 identities plus separate host pure-tool grants, bounded serial turns, scheduler attempt/cancellation/commit ownership and saved/reopened execution. Independent review and exact-head CI gate serial CN-02 integration. Inference-backed turns and side effects remain deferred pending runtime resource admission and effect/idempotency contracts. No static loop restoration. | diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md index e3132e37a..96591c6cd 100644 --- a/docs/plans/domain-architecture-and-multimodal/plan.md +++ b/docs/plans/domain-architecture-and-multimodal/plan.md @@ -302,6 +302,78 @@ 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. + +**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 diff --git a/scripts/README.md b/scripts/README.md index 0e6934c00..1a15f7897 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -258,3 +258,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..55ae40d26 --- /dev/null +++ b/scripts/check-nodelab-browser.mjs @@ -0,0 +1,193 @@ +/* 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 = []; +let failRealCatalog = false; +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 ${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); + 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' || (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; + } + 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' : 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) })); } + }); + 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' && 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'); + 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'); + 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', '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(); }); } + 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..438b248d4 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, index (index)} +
  • {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..575872ffb --- /dev/null +++ b/src/components/workbench/nodeLabController.ts @@ -0,0 +1,90 @@ +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 = null; + this.publish(); + 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..30a1f1ace --- /dev/null +++ b/src/services/workflow/customNodeAuthoring.test.ts @@ -0,0 +1,150 @@ +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 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 (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); + mode = 'full'; await controller.loadCatalog(); assert.equal(controller.view.catalog?.length, 1); + const selected = parseCustomNodeCatalog(catalog())[0]; + 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']); +}); + +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();