From 3f2295bf7b9a770dab8b9d2413644cdf1fb6c978 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 2 Jun 2026 21:12:02 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(dsl-engine):=20registry=5Fjson=20?= =?UTF-8?q?=E2=80=94=20full=20discovery=20envelope=20for=20the=20Python=20?= =?UTF-8?q?engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/hm-dsl-engine/src/lib.rs | 5 +++ crates/hm-dsl-engine/src/python_engine.rs | 20 +++++++++++ crates/hm-dsl-engine/src/ts_engine.rs | 7 ++++ .../hm-dsl-engine/tests/python_engine_test.rs | 34 +++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/crates/hm-dsl-engine/src/lib.rs b/crates/hm-dsl-engine/src/lib.rs index bb4b4b5a..2d80324f 100644 --- a/crates/hm-dsl-engine/src/lib.rs +++ b/crates/hm-dsl-engine/src/lib.rs @@ -25,6 +25,11 @@ pub struct PipelineMeta { pub trait DslEngine: Send + Sync { async fn list_pipelines(&self, project_dir: &Path) -> anyhow::Result>; async fn render_pipeline_json(&self, project_dir: &Path, slug: &str) -> anyhow::Result; + /// Emit the full discovery envelope JSON for every pipeline in the repo: + /// `{"schema_version": "...", "pipelines": [{slug, name, allow_manual, + /// triggers, definition}, ...]}`. Returned verbatim from the DSL runtime so + /// the backend's pipeline discovery can consume it directly. + async fn registry_json(&self, project_dir: &Path) -> anyhow::Result; } /// Return an appropriate [`DslEngine`] for the given language. diff --git a/crates/hm-dsl-engine/src/python_engine.rs b/crates/hm-dsl-engine/src/python_engine.rs index 654f0a2c..3724c133 100644 --- a/crates/hm-dsl-engine/src/python_engine.rs +++ b/crates/hm-dsl-engine/src/python_engine.rs @@ -23,6 +23,20 @@ envelope = json.loads(hm.dump_registry_json()) print(json.dumps([{'slug': p['slug'], 'name': p['name']} for p in envelope['pipelines']])) "; +const REGISTRY_JSON_SCRIPT: &str = "\ +import sys, json, pathlib, importlib.util +try: + import harmont as hm +except ImportError as e: + print(f'error: {e}\\n -> install with: pip install croniter python-dateutil', file=sys.stderr) + sys.exit(1) +for p in sorted(pathlib.Path('.harmont').glob('*.py')): + spec = importlib.util.spec_from_file_location(f'_harmont_{p.stem}', p) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) +sys.stdout.write(hm.dump_registry_json()) +"; + const RENDER_PIPELINE_SCRIPT: &str = "\ import sys, json, pathlib, importlib.util try: @@ -114,4 +128,10 @@ impl DslEngine for SubprocessPythonEngine { .await .context("rendering pipeline via python3") } + + async fn registry_json(&self, project_dir: &Path) -> Result { + self.run_script(project_dir, REGISTRY_JSON_SCRIPT, &[]) + .await + .context("dumping pipeline registry via python3") + } } diff --git a/crates/hm-dsl-engine/src/ts_engine.rs b/crates/hm-dsl-engine/src/ts_engine.rs index e25b7c58..7e688298 100644 --- a/crates/hm-dsl-engine/src/ts_engine.rs +++ b/crates/hm-dsl-engine/src/ts_engine.rs @@ -223,4 +223,11 @@ impl DslEngine for SubprocessTsEngine { .await .context("rendering pipeline via JS runtime") } + + async fn registry_json(&self, _project_dir: &Path) -> Result { + bail!( + "the discovery envelope (hm pipelines) is not yet supported for \ + TypeScript pipelines; only Python pipelines are supported today" + ) + } } diff --git a/crates/hm-dsl-engine/tests/python_engine_test.rs b/crates/hm-dsl-engine/tests/python_engine_test.rs index b6817891..413b671a 100644 --- a/crates/hm-dsl-engine/tests/python_engine_test.rs +++ b/crates/hm-dsl-engine/tests/python_engine_test.rs @@ -39,3 +39,37 @@ def ci() -> hm.Step: let v: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(v["version"], "0"); } + +#[tokio::test] +async fn python_registry_json_carries_triggers_and_allow_manual() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let harmont = dir.path().join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r#"import harmont as hm + +@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')], allow_manual=False) +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +"#, + ) + .unwrap(); + + let engine = hm_dsl_engine::engine_for(hm_dsl_engine::DslLanguage::Python).unwrap(); + let json = engine.registry_json(dir.path()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + + let p = &v["pipelines"][0]; + assert_eq!(p["slug"], "ci"); + assert_eq!(p["name"], "CI"); + assert_eq!(p["allow_manual"], false); + assert_eq!(p["triggers"][0]["event"], "push"); + assert_eq!(p["triggers"][0]["branches"][0], "main"); + assert_eq!(p["definition"]["version"], "0"); +} From a3e655e65fdf33ad0ae754a49e886bd85773b41d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 2 Jun 2026 21:17:37 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(hm):=20add=20'hm=20pipelines'=20?= =?UTF-8?q?=E2=80=94=20print=20the=20discovery=20envelope=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/hm/src/cli/mod.rs | 5 ++++ crates/hm/src/cli/pipelines.rs | 36 ++++++++++++++++++++++++ crates/hm/tests/cmd_pipelines.rs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 crates/hm/src/cli/pipelines.rs create mode 100644 crates/hm/tests/cmd_pipelines.rs diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index bc1c8d1a..4a68445f 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod pipelines; pub mod plugin; pub mod run; pub mod version; @@ -45,6 +46,9 @@ pub enum Command { /// Run a pipeline locally via Docker. Run(RunArgs), + /// Print the pipeline discovery envelope (JSON) for every pipeline in the repo. + Pipelines(pipelines::PipelinesArgs), + /// Show hm version. Version, @@ -91,6 +95,7 @@ pub struct CacheRestoreArgs { pub async fn dispatch(command: Command, ctx: RunContext) -> Result { match command { Command::Run(args) => crate::commands::run::handle(args, ctx).await, + Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0), Command::Cache(cmd) => match cmd { CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await, CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await, diff --git a/crates/hm/src/cli/pipelines.rs b/crates/hm/src/cli/pipelines.rs new file mode 100644 index 00000000..acc2c971 --- /dev/null +++ b/crates/hm/src/cli/pipelines.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use hm_dsl_engine::{detect, engine_for}; + +#[derive(Debug, Clone, Parser)] +pub struct PipelinesArgs { + /// Source root containing `.harmont/` (defaults to cwd). + #[arg(short, long)] + pub dir: Option, +} + +/// Print the discovery envelope JSON (all pipelines) to stdout. +/// +/// # Errors +/// +/// Returns an error if the language can't be detected, the engine can't start, +/// or the DSL runtime fails to evaluate the pipelines. +pub async fn run(args: PipelinesArgs) -> Result<()> { + let repo_root = match args.dir { + Some(d) => d, + None => std::env::current_dir().context("cannot determine current directory")?, + }; + + let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?; + let engine = engine_for(lang).context("initializing DSL engine")?; + let json = engine + .registry_json(&repo_root) + .await + .context("dumping pipeline registry")?; + + // Machine-facing: raw envelope JSON on stdout, nothing else. + print!("{json}"); + Ok(()) +} diff --git a/crates/hm/tests/cmd_pipelines.rs b/crates/hm/tests/cmd_pipelines.rs new file mode 100644 index 00000000..5e282575 --- /dev/null +++ b/crates/hm/tests/cmd_pipelines.rs @@ -0,0 +1,47 @@ +//! `hm pipelines` emits the discovery envelope JSON to stdout. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stderr +)] + +use assert_cmd::Command; + +#[test] +fn pipelines_emits_discovery_envelope() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let harmont = dir.path().join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r"import harmont as hm + +@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')]) +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +", + ) + .unwrap(); + + let out = Command::cargo_bin("hm") + .unwrap() + .arg("pipelines") + .arg("--dir") + .arg(dir.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["pipelines"][0]["slug"], "ci"); + assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push"); +} From 7a1c254cd78a673cb9366b030800dd6abbd7ad6a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 2 Jun 2026 21:24:42 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(hm):=20add=20'hm=20render=20'=20?= =?UTF-8?q?=E2=80=94=20print=20one=20pipeline's=20v0=20IR=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/hm/src/cli/mod.rs | 5 +++ crates/hm/src/cli/render.rs | 41 +++++++++++++++++++ crates/hm/tests/cmd_render.rs | 75 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 crates/hm/src/cli/render.rs create mode 100644 crates/hm/tests/cmd_render.rs diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index 4a68445f..60b31000 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -1,5 +1,6 @@ pub mod pipelines; pub mod plugin; +pub mod render; pub mod run; pub mod version; @@ -49,6 +50,9 @@ pub enum Command { /// Print the pipeline discovery envelope (JSON) for every pipeline in the repo. Pipelines(pipelines::PipelinesArgs), + /// Render one pipeline's v0 IR (JSON) without running it. + Render(render::RenderArgs), + /// Show hm version. Version, @@ -96,6 +100,7 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result { match command { Command::Run(args) => crate::commands::run::handle(args, ctx).await, Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0), + Command::Render(args) => crate::cli::render::run(args).await.map(|()| 0), Command::Cache(cmd) => match cmd { CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await, CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await, diff --git a/crates/hm/src/cli/render.rs b/crates/hm/src/cli/render.rs new file mode 100644 index 00000000..4a7d6202 --- /dev/null +++ b/crates/hm/src/cli/render.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use hm_dsl_engine::{detect, engine_for}; + +#[derive(Debug, Clone, Parser)] +pub struct RenderArgs { + /// Pipeline slug to render. + #[arg()] + pub slug: String, + + /// Source root containing `.harmont/` (defaults to cwd). + #[arg(short, long)] + pub dir: Option, +} + +/// Render one pipeline's v0 IR JSON to stdout without executing it. +/// +/// # Errors +/// +/// Returns an error if the language can't be detected, the engine can't start, +/// or the slug is unknown / fails to render (the available slugs are written to +/// stderr by the DSL runtime). +pub async fn run(args: RenderArgs) -> Result<()> { + let repo_root = match args.dir { + Some(d) => d, + None => std::env::current_dir().context("cannot determine current directory")?, + }; + + let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?; + let engine = engine_for(lang).context("initializing DSL engine")?; + let json = engine + .render_pipeline_json(&repo_root, &args.slug) + .await + .with_context(|| format!("rendering pipeline {:?}", args.slug))?; + + // Machine-facing: raw v0 IR JSON on stdout, nothing else. + print!("{json}"); + Ok(()) +} diff --git a/crates/hm/tests/cmd_render.rs b/crates/hm/tests/cmd_render.rs new file mode 100644 index 00000000..f28d241b --- /dev/null +++ b/crates/hm/tests/cmd_render.rs @@ -0,0 +1,75 @@ +//! `hm render ` emits one pipeline's v0 IR JSON to stdout. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stderr +)] + +use assert_cmd::Command; + +fn write_fixture(dir: &std::path::Path) { + let harmont = dir.join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r"import harmont as hm + +@hm.pipeline('ci') +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +", + ) + .unwrap(); +} + +#[test] +fn render_emits_v0_ir_for_slug() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + write_fixture(dir.path()); + + let out = Command::cargo_bin("hm") + .unwrap() + .arg("render") + .arg("ci") + .arg("--dir") + .arg(dir.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["version"], "0"); + assert!(v["graph"].is_object(), "expected a graph object in the v0 IR, got: {v}"); +} + +#[test] +fn render_unknown_slug_fails_with_available_on_stderr() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + write_fixture(dir.path()); + + Command::cargo_bin("hm") + .unwrap() + .arg("render") + .arg("nope") + .arg("--dir") + .arg(dir.path()) + .assert() + .failure() + // Both the bad slug and the list of available slugs must reach stderr. + .stderr(predicates::str::contains("nope")) + .stderr(predicates::str::contains("ci")); +} From 0a186dbbdbe7968e18c32fe90343c9f15f5b08e0 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 2 Jun 2026 21:34:55 +0000 Subject: [PATCH 4/4] polish(hm): drop unused json import in registry script; tighten render test assertion --- crates/hm-dsl-engine/src/python_engine.rs | 2 +- crates/hm/tests/cmd_render.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/hm-dsl-engine/src/python_engine.rs b/crates/hm-dsl-engine/src/python_engine.rs index 3724c133..4ff3f21a 100644 --- a/crates/hm-dsl-engine/src/python_engine.rs +++ b/crates/hm-dsl-engine/src/python_engine.rs @@ -24,7 +24,7 @@ print(json.dumps([{'slug': p['slug'], 'name': p['name']} for p in envelope['pipe "; const REGISTRY_JSON_SCRIPT: &str = "\ -import sys, json, pathlib, importlib.util +import sys, pathlib, importlib.util try: import harmont as hm except ImportError as e: diff --git a/crates/hm/tests/cmd_render.rs b/crates/hm/tests/cmd_render.rs index f28d241b..af649b98 100644 --- a/crates/hm/tests/cmd_render.rs +++ b/crates/hm/tests/cmd_render.rs @@ -48,7 +48,10 @@ fn render_emits_v0_ir_for_slug() { let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); assert_eq!(v["version"], "0"); - assert!(v["graph"].is_object(), "expected a graph object in the v0 IR, got: {v}"); + assert!( + v["graph"].is_object(), + "expected a graph object in the v0 IR, got: {v}" + ); } #[test] @@ -71,5 +74,5 @@ fn render_unknown_slug_fails_with_available_on_stderr() { .failure() // Both the bad slug and the list of available slugs must reach stderr. .stderr(predicates::str::contains("nope")) - .stderr(predicates::str::contains("ci")); + .stderr(predicates::str::contains("available: ci")); }