Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions core/daglang/daglang-lower/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5678,13 +5678,16 @@ fn add_service_call_edges(
known_interface_types: &HashSet<String>,
data_values: &HashMap<String, serde_json::Value>,
) -> Result<(), LowerError> {
// Track transport endpoint usage across ALL callables in ALL modules so
// that the second callable to reference the same service operation gets
// a cloned triplet (_c1, _c2, …) instead of wiring duplicate scalar
// edges to the original. Previously this was per-module, which meant
// two modules calling the same operation both wired to the shared
// prepare node — causing "multiple upstream edges" at execution time
// (BT-E1).
let mut endpoint_use_count: HashMap<String, usize> = HashMap::new();
for module in &project.modules {
let module_name = module.module_path.as_dotted();
// Track transport endpoint usage across ALL callables in the module so
// that the second callable to reference the same service operation gets
// a cloned triplet (_c1, _c2, …) instead of wiring duplicate scalar
// edges to the original.
let mut endpoint_use_count: HashMap<String, usize> = HashMap::new();
for item in &module.ast.items {
let (item_name, params, stmts, uses_binding_types, body_lossy) = match &item.node {
Item::FnDef(def) => (
Expand Down
70 changes: 70 additions & 0 deletions core/daglang/daglang-lower/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3273,3 +3273,73 @@ func caller(id: String) -> { name: String } {
"error should mention service, operation, and missing transport; got: {msg}",
);
}

/// BT-E1: Two modules calling the same service operation must each get their
/// own transport triplet clone. Before the fix, `endpoint_use_count` was
/// per-module, so the second module would wire to the original shared prepare
/// node — producing duplicate scalar edges that fail at execution time.
#[test]
fn cross_module_service_call_gets_cloned_triplet() {
let typed = typed_project_from_sources(&[
(
"dsl/services/api.dag",
r#"module shared.api
service remote.Api {
operation Fetch(query: String) -> { data: String } {
transport rest { method: GET, path: "/fetch" }
}
}"#,
),
(
"dsl/callers/alpha.dag",
r#"module callers.alpha
import shared.api
func alpha_fetch(q: String) -> { data: String } {
result = remote.Api.Fetch(query: q)
return { data: result.data }
}"#,
),
(
"dsl/callers/beta.dag",
r#"module callers.beta
import shared.api
func beta_fetch(q: String) -> { data: String } {
result = remote.Api.Fetch(query: q)
return { data: result.data }
}"#,
),
]);
let dag = lower_typed_project(&typed).expect("lowering should succeed (BT-E1)");

// The original triplet exists.
let suffix = "shared_api_remote_Api_Fetch";
let original_prepare = format!("prepare_transport_{suffix}");
assert!(
dag.nodes.iter().any(|n| n.id.0 == original_prepare),
"original prepare node should exist",
);

// The second caller should get a cloned triplet (_c1 suffix).
let cloned_prepare = format!("prepare_transport_{suffix}_c1");
assert!(
dag.nodes.iter().any(|n| n.id.0 == cloned_prepare),
"cloned prepare node should exist for second caller (BT-E1): nodes = {:?}",
dag.nodes.iter().map(|n| &n.id.0).collect::<Vec<_>>(),
);

// Both prepare nodes should have their own scalar inputs (no shared wiring).
let original_edges: Vec<_> = dag
.edges
.iter()
.filter(|e| e.to_node.0 == original_prepare)
.collect();
let cloned_edges: Vec<_> = dag
.edges
.iter()
.filter(|e| e.to_node.0 == cloned_prepare)
.collect();
assert!(
!original_edges.is_empty() && !cloned_edges.is_empty(),
"both triplets should have incoming edges",
);
}
3 changes: 3 additions & 0 deletions core/daglang/daglang-resolve/tests/module_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ fn real_corpus_dependency_counts_match_expected_snapshot() {
("shared.gist_modes".into(), 5),
("std.access".into(), 0),
("std.box_draw".into(), 3),
("std.ci".into(), 0),
("std.ci_render".into(), 1),
("std.fermi".into(), 1),
("std.fidelity".into(), 2),
("std.filesystem".into(), 1),
Expand All @@ -243,6 +245,7 @@ fn real_corpus_dependency_counts_match_expected_snapshot() {
("std.width".into(), 2),
("tools.bootstrap".into(), 4),
("tools.build".into(), 3),
("tools.cigen".into(), 3),
("tools.clippy".into(), 4),
("tools.codegen".into(), 2),
("tools.deps".into(), 4),
Expand Down
3 changes: 3 additions & 0 deletions core/daglang/daglang-syntax/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub fn expected_dsl_files_sorted() -> Vec<&'static str> {
"shared/gist_modes.dag",
"std/access.dag",
"std/box_draw.dag",
"std/ci.dag",
"std/ci_render.dag",
"std/fermi.dag",
"std/fidelity.dag",
"std/filesystem.dag",
Expand All @@ -125,6 +127,7 @@ pub fn expected_dsl_files_sorted() -> Vec<&'static str> {
"std/width.dag",
"tools/bootstrap.dag",
"tools/build.dag",
"tools/cigen.dag",
"tools/clippy.dag",
"tools/codegen.dag",
"tools/deps.dag",
Expand Down
150 changes: 150 additions & 0 deletions dsl/std/ci.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// std/ci.dag -- CI model types: tautological definitions.
//
// "What is a CI workflow?" -- provider-independent model of CI/CD pipelines.
// Each type is a compositional building block. Data declarations are
// tautological assertions about shared CI configurations (Rust cache paths,
// cargo env, default runners).
//
// Layering:
// Layer 0: Primitives (CiStepKind, CiTriggerEvent, CiPermissionLevel)
// Layer 1: Building blocks (CiStep, CiTrigger, CiPermission, CiCache, CiEnv)
// Layer 2: Composition (CiJob, CiWorkflow)
// Layer 3: Provider (CiProvider = GitHub | GitLab)
//
// Consumers: std/ci_render.dag (rendering), tools/cigen.dag (generation).

module std.ci

// ── Layer 0: Primitives ──────────────────────────────────────────────

// "What kind of step?" -- the three shapes a CI step can take.
type CiStepKind
= Run { command: String }
| Uses { action: String, with: List<CiEnv> }
| DagRun { binary: String, args: List<String> }

// "What triggers a workflow?"
type CiTriggerEvent
= Push
| PullRequest
| Schedule { cron: String }
| WorkflowDispatch

// "What permission level?"
type CiPermissionLevel
= PermRead
| PermWrite
| PermNone

// "Which CI provider?"
type CiProvider
= GitHub
| GitLab

// ── Layer 1: Building blocks ─────────────────────────────────────────

// "What is an environment variable?"
type CiEnv {
key: String
value: String
}

// "What is a permission scope?"
type CiPermission {
scope: String
level: CiPermissionLevel
}

// "What is a CI step?"
type CiStep {
name: String
kind: CiStepKind
env: List<CiEnv>
}

// "What is a trigger?"
type CiTrigger {
event: CiTriggerEvent
branches: List<String>
}

// "What is a cache?"
type CiCache {
key: String
paths: List<String>
restore_keys: List<String>
}

// "What is a checkout?"
type CiCheckout {
fetch_depth: Int?
submodules: String?
}

// ── Layer 2: Composition ─────────────────────────────────────────────

// "What is a CI job?"
type CiJob {
name: String
runner: String
timeout_minutes: Int
steps: List<CiStep>
}

// "What is a CI workflow?"
type CiWorkflow {
name: String
triggers: List<CiTrigger>
permissions: List<CiPermission>
env: List<CiEnv>
jobs: List<CiJob>
cache: CiCache?
checkout: CiCheckout?
secrets: List<String>
}

// "What is a CI config?" -- discovery result from Rust extern bridge.
type CiConfig {
workflow_name: String
runner: String
timeout_minutes: Int
branches: List<String>
permissions: List<CiPermission>
env: List<CiEnv>
cache: CiCache
checkout: CiCheckout
secrets: List<String>
tool_command: String
generator_name: String
regenerate_command: String
}

// ── Layer 3: Shared data declarations ────────────────────────────────

// "What is the Rust cargo cache?"
data rust_cache: CiCache = {
key: "cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}",
paths: [
"~/.cargo/bin/",
"~/.cargo/registry/index/",
"~/.cargo/registry/cache/",
"~/.cargo/git/db/"
],
restore_keys: ["cargo-${{ runner.os }}-"]
}

// "What is the standard Rust CI env?"
data rust_ci_env: List<CiEnv> = [
{ key: "CARGO_TERM_COLOR", value: "always" },
{ key: "RUSTFLAGS", value: "-D warnings" }
]

// "What is the default checkout?"
data default_checkout: CiCheckout = {
fetch_depth: null,
submodules: null
}

// Output paths for provider-specific YAML.
data github_output_path: String = ".github/workflows/ci.yml"
data gitlab_output_path: String = ".gitlab-ci.yml"
Loading
Loading