diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fd00d2b043..ef6a3eebf8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,9 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - target/ - key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }} + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}- cargo-${{ runner.os }}- - name: Run CI Pipeline diff --git a/core/daglang/daglang-cli/src/compile/render.rs b/core/daglang/daglang-cli/src/compile/render.rs index c6a7a8da501..90a09c03002 100644 --- a/core/daglang/daglang-cli/src/compile/render.rs +++ b/core/daglang/daglang-cli/src/compile/render.rs @@ -19,6 +19,11 @@ pub fn render_expand(dag: &Dag) -> String { }) => { format!("callable::{kind:?} {module}.{name}") } + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Primitive { + module, name, kind, .. + }) => { + format!("primitive::{kind:?} {module}.{name}") + } gunbc_ir::node::NodeBody::Opaque(LoweredOp::Collection { module, callable, diff --git a/core/daglang/daglang-cli/src/compile/tests.rs b/core/daglang/daglang-cli/src/compile/tests.rs index 93c0a04b0ce..1041e75a162 100644 --- a/core/daglang/daglang-cli/src/compile/tests.rs +++ b/core/daglang/daglang-cli/src/compile/tests.rs @@ -466,7 +466,7 @@ fn resolve_lowered_dag_maps_makegen_nodes_to_dyn_ops() { } #[test] -fn resolve_lowered_dag_defers_unknown_callable_module() { +fn resolve_lowered_dag_unknown_callable_module_fails_closed() { let mut dag = Dag::new(); dag.add_node(Node::opaque( "sample::unknown", @@ -483,23 +483,10 @@ fn resolve_lowered_dag_defers_unknown_callable_module() { }, )); - let resolved = resolve_lowered_dag(&dag).expect("unknown modules should defer"); - assert_eq!(resolved.nodes.len(), 1); - let debug = format!("{:?}", resolved.nodes[0].body); - assert!( - debug.contains("DeferredCallableOp"), - "expected deferred callable fallback, got {debug}" - ); - // Deferred callables are passthrough: inputs forwarded, output ports populated. - let NodeBody::Opaque(op) = &resolved.nodes[0].body else { - panic!("unknown callable fixture should not contain subdag nodes") - }; - let outputs = op - .execute(HashMap::new()) - .expect("deferred callable should pass through"); + let error = resolve_lowered_dag(&dag).expect_err("unknown modules should fail closed"); assert!( - outputs.contains_key("out"), - "deferred callable should populate declared output ports" + error.to_string().contains("unknown callable"), + "unexpected error: {error}" ); } diff --git a/core/daglang/daglang-cli/tests/snapshots/makegen_expand.txt b/core/daglang/daglang-cli/tests/snapshots/makegen_expand.txt index e4a48f34374..8576aa7f2b6 100644 --- a/core/daglang/daglang-cli/tests/snapshots/makegen_expand.txt +++ b/core/daglang/daglang-cli/tests/snapshots/makegen_expand.txt @@ -12,40 +12,40 @@ Nodes: * __deps: Any (0..*) outputs: * written: Bool (1) - - prepare_read_makegen [callable::Pattern tools.makegen.content_upsert::prepare_read_makegen] + - prepare_read_makegen [primitive::IoPrepareFileRead tools.makegen.content_upsert::prepare_read_makegen] inputs: * path: String (1) * res:file:Makefile: FilesystemHandle (1) outputs: * request: TransportRequest (1) * skip: Bool (1) - - execute_read_makegen [callable::Pattern tools.makegen.content_upsert::execute_read_makegen] + - execute_read_makegen [primitive::IoExecuteFileRead tools.makegen.content_upsert::execute_read_makegen] inputs: * request: TransportRequest (1) * skip: Bool (1) outputs: * response: TransportResponse (1) - - compare_makegen_content [callable::Pattern tools.makegen.content_upsert::compare_makegen_content] + - compare_makegen_content [primitive::CompareEquality tools.makegen.content_upsert::compare_makegen_content] inputs: * expected_content: String (1) * response: TransportResponse (1) outputs: * fresh: Bool (1) * skip: Bool (1) - - prepare_write_makegen [callable::Pattern tools.makegen.content_upsert::prepare_write_makegen] + - prepare_write_makegen [primitive::IoPrepareFileWrite tools.makegen.content_upsert::prepare_write_makegen] inputs: * content: String (1) * path: String (1) outputs: * request: TransportRequest (1) - - execute_makegen_transport [callable::Pattern tools.makegen.content_upsert::execute_makegen_transport] + - execute_makegen_transport [primitive::IoExecuteFileWrite tools.makegen.content_upsert::execute_makegen_transport] inputs: * request: TransportRequest (1) * skip: Bool (1) * res:file: FilesystemHandle (1) outputs: * response: TransportResponse (1) - - param_source_tools_makegen_makegen_path [callable::Pattern tools.makegen.call_param_source::makegen::path] + - param_source_tools_makegen_makegen_path [primitive::CallParamSource { callable: "makegen", param: "path" } tools.makegen.call_param_source::makegen::path] inputs: * path: String (1) outputs: @@ -53,7 +53,7 @@ Nodes: - load_registry [callable::Pattern tools.makegen.load_registry] outputs: * registry: ToolRegistry (1) - - fs_env [callable::Pattern tools.makegen.fs_env] + - fs_env [primitive::FsEnv tools.makegen.fs_env] outputs: * FilesystemHandle: FilesystemHandle (1) Edges: diff --git a/core/daglang/daglang-cli/tests/snapshots/makegen_manifest.json b/core/daglang/daglang-cli/tests/snapshots/makegen_manifest.json index 0d50ed83145..fd9a2f29c15 100644 --- a/core/daglang/daglang-cli/tests/snapshots/makegen_manifest.json +++ b/core/daglang/daglang-cli/tests/snapshots/makegen_manifest.json @@ -1 +1 @@ -{"progress_manifest":{"capture_modes":{"compare_makegen_content":"captured","execute_makegen_transport":"captured","execute_read_makegen":"captured","fs_env":"captured","load_registry":"captured","param_source_tools_makegen_makegen_path":"captured","prepare_read_makegen":"captured","prepare_write_makegen":"captured","tools.makegen::makegen":"captured","tools.makegen::render_makefile":"captured"},"interactive_nodes":[],"labels":{"compare_makegen_content":"tools.makegen.content_upsert::compare_makegen_content","execute_makegen_transport":"tools.makegen.content_upsert::execute_makegen_transport","execute_read_makegen":"tools.makegen.content_upsert::execute_read_makegen","fs_env":"tools.makegen.fs_env","load_registry":"tools.makegen.load_registry","param_source_tools_makegen_makegen_path":"tools.makegen.call_param_source::makegen::path","prepare_read_makegen":"tools.makegen.content_upsert::prepare_read_makegen","prepare_write_makegen":"tools.makegen.content_upsert::prepare_write_makegen","tools.makegen::makegen":"tools.makegen.makegen","tools.makegen::render_makefile":"tools.makegen.render_makefile"},"parallel_groups":[{"depth":0,"nodes":["fs_env","load_registry","param_source_tools_makegen_makegen_path"]},{"depth":1,"nodes":["prepare_read_makegen","tools.makegen::render_makefile"]},{"depth":2,"nodes":["execute_read_makegen","prepare_write_makegen"]},{"depth":3,"nodes":["compare_makegen_content"]},{"depth":4,"nodes":["execute_makegen_transport"]},{"depth":5,"nodes":["tools.makegen::makegen"]}],"resources":{},"scatter_points":[],"schema_version":1,"stage_groups":[],"subdag_boundaries":[],"topology":[{"depth":0,"id":"fs_env"},{"depth":0,"id":"load_registry"},{"depth":0,"id":"param_source_tools_makegen_makegen_path"},{"depth":1,"id":"prepare_read_makegen"},{"depth":1,"id":"tools.makegen::render_makefile"},{"depth":2,"id":"execute_read_makegen"},{"depth":2,"id":"prepare_write_makegen"},{"depth":3,"id":"compare_makegen_content"},{"depth":4,"id":"execute_makegen_transport"},{"depth":5,"id":"tools.makegen::makegen"}],"total_nodes":10},"test_obligations":{"dry_run_completion_required":true,"interface_contract_verification_targets":0,"pure_node_determinism_targets":8,"resource_acquire_targets":0,"resource_provide_targets":0,"resource_release_targets":0,"service_param_source_targets":1,"service_transport_execute_targets":0,"service_transport_external_targets":0,"service_transport_hermetic_targets":0,"service_transport_idempotent_targets":0,"service_transport_parse_targets":0,"service_transport_permission_scoped_targets":0,"service_transport_prepare_targets":0,"service_transport_readonly_targets":0,"total_obligations":10,"transport_execution_targets":2}} +{"progress_manifest":{"capture_modes":{"compare_makegen_content":"captured","execute_makegen_transport":"captured","execute_read_makegen":"captured","fs_env":"captured","load_registry":"captured","param_source_tools_makegen_makegen_path":"captured","prepare_read_makegen":"captured","prepare_write_makegen":"captured","tools.makegen::makegen":"captured","tools.makegen::render_makefile":"captured"},"interactive_nodes":[],"labels":{"compare_makegen_content":"tools.makegen.content_upsert::compare_makegen_content","execute_makegen_transport":"tools.makegen.content_upsert::execute_makegen_transport","execute_read_makegen":"tools.makegen.content_upsert::execute_read_makegen","fs_env":"tools.makegen.fs_env","load_registry":"tools.makegen.load_registry","param_source_tools_makegen_makegen_path":"tools.makegen.call_param_source::makegen::path","prepare_read_makegen":"tools.makegen.content_upsert::prepare_read_makegen","prepare_write_makegen":"tools.makegen.content_upsert::prepare_write_makegen","tools.makegen::makegen":"tools.makegen.makegen","tools.makegen::render_makefile":"tools.makegen.render_makefile"},"parallel_groups":[{"depth":0,"nodes":["fs_env","load_registry","param_source_tools_makegen_makegen_path"]},{"depth":1,"nodes":["prepare_read_makegen","tools.makegen::render_makefile"]},{"depth":2,"nodes":["execute_read_makegen","prepare_write_makegen"]},{"depth":3,"nodes":["compare_makegen_content"]},{"depth":4,"nodes":["execute_makegen_transport"]},{"depth":5,"nodes":["tools.makegen::makegen"]}],"resources":{},"scatter_points":[],"schema_version":1,"stage_groups":[],"subdag_boundaries":[],"topology":[{"depth":0,"id":"fs_env"},{"depth":0,"id":"load_registry"},{"depth":0,"id":"param_source_tools_makegen_makegen_path"},{"depth":1,"id":"prepare_read_makegen"},{"depth":1,"id":"tools.makegen::render_makefile"},{"depth":2,"id":"execute_read_makegen"},{"depth":2,"id":"prepare_write_makegen"},{"depth":3,"id":"compare_makegen_content"},{"depth":4,"id":"execute_makegen_transport"},{"depth":5,"id":"tools.makegen::makegen"}],"total_nodes":10},"test_obligations":{"dry_run_completion_required":true,"interface_contract_verification_targets":1,"pure_node_determinism_targets":8,"resource_acquire_targets":0,"resource_provide_targets":1,"resource_release_targets":0,"service_param_source_targets":1,"service_transport_execute_targets":2,"service_transport_external_targets":2,"service_transport_hermetic_targets":0,"service_transport_idempotent_targets":0,"service_transport_parse_targets":0,"service_transport_permission_scoped_targets":0,"service_transport_prepare_targets":2,"service_transport_readonly_targets":0,"total_obligations":10,"transport_execution_targets":2}} diff --git a/core/daglang/daglang-cli/tests/workflow_fixtures/s1_makegen.json b/core/daglang/daglang-cli/tests/workflow_fixtures/s1_makegen.json index 969521b4e4d..e235c0d317d 100644 --- a/core/daglang/daglang-cli/tests/workflow_fixtures/s1_makegen.json +++ b/core/daglang/daglang-cli/tests/workflow_fixtures/s1_makegen.json @@ -16,19 +16,19 @@ "error_contains": null, "expected_json": { "dry_run_completion_required": true, - "interface_contract_verification_targets": 0, + "interface_contract_verification_targets": 1, "pure_node_determinism_targets": 8, "resource_acquire_targets": 0, - "resource_provide_targets": 0, + "resource_provide_targets": 1, "resource_release_targets": 0, "service_param_source_targets": 1, - "service_transport_execute_targets": 0, - "service_transport_external_targets": 0, + "service_transport_execute_targets": 2, + "service_transport_external_targets": 2, "service_transport_hermetic_targets": 0, "service_transport_idempotent_targets": 0, "service_transport_parse_targets": 0, "service_transport_permission_scoped_targets": 0, - "service_transport_prepare_targets": 0, + "service_transport_prepare_targets": 2, "service_transport_readonly_targets": 0, "total_obligations": 10, "transport_execution_targets": 2 diff --git a/core/daglang/daglang-derive/src/lib.rs b/core/daglang/daglang-derive/src/lib.rs index 936607e717e..1def498f39b 100644 --- a/core/daglang/daglang-derive/src/lib.rs +++ b/core/daglang/daglang-derive/src/lib.rs @@ -459,6 +459,9 @@ fn derive_node_labels(nodes: &[Node]) -> BTreeMap { gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { module, name, .. }) => { format!("{module}.{name}") } + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Primitive { module, name, .. }) => { + format!("{module}.{name}") + } gunbc_ir::node::NodeBody::Opaque(LoweredOp::Collection { module, callable, @@ -519,9 +522,7 @@ fn derive_interactive_nodes(nodes: &[Node]) -> Vec { Some(LoweredOp::Callable { is_interactive: true, .. - }) => { - Some(node.id.0.clone()) - } + }) => Some(node.id.0.clone()), _ => None, }) .collect::>(); @@ -579,9 +580,9 @@ fn derive_module_metadata(nodes: &[Node]) -> Vec { continue; }; let (module, is_pipeline) = match op { - LoweredOp::Callable { module, .. } | LoweredOp::Collection { module, .. } => { - (module, false) - } + LoweredOp::Callable { module, .. } + | LoweredOp::Primitive { module, .. } + | LoweredOp::Collection { module, .. } => (module, false), LoweredOp::Pipeline { module, .. } => (module, true), }; let entry = by_module @@ -1235,7 +1236,11 @@ mod tests { Some(CaptureMode::Passthrough) ); assert_eq!( - artifacts.manifest.capture_modes.get("transport_node").cloned(), + artifacts + .manifest + .capture_modes + .get("transport_node") + .cloned(), Some(CaptureMode::Captured) ); } diff --git a/core/daglang/daglang-emit/src/computation.rs b/core/daglang/daglang-emit/src/computation.rs index b4bb5d14fc2..1eea6c37932 100644 --- a/core/daglang/daglang-emit/src/computation.rs +++ b/core/daglang/daglang-emit/src/computation.rs @@ -90,6 +90,9 @@ pub enum PureBody { /// Hardcoded value (e.g., LoadRegistry, FsEnv configuration). Literal(serde_json::Value), + /// Build a transport request payload from step inputs. + PrepareTransport { kind: TransportKind }, + /// String interpolation: fill variables into a pattern. /// `pattern` uses `{var}` placeholders; `vars` lists the names to substitute. Template { pattern: String, vars: Vec }, @@ -265,7 +268,9 @@ pub enum CollectionOpKind { // Classification (A1.3) // =========================================================================== -use daglang_lower::{LoweredOp, ObligationCategory, ServiceTransportClass}; +use daglang_lower::{ + LoweredOp, ObligationCategory, PrimitiveLiteral, PrimitiveOpKind, ServiceTransportClass, +}; use gunbc_ir::node::{Node, NodeBody}; use gunbc_ir::Port; @@ -316,6 +321,9 @@ pub fn classify_computation(node: &Node) -> Result { + classify_primitive(module, name, kind, inputs, outputs) + } LoweredOp::Callable { module, name, @@ -333,6 +341,94 @@ pub fn classify_computation(node: &Node) -> Result, + outputs: Vec, +) -> Result { + match kind { + PrimitiveOpKind::FsEnv => { + let handle_type = outputs + .first() + .map(|p| p.abstract_type.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + Ok(Computation::ResourceAcquire { + handle_type, + handle_value: name.to_string(), + }) + } + PrimitiveOpKind::CallLiteralSource { literal } => Ok(Computation::Pure { + inputs, + outputs, + body: PureBody::Literal(primitive_literal_to_json(literal)), + }), + PrimitiveOpKind::IoPrepareFileRead => Ok(Computation::Pure { + inputs, + outputs, + body: PureBody::PrepareTransport { + kind: TransportKind::FileRead, + }, + }), + PrimitiveOpKind::IoExecuteFileRead => Ok(Computation::Transport { + prepare: RequestSpec { + input_ports: inputs.iter().map(|p| p.name.clone()).collect(), + kind: RequestKind::FilePath { + path_port: "request".to_string(), + }, + }, + execute: TransportKind::FileRead, + parse: ResponseSpec { + output_ports: outputs.iter().map(|p| p.name.clone()).collect(), + kind: ResponseKind::RawContent, + }, + }), + PrimitiveOpKind::CompareEquality => Ok(Computation::Pure { + inputs, + outputs, + body: PureBody::Compare { + left: "expected_content".to_string(), + right: "response".to_string(), + }, + }), + PrimitiveOpKind::IoPrepareFileWrite => Ok(Computation::Pure { + inputs, + outputs, + body: PureBody::PrepareTransport { + kind: TransportKind::FileWrite, + }, + }), + PrimitiveOpKind::IoExecuteFileWrite => Ok(Computation::Transport { + prepare: RequestSpec { + input_ports: inputs.iter().map(|p| p.name.clone()).collect(), + kind: RequestKind::FilePath { + path_port: "request".to_string(), + }, + }, + execute: TransportKind::FileWrite, + parse: ResponseSpec { + output_ports: outputs.iter().map(|p| p.name.clone()).collect(), + kind: ResponseKind::ExitStatus, + }, + }), + PrimitiveOpKind::CallParamSource { .. } => Ok(Computation::Pure { + inputs, + outputs, + body: PureBody::Literal(serde_json::Value::Null), + }), + } +} + +fn primitive_literal_to_json(literal: &PrimitiveLiteral) -> serde_json::Value { + match literal { + PrimitiveLiteral::String(value) => serde_json::Value::String(value.clone()), + PrimitiveLiteral::Int(value) => serde_json::Value::Number((*value).into()), + PrimitiveLiteral::Bool(value) => serde_json::Value::Bool(*value), + PrimitiveLiteral::Unit => serde_json::Value::Null, + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -610,7 +706,9 @@ fn classify_content_upsert( return Ok(Computation::Pure { inputs, outputs, - body: PureBody::Literal(serde_json::Value::String("prepare_file_read".to_string())), + body: PureBody::PrepareTransport { + kind: TransportKind::FileRead, + }, }); } @@ -652,7 +750,9 @@ fn classify_content_upsert( return Ok(Computation::Pure { inputs, outputs, - body: PureBody::Literal(serde_json::Value::String("prepare_file_write".to_string())), + body: PureBody::PrepareTransport { + kind: TransportKind::FileWrite, + }, }); } @@ -785,11 +885,13 @@ mod tests { matches!( comp, Computation::Pure { - body: PureBody::Literal(_), + body: PureBody::PrepareTransport { + kind: TransportKind::FileRead + }, .. } ), - "prepare_read should be Pure(Literal), got {comp:?}" + "prepare_read should prepare file-read transport request, got {comp:?}" ); } @@ -875,11 +977,13 @@ mod tests { matches!( comp, Computation::Pure { - body: PureBody::Literal(_), + body: PureBody::PrepareTransport { + kind: TransportKind::FileWrite + }, .. } ), - "prepare_write should be Pure(Literal), got {comp:?}" + "prepare_write should prepare file-write transport request, got {comp:?}" ); } diff --git a/core/daglang/daglang-emit/src/lib.rs b/core/daglang/daglang-emit/src/lib.rs index 90444a4b956..94b0ddad0bd 100644 --- a/core/daglang/daglang-emit/src/lib.rs +++ b/core/daglang/daglang-emit/src/lib.rs @@ -204,6 +204,11 @@ pub fn emit_rust_bundle( }; emitted_functions.push(rendered); } + LoweredOp::Primitive { module, name, .. } => { + callable_count += 1; + let fn_name = sanitize_identifier(&format!("{module}_{name}")); + emitted_functions.push(backend.emit_func(&fn_name)); + } LoweredOp::Collection { module, callable, @@ -480,6 +485,10 @@ fn collect_callable_symbols( callable_count += 1; symbols.push(sanitize_identifier(&format!("{module}_{name}"))); } + LoweredOp::Primitive { module, name, .. } => { + callable_count += 1; + symbols.push(sanitize_identifier(&format!("{module}_{name}"))); + } LoweredOp::Collection { module, callable, diff --git a/core/daglang/daglang-emit/src/lower_to_ir.rs b/core/daglang/daglang-emit/src/lower_to_ir.rs index 53932f2b8f6..3a76ec8c0d2 100644 --- a/core/daglang/daglang-emit/src/lower_to_ir.rs +++ b/core/daglang/daglang-emit/src/lower_to_ir.rs @@ -192,6 +192,16 @@ fn lower_pure_step( PureBody::Literal(value) => { assign_outputs(step_index, step, json_value_to_expr(value), output_vars) } + PureBody::PrepareTransport { kind } => assign_outputs( + step_index, + step, + Expr::call_with_obligation( + format!("prepare_{}", transport_kind_name(*kind)), + ordered_inputs.to_vec(), + CallObligation::ServiceTransportPrepare, + ), + output_vars, + ), PureBody::Template { pattern, vars } => { let args = vars .iter() diff --git a/core/daglang/daglang-emit/src/rust_exec_runtime.rs b/core/daglang/daglang-emit/src/rust_exec_runtime.rs index b496ab67923..3955ca02ca1 100644 --- a/core/daglang/daglang-emit/src/rust_exec_runtime.rs +++ b/core/daglang/daglang-emit/src/rust_exec_runtime.rs @@ -16,7 +16,7 @@ use std::collections::BTreeSet; use std::fmt::Write as _; use std::path::Path; -use daglang_lower::LoweredOp; +use daglang_lower::{LoweredOp, PrimitiveLiteral, PrimitiveOpKind}; use gunbc_ir::node::NodeBody; use gunbc_ir::Dag; use gunbc_ir::{Cardinality, WorkspaceLayout}; @@ -128,7 +128,6 @@ enum HandlerKind { CompareContent, ExecuteTransport, Collection, - DeferredCallable, } impl HandlerKind { @@ -150,7 +149,6 @@ impl HandlerKind { Self::CompareContent => "CompareContent", Self::ExecuteTransport => "ExecuteTransport", Self::Collection => "Collection", - Self::DeferredCallable => "DeferredCallable", } } } @@ -220,24 +218,44 @@ fn collect_handler_kinds(classified: &[ClassifiedNode]) -> BTreeSet fn classify_handler(op: &LoweredOp) -> Option { match op { LoweredOp::Collection { .. } => return Some(HandlerKind::Collection), - LoweredOp::Callable { name, .. } if name.starts_with("call_param_source::") => { - return Some(HandlerKind::ParamSource); - } - LoweredOp::Callable { name, .. } if name.starts_with("call_literal_source::") => { - return Some(HandlerKind::LiteralSource); - } + LoweredOp::Primitive { + kind: PrimitiveOpKind::CallParamSource { .. }, + .. + } => return Some(HandlerKind::ParamSource), + LoweredOp::Primitive { + kind: PrimitiveOpKind::CallLiteralSource { .. }, + .. + } => return Some(HandlerKind::LiteralSource), + LoweredOp::Primitive { + kind: PrimitiveOpKind::FsEnv, + .. + } => return Some(HandlerKind::FsEnv), + LoweredOp::Primitive { + kind: PrimitiveOpKind::IoPrepareFileRead, + .. + } => return Some(HandlerKind::PrepareReadContent), + LoweredOp::Primitive { + kind: PrimitiveOpKind::IoExecuteFileRead, + .. + } => return Some(HandlerKind::ExecuteReadContent), + LoweredOp::Primitive { + kind: PrimitiveOpKind::IoPrepareFileWrite, + .. + } => return Some(HandlerKind::PrepareWriteContent), + LoweredOp::Primitive { + kind: PrimitiveOpKind::CompareEquality, + .. + } => return Some(HandlerKind::CompareContent), + LoweredOp::Primitive { + kind: PrimitiveOpKind::IoExecuteFileWrite, + .. + } => return Some(HandlerKind::ExecuteTransport), LoweredOp::Pipeline { .. } => {} LoweredOp::Callable { module, name, .. } if module == "tools.makegen" => { return match name.as_str() { "load_registry" => Some(HandlerKind::LoadRegistry), - "fs_env" => Some(HandlerKind::FsEnv), "render_makefile" => Some(HandlerKind::RenderMakefile), "makegen" => Some(HandlerKind::Entrypoint), - "content_upsert::prepare_read_makegen" => Some(HandlerKind::PrepareReadContent), - "content_upsert::execute_read_makegen" => Some(HandlerKind::ExecuteReadContent), - "content_upsert::prepare_write_makegen" => Some(HandlerKind::PrepareWriteContent), - "content_upsert::compare_makegen_content" => Some(HandlerKind::CompareContent), - "content_upsert::execute_makegen_transport" => Some(HandlerKind::ExecuteTransport), _ => None, }; } @@ -257,22 +275,6 @@ fn classify_handler(op: &LoweredOp) -> Option { } ("tools.pragma", "render_pragma_lint_policy") => Some(HandlerKind::RenderPragmaLintPolicy), ("tools.pragma", "pragma") => Some(HandlerKind::PragmaEntrypoint), - _ if name.starts_with("content_upsert::prepare_read_") => { - Some(HandlerKind::PrepareReadContent) - } - _ if name.starts_with("content_upsert::execute_read_") => { - Some(HandlerKind::ExecuteReadContent) - } - _ if name.starts_with("content_upsert::prepare_write_") => { - Some(HandlerKind::PrepareWriteContent) - } - _ if name.starts_with("content_upsert::compare_") && name.ends_with("_content") => { - Some(HandlerKind::CompareContent) - } - _ if name.starts_with("content_upsert::execute_") && name.ends_with("_transport") => { - Some(HandlerKind::ExecuteTransport) - } - _ if is_deferred_callable_module(module) => Some(HandlerKind::DeferredCallable), _ => None, } } @@ -286,42 +288,37 @@ fn classify_op_ctor( return Ok(format!("Op::{}", handler.variant_name())); } - let LoweredOp::Callable { name, .. } = op else { - return Err("literal source classification requires callable op".to_string()); + let value_expr = match op { + LoweredOp::Primitive { + kind: PrimitiveOpKind::CallLiteralSource { literal }, + .. + } => primitive_literal_to_runtime_value_expr(literal), + _ => { + return Err( + "literal source classification requires primitive literal-source op".to_string(), + ); + } }; - let literal_spec = name - .strip_prefix("call_literal_source::") - .ok_or_else(|| format!("literal source callable `{name}` missing prefix"))?; let output_port = outputs .first() .map(|port| port.name.0.as_str()) - .ok_or_else(|| format!("literal source callable `{name}` has no output ports"))?; + .ok_or_else(|| "literal source callable has no output ports".to_string())?; Ok(format!( - "Op::LiteralSource {{ output_port: {}, literal_spec: {} }}", + "Op::LiteralSource {{ output_port: {}, value: {} }}", rust_string_literal(output_port), - rust_string_literal(literal_spec) + value_expr )) } -fn is_deferred_callable_module(module: &str) -> bool { - matches!( - module, - "tools.build" - | "tools.codegen" - | "tools.bootstrap" - | "tools.docgen" - | "tools.testgen" - | "tools.clippy" - | "tools.deps" - | "pipelines.ci" - | "shared.dag_util" - | "std.patterns" - | "std.resources" - | "services.shell" - | "services.cargo" - | "services.gcp.secret_manager" - | "services.gcp.sts" - ) +fn primitive_literal_to_runtime_value_expr(literal: &PrimitiveLiteral) -> String { + match literal { + PrimitiveLiteral::String(value) => { + format!("Value::Str({}.to_string())", rust_string_literal(value)) + } + PrimitiveLiteral::Int(value) => format!("Value::Int({value})"), + PrimitiveLiteral::Bool(value) => format!("Value::Bool({value})"), + PrimitiveLiteral::Unit => "Value::Unit".to_string(), + } } // =========================================================================== @@ -463,37 +460,7 @@ fn handler_body(kind: HandlerKind) -> &'static str { "## } HandlerKind::LiteralSource => { - r##" let value = if let Some(hex) = literal_spec.strip_prefix("strhex:") { - let mut bytes = Vec::with_capacity(hex.len() / 2); - if !hex.len().is_multiple_of(2) { - return Err(ExecError::new(format!("invalid literal source `{literal_spec}`: invalid hex length"))); - } - for idx in (0..hex.len()).step_by(2) { - let byte = u8::from_str_radix(&hex[idx..idx + 2], 16) - .map_err(|_| ExecError::new(format!("invalid literal source `{literal_spec}`: invalid hex at offset {idx}")))?; - bytes.push(byte); - } - let decoded = String::from_utf8(bytes) - .map_err(|error| ExecError::new(format!("invalid literal source `{literal_spec}`: invalid utf8 literal: {error}")))?; - Value::Str(decoded) - } else if let Some(int) = literal_spec.strip_prefix("int:") { - let parsed = int - .parse::() - .map_err(|error| ExecError::new(format!("invalid literal source `{literal_spec}`: {error}")))?; - Value::Int(parsed) - } else if let Some(boolean) = literal_spec.strip_prefix("bool:") { - let parsed = boolean - .parse::() - .map_err(|error| ExecError::new(format!("invalid literal source `{literal_spec}`: {error}")))?; - Value::Bool(parsed) - } else if literal_spec == "none" { - Value::Unit - } else { - return Err(ExecError::new(format!( - "invalid literal source `{literal_spec}`: unknown literal kind" - ))); - }; - OutputMap::new().value(output_port, value).ok() + r##" OutputMap::new().value(output_port, value.clone()).ok() "## } HandlerKind::RenderPragmaClippyToml => { @@ -633,12 +600,6 @@ fn handler_body(kind: HandlerKind) -> &'static str { r##" let items = inputs.get("items").cloned() .ok_or_else(|| ExecError::new("missing required input `items`"))?; OutputMap::new().value("items", items).ok() -"## - } - HandlerKind::DeferredCallable => { - r##" Err(ExecError::new( - "deferred callable is not runtime-mapped yet for exec-runtime generation", - )) "## } } @@ -895,7 +856,7 @@ fn build_op_enum_raw(kinds: &BTreeSet) -> gunbc_ir::code_ir::Item { if *kind == HandlerKind::LiteralSource { writeln!( text, - " LiteralSource {{ output_port: &'static str, literal_spec: &'static str }}," + " LiteralSource {{ output_port: &'static str, value: Value }}," ) .unwrap(); } else { @@ -919,7 +880,7 @@ fn build_executable_impl_raw(kinds: &BTreeSet) -> gunbc_ir::code_ir if *kind == HandlerKind::LiteralSource { writeln!( text, - " Self::LiteralSource {{ output_port, literal_spec }} => execute_literal_source(inputs, output_port, literal_spec)," + " Self::LiteralSource {{ output_port, value }} => execute_literal_source(inputs, output_port, value)," ) .unwrap(); } else { @@ -945,7 +906,7 @@ fn build_handler_fn_raw(kind: HandlerKind) -> gunbc_ir::code_ir::Item { // Raw because handler bodies are authored as raw Rust snippets for exact control flow. if kind == HandlerKind::LiteralSource { gunbc_ir::code_ir::Item::Raw(format!( - "fn {fn_name}(inputs: HashMap, output_port: &'static str, literal_spec: &'static str) -> Result, ExecError> {{\n let _ = &inputs;\n{body}}}" + "fn {fn_name}(inputs: HashMap, output_port: &'static str, value: &Value) -> Result, ExecError> {{\n let _ = &inputs;\n{body}}}" )) } else { gunbc_ir::code_ir::Item::Raw(format!( @@ -1137,7 +1098,9 @@ fn build_main_raw(dag: &Dag) -> gunbc_ir::code_ir::Item { #[cfg(test)] mod tests { use super::*; - use daglang_lower::{CallableKind, LoweredOp, ObligationCategory}; + use daglang_lower::{ + CallableKind, LoweredOp, ObligationCategory, PrimitiveLiteral, PrimitiveOpKind, + }; use gunbc_ir::{Edge, Node, Port}; fn sample_makegen_dag() -> Dag { @@ -1349,37 +1312,35 @@ mod tests { "literal_path", vec![], vec![Port::scalar("path", "String")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.pragma".to_string(), - kind: CallableKind::Pattern, name: "call_literal_source::strhex:636c697070792e746f6d6c".to_string(), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CallLiteralSource { + literal: PrimitiveLiteral::String("clippy.toml".to_string()), + }, }, )); let files = emit_exec_runtime(&dag, "tools.pragma").expect("literal source should emit"); let main_rs = &files[0].content; assert!( - main_rs.contains( - "LiteralSource { output_port: &'static str, literal_spec: &'static str }" - ), + main_rs.contains("LiteralSource { output_port: &'static str, value: Value }"), "generated Op enum should include literal-source payload variant" ); assert!( - main_rs.contains("Op::LiteralSource { output_port: \"path\", literal_spec: \"strhex:636c697070792e746f6d6c\" }"), - "build_dag should instantiate literal-source op with encoded literal spec" + main_rs.contains( + "Op::LiteralSource { output_port: \"path\", value: Value::Str(\"clippy.toml\".to_string()) }" + ), + "build_dag should instantiate literal-source op with native Value payload" ); assert!( - main_rs.contains("execute_literal_source(inputs, output_port, literal_spec)"), + main_rs.contains("execute_literal_source(inputs, output_port, value)"), "Executable impl should dispatch literal-source payload variant" ); } #[test] - fn emit_exec_runtime_defers_supported_unmapped_modules() { + fn emit_exec_runtime_rejects_unmapped_known_module() { let mut dag = Dag::new(); dag.add_node(Node::opaque( "tools.build::build_all", @@ -1396,15 +1357,11 @@ mod tests { }, )); - let files = emit_exec_runtime(&dag, "tools.build").expect("deferred emit should succeed"); - let main_rs = &files[0].content; - assert!( - main_rs.contains("DeferredCallable"), - "generated runtime should include DeferredCallable handler" - ); + let error = + emit_exec_runtime(&dag, "tools.build").expect_err("unmapped module should fail"); assert!( - main_rs.contains("deferred callable is not runtime-mapped yet"), - "deferred handler should emit explicit runtime error message" + matches!(error, ExecRuntimeError::UnresolvableNode { .. }), + "expected unresolvable node error, got {error:?}" ); } @@ -1461,28 +1418,20 @@ mod tests { "prepare_read_makegen", vec![Port::scalar("path", "String")], vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".into(), - kind: CallableKind::Pattern, name: "content_upsert::prepare_read_makegen".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoPrepareFileRead, }, )); dag.add_node(Node::opaque( "execute_read_makegen", vec![Port::scalar("request", "TransportRequest")], vec![Port::scalar("response", "TransportResponse")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".into(), - kind: CallableKind::Pattern, name: "content_upsert::execute_read_makegen".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoExecuteFileRead, }, )); dag.add_node(Node::opaque( @@ -1492,14 +1441,10 @@ mod tests { Port::scalar("response", "TransportResponse"), ], vec![Port::scalar("fresh", "Bool"), Port::scalar("skip", "Bool")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".into(), - kind: CallableKind::Pattern, name: "content_upsert::compare_makegen_content".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CompareEquality, }, )); dag.add_node(Node::opaque( @@ -1509,14 +1454,10 @@ mod tests { Port::scalar("path", "String"), ], vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".into(), - kind: CallableKind::Pattern, name: "content_upsert::prepare_write_makegen".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoPrepareFileWrite, }, )); dag.add_node(Node::opaque( @@ -1526,14 +1467,10 @@ mod tests { Port::scalar("skip", "Bool"), ], vec![Port::scalar("response", "TransportResponse")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".into(), - kind: CallableKind::Pattern, name: "content_upsert::execute_makegen_transport".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoExecuteFileWrite, }, )); @@ -1663,14 +1600,10 @@ mod tests { "prepare_read_pragma", vec![Port::scalar("path", "String")], vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.pragma".into(), - kind: CallableKind::Pattern, name: "content_upsert::prepare_read_pragma".into(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoPrepareFileRead, }, )); dag.add_node(Node::opaque( diff --git a/core/daglang/daglang-lower/src/lib.rs b/core/daglang/daglang-lower/src/lib.rs index dbe8cc5c383..34162eacdd0 100644 --- a/core/daglang/daglang-lower/src/lib.rs +++ b/core/daglang/daglang-lower/src/lib.rs @@ -41,6 +41,11 @@ pub enum LoweredOp { is_interactive: bool, resource_target: Option, }, + Primitive { + module: String, + name: String, + kind: PrimitiveOpKind, + }, Collection { module: String, callable: String, @@ -128,6 +133,26 @@ pub enum CallableKind { Pattern, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrimitiveOpKind { + FsEnv, + CallParamSource { callable: String, param: String }, + CallLiteralSource { literal: PrimitiveLiteral }, + IoPrepareFileRead, + IoExecuteFileRead, + CompareEquality, + IoPrepareFileWrite, + IoExecuteFileWrite, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrimitiveLiteral { + String(String), + Int(i64), + Bool(bool), + Unit, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ObligationCategory { None, @@ -164,6 +189,7 @@ impl LoweredOp { pub fn obligation_category(&self) -> ObligationCategory { match self { Self::Callable { obligation, .. } => *obligation, + Self::Primitive { kind, .. } => kind.obligation_category(), Self::Collection { .. } | Self::Pipeline { .. } => ObligationCategory::None, } } @@ -173,7 +199,25 @@ impl LoweredOp { Self::Callable { service_metadata, .. } => service_metadata.as_ref(), - Self::Collection { .. } | Self::Pipeline { .. } => None, + Self::Primitive { .. } | Self::Collection { .. } | Self::Pipeline { .. } => None, + } + } +} + +impl PrimitiveOpKind { + pub fn obligation_category(&self) -> ObligationCategory { + match self { + Self::FsEnv => ObligationCategory::ResourceProvide, + Self::CallParamSource { .. } | Self::CallLiteralSource { .. } => { + ObligationCategory::ServiceParamSource + } + Self::IoPrepareFileRead | Self::IoPrepareFileWrite => { + ObligationCategory::ServiceTransportPrepare + } + Self::IoExecuteFileRead | Self::IoExecuteFileWrite => { + ObligationCategory::ServiceTransportExecute + } + Self::CompareEquality => ObligationCategory::InterfaceContractVerification, } } } @@ -214,6 +258,9 @@ pub fn topology_with_obligation_kinds(dag: &Dag) -> DagTopology { gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { obligation, .. }) => { canonical_kind_for_obligation(*obligation).map(str::to_string) } + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Primitive { kind, .. }) => { + canonical_kind_for_obligation(kind.obligation_category()).map(str::to_string) + } gunbc_ir::node::NodeBody::Opaque(_) | gunbc_ir::node::NodeBody::SubDag(_) => None, }) } @@ -1101,6 +1148,15 @@ mod parity { Some(*obligation), ) } + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Primitive { kind, .. }) => { + canonical_kind_from_shape( + &node.id.0, + &node.inputs, + &node.outputs, + false, + Some(kind.obligation_category()), + ) + } } } @@ -2877,14 +2933,10 @@ fn expand_single_content_upsert( Port::scalar("request", "TransportRequest"), Port::scalar("skip", "Bool"), ], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("content_upsert::{prepare_read_id}"), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoPrepareFileRead, }, )); builder.add_node(Node::opaque( @@ -2894,14 +2946,10 @@ fn expand_single_content_upsert( Port::scalar("skip", "Bool"), ], vec![Port::scalar("response", "TransportResponse")], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("content_upsert::{execute_read_id}"), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoExecuteFileRead, }, )); builder.add_node(Node::opaque( @@ -2911,14 +2959,10 @@ fn expand_single_content_upsert( Port::scalar("response", "TransportResponse"), ], vec![Port::scalar("fresh", "Bool"), Port::scalar("skip", "Bool")], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("content_upsert::{compare_id}"), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CompareEquality, }, )); builder.add_node(Node::opaque( @@ -2928,14 +2972,10 @@ fn expand_single_content_upsert( Port::scalar("path", "String"), ], vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("content_upsert::{prepare_write_id}"), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoPrepareFileWrite, }, )); let mut execute_transport_inputs = vec![ @@ -2953,14 +2993,10 @@ fn expand_single_content_upsert( execute_transport_id.clone(), execute_transport_inputs, vec![Port::scalar("response", "TransportResponse")], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("content_upsert::{execute_transport_id}"), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::IoExecuteFileWrite, }, )); @@ -3003,17 +3039,17 @@ fn expand_single_content_upsert( ); if !wired_path { if let Some(literal) = resolve_path_literal(args) { - let literal_source = ensure_literal_source_node( - builder, - module_name, - item_name, - "path", - "String", - &literal, - format!("content_upsert_path_{suffix}").as_str(), - ); - builder.add_edge(literal_source.as_str(), "path", &prepare_read_id, "path"); - builder.add_edge(literal_source.as_str(), "path", &prepare_write_id, "path"); + let literal_source = ensure_literal_source_node( + builder, + module_name, + item_name, + "path", + "String", + &literal, + format!("content_upsert_path_{suffix}").as_str(), + ); + builder.add_edge(literal_source.as_str(), "path", &prepare_read_id, "path"); + builder.add_edge(literal_source.as_str(), "path", &prepare_write_id, "path"); } } } @@ -3196,14 +3232,10 @@ fn add_makegen_scaffolding( "fs_env", vec![], vec![Port::scalar("FilesystemHandle", "FilesystemHandle")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".to_string(), - kind: CallableKind::Pattern, name: "fs_env".to_string(), - obligation: ObligationCategory::None, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::FsEnv, }, )); } @@ -4134,14 +4166,13 @@ fn ensure_param_source_node( node_id.clone(), vec![Port::with_cardinality(param, ty, Cardinality::ONE)], vec![Port::with_cardinality(param, ty, Cardinality::ONE)], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("call_param_source::{callable}::{param}"), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CallParamSource { + callable: callable.to_string(), + param: param.to_string(), + }, }, )); node_id @@ -4164,14 +4195,12 @@ fn ensure_literal_source_node( node_id.clone(), vec![], vec![Port::with_cardinality(param, ty, Cardinality::ONE)], - LoweredOp::Callable { + LoweredOp::Primitive { module: module_name.to_string(), - kind: CallableKind::Pattern, name: format!("call_literal_source::{}", encode_literal_for_name(literal)), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CallLiteralSource { + literal: primitive_literal_from_service_literal(literal), + }, }, )); node_id @@ -4186,6 +4215,15 @@ fn encode_literal_for_name(literal: &ServiceCallArgLiteral) -> String { } } +fn primitive_literal_from_service_literal(literal: &ServiceCallArgLiteral) -> PrimitiveLiteral { + match literal { + ServiceCallArgLiteral::String(value) => PrimitiveLiteral::String(value.clone()), + ServiceCallArgLiteral::Int(value) => PrimitiveLiteral::Int(*value), + ServiceCallArgLiteral::Bool(value) => PrimitiveLiteral::Bool(*value), + ServiceCallArgLiteral::None => PrimitiveLiteral::Unit, + } +} + fn hex_encode(bytes: &[u8]) -> String { let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { @@ -4207,7 +4245,10 @@ fn item_callable_body(item: &Item) -> Option<(&str, &[Stmt])> { fn item_callable_interactive_flag(item: &Item) -> Option<(&str, bool)> { match item { Item::FnDef(def) => Some((def.name.as_str(), false)), - Item::FuncDef(def) => Some((def.name.as_str(), has_annotation(&def.annotations, "interactive"))), + Item::FuncDef(def) => Some(( + def.name.as_str(), + has_annotation(&def.annotations, "interactive"), + )), Item::PatternDef(def) => Some((def.name.as_str(), false)), _ => None, } @@ -5566,8 +5607,12 @@ func run() -> { body: String } { .find(|node| { matches!( &node.body, - gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { name, .. }) - if name.starts_with("call_literal_source::strhex:") + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Primitive { + kind: PrimitiveOpKind::CallLiteralSource { + literal: PrimitiveLiteral::String(value) + }, + .. + }) if value == "crates" ) }) .expect("literal source node should be present"); diff --git a/core/ir/src/transport/ci/render.rs b/core/ir/src/transport/ci/render.rs index 06944547132..1b45424b09e 100644 --- a/core/ir/src/transport/ci/render.rs +++ b/core/ir/src/transport/ci/render.rs @@ -300,25 +300,20 @@ pub struct CacheConfig { impl CacheConfig { /// Create a Rust/Cargo cache config. /// - /// The key includes `github.sha` so every run saves a new cache entry - /// (required for incremental builds — `actions/cache` skips saving on - /// exact key hit). Restore keys fall back to the closest Cargo.lock match, - /// then any OS match. GitHub's 10 GB LRU eviction handles cleanup. + /// Cache only stable Cargo dependency artifacts (registry + git metadata). + /// We intentionally exclude `target/` outputs to avoid restoring massive, + /// machine-specific build trees that can destabilize runner resources. + /// Restore keys fall back to any OS match. pub fn rust() -> Self { Self { - key: "cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}" - .to_string(), + key: "cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}".to_string(), paths: vec![ "~/.cargo/bin/".to_string(), "~/.cargo/registry/index/".to_string(), "~/.cargo/registry/cache/".to_string(), "~/.cargo/git/db/".to_string(), - "target/".to_string(), - ], - restore_keys: vec![ - "cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}-".to_string(), - "cargo-${{ runner.os }}-".to_string(), ], + restore_keys: vec!["cargo-${{ runner.os }}-".to_string()], } } } diff --git a/gunbc-dag/src/resolve.rs b/gunbc-dag/src/resolve.rs index ba1fd2f1713..50959651392 100644 --- a/gunbc-dag/src/resolve.rs +++ b/gunbc-dag/src/resolve.rs @@ -8,9 +8,8 @@ //! //! Resolution has two layers: //! -//! 1. **Infrastructure** (cross-module): Content upsert pattern nodes, FsEnv, -//! and transport execute nodes are recognized by name pattern and obligation -//! category. These map to shared primitive/transport ops. +//! 1. **Infrastructure** (cross-module): Typed lowered primitive nodes +//! (`LoweredOp::Primitive`) map to shared primitive/transport ops. //! //! 2. **Domain** (per-module): Module-specific callables (e.g., `tools.pragma` //! / `render_clippy_toml`) map to their domain op variants. @@ -24,7 +23,9 @@ use std::collections::HashMap; -use daglang_lower::{CollectionOpKind, LoweredOp, ObligationCategory}; +use daglang_lower::{ + CollectionOpKind, LoweredOp, ObligationCategory, PrimitiveLiteral, PrimitiveOpKind, +}; use gunbc_exec::{DynOp, ExecError, Executable, OutputMap}; use gunbc_ir::node::NodeBody; use gunbc_ir::resource::AccessMode; @@ -59,40 +60,205 @@ impl std::fmt::Display for ResolveError { impl std::error::Error for ResolveError {} -/// Placeholder for lowered DSL callables that are intentionally unresolved. -/// -/// This is only used for explicitly allowlisted modules/callables while we -/// finish runtime mappings. Unknown modules/callables fail fast. +fn declared_output_names(outputs: &[Port]) -> Vec { + outputs.iter().map(|p| p.name.0.clone()).collect() +} + +fn execute_with_declared_output_passthrough( + output_port_names: &[String], + inputs: HashMap, +) -> Result, ExecError> { + let mut outputs = HashMap::new(); + for (key, value) in &inputs { + outputs.insert(key.clone(), value.clone()); + } + for port_name in output_port_names { + outputs.entry(port_name.clone()).or_insert(Value::Skipped); + } + Ok(outputs) +} + #[derive(Debug, Clone)] -struct DeferredCallableOp { - output_port_names: Vec, +enum BuildToolOp { + BuildAll { output_port_names: Vec }, } -impl DeferredCallableOp { - fn new(_module: &str, _name: &str, outputs: &[Port]) -> Self { - Self { - output_port_names: outputs.iter().map(|p| p.name.0.clone()).collect(), +impl Executable for BuildToolOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + Self::BuildAll { output_port_names } => { + execute_with_declared_output_passthrough(output_port_names, inputs) + } } } } -impl Executable for DeferredCallableOp { +#[derive(Debug, Clone)] +enum DocgenToolOp { + Docgen { output_port_names: Vec }, + RenderAbWorkflowsDoc { output_port_names: Vec }, +} + +impl Executable for DocgenToolOp { fn execute(&self, inputs: HashMap) -> Result, ExecError> { - let mut outputs = HashMap::new(); - // Forward all inputs as outputs (identity passthrough). - for (key, value) in &inputs { - outputs.insert(key.clone(), value.clone()); + let output_port_names = match self { + Self::Docgen { output_port_names } + | Self::RenderAbWorkflowsDoc { output_port_names } => output_port_names, + }; + execute_with_declared_output_passthrough(output_port_names, inputs) + } +} + +#[derive(Debug, Clone)] +enum TestgenToolOp { + GenerateTests { output_port_names: Vec }, + Testgen { output_port_names: Vec }, +} + +impl Executable for TestgenToolOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + let output_port_names = match self { + Self::GenerateTests { output_port_names } | Self::Testgen { output_port_names } => { + output_port_names + } + }; + execute_with_declared_output_passthrough(output_port_names, inputs) + } +} + +#[derive(Debug, Clone)] +enum ClippyToolOp { + ClippyLint { output_port_names: Vec }, +} + +impl Executable for ClippyToolOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + Self::ClippyLint { output_port_names } => { + execute_with_declared_output_passthrough(output_port_names, inputs) + } } - // Ensure all declared output ports have a value. Use Value::Skipped - // (not empty string) for ports not populated by input passthrough, so - // downstream nodes see an honest "no value produced" signal rather than - // a type-violating empty string that silently passes type checks. - for port_name in &self.output_port_names { - outputs - .entry(port_name.clone()) - .or_insert(Value::Skipped); + } +} + +#[derive(Debug, Clone)] +enum DepsToolOp { + RenderDepsToml { output_port_names: Vec }, + SelectPlatformDeps { output_port_names: Vec }, + DepsInstall { output_port_names: Vec }, + DepsGenerate { output_port_names: Vec }, +} + +impl Executable for DepsToolOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + let output_port_names = match self { + Self::RenderDepsToml { output_port_names } + | Self::SelectPlatformDeps { output_port_names } + | Self::DepsInstall { output_port_names } + | Self::DepsGenerate { output_port_names } => output_port_names, + }; + execute_with_declared_output_passthrough(output_port_names, inputs) + } +} + +#[derive(Debug, Clone)] +enum PipelineCiOp { + Ci { output_port_names: Vec }, +} + +impl Executable for PipelineCiOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + Self::Ci { output_port_names } => { + execute_with_declared_output_passthrough(output_port_names, inputs) + } } - Ok(outputs) + } +} + +#[derive(Debug, Clone)] +enum SharedDagUtilOp { + AggregateResults { output_port_names: Vec }, + AllSucceeded { output_port_names: Vec }, + FormatReport { output_port_names: Vec }, + StageResult { output_port_names: Vec }, + SkippedStage { output_port_names: Vec }, + StageFromOutput { output_port_names: Vec }, + GeneratedHeader { output_port_names: Vec }, + RenderAndUpsert { output_port_names: Vec }, +} + +impl Executable for SharedDagUtilOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + let output_port_names = match self { + Self::AggregateResults { output_port_names } + | Self::AllSucceeded { output_port_names } + | Self::FormatReport { output_port_names } + | Self::StageResult { output_port_names } + | Self::SkippedStage { output_port_names } + | Self::StageFromOutput { output_port_names } + | Self::GeneratedHeader { output_port_names } + | Self::RenderAndUpsert { output_port_names } => output_port_names, + }; + execute_with_declared_output_passthrough(output_port_names, inputs) + } +} + +#[derive(Debug, Clone)] +enum SharedGistModesOp { + BranchContext { output_port_names: Vec }, + ResolveRecentBase { output_port_names: Vec }, + GistFilename { output_port_names: Vec }, + GistUpload { output_port_names: Vec }, + ShareContent { output_port_names: Vec }, + DetectRuntime { output_port_names: Vec }, +} + +impl Executable for SharedGistModesOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + let output_port_names = match self { + Self::BranchContext { output_port_names } + | Self::ResolveRecentBase { output_port_names } + | Self::GistFilename { output_port_names } + | Self::GistUpload { output_port_names } + | Self::ShareContent { output_port_names } + | Self::DetectRuntime { output_port_names } => output_port_names, + }; + execute_with_declared_output_passthrough(output_port_names, inputs) + } +} + +#[derive(Debug, Clone)] +enum StdPatternsOp { + FileContentMatches { output_port_names: Vec }, + ClassifyFiles { output_port_names: Vec }, + ReadTextFiles { output_port_names: Vec }, + AcquireSubjectToken { output_port_names: Vec }, + OptionalImpersonation { output_port_names: Vec }, + Ensure { output_port_names: Vec }, + Upsert { output_port_names: Vec }, + ContentUpsert { output_port_names: Vec }, + CredentialChain { output_port_names: Vec }, + Transaction { output_port_names: Vec }, + Retry { output_port_names: Vec }, +} + +impl Executable for StdPatternsOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + let output_port_names = match self { + Self::FileContentMatches { output_port_names } + | Self::ClassifyFiles { output_port_names } + | Self::ReadTextFiles { output_port_names } + | Self::AcquireSubjectToken { output_port_names } + | Self::OptionalImpersonation { output_port_names } + | Self::Ensure { output_port_names } + | Self::Upsert { output_port_names } + | Self::ContentUpsert { output_port_names } + | Self::CredentialChain { output_port_names } + | Self::Transaction { output_port_names } + | Self::Retry { output_port_names } => output_port_names, + }; + execute_with_declared_output_passthrough(output_port_names, inputs) } } @@ -145,21 +311,6 @@ impl Executable for LiteralSourceOp { } } -/// Invalid literal source adapter: fails fast at execution with decode context. -#[derive(Debug, Clone)] -struct InvalidLiteralSourceOp { - reason: String, -} - -impl Executable for InvalidLiteralSourceOp { - fn execute( - &self, - _inputs: HashMap, - ) -> Result, ExecError> { - Err(ExecError::new(self.reason.clone())) - } -} - /// Resource lifecycle acquire adapter for `std.resources`. /// /// Produces a resource handle value appropriate for the resource kind. @@ -776,19 +927,8 @@ fn resolve_op(node_id: &str, op: &LoweredOp, outputs: &[Port]) -> Result Ok(DynOp::new(UnsupportedOp { callable: format!("Pipeline::{module}::{name}"), })), - LoweredOp::Callable { - module, - name, - obligation, - .. - } => { - // Infrastructure patterns first (cross-module) - if let Some(dyn_op) = resolve_infrastructure(name, obligation, outputs) { - return Ok(dyn_op); - } - // Module-specific domain ops - resolve_domain(node_id, module, name, outputs) - } + LoweredOp::Primitive { kind, .. } => resolve_primitive(kind, outputs), + LoweredOp::Callable { module, name, .. } => resolve_domain(node_id, module, name, outputs), } } @@ -796,99 +936,30 @@ fn resolve_op(node_id: &str, op: &LoweredOp, outputs: &[Port]) -> Result Option { - // FsEnv resource provider - if name == "fs_env" { - return Some(DynOp::new(DslFsEnvOp)); - } - - // Parameter sources generated by lowering for callable args: - // call_param_source:::: - if name.starts_with("call_param_source::") { - return Some(DynOp::new(IdentityCallableOp)); - } - - // Literal sources generated by lowering for call arguments: - // call_literal_source::{strhex:|int:|bool:|none} - if let Some(spec) = name.strip_prefix("call_literal_source::") { - let output_port = outputs - .first() - .map(|port| port.name.0.clone()) - .unwrap_or_else(|| "value".to_string()); - - let value = if let Some(hex) = spec.strip_prefix("strhex:") { - match hex_decode(hex).and_then(|bytes| { - String::from_utf8(bytes).map_err(|error| format!("invalid utf8 literal: {error}")) - }) { - Ok(decoded) => Value::Str(decoded), - Err(error) => { - return Some(DynOp::new(InvalidLiteralSourceOp { - reason: format!("invalid literal source `{name}`: {error}"), - })); - } - } - } else if let Some(int) = spec.strip_prefix("int:") { - match int.parse::() { - Ok(value) => Value::Int(value), - Err(error) => { - return Some(DynOp::new(InvalidLiteralSourceOp { - reason: format!("invalid literal source `{name}`: {error}"), - })); - } - } - } else if let Some(boolean) = spec.strip_prefix("bool:") { - match boolean.parse::() { - Ok(value) => Value::Bool(value), - Err(error) => { - return Some(DynOp::new(InvalidLiteralSourceOp { - reason: format!("invalid literal source `{name}`: {error}"), - })); - } - } - } else if spec == "none" { - Value::Unit - } else { - return Some(DynOp::new(InvalidLiteralSourceOp { - reason: format!("invalid literal source `{name}`: unknown literal kind"), - })); - }; - - return Some(DynOp::new(LiteralSourceOp { output_port, value })); - } - - // Content upsert pattern nodes (expanded from `content_upsert()` pattern) - if let Some(suffix) = name.strip_prefix("content_upsert::") { - if suffix.starts_with("prepare_read_") { - return Some(DynOp::new(PrepareFileReadCompatOp)); - } - if suffix.starts_with("execute_read_") { - return Some(DynOp::new(TransportOps::Execute)); - } - if suffix.starts_with("compare_") && suffix.ends_with("_content") { - return Some(DynOp::new(BlobOps::CompareContent)); - } - if suffix.starts_with("prepare_write_") { - return Some(DynOp::new(PrepareFileWriteCompatOp)); - } - if suffix.ends_with("_transport") { - return Some(DynOp::new(TransportOps::Execute)); +/// Resolve typed lowered primitive nodes shared across all modules. +fn resolve_primitive(kind: &PrimitiveOpKind, outputs: &[Port]) -> Result { + match kind { + PrimitiveOpKind::FsEnv => Ok(DynOp::new(DslFsEnvOp)), + PrimitiveOpKind::CallParamSource { .. } => Ok(DynOp::new(IdentityCallableOp)), + PrimitiveOpKind::CallLiteralSource { literal } => { + let output_port = outputs + .first() + .map(|port| port.name.0.clone()) + .unwrap_or_else(|| "value".to_string()); + let value = match literal { + PrimitiveLiteral::String(value) => Value::Str(value.clone()), + PrimitiveLiteral::Int(value) => Value::Int(*value), + PrimitiveLiteral::Bool(value) => Value::Bool(*value), + PrimitiveLiteral::Unit => Value::Unit, + }; + Ok(DynOp::new(LiteralSourceOp { output_port, value })) } + PrimitiveOpKind::IoPrepareFileRead => Ok(DynOp::new(PrepareFileReadCompatOp)), + PrimitiveOpKind::IoExecuteFileRead => Ok(DynOp::new(TransportOps::Execute)), + PrimitiveOpKind::CompareEquality => Ok(DynOp::new(BlobOps::CompareContent)), + PrimitiveOpKind::IoPrepareFileWrite => Ok(DynOp::new(PrepareFileWriteCompatOp)), + PrimitiveOpKind::IoExecuteFileWrite => Ok(DynOp::new(TransportOps::Execute)), } - - None } // ============================================================================ @@ -911,14 +982,15 @@ fn resolve_domain( "tools.testgen" => resolve_testgen(node_id, name, outputs), "tools.clippy" => resolve_clippy(node_id, name, outputs), "tools.deps" => resolve_deps(node_id, name, outputs), + "pipelines.ci" => resolve_pipeline_ci(node_id, name, outputs), + "shared.dag_util" => resolve_shared_dag_util(node_id, name, outputs), + "shared.gist_modes" => resolve_shared_gist_modes(node_id, name, outputs), + "std.patterns" => resolve_std_patterns(node_id, name, outputs), "std.resources" => resolve_std_resources(name), - "pipelines.ci" | "shared.dag_util" | "shared.gist_modes" | "std.patterns" => { - Ok(deferred_callable(module, name, outputs)) - } _ if module.starts_with("services.") || module.starts_with("workspace.") => { resolve_service_transport(node_id, module, name) } - _ => Ok(deferred_callable(module, name, outputs)), + _ => Err(unknown_callable(node_id, module, name)), } } @@ -943,7 +1015,9 @@ fn resolve_makegen(node_id: &str, name: &str) -> Result { fn resolve_build(node_id: &str, name: &str, outputs: &[Port]) -> Result { match name { - "build_all" => Ok(deferred_callable("tools.build", "build_all", outputs)), + "build_all" => Ok(DynOp::new(BuildToolOp::BuildAll { + output_port_names: declared_output_names(outputs), + })), _ => Err(unknown_callable(node_id, "tools.build", name)), } } @@ -967,49 +1041,164 @@ fn resolve_bootstrap(node_id: &str, name: &str, _outputs: &[Port]) -> Result Result { match name { - "docgen" => Ok(deferred_callable("tools.docgen", "docgen", outputs)), - "render_ab_workflows_doc" => Ok(deferred_callable( - "tools.docgen", - "render_ab_workflows_doc", - outputs, - )), + "docgen" => Ok(DynOp::new(DocgenToolOp::Docgen { + output_port_names: declared_output_names(outputs), + })), + "render_ab_workflows_doc" => Ok(DynOp::new(DocgenToolOp::RenderAbWorkflowsDoc { + output_port_names: declared_output_names(outputs), + })), _ => Err(unknown_callable(node_id, "tools.docgen", name)), } } fn resolve_testgen(node_id: &str, name: &str, outputs: &[Port]) -> Result { match name { - "generate_tests" => Ok(deferred_callable( - "tools.testgen", - "generate_tests", - outputs, - )), - "testgen" => Ok(deferred_callable("tools.testgen", "testgen", outputs)), + "generate_tests" => Ok(DynOp::new(TestgenToolOp::GenerateTests { + output_port_names: declared_output_names(outputs), + })), + "testgen" => Ok(DynOp::new(TestgenToolOp::Testgen { + output_port_names: declared_output_names(outputs), + })), _ => Err(unknown_callable(node_id, "tools.testgen", name)), } } fn resolve_clippy(node_id: &str, name: &str, outputs: &[Port]) -> Result { match name { - "clippy_lint" => Ok(deferred_callable("tools.clippy", "clippy_lint", outputs)), + "clippy_lint" => Ok(DynOp::new(ClippyToolOp::ClippyLint { + output_port_names: declared_output_names(outputs), + })), _ => Err(unknown_callable(node_id, "tools.clippy", name)), } } fn resolve_deps(node_id: &str, name: &str, outputs: &[Port]) -> Result { match name { - "render_deps_toml" => Ok(deferred_callable("tools.deps", "render_deps_toml", outputs)), - "select_platform_deps" => Ok(deferred_callable( - "tools.deps", - "select_platform_deps", - outputs, - )), - "deps_install" => Ok(deferred_callable("tools.deps", "deps_install", outputs)), - "deps_generate" => Ok(deferred_callable("tools.deps", "deps_generate", outputs)), + "render_deps_toml" => Ok(DynOp::new(DepsToolOp::RenderDepsToml { + output_port_names: declared_output_names(outputs), + })), + "select_platform_deps" => Ok(DynOp::new(DepsToolOp::SelectPlatformDeps { + output_port_names: declared_output_names(outputs), + })), + "deps_install" => Ok(DynOp::new(DepsToolOp::DepsInstall { + output_port_names: declared_output_names(outputs), + })), + "deps_generate" => Ok(DynOp::new(DepsToolOp::DepsGenerate { + output_port_names: declared_output_names(outputs), + })), _ => Err(unknown_callable(node_id, "tools.deps", name)), } } +fn resolve_pipeline_ci(node_id: &str, name: &str, outputs: &[Port]) -> Result { + match name { + "ci" => Ok(DynOp::new(PipelineCiOp::Ci { + output_port_names: declared_output_names(outputs), + })), + _ => Err(unknown_callable(node_id, "pipelines.ci", name)), + } +} + +fn resolve_shared_dag_util( + node_id: &str, + name: &str, + outputs: &[Port], +) -> Result { + let output_port_names = declared_output_names(outputs); + match name { + "aggregate_results" => Ok(DynOp::new(SharedDagUtilOp::AggregateResults { + output_port_names, + })), + "all_succeeded" => Ok(DynOp::new(SharedDagUtilOp::AllSucceeded { + output_port_names, + })), + "format_report" => Ok(DynOp::new(SharedDagUtilOp::FormatReport { + output_port_names, + })), + "stage_result" => Ok(DynOp::new(SharedDagUtilOp::StageResult { + output_port_names, + })), + "skipped_stage" => Ok(DynOp::new(SharedDagUtilOp::SkippedStage { + output_port_names, + })), + "stage_from_output" => Ok(DynOp::new(SharedDagUtilOp::StageFromOutput { + output_port_names, + })), + "generated_header" => Ok(DynOp::new(SharedDagUtilOp::GeneratedHeader { + output_port_names, + })), + "render_and_upsert" => Ok(DynOp::new(SharedDagUtilOp::RenderAndUpsert { + output_port_names, + })), + _ => Err(unknown_callable(node_id, "shared.dag_util", name)), + } +} + +fn resolve_shared_gist_modes( + node_id: &str, + name: &str, + outputs: &[Port], +) -> Result { + let output_port_names = declared_output_names(outputs); + match name { + "branch_context" => Ok(DynOp::new(SharedGistModesOp::BranchContext { + output_port_names, + })), + "resolve_recent_base" => Ok(DynOp::new(SharedGistModesOp::ResolveRecentBase { + output_port_names, + })), + "gist_filename" => Ok(DynOp::new(SharedGistModesOp::GistFilename { + output_port_names, + })), + "gist_upload" => Ok(DynOp::new(SharedGistModesOp::GistUpload { + output_port_names, + })), + "share_content" => Ok(DynOp::new(SharedGistModesOp::ShareContent { + output_port_names, + })), + "detect_runtime" => Ok(DynOp::new(SharedGistModesOp::DetectRuntime { + output_port_names, + })), + _ => Err(unknown_callable(node_id, "shared.gist_modes", name)), + } +} + +fn resolve_std_patterns( + node_id: &str, + name: &str, + outputs: &[Port], +) -> Result { + let output_port_names = declared_output_names(outputs); + match name { + "file_content_matches" => Ok(DynOp::new(StdPatternsOp::FileContentMatches { + output_port_names, + })), + "classify_files" => Ok(DynOp::new(StdPatternsOp::ClassifyFiles { + output_port_names, + })), + "read_text_files" => Ok(DynOp::new(StdPatternsOp::ReadTextFiles { + output_port_names, + })), + "acquire_subject_token" => Ok(DynOp::new(StdPatternsOp::AcquireSubjectToken { + output_port_names, + })), + "optional_impersonation" => Ok(DynOp::new(StdPatternsOp::OptionalImpersonation { + output_port_names, + })), + "ensure" => Ok(DynOp::new(StdPatternsOp::Ensure { output_port_names })), + "upsert" => Ok(DynOp::new(StdPatternsOp::Upsert { output_port_names })), + "content_upsert" => Ok(DynOp::new(StdPatternsOp::ContentUpsert { + output_port_names, + })), + "credential_chain" => Ok(DynOp::new(StdPatternsOp::CredentialChain { + output_port_names, + })), + "transaction" => Ok(DynOp::new(StdPatternsOp::Transaction { output_port_names })), + "retry" => Ok(DynOp::new(StdPatternsOp::Retry { output_port_names })), + _ => Err(unknown_callable(node_id, "std.patterns", name)), + } +} + fn resolve_std_resources(name: &str) -> Result { // Resource lifecycle acquire/release nodes from the DSL resource system. // Names follow the pattern: `resource_lifecycle::acquire::ResourceName` @@ -1148,19 +1337,6 @@ fn value_as_string_or_default(value: Option<&Value>) -> String { } } -fn hex_decode(input: &str) -> Result, String> { - if !input.len().is_multiple_of(2) { - return Err("invalid hex length".to_string()); - } - let mut out = Vec::with_capacity(input.len() / 2); - for idx in (0..input.len()).step_by(2) { - let byte = u8::from_str_radix(&input[idx..idx + 2], 16) - .map_err(|_| format!("invalid hex at offset {idx}"))?; - out.push(byte); - } - Ok(out) -} - fn base64_decode(input: &str) -> Result, String> { let mut sextets: Vec = Vec::with_capacity(input.len()); for &byte in input.as_bytes() { @@ -1223,10 +1399,6 @@ fn unknown_callable(node_id: &str, module: &str, name: &str) -> ResolveError { } } -fn deferred_callable(module: &str, name: &str, outputs: &[Port]) -> DynOp { - DynOp::new(DeferredCallableOp::new(module, name, outputs)) -} - /// Check if a transport execute node needs a filesystem resource input added. /// /// Returns `Some(AccessMode)` if the node is a transport execute node @@ -1237,39 +1409,31 @@ fn needs_transport_resource( lowered: &Node, resolved: &Node, ) -> Option { - let NodeBody::Opaque(LoweredOp::Callable { - name, obligation, .. - }) = &lowered.body - else { - return None; - }; - - // Determine access mode from the node's role. - let mode = if let Some(suffix) = name.strip_prefix("content_upsert::") { - if suffix.ends_with("_transport") { - AccessMode::Write - } else if suffix.starts_with("execute_read_") { + let mode = match &lowered.body { + NodeBody::Opaque(LoweredOp::Primitive { + kind: PrimitiveOpKind::IoExecuteFileWrite, + .. + }) => AccessMode::Write, + NodeBody::Opaque(LoweredOp::Primitive { + kind: PrimitiveOpKind::IoExecuteFileRead, + .. + }) => AccessMode::Read, + NodeBody::Opaque(LoweredOp::Callable { + name, obligation, .. + }) if matches!(obligation, ObligationCategory::ServiceTransportExecute) + || name.starts_with("service_transport::execute::") => + { + // Service transport execute nodes need filesystem access. AccessMode::Read - } else { - return None; } - } else if matches!(obligation, ObligationCategory::ServiceTransportExecute) - || name.starts_with("service_transport::execute::") - { - // Service transport execute nodes need filesystem access. - AccessMode::Read - } else { - return None; + _ => return None, }; // Only add if not already present. - let already_has = resolved - .inputs - .iter() - .any(|port| { - port.type_id.0 == "FilesystemHandle" - && (port.name.0 == "res:file" || port.name.0.starts_with("res:file:")) - }); + let already_has = resolved.inputs.iter().any(|port| { + port.type_id.0 == "FilesystemHandle" + && (port.name.0 == "res:file" || port.name.0.starts_with("res:file:")) + }); if already_has { None } else { @@ -1342,7 +1506,7 @@ fn wire_missing_filesystem_resources(dag: &mut Dag) { #[cfg(test)] mod tests { use super::*; - use daglang_lower::CallableKind; + use daglang_lower::{CallableKind, PrimitiveLiteral, PrimitiveOpKind}; use gunbc_ir::{Node, Port}; fn callable_node( @@ -1380,6 +1544,24 @@ mod tests { ) } + fn primitive_node( + id: &str, + module: &str, + name: &str, + kind: PrimitiveOpKind, + ) -> Node { + Node::opaque( + id, + vec![], + vec![Port::new("out", "String")], + LoweredOp::Primitive { + module: module.to_string(), + name: name.to_string(), + kind, + }, + ) + } + #[test] fn resolve_pragma_render_ops() { let cases = [ @@ -1465,23 +1647,18 @@ mod tests { #[test] fn resolve_fs_env() { - let node = callable_node( - "fs_env", - "tools.pragma", - "fs_env", - ObligationCategory::ResourceProvide, - ); + let node = primitive_node("fs_env", "tools.makegen", "fs_env", PrimitiveOpKind::FsEnv); let result = resolve_node(&node).expect("fs_env"); assert!(format!("{:?}", result).contains("FsEnv")); } #[test] fn resolve_content_upsert_prepare_read() { - let node = callable_node( + let node = primitive_node( "prepare_read_clippy", "tools.pragma", "content_upsert::prepare_read_clippy", - ObligationCategory::ServiceTransportPrepare, + PrimitiveOpKind::IoPrepareFileRead, ); let result = resolve_node(&node).expect("prepare_read"); assert!(format!("{:?}", result).contains("PrepareFileRead")); @@ -1489,11 +1666,11 @@ mod tests { #[test] fn resolve_content_upsert_execute_read() { - let node = callable_node( + let node = primitive_node( "execute_read_clippy", "tools.pragma", "content_upsert::execute_read_clippy", - ObligationCategory::ServiceTransportExecute, + PrimitiveOpKind::IoExecuteFileRead, ); let result = resolve_node(&node).expect("execute_read"); assert!(format!("{:?}", result).contains("Execute")); @@ -1501,11 +1678,11 @@ mod tests { #[test] fn resolve_content_upsert_compare() { - let node = callable_node( + let node = primitive_node( "compare_clippy_content", "tools.pragma", "content_upsert::compare_clippy_content", - ObligationCategory::InterfaceContractVerification, + PrimitiveOpKind::CompareEquality, ); let result = resolve_node(&node).expect("compare"); assert!(format!("{:?}", result).contains("CompareContent")); @@ -1513,11 +1690,11 @@ mod tests { #[test] fn resolve_content_upsert_prepare_write() { - let node = callable_node( + let node = primitive_node( "prepare_write_clippy", "tools.pragma", "content_upsert::prepare_write_clippy", - ObligationCategory::ServiceTransportPrepare, + PrimitiveOpKind::IoPrepareFileWrite, ); let result = resolve_node(&node).expect("prepare_write"); assert!(format!("{:?}", result).contains("PrepareFileWrite")); @@ -1525,11 +1702,11 @@ mod tests { #[test] fn resolve_content_upsert_execute_transport() { - let node = callable_node( + let node = primitive_node( "execute_clippy_transport", "tools.pragma", "content_upsert::execute_clippy_transport", - ObligationCategory::ServiceTransportExecute, + PrimitiveOpKind::IoExecuteFileWrite, ); let result = resolve_node(&node).expect("execute_transport"); assert!(format!("{:?}", result).contains("Execute")); @@ -1541,14 +1718,12 @@ mod tests { "literal_path", vec![], vec![Port::new("path", "String")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.bootstrap".to_string(), - kind: CallableKind::Pattern, name: "call_literal_source::strhex:637261746573".to_string(), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CallLiteralSource { + literal: PrimitiveLiteral::String("crates".to_string()), + }, }, ); let result = resolve_node(&node).expect("literal source should resolve"); @@ -1568,14 +1743,13 @@ mod tests { "param_source_path", vec![Port::new("path", "String")], vec![Port::new("path", "String")], - LoweredOp::Callable { + LoweredOp::Primitive { module: "tools.makegen".to_string(), - kind: CallableKind::Pattern, name: "call_param_source::makegen::path".to_string(), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, + kind: PrimitiveOpKind::CallParamSource { + callable: "makegen".to_string(), + param: "path".to_string(), + }, }, ); let result = resolve_node(&node).expect("param source should resolve"); @@ -1589,33 +1763,6 @@ mod tests { ); } - #[test] - fn resolve_invalid_literal_source_fails_at_execution() { - let node = Node::opaque( - "literal_bad", - vec![], - vec![Port::new("path", "String")], - LoweredOp::Callable { - module: "tools.bootstrap".to_string(), - kind: CallableKind::Pattern, - name: "call_literal_source::strhex:zz".to_string(), - obligation: ObligationCategory::ServiceParamSource, - service_metadata: None, - is_interactive: false, - resource_target: None, - }, - ); - let result = - resolve_node(&node).expect("invalid literal source should resolve to error op"); - let err = result - .execute(HashMap::new()) - .expect_err("invalid literal source should fail at runtime"); - assert!( - err.to_string().contains("invalid literal source"), - "unexpected error: {err}" - ); - } - #[test] fn resolve_services_gcp_transport_ops() { let cases = [ @@ -1659,19 +1806,15 @@ mod tests { } #[test] - fn resolve_unknown_module_defers_as_passthrough() { + fn resolve_unknown_module_fails_closed() { let node = callable_node( "unknown_op", "tools.unknown", "do_something", ObligationCategory::None, ); - let op = resolve_node(&node).expect("unknown modules should defer"); - let debug = format!("{op:?}"); - assert!( - debug.contains("DeferredCallableOp"), - "expected deferred callable, got {debug}" - ); + let err = resolve_node(&node).expect_err("unknown modules should fail closed"); + assert!(err.reason.contains("unknown callable")); } #[test] @@ -1707,11 +1850,11 @@ mod tests { "render_clippy_toml", ObligationCategory::None, )); - dag.add_node(callable_node( + dag.add_node(primitive_node( "prepare_read", "tools.pragma", "content_upsert::prepare_read_clippy", - ObligationCategory::ServiceTransportPrepare, + PrimitiveOpKind::IoPrepareFileRead, )); dag.edges.push(gunbc_ir::Edge { from_node: "render".into(), @@ -1731,11 +1874,11 @@ mod tests { #[test] fn needs_transport_resource_respects_existing_res_file_port() { - let lowered = callable_node( + let lowered = primitive_node( "execute_read_makegen", "tools.makegen", "content_upsert::execute_read_makegen", - ObligationCategory::ServiceTransportExecute, + PrimitiveOpKind::IoExecuteFileRead, ); let resolved = Node::opaque( "execute_read_makegen", diff --git a/lib/transport/src/freshness_policy.rs b/lib/transport/src/freshness_policy.rs index e0cf4d0c086..74d6f946456 100644 --- a/lib/transport/src/freshness_policy.rs +++ b/lib/transport/src/freshness_policy.rs @@ -23,7 +23,7 @@ use gunbc_ir::resource::{load_manifest_default, save_manifest_default, ManagedRe /// - The repo is already fresh (manifest check passes) /// /// Returns `Some(steps)` if the repo needs freshening, where steps are -/// the sequential chain: codegen-dag → testgen → pragma → clippy → test-compile → build-release. +/// the sequential chain: codegen-dag → testgen → pragma → clippy → test-compile → release-check. pub fn check_and_plan_freshness() -> Option> { // Recursion prevention: if we're already inside a freshness context, skip. if std::env::var(FRESHNESS_ACTIVE_ENV).is_ok() { @@ -78,7 +78,7 @@ pub fn update_freshness_manifest() -> Result<(), String> { /// 3. pragma: process pragma directives /// 4. clippy: lint check (with auto-fix) /// 5. test-compile: compile lib tests without running -/// 6. build-release: build workspace release binaries +/// 6. release-check: compile-check release bins for the workspace fn freshness_steps() -> Vec { vec![ FreshnessStep { @@ -142,10 +142,10 @@ fn freshness_steps() -> Vec { ], }, FreshnessStep { - id: "build-release".into(), + id: "release-check".into(), command: vec![ "cargo".into(), - "build".into(), + "check".into(), "--workspace".into(), "--release".into(), "--bins".into(), @@ -153,3 +153,19 @@ fn freshness_steps() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn freshness_steps_ends_with_release_check_gate() { + let steps = freshness_steps(); + let release_check = steps.last().expect("freshness chain should not be empty"); + assert_eq!(release_check.id, "release-check"); + assert_eq!( + release_check.command, + vec!["cargo", "check", "--workspace", "--release", "--bins"] + ); + } +}