diff --git a/README.md b/README.md
index dd601ce..8df6fcf 100644
--- a/README.md
+++ b/README.md
@@ -75,56 +75,6 @@ Save this as `.harmont/pipeline.py` (or `.harmont/pipeline.ts`):
Python
-```python
-import harmont as hm
-
-@hm.pipeline("ci")
-def ci() -> hm.Step:
- return (
- hm.sh("echo 'hello from harmont'", label="hello")
- .sh("uname -a", label="env")
- )
-```
-
-
-
-
-TypeScript
-
-```typescript
-import { sh, pipeline, type PipelineDefinition } from "harmont";
-
-const pipelines: PipelineDefinition[] = [
- {
- slug: "ci",
- pipeline: pipeline(
- sh("echo 'hello from harmont'", { label: "hello" })
- .sh("uname -a", { label: "env" }),
- ),
- },
-];
-
-export default pipelines;
-```
-
-
-
-### 2. Run it
-
-```sh
-hm run ci
-```
-
-If the repo declares only one pipeline, the slug is optional - just `hm run`.
-
-### Real-world example
-
-For production pipelines, use typed toolchains - they generate test, lint, and
-format steps from your project layout:
-
-
-Python
-
```python
import harmont as hm
from harmont.python import PythonToolchain
@@ -177,6 +127,14 @@ export default pipelines;
+### 2. Run it
+
+```sh
+hm run ci
+```
+
+If the repo declares only one pipeline, the slug is optional - just `hm run`.
+
Browse the [example projects](./examples) for idiomatic pipelines in Rust,
Go, Python, Java, C++, React, Next.js, and more.
diff --git a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs
index 024ea87..74d839d 100644
--- a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs
+++ b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs
@@ -50,8 +50,6 @@ fn edge_kinds(g: &PipelineGraph) -> (usize, usize) {
(builds_in, depends_on)
}
-// ---- Python fixtures ----
-
#[test]
fn python_monorepo_ci() {
let g = load_fixture("python", "monorepo-ci");
@@ -115,8 +113,6 @@ fn python_kitchen_sink() {
}
}
-// ---- TypeScript fixtures ----
-
#[test]
fn ts_monorepo_ci() {
let g = load_fixture("ts", "monorepo-ci");
@@ -145,8 +141,6 @@ fn ts_kitchen_sink() {
assert!(g.node_count() >= 12);
}
-// ---- Structural invariants on all fixtures ----
-
#[test]
fn all_fixtures_have_valid_structure() {
for dsl in ["python", "ts"] {
@@ -172,8 +166,6 @@ fn all_fixtures_have_valid_structure() {
}
}
-// ---- Cross-DSL parity ----
-
#[test]
fn parity_node_count() {
for scenario in SCENARIOS {
diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs
index aace559..bc1c8d1 100644
--- a/crates/hm/src/cli/mod.rs
+++ b/crates/hm/src/cli/mod.rs
@@ -67,6 +67,8 @@ pub enum CacheCommand {
Save(CacheSaveArgs),
/// Restore harmont Docker images from a cache directory.
Restore(CacheRestoreArgs),
+ /// Remove all cached workspaces and Docker images.
+ Clean,
}
#[derive(Debug, Clone, clap::Args)]
@@ -92,6 +94,7 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result {
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,
+ CacheCommand::Clean => crate::commands::cache::handle_clean().await,
},
Command::Version => version::run().await.map(|()| 0),
Command::Plugin(cmd) => plugin::run(cmd).await.map(|()| 0),
diff --git a/crates/hm/src/commands/cache/clean.rs b/crates/hm/src/commands/cache/clean.rs
new file mode 100644
index 0000000..86849fc
--- /dev/null
+++ b/crates/hm/src/commands/cache/clean.rs
@@ -0,0 +1,100 @@
+use anyhow::Result;
+
+/// # Errors
+/// Returns an error if workspace cache removal or Docker image listing fails.
+pub async fn handle_clean() -> Result {
+ let mut cleaned = if let Some(ws_cache) = hm_util::dirs::harmont_workspace_cache_dir()
+ && ws_cache.exists()
+ {
+ let size = dir_size(&ws_cache);
+ std::fs::remove_dir_all(&ws_cache)?;
+ tracing::info!(
+ path = %ws_cache.display(),
+ "removed workspace cache ({})",
+ human_bytes(size),
+ );
+ true
+ } else {
+ false
+ };
+
+ let docker = match crate::orchestrator::docker_client::DockerClient::connect() {
+ Ok(d) => match d.ping().await {
+ Ok(()) => Some(d),
+ Err(e) => {
+ tracing::warn!(%e, "Docker daemon unreachable — skipping image cleanup");
+ None
+ }
+ },
+ Err(e) => {
+ tracing::warn!(%e, "cannot connect to Docker — skipping image cleanup");
+ None
+ }
+ };
+
+ if let Some(docker) = &docker {
+ let cache_images = docker.list_images_by_prefix("harmont-cache/").await?;
+ for tag in &cache_images {
+ if let Err(e) = docker.remove_image(tag).await {
+ tracing::warn!(image = %tag, %e, "failed to remove cached image");
+ } else {
+ tracing::info!(image = %tag, "removed cached Docker image");
+ cleaned = true;
+ }
+ }
+
+ let ephemeral_images = docker
+ .list_images_by_prefix("harmont-local-ephemeral/")
+ .await?;
+ for tag in &ephemeral_images {
+ if let Err(e) = docker.remove_image(tag).await {
+ tracing::warn!(image = %tag, %e, "failed to remove ephemeral image");
+ } else {
+ tracing::info!(image = %tag, "removed ephemeral Docker image");
+ cleaned = true;
+ }
+ }
+ }
+
+ if !cleaned {
+ tracing::info!("nothing to clean");
+ }
+
+ Ok(0)
+}
+
+fn dir_size(path: &std::path::Path) -> u64 {
+ fn walk(p: &std::path::Path) -> u64 {
+ std::fs::read_dir(p)
+ .into_iter()
+ .flatten()
+ .filter_map(std::result::Result::ok)
+ .map(|e| {
+ let path = e.path();
+ if path.is_dir() {
+ walk(&path)
+ } else {
+ e.metadata().map_or(0, |m| m.len())
+ }
+ })
+ .sum()
+ }
+ walk(path)
+}
+
+#[allow(
+ clippy::cast_precision_loss,
+ reason = "human-readable display; sub-byte precision irrelevant"
+)]
+fn human_bytes(bytes: u64) -> String {
+ let b = bytes as f64;
+ if bytes < 1024 {
+ format!("{bytes}B")
+ } else if bytes < 1024 * 1024 {
+ format!("{:.1}KB", b / 1024.0)
+ } else if bytes < 1024 * 1024 * 1024 {
+ format!("{:.1}MB", b / (1024.0 * 1024.0))
+ } else {
+ format!("{:.1}GB", b / (1024.0 * 1024.0 * 1024.0))
+ }
+}
diff --git a/crates/hm/src/commands/cache/mod.rs b/crates/hm/src/commands/cache/mod.rs
index c011ea7..d7174a6 100644
--- a/crates/hm/src/commands/cache/mod.rs
+++ b/crates/hm/src/commands/cache/mod.rs
@@ -1,6 +1,8 @@
+mod clean;
pub mod manifest;
mod restore;
mod save;
+pub use clean::handle_clean;
pub use restore::handle_restore;
pub use save::handle_save;
diff --git a/crates/hm/src/orchestrator/archive.rs b/crates/hm/src/orchestrator/archive.rs
index c6cd64c..f5662b5 100644
--- a/crates/hm/src/orchestrator/archive.rs
+++ b/crates/hm/src/orchestrator/archive.rs
@@ -42,6 +42,12 @@ impl ArchiveStore {
.unwrap_or(0)
}
+ /// Return a clone of the full archive bytes, or `None` if unknown.
+ #[must_use]
+ pub fn get_bytes(&self, id: ArchiveId) -> Option> {
+ self.archives.lock().ok()?.get(&id).cloned()
+ }
+
/// Read up to `max` bytes from offset `offset`. Returns empty
/// when offset is beyond end, or when the archive is unknown.
#[must_use]
diff --git a/crates/hm/src/orchestrator/cache.rs b/crates/hm/src/orchestrator/cache.rs
index 1e3208e..12c1d3c 100644
--- a/crates/hm/src/orchestrator/cache.rs
+++ b/crates/hm/src/orchestrator/cache.rs
@@ -1,16 +1,14 @@
//! Host-side cache decision.
//!
-//! Resolves a wire-typed [`CommandStep`] against the local COW
-//! workspace cache directory and returns the wire-typed
-//! [`CacheDecision`] consumed by step execution.
+//! Resolves a wire-typed [`CommandStep`] against Docker image tags
+//! to decide whether to skip execution (cache hit) or run + commit.
//!
//! Cache keys are computed by `harmont.keygen` at plan time and ride
//! along the JSON in `cache.key`.
-use std::path::{Path, PathBuf};
+use hm_plugin_protocol::CommandStep;
-use anyhow::{Context, Result};
-use hm_plugin_protocol::{CacheDecision, CommandStep, SnapshotRef};
+use super::docker_client::DockerClient;
fn sanitize_for_tag(s: &str) -> String {
s.chars()
@@ -24,123 +22,43 @@ fn sanitize_for_tag(s: &str) -> String {
.collect()
}
-// ---------------------------------------------------------------------------
-// COW workspace cache
-// ---------------------------------------------------------------------------
-
-/// The outcome of a COW workspace cache lookup.
-#[derive(Debug)]
-pub struct CowCacheOutcome {
- pub decision: CacheDecision,
- pub cache_to: Option,
- pub stale_dirs: Vec,
-}
-
-/// Resolve the on-disk cache directory for a step's COW workspace.
+/// Derive a deterministic Docker image tag for a cacheable step.
///
/// Returns `None` when the step has no cache, a `"none"` policy, or no
-/// cache key — matching the same guard logic as [`cache_image_tag`].
-///
-/// # Errors
-/// Returns an error if the config directory cannot be resolved.
-pub fn cow_cache_dir(step: &CommandStep) -> Result