Wrap a repo that has its own task runner
Some subtrees already have a task runner, a lockfile, and an internal dependency
graph of their own. You do not have to flatten one into individual Lattice
workspaces to bring it into the graph. Declare it as a single workspace whose
scripts shell out to the runner it already has, and it becomes one node
Lattice schedules and caches like any other.
This guide works through examples/nested-repo in the Lattice repo. Every block
below was captured against a copy of it.
The shape
lattice.json two workspaces: frontend, api
frontend/ an inner monorepo with its own task runner and lockfile
packages/ui/ built first (an inner dependency)
packages/site/ depends on ui, emits dist/bundle.js
services/api/ reads site's bundle at run time
frontend wraps a repo with its own inner ordering, ui before site. api
reads what frontend produced.
Declare the wrapper
{
"$schema": ".lattice/schema.json",
"latticeVersion": "1.1.1",
"workspaces": [
{
"name": "frontend",
"path": "frontend",
"auto": false,
"scripts": {
"build": "sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run build; else echo \"[frontend] turbo is not installed — run: (cd frontend && npm install)\" >&2; exit 1; fi'",
"test": "sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run test; else echo \"[frontend] turbo is not installed — run: (cd frontend && npm install)\" >&2; exit 1; fi'",
"clean": "sh -c 'rm -rf .turbo packages/*/dist && echo \"frontend clean complete\"'"
}
},
{
"name": "api",
"path": "services/api",
"auto": false,
"dependsOn": ["frontend"],
"scripts": {
"build": "mkdir -p dist && cp src/serve.sh dist/serve.sh && chmod +x dist/serve.sh && echo 'api built'",
"test": "sh -c 'test -x dist/serve.sh && echo \"api test ok\"'",
"serve": "sh dist/serve.sh",
"clean": "sh -c 'rm -rf dist && echo \"api clean complete\"'"
}
}
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"],
"outputs": ["dist/**", "packages/*/dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"]
},
"serve": {
"dependsOn": ["build"],
"cache": false
},
"clean": {
"cache": false
}
},
"settings": {
"maxCacheSize": "1GB"
}
}
Nothing here names the inner packages, and the root declares no engines. The
runner Lattice invokes for frontend:build comes from that repo’s own
node_modules, installed the ordinary way for its ecosystem
(cd frontend && npm install).
Set auto: false on the wrapper
Both workspaces set "auto": false, which skips driver detection and runs only
what scripts declares. A wrapped subtree wants that, because the handoff is
specific: which runner binary, invoked from where, with which flags. Detection
has no way to produce that.
The script above names node_modules/.bin/turbo by path so it can test for the
binary and print an install hint when it is missing. Locating it is not the
reason: Lattice puts frontend/node_modules/.bin on the task’s PATH, so a
bare turbo run build would find the same binary. Write the plain command if
you do not want the guard.
A manual workspace needs a script for any task you run directly against it.
frontend has no serve script, because it has nothing to serve. Running
serve repo-wide, or filtered to api, works. Running it scoped to frontend
fails to resolve. A task pulled in only as a dependency is skipped where a
workspace has no command for it.
Cover the whole subtree with inputs
The inner runner tracks its own file dependencies and keeps its own cache. That
changes what your inputs and outputs need to cover, not whether you declare
them. Lattice hashes frontend as one unit with no view into ui or site
individually, so inputs has to catch a change anywhere inside:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"],
"outputs": ["dist/**", "packages/*/dist/**"]
}
}
}
**/* is wide because the wrapped repo owns its internal layout. The precision
lives in ignore:
| Ignored | Why |
|---|---|
**/node_modules/** |
The installed tree. The lockfile is hashed instead. See Caching. |
**/.turbo/** |
The inner runner’s own cache directory. It changes on every run regardless of source, which would make every build a miss. |
**/dist/** |
Build output. An output glob left inside inputs feeds yesterday’s build into today’s key. |
outputs has to name every artifact directory the wrapped repo produces, at
every level: dist/** for api, packages/*/dist/** for each inner package.
Lattice restores exactly those paths on a hit and no others.
Two caches, not one
Wrapping a subtree does not disable its inner cache. It adds a second one above
it. Lattice’s cache covers frontend as a whole, and a hit restores every inner
package’s dist/ in one step without invoking the inner runner at all. The
inner cache covers individual packages, and it only comes into play on a Lattice
miss, when Lattice hands the whole workspace back to its runner and the runner
decides package by package what reruns.
So the first build after a change inside frontend costs one inner rebuild,
narrowed by the inner cache to the package that changed. The second costs
nothing. This is the trade the wrapper buys: Lattice’s cache is coarse and
language-agnostic, which is what lets the rest of the repo treat frontend as
one node.
Run it
With the inner repo’s dependencies installed once
(cd frontend && npm install), from the repo root:
$ lattice run build
frontend:build: running
frontend:build: done (1.43s)
api:build: running
api:build: done (0.01s)
lattice: 2 tasks, 0 cached, 0 failed, 1.46s
Again with nothing changed, and both workspaces come back from cache without the inner runner executing:
$ lattice run build
frontend:build: cache hit [176b1a72]
api:build: cache hit [25020aef]
lattice: 2 tasks, 2 cached, 0 failed, 0.01s, 1.44s saved
lattice: full power, nothing to run
Edit a file anywhere under frontend, say packages/ui/src/index.js, and both
workspaces rerun. frontend misses because its inputs changed, and api
misses because a task’s cache key includes the keys of its prerequisites:
$ lattice run build
frontend:build: running
frontend:build: done (0.54s)
api:build: running
api:build: done (0.01s)
lattice: 2 tasks, 0 cached, 0 failed, 0.56s
To see which of the two reasons applied, add -v. The miss line names the
component that moved, inputs changed for frontend and
dependencies changed for api. See Caching.
Check the handoff before it runs
--dry-run prints the resolved commands and executes nothing, which is how to
read a handoff script before it fires:
$ lattice run build --dry-run
❖ lattice dry run · build
→ frontend:build sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run build; else echo "[frontend] turbo is not installed — run: (cd frontend && npm install)" >&2; exit 1; fi'
→ api:build mkdir -p dist && cp src/serve.sh dist/serve.sh && chmod +x dist/serve.sh && echo 'api built'
Run a task only the wrapper’s dependent has
serve exists on api alone, so filter to it. serve depends on build, so
the filter still pulls frontend:build in and api never serves a bundle that
was not built:
$ lattice run serve --filter api -v
lattice: running `build+serve` across 2 workspaces
lattice: frontend:build: hash 4a12ab5673815fee
frontend:build: cache hit [4a12ab56]
lattice: api:build: hash e29eccac6575e5f2
api:build: cache hit [e29eccac]
lattice: api:serve: hash 31369803a034db9a
api:serve: running
api:serve: api serving:
api:serve: module.exports = {"site":"site@1","button":"ui/button@1"};
api:serve: done (0.01s)
lattice: 3 tasks, 2 cached, 0 failed, 0.01s, 0.55s saved
See Selecting what runs.
Wrap, or flatten?
Wrap the subtree as one workspace when:
- it has its own task runner, lockfile, and internal dependency graph, and the rest of the repo does not need to schedule that graph
- its packages are only ever built or tested together, never targeted individually from outside the subtree
- adopting it incrementally matters more than exposing its internals. See Adopting Lattice.
Flatten it into individual workspaces when another workspace needs to depend on one specific inner package, when you want a rebuild narrowed to the single inner package that changed, or when the inner runner is the only thing standing between you and running one package’s tests directly. Flattening trades the one-line handoff for per-package caching and dependency edges at Lattice’s own resolution.