Architecture
This page is for people about to change Lattice itself. It names crates, modules, and types directly.
Crate layout
Lattice is a Cargo workspace of ten crates under crates/, plus the desktop
app’s backend at apps/desktop/src-tauri. Nine of the ten ship in the tool. The
tenth exists only for the test suites.
| Crate | Owns |
|---|---|
lattice-config |
lattice.json types (LatticeConfig, WorkspaceConfig, PipelineTask, Settings, EngineSpec), the WELL_KNOWN_ENGINES, MANIFESTS, and LOCKFILES tables, load_config, find_root, validation, and the bundled JSON Schema (schema::ensure_schema) |
lattice-workspace |
Workspace discovery, the driver evidence ladder (DRIVERS, detect_drivers, infer_task_command), and, in its toolchain submodule, the engine gradient and provisioning |
dagger |
Builds the cross-workspace ExecutionGraph from resolved workspaces plus the task map, and flattens it into a Schedule |
lattice-cache |
Cache identity (compute_key, compute_key_detailed, KEY_COMPONENTS) and storage (the CacheStore trait, LocalStore) |
lattice-runner |
The async scheduler (execute_tasks): spawns tasks, wires the cache, injects toolchain PATHs, manages persistent children and signal teardown |
lattice-events |
TaskEvent, the Reporter trait, CacheMiss, OutputLine. Depends on serde and nothing else |
lattice-output |
OutputMode, the two terminal reporters (InteractiveReporter, CiReporter), and splash rendering |
lattice-project |
One opened repo (Project) and the pipeline both front ends run against it: plan, run, RunOutcome, the scaffold module lattice init writes through, and the view wire types |
lattice |
The lattice binary: the clap surface, the subcommands, the version-pin handover |
lattice-testkit |
Dev-only. Task commands spelled for whichever shell will run them, so a test means the same thing on every platform, plus the stand-in programs the suites put on PATH |
The root lattice.json declares every crate plus apps/web and apps/desktop
as workspaces, so the repo runs its own tasks through itself.
Dependency direction
The internal edges, non-dev, regenerated with:
cargo metadata --no-deps --format-version 1 \
| jq -r '[.packages[].name] as $ws | .packages | sort_by(.name)[]
| "\(.name) -> " + ([.dependencies[] | select(.kind == null)
| select(.name | IN($ws[])) | .name] | sort | join(", ")
| if . == "" then "(nothing internal)" else . end)'
dagger -> lattice-config, lattice-workspace
lattice -> dagger, lattice-cache, lattice-config, lattice-output,
lattice-project, lattice-runner, lattice-workspace
lattice-cache -> lattice-config
lattice-config -> (nothing internal)
lattice-desktop -> dagger, lattice-config, lattice-events, lattice-project,
lattice-runner, lattice-workspace
lattice-events -> (nothing internal)
lattice-output -> lattice-events
lattice-project -> dagger, lattice-cache, lattice-config, lattice-events,
lattice-runner, lattice-workspace
lattice-runner -> dagger, lattice-cache, lattice-config, lattice-events,
lattice-workspace
lattice-testkit -> (nothing internal)
lattice-workspace -> lattice-config
This is a graph rather than a chain, which is why it is listed as edges and not
drawn as a tree. Three crates depend on nothing internal. lattice-config is
the base every other crate models on top of. lattice-events depends on serde
alone and is the only thing the runner and the reporters share.
lattice-testkit appears solely as a dev-dependency, of lattice,
lattice-project, lattice-runner, and lattice-workspace.
lattice-cache depends on lattice-config and nothing else, because
compute_key needs a PipelineTask and a filesystem path rather than workspace
discovery or a graph. Cache identity is therefore testable in isolation from how
a task came to exist.
Three edges are worth reading twice, because the crate names suggest the opposite.
lattice-runner does not depend on petgraph
petgraph is a dependency of dagger alone. dagger builds a
petgraph::graph::DiGraph<TaskNode, ()> internally, topologically sorts it, then
calls build_schedule to flatten it into a petgraph-independent Schedule:
Vec<HashSet<usize>> prerequisites, Vec<Vec<usize>> dependents, and a
Vec<usize> indegree count, all indexed by position rather than by petgraph’s
NodeIndex.
lattice-runner imports dagger::{build_schedule, ExecutionGraph, Schedule}
and its scheduling loop in execute_tasks walks plain integer indices. Two
things follow. dagger’s own Schedule unit tests exercise scheduling shape
without a live process, and the graph library can be replaced without touching
the runner.
lattice-runner does not depend on the crate that renders its output
The events and the Reporter trait live in lattice-events. The runner emits
typed TaskEvents and calls Reporter hooks (run_start, event,
surface_failure, run_summary, note, warn, task_note, task_warn,
finish), and never touches console, indicatif, or println! for task
status.
Both lived in lattice-output alongside the renderers once, which meant the
async executor depended on a terminal stack for two type definitions. The
desktop app is the third Reporter implementation and would have had to link
indicatif to watch a run.
InteractiveReporter (an indicatif::MultiProgress TUI) and CiReporter
(plain, greppable lines) are the two implementations make_reporter picks
between. ChannelReporter in apps/desktop/src-tauri is the third, and adding
it required no change to the runner.
Both front ends run the same pipeline
Opening a repo is four steps that always happen together: find the root, keep the
schema file present, load the config, resolve the workspaces. Project::open
and Project::open_root in lattice-project do all four, and running a task
goes through lattice_project::plan and lattice_project::run in the same
crate. Neither the CLI subcommand nor the Tauri command holds any of it, so the
window and the terminal cannot disagree about what a task is or whether it needs
to run.
lattice_project::run returns a RunOutcome whose exit_code() the CLI exits
with: 0 completed, 1 failed, 130 interrupted. Nothing in the crate renders
anything or ends the process.
Data flow of lattice run
One invocation, end to end:
- Entry.
Cli::parse()(clap), thenCli::execute(). - Version-pin handover. Unless the subcommand is
upgrade,completions, orversion(Cli::skips_pin_handover),Cli::executecallscrate::drift::repo_root()thendrift::honor_pin. That readslatticeVersionout of thelattice.jsontext directly rather than throughlattice-config, so a schema the running binary cannot parse still yields a pin. If a managed binary under.lattice/bindoes not match, it installs andexecs the pinned version instead. - Open the repo.
Project::openwalks up from the cwd withlattice_config::find_root, callslattice_config::schema::ensure_schemato write.lattice/schema.jsonif absent, thenlattice_config::load_config(which parses and callsLatticeConfig::validate) andlattice_workspace::discover_workspaces. - Reject unknown tasks.
Project::require_known_tasksfails before anything is provisioned or spawned, listing the tasks that do exist. - Output mode.
crate::cli::detect_output_modeconsults the real TTY and theCIenvironment variable throughlattice_output::detect_mode. If the requested tasks’ transitive closure holds a persistent task (dagger::includes_persistent_task), an otherwise-Interactiverun is forced toRaw. - Workspace discovery and driver detection. Inside step 3,
discover_workspacesresolves each workspace’s merged engine map (lattice_config::resolve_engines), walks the evidence ladder (detect_drivers) forautoworkspaces, and resolves each root task’s command (infer_task_command) unless ascriptsentry supplies one. See Driver detection for the ladder. - Graph construction.
dagger::build_execution_graph_selectedexpands the requested root tasks across the resolved workspaces intoTaskNodes, wires^taskand bare-taskedges, rejects cycles and a persistent task with a dependent, and topologically sorts the result into anExecutionGraph. Under--filter, the matched workspaces are the roots: the graph narrows to them plus their transitive prerequisites, flagged on the node so--dry-runcan tag them. - Schedule.
dagger::build_scheduleflattens that graph into the petgraph-independentScheduleabove. - Scheduler execution.
lattice_runner::execute_tasksresolves each workspace’s merged engines into aPATHprefix and an identity string throughlattice_workspace::toolchain::provision_and_resolve, memoized so an identical engine spec provisions once, then appends the project’s dependency bin directories fromlattice_workspace::dependency_bin_dirs. Those go after the toolchain’s, so a pin still decides which copy of a tool runs. Then it drives the in-degree scheduler: a node at indegree zero is spawned as a Tokio task under aSemaphore-capped concurrency. Each spawned task computes its cache key withlattice_cache::compute_key_detailed, looks it up through theCacheStore, and either restores the artifact or runs the command through the platform shell (sh -corcmd /C) with the resolvedPATHprepended. On success it stores outputs back through the sameCacheStore. See Cache internals and Engines and provisioning. - Event emission and reporting. Every state change (
Started,CacheHit,CacheMiss,Output,Finished,Failed,PersistentExited,Skipped) is alattice_events::TaskEventsent over an internalmpscchannel and forwarded to theReporterchosen in step 5.
Tests
Three layers.
Unit tests are colocated as #[cfg(test)] mod tests at the bottom of the
file they cover. dagger covers graph construction, cycle and persistent-leaf
rejection, and Schedule shape. lattice-cache covers hash stability and
sensitivity plus the store, lookup, restore, corruption, and prune round trip.
lattice-workspace covers every rung of the evidence ladder and role
composition and conflict; its toolchain submodule covers classify,
parse_version, satisfies, and a provision-then-reuse round trip against a
fake installed tool. lattice-runner covers ordering, concurrency, fail-fast
against --continue, the cache round trip, and persistent tasks: not blocking
the graph, reporting an exit, and staying silent when the exit was a shutdown
kill. All of it runs against a RecordingReporter double. The lattice crate
covers version-nag gating and pin-handover decisions as pure functions.
Black-box end-to-end tests live under crates/lattice/tests/, each a
#[test] binary that builds a throwaway repo in a tempfile::TempDir through
the shared Fixture helper in common/mod.rs and drives the compiled binary
with assert_cmd:
| File | Covers |
|---|---|
e2e_run.rs |
Caching, filtering, dry-run, stacked and sequential phases, keep-going |
e2e_toolchain.rs |
Provisioning and PATH injection with a fake installCmd |
e2e_halves.rs |
The task-runner and toolchain-manager halves used independently, plus prune and completions |
e2e_init.rs |
Scaffolding |
e2e_passthrough.rs |
Wrapping a nested repo that has its own runner |
e2e_upgrade.rs |
Version pinning and self-update |
e2e_guardrails.rs |
Defects that produced a plausible-looking run: a misordered build from a misspelled name, a hit serving a stale artifact, a prune taking the toolchains with it |
scripts/stress-test.sh is a single dependency-free hermetic script, just
under 2,000 lines, that builds the release or debug binary, generates a
production-shaped multi-language monorepo plus focused sub-repos in a temp
directory, and exercises every command and flag against it: top-level surfaces,
init, setup, run (ordering, filtering, dry-run, concurrency), caching,
persistent tasks, failure handling, driver detection across roughly sixteen
ecosystems, error paths, prune, nested-repo passthrough, install and upgrade
and pinning, the shipped agent skill, and the desktop app’s wiring. It needs no
network and no real language toolchains: driver detection is verified through
--dry-run, and provisioning through a fake locally-installed toolchain.
AGENTS.md requires updating it alongside any change to CLI surface or behavior.
Extension seams
Where a change is most likely to land.
Adding a driver
Add a DriverSpec to the DRIVERS array in lattice-workspace: tool,
language, roles, fingerprint files, version_cmd, and an invoke_tpl
holding a {task} placeholder. roles is a slice, because a tool may fill
several, and the highest-ranked one is the role it competes for as a driver.
Rank runs Runtime < BuildTool < PackageManager < TaskRunner. That
decides both drive rank and what the tool composes with or conflicts against.
If a fingerprint should read as a native declaration rather than a generic
lockfile, add it to is_native_fingerprint. The language slug is what the
desktop app keys its ecosystem artwork off, and a stress-test assertion fails if
a slug has no artwork. Regenerate the tables on
Toolchains from the array rather than from memory.
Adding a well-known engine
Add one (name, version command) row to WELL_KNOWN_ENGINES in
lattice-config. That one table answers both whether a string-form constraint on
the name is valid and how to read the tool’s version, so the two cannot
disagree. A driver in DRIVERS must also have a row here with the same version
command; a test in each crate enforces both halves.
Adding a subcommand
Add a variant to the Commands enum in the lattice crate’s CLI module, an
Args struct and execute method in a new file under
crates/lattice/src/commands/, and a registration in that directory’s mod.rs.
Decide whether the subcommand needs the version-pin handover (extend
skips_pin_handover if not) and whether it needs the version-drift nag (call
maybe_emit_version_nag if so, as run and setup do).
Adding a cache backend
Implement the CacheStore trait from lattice-cache: lookup, store,
restore, prune, and touch. Five more have default bodies —
record_fingerprint and last_fingerprint for miss explanations, and
record_run, recorded_runs, and usage for what lattice stats reports — so
a backend that has no answer for them still compiles. LocalStore is the only
implementation today. The trait exists so a future remote backend can slot in
without lattice-runner changing, and execute_tasks holds its store behind
Arc<dyn CacheStore>.