Services
cairn services owns the multi-service environment a spec pool needs: docker, conditional data seeding, and tmux session management — all config-driven, started once before the pool and stopped after the last spec. cairn services status is the read-only check; the lifecycle itself runs automatically on cairn run unless disabled. cairn services up / down keep the stack running between runs when you explore against it.
cairn services status
cairn services status
cairn services status --config ./cairntrace.config.yml --env local
cairn services status --config ./cairntrace.config.yml --project my-appReports the current state of the configured services environment (docker, seed freshness, tmux session) for one environment: --env <name> (default: the config's defaultEnvironment, else local) selects its effective services block, per-environment overrides applied. It also reports the config's cairn services up owner lock: state (absent, held, unreadable), who holds it, for which env and since when (ageSeconds). A lock held for this environment also gets a quick liveness look — with the environment's scoped secrets, like a run — and stale: true with the problems when the services it owns are not actually up; a phase it cannot see is listed under unchecked and trusted. --project <name> overrides the project name (default: from config); --config <path> picks an explicit config. It also lists each services.tunnels entry (recorded state, pid, whether the process runs) and the names a services.provisioner exports (never values). Output supports --format json|yaml|md.
Keeping services up: cairn services up / down
Exploring a page (cairn discover, accompany sessions, manual checks) needs the stack up between runs, while a normal cairn run starts it and tears it down. services up boots it once and hands ownership to an explicit lock:
cairn services up --env local # docker → seed → tmux, left running
cairn discover /login --env local # explore against the warm stack
cairn run flows/new.yml --env local --reuse-services # warm stack, cold browser
cairn services down --env local # full teardown, lock removedcairn services up [--config <path>] [--env <name>]starts docker, seed and tmux through the same code path ascairn run(same reuse rules, scoped TinyVault secrets, redacted narration on stderr), leaves them running, and writes the config's owner lock atomically:{version: 1, owner: "services-up", project, env, configPath, startedAt, pid, by: "cli" | "mcp"}. There is one lock per config file,~/.cairntrace/services/<config dir>.<hash>.lock.json, keyed by the config's resolved path, so two repos never share one, even when theirproject:names match or are both unset.pidis informational — the command exits once the services are up. Runningupagain for the same environment re-runs the boot (healing missing or idle tmux windows) and refreshes the lock. A failed boot tears down what it started and writes no lock (exit 2). Exit 4 covers three cases: no config found from the working directory, an unknown--env, and an environment without services. An explicit--configthat does not exist or cannot be read is exit 2.Environments share the lock. Environments inherit the config's compose project and tmux session, so
upfor another environment of the same config refuses with exit 4 while the lock is held.While the lock exists,
cairn runfor the locked environment refuses with exit 4 and a message naming the lock: it would otherwise start and tear down a stack it does not own. So does a run of any other environment of the config. The refusal comes before any hook, service, webServer or browser starts; scoped TinyVault secrets are already resolved, and the invocation journal records the refusal. When the services behind the lock are no longer up (a reboot, a crash), the refusal says the lock is stale and names what is down. Under--format json|yamlthe refused run still prints a schema-valid erroredRunResult(orBatchRunResultfor several specs) withfailure.phase: "invocation", as does a services or webServer boot failure (exit 2); MCPcairn_runreturns the same document, and--junitis written.--no-servicesand environments withservices: falseare not affected.--services-dry-runprints alock:line (would reuse / would refuse) instead of refusing; it does not check liveness.cairn auditfollows the same rules and takes--reuse-servicestoo.cairn run --reuse-services(MCPcairn_runreuseServices: true) runs against the locked stack. First it takes one quick readiness look:- the docker
readinessCheck. Without one, a Compose command is checked withdocker compose ps, using the command's own-f/-p/--project-directory/--env-fileoptions anddocker.env. A command it cannot read (shell operators, a wrapper script) or apsthat fails is trusted and logged as unchecked; setreadinessCheckfor an exact check; - the tmux session and every window, with no pane that died or fell back to an idle shell;
readyOn.urlanswering 2xx/3xx (any answer withanyResponse: true), and one look at eachreadyOn.gateanddocker.readygate.
Then it starts nothing and tears nothing down, and the browser starts cold (unless
coldStartis set explicitly). Its events say so:services.docker.reuse,services.seed.skip,services.tmux.reuse, never astartorteardownevent; run-local service evidence still records the reused session. A stale lock (the services it owns are not up) fails with exit 4 and names what is down. So do--reuse-serviceswithout a lock, a lock held for another environment, and an unreadable lock file.- the docker
cairn services down [--config <path>] [--env <name>]is the full teardown. It runs the configuredteardowncommands in order, without the reuse skipping a run applies, sodocker compose downruns when the config lists it. Adockerphase that no teardown command stops gets a warning, because its containers keep running. Then it runstmux kill-sessionif the session is still alive, and removes the lock. It also stops a stack a normal run left alive for reuse (no lock needed). While the lock is held for another environment it refuses with exit 4 and tears nothing down. A failing teardown command is reported with exit 2 (exit 8 when it is acriticalentry or the provisioner'sdown); the rest still runs and the lock is still removed. Tunnels a state file names are stopped, and the provisioner'sdownruns last.Under the run lock. When the config takes
run: { lock }(top level or in any environment), a livecairn runof the config (or of itsproject:, for a project-scope lock) owns the stack:cairn services up,downandrestart(andcairn_services_up|down|restart) refuse with exit 4, touch nothing — no provisionerdownunder a live run's billable resource, no tunnel stopped, no window restarted — and name the owner (pid, environment, age, command line with--varkeys only). Their result'srunLocksays what happened:refused(withreasonandowner),held(the command took the lock for its own duration, so acairn runstarted meanwhile refuses; the lock file names the holder,command: services down),reclaimed(the owner was gone: taken over as a run would, unlessstaleAfterPidDead: false) ornested. A run exportsCAIRN_RUN_LOCK(its lock file) to every process it starts, so acairn services …command that a suite hook or arun:step of that run calls runs under the run's lock (nested) instead of refusing. There is no--force: stop the run instead (Ctrl-C, orkill <pid>), which runs its own teardown, the provisioner'sdownincluded; once its process is gone the lock is reclaimed.
Both commands support --format json|yaml|md (urn:cairntrace.dev:services-up:v1 with phases, lock and redacted lifecycle events; urn:cairntrace.dev:services-down:v1 with teardown[], tmuxKilled and removedLock). MCP agents use cairn_services_up / cairn_services_down (config, env); they wait for a cairn_run of the same server that holds the same config, and they refuse unless the server runs as cairn mcp --allow-services (or with CAIRN_MCP_ALLOW_SERVICES=1). The same flag gates the services boot of MCP cairn_run, cairn_spec_finish and cairn_audit: without it, a call whose config would start services fails with exit 4 before anything starts (pass noServices or reuseServices). For environments whose services provision paid or shared infrastructure, also mark the environment policy: { trait: protected } and list it in the specs' requires.env with an optIn variable that only your own driver sets.
The lifecycle (on cairn run)
When services: is configured, cairn run starts the environment once, runs the spec pool, then tears it down. The phases:
version: 1
environments:
local: {}
services:
docker:
command: "docker compose up -d"
reuseExisting: true
readinessCheck: "curl -sf http://localhost:27017"
healthcheck:
command: "curl -sf http://localhost:9200/_cluster/health | grep -q green"
intervalSeconds: 15
retries: 5
seed:
command: "yarn demo-import"
ttlSeconds: 21600
freshnessCheck: "mongosh --quiet --eval 'db.count()' mongodb://localhost:27017/db"
# Always run after seed (even when skipped as fresh) — lightweight fixture ensure.
postCommands:
- "mongosh mongodb://localhost:27017/db --quiet tools/ensure-fixture.js"
tmux:
session: myapp
reuseExisting: true
waitForReadyBeforeNext: true # opt-in: gate each later window on prior readyOn
options:
- { key: mouse, value: "on" }
env:
NODE_ENV: development
windows:
- name: web
cwd: web-app
command: "yarn serve"
readyOn: { url: http://localhost:8080 }
healthcheck:
command: "curl -sf http://localhost:8080/healthz"
intervalSeconds: 20
retries: 3
artifacts:
when: on-failure
capture: [lifecycle, tmux, docker, seed]
maxLinesPerSource: 2000
maxBytesPerSource: 524288
maxBytesPerRun: 8388608
stash:
enabled: true
autoStash: always
capture: [tmux, docker, seed]
tags: [services, myapp]
teardown:
- "tmux kill-session -t myapp"
- "docker compose down"- docker —
commandruns once;reuseExisting: trueskips if the readiness check already passes.readinessCheckgates startup and is polled (1s cadence) until it exits 0 or the phase deadline (readyTimeoutMs, 0 = indefinite) is reached — containers routinely need a few seconds afterStartedbefore they accept connections.readylists readiness gates waited after it — also when running containers are reused.healthcheckkeeps polling after readiness until green orretriesis exhausted. - seed — runs after docker is healthy. Freshness is tracked at
~/.cairntrace/services/<project>.seed.jsonwith a three-layer check (fingerprint + TTL + optional data-level command). A fresh-enough seed is reused; otherwise the seed command re-runs. OptionalpostCommandsalways run after that decision (skip or complete) — use them for lightweight fixture ensure scripts the bulk import does not ship (strings, or named objects withwhen,continueOnError,timeout; see Seed transaction, which also coversphases,commitandexpectOutput). - tmux — a named session with one or more windows, each with its own
cwd,command,readyOn,after, andhealthcheck.readyOncan be{ url }(a 2xx/3xx answer, see readiness gates) or{ text }; either one is enough.readyOn.gateadds a gate that must also pass.after: [gates]delays booting the window until those gates pass (the database a worker connects to). By default Cairn boots every window before waiting for readiness. SetwaitForReadyBeforeNext: trueto boot in declaration order and wait for each window'sreadyOnbefore creating or starting the next; all windows share the singlereadyTimeoutMsdeadline, and a dead/disappeared pane fails immediately. On reuse, missing windows are created and idle panes (shell prompt, no running service) are re-launched; busy panes are left alone. If docker was freshly started this run, the whole session is recreated so app processes reconnect to new containers. Cairn waits for the interactive shell beforesend-keysand clears pane history first soreadyOntext cannot match stale scrollback. - artifacts — saves bounded, redacted lifecycle, docker/provisioner command output, tmux, local Compose, and seed/post-command evidence inside each run while the services are still alive. A remote provisioner such as Chalupa retains its launch/tunnel transcript without probing an unrelated local Compose project. The block is optional and defaults to
when: on-failure, all four sources, 2,000 lines and 512 KiB per source, and 8 MiB total per run. Setwhen: alwayswhile stabilizing a suite orneverto disable it. Collection errors are recorded inservices/manifest.jsonand never change the test verdict. - stash — optionally saves session artifacts (tmux panes, docker logs, seed output) in the local file.cheap vault. It does not upload or replicate them.
- teardown — after the last spec. When tmux reuse is on (the default), cairn leaves the session alive and also skips
docker compose downso infra the live panes need is not torn out from under them. Withtmux.reuseExisting: false, full teardown runs (tmux kill + docker down). The same commands run when a phase fails mid-boot (failure cleanup). Each command leaves aservices.teardown.complete/services.teardown.failevent (index,exitCode,durationMs) in the invocation journal, and its redacted output inlogs/services-teardown.log. - teardown on SIGINT / SIGTERM — the process is exiting, so cairn runs a synchronous teardown: it kills the tmux session it created, then waits up to
CAIRN_SERVICES_SIGNAL_GRACE_MS(default 5000) for a boot command that is still running (docker, seed, readiness check, healthcheck, post-command) to exit. A terminal Ctrl-C reached that command too, and a provisioner that is cancelling itsupholds its state lock until it exits, so a teardown started meanwhile would race it. cairn sends it no signal of its own, because a second signal turns a graceful cancel into a forced one. A command still running after the grace keeps running, as in 2.x, and the teardown proceeds. Then cairn runs the teardown commands that have not run yet, each capped atCAIRN_SERVICES_SIGNAL_TEARDOWN_TIMEOUT_MS(default 10000). Thetunnelsstay up while those commands run (a teardown command that dumps data through a tunnel still reaches it) and stop right before the provisioner'sdown, the order of a normal stop: teardown commands, tmux kill, tunnels, provisionerdown. Teardown commands (normal, failure cleanup, signal path) run detached: in their own process group and session, with no terminal and their output in a private temp file, so the Ctrl-C or group SIGTERM that stops cairn does not kill a provisioner'sdownhalfway, and one still running after cairn exits does not die on a closed pipe. A teardown command must therefore not prompt. When the signal lands while the normal teardown runs a command, cairn waits up toCAIRN_SERVICES_SIGNAL_TEARDOWN_TIMEOUT_MSfor it and never starts a second copy while it is alive, because two copies of a provisioner'sdownrace for its state lock; past the wait it is left to finish in the background (the event names its output file). If nothing of it is left (something killed its process group), cairn runs it again. Each step is recorded as aservices.teardown.signalevent (kind: bootwithexited; orindexwithstatus: completed | failed | timed-out | finished | in-flight | re-run), with the output inlogs/services-teardown.log. Raise both budgets for slow remote provisioners, for exampleCAIRN_SERVICES_SIGNAL_GRACE_MS=180000 CAIRN_SERVICES_SIGNAL_TEARDOWN_TIMEOUT_MS=240000whenup/downcreate and destroy cloud machines, so the teardown starts after the cancelledupreleased its lock anddowncan finish. While this cleanup runs (with the suite'safterhooks before it, andrun.finallyand the run lock after it), a second Ctrl-C, a SIGTERM or a SIGHUP is ignored: cairn printscleanup in progress (critical teardown pending); further Ctrl-C is ignored until it ends, send SIGKILL to forcebefore the slow part, and only SIGKILL stops it early. Every step is bounded, so it always ends.
Service operations: restart, logs, supervision
A stack that outlives one run needs the small operations a Taskfile used to carry: restart one service, read its output since the restart, keep a tunnel up, bring a billable resource up and always take it down, rewrite a config file the services read. They are config, not scripts. Every key is optional; a config that uses none of them behaves as before.
version: 1
environments:
local: {}
services:
provisioner: # a resource this run creates and must destroy
up: "bun tools/create-sandbox.ts"
down: { run: "bun tools/destroy-sandbox.ts", timeout: 10m } # critical, onSignal: wait by default
exports:
REMOTE_HOST: "bun tools/sandbox-host.ts" # one value per command
tunnels:
- name: remote-db
command: "ssh -N -L 27018:localhost:27017 ${env.REMOTE_USER}@$REMOTE_HOST"
restart: always
giveUpAfter: 5
backoff: { initial: 1s, max: 30s }
ready: "tcp://127.0.0.1:27018"
files:
- path: app/config.local.json
json: { db: { host: "${exports.REMOTE_HOST}", port: 27018 } }
restart: [api] # restart this window when the file changed
tmux:
session: myapp
columns: 250 # session size (default 250x50)
rows: 50
windows:
- name: api
command: "yarn api"
readyOn: { text: "listening on" }
restart: { policy: on-exit, backoff: 2s, max: 3 }
healthcheck:
command: "curl -sf http://localhost:3000/healthz"
intervalSeconds: 10
retries: 3
onUnhealthy: restart # or warnOrder. provisioner → tunnels → docker → files → seed → tmux. Teardown runs the other way: supervision stops, the teardown commands run, the tmux session is killed, the tunnels stop, and the provisioner's down runs last. A phase that needs an earlier one (a seed through a tunnel, a window reading a rewritten file) therefore always finds it up.
cairn services restart and cairn services logs
cairn services restart api worker --stop-timeout 60s --ready-timeout 3m --json
cairn services logs api --since-restart --lines 100
cairn services logs api --wait "listening on \d+" --timeout 2m # exit 0 on a match, 1 on timeout
cairn services logs api --followrestart <window...> handles the windows one after the other. For each: Ctrl-C (a second one at 40% of --stop-timeout, default 30s), wait for the pane's process to exit — cairn never hard-kills a service, and a window that ignores Ctrl-C fails the restart with stop-timeout — clear the pane history, print a marker line @@cairn-restart:<id>@@, resend the window's env, preCommands (with skipIf) and command, and wait for readyOn of the new generation. readyOn.text is matched only below the marker, so stale scrollback never counts as readiness; a readyOn.url or gate is checked as for a boot. A failure stops the sequence and the remaining windows are reported skipped. It refuses, touching nothing (exit 4), when the session is not running, a name is not a window of the configured session, the window is missing from the live session, or a live cairn run is supervising the session (it keeps a ~/.cairntrace/services/tmux-supervisor.<session>.<hash>.json marker while it does; let that run restart its windows, or restart after it ends): cairn never sends keys to a pane it did not configure. tmux targets are exact (=session:=window), so a session or window whose name merely starts with the configured one (app-wt, web-worker) is never touched. A pane counts as running while its shell has a foreground job, so a service started as bash start.sh gets its Ctrl-C.
logs <window> prints the captured pane (capture-pane -J, wrapped lines joined) after the invocation's redaction. --since-restart keeps what follows the last restart marker (the whole pane, and the result says so, when there is none); --lines <n> shows the last N (default 200); --wait <regex> [--timeout 30s] waits for a line (exit 0) or gives up (exit 1); --follow streams new lines until interrupted and takes text output only.
services exec <window> -- <command> does not exist on purpose. Typing into a pane that runs a service interleaves with its output, cannot report an exit status, and races the process. What it would be used for has a typed place: a run: step, a teardown/finally entry, a fixture, services.files, or restart.
Both commands support --format json|yaml|md (urn:cairntrace.dev:services-restart:v1, urn:cairntrace.dev:services-logs:v1). The events are services.restart.start|stop|ready|fail|giveup.
Supervision (while a run is active)
Windows with restart: { policy: on-exit, backoff?, max? } are started again when their process exits (the pane falls back to an idle shell): after the backoff (a duration, or { initial, max, factor }; default 1s doubling up to 30s), at most max times in a row (default 5; a window that stayed up 30 seconds starts counting again), then services.restart.giveup and a warning. healthcheck.onUnhealthy turns the healthcheck into a monitor for the whole run: the command runs every intervalSeconds after startPeriodSeconds, and after retries consecutive failures cairn warns (warn) or restarts the window with the same backoff and max (restart). Supervision starts after the boot and stops before the teardown. cairn services up starts the stack and exits, so nothing supervises it; use a run (or restart by hand) for that.
Tunnels
tunnels[] entries are helper processes (an SSH or cloud tunnel). Each runs in its own process group with its output in ~/.cairntrace/services/<key>-tunnel-<name>.log, and its pid, state and owner (the cairn process that started it) in ~/.cairntrace/services/<key>.tunnel.<name>.json, where <key> is the project, the environment and a hash of the config file's path — two checkouts of one project, or two environments, never share or stop each other's tunnels. ready is a readiness gate (a tcp:// target, a URL, a named gate) that must pass before the next phase; a tunnel that exits first fails the boot with its output. restart: always restarts an exiting tunnel while a run is active (after backoff, up to giveUpAfter consecutive times, default 5, then services.tunnel.giveup); never (default) reports the exit. A tunnel is stopped on every exit path: the end of the run, a failed boot, SIGINT/SIGTERM, and cairn services down; a copy a crashed earlier run left behind (same pid, same start time) is stopped before the new one starts, while one a live cairn process owns is never touched (the boot fails, naming its pid; services down reports it skipped). Only a running tunnel's own process (start time checked) is ever signalled: a pid that exited is never reused for a signal. Events: services.tunnel.start|ready|exit|restart|giveup|stop|fail.
Provisioner
provisioner brings up a resource that costs money or is shared, and guarantees it goes away:
upruns before every other phase (default budget 10 minutes,timeoutto change it).exportsare commands that each print one value (more or fewer lines is an error), run in order afterup; every later phase, the webServer, hooks, suite hooks, specs and verifiers see them as environment variables (NAME), and${exports.NAME}works inservices.files. A credential-named export (TOKEN,SECRET,PASSWORD,KEY, …) is registered for redaction; events carry only the export names.PATH,HOME,SHELL,NODE_OPTIONS,LD_*andDYLD_*cannot be exported. Tmux windows get the exports through the session environment (a credential-named export only reaches a session created with a new tmux server).downis mandatory (a provisioner without one does not validate) and defaults tocritical: trueandonSignal: wait. It is registered beforeupstarts, so it runs on every exit path: after the normal run, after a failedup, after any later phase failed, and on SIGINT/SIGTERM (the signal path waits for it up to itstimeout, 10 minutes by default). It runs through the same machinery as a critical teardown entry, last, after the tmux session and the tunnels. Adownthat fails or times out is exit 8 (cairn services downtoo, andcairn services upwhen the cleanup of its failed boot could not complete it: the result'steardown[]names the entry andevents[]holds the failed boot's events), and the failing entry is in the journal (services.teardown.failwithcritical: true, provisioner: true). Anupthat failed, timed out or was cancelled before itsexportsran still gets them evaluated (best-effort, each bounded by its timeout) so thedowncan find the resource; on SIGINT/SIGTERM duringup, cairn forwards SIGTERM to it and waits up to thedown's own signal budget before thedownruns.- A provisioned environment never reuses a tmux session: its windows would only hold connections to a resource
downdestroys. cairn services uprunsupand leaves the resource untilcairn services down; a run with--reuse-servicesevaluates theexportscommands again (nothing is created) so the run sees the values.- Per environment:
environments.<name>.services.provisionermerges key by key over the top-level one (the result must still haveupanddown;cairn config validatesays when it does not);falseremoves it. A config may declare the provisioner (or any phase) only in the environment that owns it, with no top-levelservices:block at all.tunnelsandfilesreplace as lists (falseremoves them), anddocker: falsedrops the docker phase.
Files
files[] writes a file before the seed and the tmux phase, atomically (temp file, then rename). An existing file keeps its mode; a new one is created 600 (its content may hold an export or a secret) unless the entry sets mode ("644", "0640"). A symlink is written through to the file it points at (a link whose target is missing is refused). json deep-merges an object into the file (objects merge, arrays replace, null removes a key; the indentation is kept) and an existing file that is not valid JSON, or not an object, is never overwritten; text replaces the content. String values may use ${exports.NAME} (late-bound; an unknown name fails the write) and ${env.NAME} (filled from the process environment when the config loads). A write that would not change the content is skipped, and each entry journals services.files.write|unchanged|fail with the configured path, before/after fingerprints (hmac-sha256: + 16 hex chars, keyed per run so a short secret in the content cannot be guessed from them; they compare within one run) and byte counts — never the content. restart: [windows] restarts those windows when the file changed and they were already running (a window this boot launched started with the new file).
Seed transaction
version: 1
environments:
local: {}
services:
seed:
target: "${env.SEED_DATABASE}" # part of the state key (free text)
ttlSeconds: 21600
commit: afterPostCommands
expectOutput: { notMatches: ["COLLECTION ERROR", "[Ff]atal"] }
phases:
- { name: schema, run: "bun tools/migrate.ts" }
- { name: import, run: "bun tools/import.ts", skipIf: { command: "bun tools/count.ts --min 1000" } }
- { name: verify, run: "bun tools/check-seed.ts", always: true }
postCommands:
- { name: ensure-fixture, run: "bun tools/ensure-fixture.ts", timeout: 2m }
- { name: heavy-index, run: "bun tools/index.ts", when: { suite: [full] }, continueOnError: true }
- "bun tools/legacy-ensure.ts" # a plain string keeps working- Phases replace the single
command(a seed has one or the other). A phase runs whenalwaysis set; otherwise it is skipped when a recorded success of the same command is still insidettlSeconds(with no TTL a phase has no memory and runs), or whenskipIfpasses: a shell command (skipIf: "<cmd>"or{ command }, exit 0 = skip) or a gate ({ gate: <name or inline> }). Each outcome is persisted per project + environment +targetin~/.cairntrace/services/<project>.<env>.<hash>.seed-state.json: a failed phase is recorded at once, so the next run repeats it. A run that failed on a phase is resumed: the phases that had succeeded in it are skipped next time (resumed after a failed run) and a run that gets through every phase clears that. A resume is used once and only while it can hold: a resumed phase with askipIfmust still pass it (resumed …; skipIf passed), a resume older thanttlSeconds(when set) is ignored, a phase only carried over by a resume is not carried into the next one, and a teardown — the failure cleanup, the signal path,cairn services down— drops it, so the next run repeats every phase.cairn services statusshows each phase's last outcome and a pending resume. A changed command is a new phase. Events:services.seed.phase.start|skip|complete|fail. commit: afterPostCommandsstamps freshness (a single-command seed's fingerprint/TTL record, or every phase's record) only after every post-command succeeded; a failed post-command leaves the seed un-stamped, so the next run seeds again (services.seed.commitsays which). The default (afterCommand) is the 2.x behavior: stamped as soon as the seed command succeeded. A seed withcommitortargetkeeps its record per environment and target too (the state key above); without them the record stays per project.- Post-commands are strings or
{ name, run, when, continueOnError, timeout, expectOutput }.when: { suite, env }(a name or a list; asuitecondition never matches a run without--suite) skips the command with aservices.seed.postcommand.skipevent.continueOnErrorrecords the failure and goes on; a fatal failure after tolerated ones lists them all in its error, and withcommit: afterPostCommandsa tolerated failure still blocks the commit.suites.<n>.seed.postCommands.skipmatches a named post-command bynameand a plain string by its exact command text; an entry that matches nothing is still a warning. expectOutput: { notMatches: [regex] }fails a seed command, phase or post-command that exits 0 but prints a match (the error quotes the line; the freshness record is not stamped).
Engine pin
version: 1
environments:
local: {}
requires:
cairntrace: ">=3.1" # semver range: >=, ^, ~, x-ranges, ||, hyphen ranges
runtimes:
node:
path: ./tools/node # optional: the binary node scripts run with
version: ">=22 <25" # optional: it must satisfy thisA run, cairn spec verify, cairn catalog, cairn services up|restart|logs|status, every other command that loads the config, and every MCP tool refuse a config whose requires.cairntrace this cairn does not satisfy, with exit 4 and a message naming both versions. A teardown never waits on the pin: cairn services down warns and tears down. A range npm's semver rejects (3.x.1, *-3, 03.0.1, ^3.0.01) is invalid here too. cairn doctor (it reads the config from the working directory, or --config <path>) and cairn config validate report it too. runtimes.node picks the node binary of node scripts, script verifiers and transforms: CAIRN_NODE (environment) wins, then path (relative to the config directory), then the first node on PATH that satisfies version, then the highest install that does under nvm, fnm, volta, asdf, mise or Homebrew. A node that is missing or out of range is exit 4 before anything starts; the chosen binary is exported to children as CAIRN_NODE.
Run policy (run:): lock, preflight, clean machine, belts
A wrapper script around cairn run usually does four things: it takes a lock so two suites do not fight over one stack, it refuses to start on a bad machine, it proves nothing survived, and it runs belt cleanup that must happen whatever the verdict. The config run: block declares all four, and the run engine enforces them for cairn run and for MCP cairn_run alike, so a bare cairn run --env <name> is as safe as the wrapper was. Every field is optional; a config without run: behaves as before.
version: 1
environments:
local: {}
gates:
stack-reachable: { tcp: "127.0.0.1:8080" }
run:
lock: { scope: config, staleAfterPidDead: true } # or lock: true
preflight:
- name: engine posture
json: docker/posture.json
assert: .engine.mode == "durable" and .engine.workers >= 2
- { secret: DEPLOY_TOKEN }
- { command: "bun tools/check-quota.ts", expectExit: 0, timeout: 30s }
- { gate: stack-reachable }
verifyClean: [browsers, tmux, docker-project]
finally:
- "bun tools/collect-diagnostics.ts"
services:
teardown:
- { run: "bun tools/destroy-compute.ts", critical: true, timeout: 10m, onSignal: wait }A top-level run: applies to every environment; environments.<name>.run merges over it key by key (lists replace, lock: false turns the lock off there).
Order. Config and secrets resolve, then: lock, preflight, verifyClean (before), services and webServer boot, the specs, teardown, the critical-teardown verdict, finally, verifyClean (after), and the lock is released last. A refusal in the first three phases stops the run with exit 4 before anything of cairn's starts.
lock:~/.cairntrace/locks/<label>.<hash>.run.lock.json, created atomically (the finished file is hard-linked into place, so a reader never sees half a lock and two creators cannot both win) and holding the owner's pid, start time, redacted argv (--varkeys only, never their values), invocation id, origin and environment.scope: config(default) is one lock per config file whatever the environment;scope: projectis one perproject:name across checkouts (a config withoutproject:falls back to config scope). A live foreign owner refuses with exit 4 naming its pid, age and command line, and therun.lock.refusedevent says the same. A lock whose owner process is gone, or whose pid now belongs to a younger process, is stale: it is reclaimed with a warning (run.lock.reclaimed), unlessstaleAfterPidDead: false, which refuses until you remove the file. The lock is released on every exit path: the engine's own finally, the SIGINT/SIGTERM handler (last: after the run's still-running commands were ended, services stopped andfinallyran) and a processexithook. Two MCP invocations of one config in one server refuse the second (exit 4) instead of queueing.cairn services up | down | restartrespect the lock too (see Keeping services up). A policy guards one config: an invocation whose specs come from several configs where any declaresrun:(or one config whose environments give its specs different policies) is refused with exit 4, naming each config with a spec that resolves to it — run each config's specs in an invocation of their own, or pass--config <path>to run them all under one config (--suitealone does not help: a suite's specs still load their nearest config); a config that does not load refuses too.preflight: checks run in order and the first failure refuses the run (exit 4) with a message naming it (preflight[2] "engine posture" failed: assert ... is false for docker/posture.json (.engine.workers = 1)).jsonreads a file relative to the config directory and evaluatesassert;secretneeds the name to resolve to a non-empty value in the run's scoped environment (it never prints the value; withsecrets.provider: tvaulteverypreflight[].secretname, and every${env.X}a preflightcommandor afinallyentry uses, is fetched from the vault);commandruns a shell command in the config directory with the scoped environment (expectExitdefault 0,timeoutdefault 60s; the process group is killed at the deadline);gatelooks once at a namedgates:entry (it never waits). Failure text is redacted, and observed JSON values under credential-like keys show as[redacted].when: { suite, env }(a name or a list, as on seed post-commands) limits a check to those suites and environments: asuitecondition never matches a run without--suite, a check left out is named in the log as skipped, and a failure keeps the check's own index (preflight[3]).- Assertion language (
assert): paths.a.b[0](ora.b) against the JSON document; literals (numbers,"strings",true,false,null);==!=<<=>>=(no type coercion; ordering needs two numbers or two strings);in [..];exists(present and not null);and/or/not(also&&||!) and parentheses. A missing path is unequal to everything and unordered. It is a real tokenizer and parser, nevereval; a syntax error is acairn config validateerror with its offset, and so is nesting deeper than 64 levels or more than 1024 tokens. verifyClean: asserts that nothing of this project survives, before the run (a dirty machine refuses it, exit 4) and after it (exit 9, listing what survived; nothing is killed).browsers: browser sessions the owned-session ledger names for this project (a pid only while it is still the process cairn learnt — same start time and command — and still looks like a browser cairn launches) plus agent-browser daemons of cairn run sessions (<session>.pidin agent-browser's state directory) whose working directory is inside the project; another project's browser, a discovery or user session, a process that merely mentions agent-browser and cairn's own ancestors are never flagged.tmux: the services tmux session.docker-project: containers labelled with the compose project (taken from-p/--project-nameinservices.docker.command,COMPOSE_PROJECT_NAME, else the docker working directory's name; name it explicitly with{ docker-project: <name> }when the command changes directory).{ tmux: <session> }names a session. A Docker daemon that is not running (or no docker CLI) counts as clean, with a warning: no container can be running; any other failure to query it counts as dirty (an unverifiable guarantee is not one). After the run, survivors get a short grace (CAIRN_VERIFY_CLEAN_GRACE_MS, default 3000) to finish exiting. Under--reuse-servicesthe stack iscairn services up's:tmuxanddocker-projectare not checked,browsersstill is.finally: commands run after the services/webServer teardown withCAIRN_EXIT_CODE(the code the run settled on, critical-teardown failures included, before the cleanliness check),CAIRN_INVOCATION_DIR(the journal directory),CAIRN_ENVandCAIRN_CONFIG_DIR. They are non-fatal: a failure is logged and journaled (finally.finishedwith its exit code) and never changes the exit code. A string or{ run, timeout }(default 60s). They also run after a failed boot, and not at all when the run was refused before anything started. On SIGINT/SIGTERM they run synchronously after the services teardown withCAIRN_EXIT_CODE130 / 143 (output inlogs/finally-signal-NN.log), each bounded byCAIRN_SIGNAL_HOOK_TIMEOUT_MS(default 10s) and all of them together by three times that: a window of their own, so suiteafterhooks that used up theirs never costfinallyits run.- Critical teardown: a
services.teardownentry may be{ run, critical: true, timeout, onSignal: wait }. A critical entry that exits non-zero or times out fails the run with exit 8, which outranks every verdict: precedence is 8, then 9, then the run's own code, so a billable resource that did not stop is never hidden behind a red or green test.timeoutbounds any entry of acairn run(the process group is killed);cairn services downruns every entry as a plain command.onSignal: waitmakes the SIGINT/SIGTERM path wait for the entry up to its timeout (10 minutes when none) instead of the shortCAIRN_SERVICES_SIGNAL_TEARDOWN_TIMEOUT_MScap; a critical entry that fails or is still running after the wait is recorded in the journal (the process exits 130/143 as always). - Exit codes: 4 = a refusal before anything started (live lock, failed preflight, dirty machine before, specs from several configs); 8 = critical teardown failed; 9 = dirty state after the run. When a
run:policy or a critical teardown applies,cairn runholds its--json/--yaml/ md document until that verdict, so the document agrees with the process exit code: itsexitCodeis the invocation's, a spec that passed readsstatus: erroredwithfailure.phase: invocation, andinvocationOutcome(exitCode,specsExitCode,error,runPolicy) says why (run.jsonin the run directory keeps what the spec did). A SIGINT/SIGTERM that ends cairn before that verdict prints the documents of the iterations that finished, withinvocationOutcome.exitCode130 / 143 (their top-levelexitCodestays the specs'). MCPcairn_runandcairn_run_statusreturn the same document. The journal summary carriesrunPolicy(lock,criticalTeardown,dirty,finallyFailed), and the journal events arerun.lock.acquired|reclaimed|refused|released,preflight.started|passed|failed,cleanliness.clean|dirty,finally.started|finished,invocation.bailed.
cairn run --bail (MCP bail) stops scheduling the remaining specs after the first failed or errored one. They are reported as skipped, never as results: BatchRunResult.skipped[] ({ spec, reason: "bailed", bailedBy }) and summary.skipped (not part of total), plus the invocation.bailed event. Specs already running finish, teardown runs as usual, and the exit code follows the usual precedence over the specs that ran (the skipped ones do not count), so it is never lower than the same batch without --bail. With --repeat / --matrix, later iterations do not start. A spec refused by the environment policy does not trip it.
Owned browser sessions. Every browser session cairn starts is recorded in ~/.cairntrace/sessions-ledger/ (session name, backend, invocation, owner pid, browser pids) and removed when the browser closed cleanly. cairn doctor --orphans [--kill] [--yes] [--json] lists the sessions whose cairn process is gone but whose browser survives (urn:cairntrace.dev:doctor-orphans:v1; exit 0 none, 1 some). --kill ends them after a confirmation on a terminal; a structured or non-interactive run needs --yes. A learnt pid is recorded with its start time and command, and only a process that is still that one (and still looks like a browser cairn launches: the agent-browser daemon, Playwright's browsers, --enable-automation) is listed — and checked again right before it is signalled — so a recycled pid, the desktop Chrome or another tool's browser is safe; an entry whose owner has been gone for 7 days is dropped without being resolved. --only <session|pid,...> limits the listing and the kill to those sessions / pids (Studio passes exactly what its confirmation showed).
Suites (cairn run --suite)
A wrapper script usually keeps a table from a suite name to spec paths, then adds what differs per environment: commands to run before and after, vars, which seed steps to skip. The config suites: registry declares it once, and cairn run --suite <name> [--env <env>] (MCP cairn_run { suite }) runs it through the same engine as any run. With no spec path to walk up from, the config is the cairntrace.config.yml found from the working directory, else --config <path> (none found is exit 4). The keys are in Configuration.
version: 1
environments:
local: {}
staging: {}
suites:
checkout:
specs: [flows/checkout, flows/smoke/login.yml]
order: [login]
bail: true
processEnv: { ENGINE_MODE: "${env.ENGINE_MODE:-durable}" }
labels: { cohort: "${env.ENGINE_MODE:-durable}" }
env:
staging:
bail: false
before: ["./tools/warm-cache.sh"]
after: ["./tools/collect-diagnostics.sh"]
hookTimeoutMs: 120000
seed: { postCommands: { skip: [staging-only-fixture] } }- Order of a run. Config and secrets resolve, the run policy's lock and preflight pass, services and the webServer boot, then the suite's
beforehooks run once, then the specs, then the suite'safterhooks (services still up), then teardown. - Hooks. Suite hooks are config: they run under MCP without
cairn mcp --allow-hooks, which gates only thebefore/afteracairn_runrequest carries.beforehooks stop the run on failure or timeout (exit 2).afterhooks run on every exit path once the before phase began (red specs, a failed before hook, a cancel, a SIGINT/SIGTERM — then synchronously, bounded likerun.finally), getCAIRN_EXIT_CODE, and never change the exit code.CAIRN_EXIT_CODEis the specs' verdict: a critical teardown (8) or a dirty machine (9) settles after the teardown, which runs after these hooks —run.finallysees the settled code — and on a signal it is 130 / 143. Both are killed with their process group athookTimeoutMs— the wait for them ends there even when the runtime never reports the exit, and a hook whose exit the runtime loses is settled from the process table — run in the config directory, and are journaled assuite.hook.started/suite.hook.finishedwith a live loglogs/hook-suite-<phase>-NN.log.suite.startedandsuite.finished(withhooksFailed) bracket them inevents.ndjson. - Process env.
processEnv: { NAME: value }(andenv.<name>.processEnv, merged over it by name) is exported to every process of the run:run.preflightcommands, the services phases (provisioner, tunnels, docker, seed and its post-commands), suite hooks, specs, their commands and verifiers;${env.NAME}in the config and the specs resolves to it. Values may use${env.X}and${vars.X}and resolve after the vault's secrets are in; an entry whose${env.X}is unset (no:-default) is not exported, with a warning, so it never hides the caller's own variable.PATH,HOME,SHELL,NODE_OPTIONS,LD_*,DYLD_*,TVAULT_*and what cairn sets itself (CAIRN_SUITE,CAIRN_SUITE_VAR_*,CAIRN_EXIT_CODE,CAIRN_INVOCATION_DIR,CAIRN_RUN_LOCK, …) are refused by the schema. Only names reach the log, the dry-run plan,suites listand the catalog; values are redacted like any scoped secret. - Labels.
labels: { key: value }(andenv.<name>.labels) are stamped on every run of the suite, beforesuite=<name>and your own--label(later wins), socairn stats --group-by <key>cohorts work without a wrapper;cairn stats --invocation <id>keeps the runs of one invocation. - Next to flags. Every run is labelled
suite=<name>(cairn stats --group-by suite; a--label suite=…of yours wins).--parallelbeats the suite'sparallel;--bailadds to itsbailand--no-bail(MCPbail: false) turns it off, whileenv.<name>.bailreplaces it in one environment;--varbeats suite vars — in the specs and in the hooks'CAIRN_SUITE_VAR_<NAME>alike — which resolve once the vault's secrets are in (a suite var whose${env.X}is still unset is not passed, so it never blanks the config var of that name;requires.varsis checked then);--tagnarrows the selection;--select-onlylists what would run.--before/--afterkeep their per-iteration / per-spec meaning. - Seed post-commands.
seed.postCommands.skipdrops the named post-commands from this run's seed phase (--services-dry-runlists them and the narration counts them); an entry that matches none warns.env.<name>.seed.postCommands.skipadds an environment's own skips on top, so a skip that only makes sense where a post-command exists (an environment-only fixture) does not warn in the others;cairn config validatechecks those against that environment's post-commands. - Exit codes. 2:
--suitenext to a spec path that is not one of its specs (its own narrow it), or a before hook failed; 4: unknown suite, or a reference that names no spec; 7:requiresrefused the environment.cairn config validatealso refuses two suite vars that reach hooks as the sameCAIRN_SUITE_VAR_<NAME>(a-banda_b).
Replacing a Taskfile's suite switch
A Taskfile that picks specs with a case on SUITE, flips a path before the run and collects numbers afterwards becomes config plus one command:
# cairntrace.config.yml
version: 1
environments:
local: {}
staging: {}
run:
lock: true
suites:
nightly:
specs: [flows/nightly]
bail: true
env:
staging:
before: ["./tools/set-engine.sh next"]
after: ["./tools/collect-diagnostics.sh"]
metrics:
- name: queue_depth
scope: invocation
command: ./tools/queue-depth.sh
parse: { json: $.depth }cairn run --suite nightly --env staging # was: task test SUITE=nightly ENV=staging
cairn stats --metric queue_depth.delta --group-by suiteThe lock, the spec list, the per-environment commands and the numbers are all declared; nothing is copied into a script.
Metrics probes (diagnostics/metrics.json)
The config metrics: list replaces an --after collector that measured something before and after a run. The engine samples each probe around every spec (scope: spec, the default) or around each iteration's specs (scope: invocation):
command+parse({ json: <path>, reduce? }or{ regex, group?, unit? }) runs a shell command in the config directory with the run's scoped environment;http+json: { path, reduce? }sends a GET with optionalheadersandauth: { bearer | basic }. In anhttpprobe${secrets.X},${env.X}and${vars.X}resolve at every sample, and an unset one fails that sample. Acommandis not expanded: the shell sees${vars.X}literally (cairn config validatewarns), so pass a value through the run's environment instead.sample: [before, after]is the default;every: <duration>(at least 250ms) adds periodic samples while the scope runs. Ticks stop when the scope ends: the timer is cleared and an in-flight probe is killed.- Every sample is bounded by
timeout(default 10s, at most 5m), its process group killed at the deadline. A failure is recorded with a short redacted reason, warned once per metric and journaled (metric.sampledwitherror); it never changes the verdict.
Results: <runDir>/diagnostics/metrics.json (urn:cairntrace.dev:metrics:v1) holds before, after, delta, failures and, for every, series with min, max, mean and the samples (at most 500). The flat numerics <name>.before, <name>.after, <name>.delta (and .min, .max, .mean) are merged into diagnostics/report.json after the --after hooks, keeping whatever a collector wrote, so cairn stats --metric <name>.delta reads them (cairn stats takes non-negative numbers; a negative delta stays in metrics.json). Invocation-scope rows are also written to <journalDir>/metrics.json and to every run of that iteration, stamped with the --repeat / --matrix iteration. Resolved secrets, tokens and header values never reach artifacts: an HTTP source is named by origin and path (or the config template without its query).
Readiness gates
A readiness gate is a typed probe with a waiting policy, declared once in the config's top-level gates: registry and referenced by name (or written inline) wherever something has to be ready:
version: 1
environments:
local: {}
gates:
mongo:
tcp: localhost:27017
search:
http:
url: http://localhost:9200/_cluster/health
json: { status: { in: [yellow, green] } }
broker:
all:
- tcp: localhost:5672
- http:
url: http://localhost:15672/api/health/checks/alarms
auth: { basic: "${secrets.BROKER_USER}:${secrets.BROKER_PASSWORD}" }
web:
all:
- http: http://localhost:8080/
- http: { url: http://localhost:8080/api/session, status: [200, 401, 403] }
stable: 2 # two passing attempts in a row
every: 2s # pause between attempts (default 1s)
timeout: 5m # budget of the wait (default 60s; 0 = no deadline)
worker:
command: { run: "curl -fsS http://127.0.0.1:9005/ready", exitCode: 0 }
stable: 3
services:
docker:
command: docker compose up -d
ready: [mongo, search, broker]
tmux:
session: myapp
windows:
- name: worker
command: bun run worker
after: [mongo]
readyOn: { text: "listening", gate: worker }
- name: web
command: bun run web
readyOn: { gate: web }
webServer:
command: bun run preview
url: http://localhost:4173
ready: [search]Every gate is exactly one probe:
tcp—host:port(or{host, port, timeoutMs}); ready when a connection opens.http— a URL, or{url, method, status, json, text, headers, auth, timeoutMs}.statustakes a code, a class (2xx), a range (200-299) or a list; the default is 2xx/3xx, and redirects are not followed.jsonmaps dotted paths (checks.db,items.0.state,items.length) to a value or a matcher (equals,in,contains,matches,exists,gt/gte/lt/lte).textrequires a substring of the body.authis{basic: "user:pass"},{basic: {username, password}}or{bearer: token}; credentials never appear in events or errors. Write them as${secrets.X}: it resolves from the run's scoped environment (TinyVault keys included) when the gate runs, and an unset one fails the attempt asX not set.${env.X}is substituted when the config loads, like anywhere else in the config — an unset variable becomes an empty string, and a 401/403 answer then says which credential is empty.command— a shell command, or{run, exitCode, stdout, cwd, env, timeoutMs}; ready on an accepted exit code (default 0). Each attempt runs in its own process group: the shell's exit settles it, and whatever it left running is killed — so is everything when it overruns itstimeoutMs(default 30s). Registry gates run from the config's directory.gate— another registry gate by name.all/any— lists of gates or references, checked in parallel each attempt.
The waiting policy belongs to the gate being waited on: stable (consecutive passing attempts; a failure restarts the count, and a nested gate's own stable counts its own streak — once per attempt, even when the gate is reached through two paths), every and timeout. A one-look liveness check (--reuse-services, cairn services status) ignores stable at every depth. A string reference is a registry name, except http(s)://… (an HTTP gate needing 2xx/3xx) and tcp://host:port. Unknown names and reference cycles are config errors (cairn config validate, and any command that loads the config) — in the registry and in services / webServer references alike; a spec's preconditions.wait names are resolved when the spec runs, before its first wait.
Where gates apply:
| Field | When | Budget when the gate has no timeout |
|---|---|---|
services.docker.ready | after the start command and readinessCheck, and on container reuse | what readinessCheck left of docker readyTimeoutMs (default 120s; all of it on reuse) |
tmux windows[].after | before the window is booted (a live window is left alone); probes see the session + window env | tmux readyTimeoutMs (default 90s), per gate |
tmux windows[].readyOn.gate | polled with the pane checks; must pass in addition to url/text | the window's readiness deadline |
webServer.ready | after url / waitForText, also for a reused server | what is left of readyTimeoutMs |
spec preconditions.wait | before the precondition commands; a gate that is not ready fails the run like a precondition named wait <gate> | 60s |
cairn wait / MCP cairn_wait | on demand | 60s, or --timeout |
A spec's preconditions.wait writes gate.started (name, budgetMs, scope), gate.attempt (attempt, ok, detail; identical attempts are coalesced to one every 5s), and gate.passed / gate.failed (attempts, durationMs, lastDetail, timedOut, cancelled) to the run's events.ndjson, and sets the phase banner (phase.changed with the gate name as item and its budget). cairn wait narrates the same attempts on stderr. Services waits record services.docker.readiness-check / services.tmux.ready-wait events with the gate name and budget, and log each failed attempt at debug level.
cairn wait
cairn wait mongo search # config gates, in order
cairn wait http://localhost:8080/health --timeout 2m --stable 2
cairn wait http://localhost:9200/ --status 2xx,401 --json
cairn wait tcp://localhost:5672 --every 500msTargets are waited in order and the wait stops at the first that is not ready. --status and --any-response apply to URL targets; --timeout, --every and --stable override every target's policy. --config / --env pick the registry and the environment whose scoped secrets gates may reference; a wait on URL and tcp:// targets only does not read a config. Attempt narration goes to stderr; stdout carries urn:cairntrace.dev:wait:v1 (ok, gates[] with name, ok, attempts, durationMs, budgetMs, lastDetail, timedOut, cancelled, plus exitCode, error, config). Exit codes: 0 ready, 1 not ready (failed, timed out or cancelled), 2 a --config that cannot be read, 4 invalid input (unknown gate or env, an invalid config, a bad flag). MCP: cairn_wait {targets, config, env, status, anyResponse, timeoutMs, everyMs, stable} — keep timeoutMs below your client's tool timeout.
URL readiness needs 2xx/3xx
The readiness URL of webServer — its url, or the environment baseUrl it probes when the block sets neither url nor waitForText — and tmux readyOn.url used to count any HTTP answer as ready, a 503 Service Unavailable from a proxy in front of a booting app included. They now need a 2xx or 3xx answer. Set anyResponse: true on the webServer block or the readyOn to keep the old rule (an app whose root answers 401 to anonymous requests, for example), or point the URL at a health route. The timeout error names the last status and the anyResponse fix, and a URL that keeps answering the same non-ready status for 10 seconds gets one warning (readiness: <url> has answered 401 for 10s; readiness needs a 2xx/3xx answer …) long before the timeout; a dev server that answers 503 while it warms up and then 200 does not. The port-conflict check of webServer (reuseExisting) still treats any answer as "something is listening", and a reused server must now also be ready within readyTimeoutMs.
Reading service artifacts from a run
Runs that capture service evidence keep it inside the self-contained artifact pack under services/: services/manifest.json describes the capture and services/tmux/<window>.log contains each sanitized tmux window log. Use the run-aware log commands so the evidence remains tied to the exact behavioral run that produced it:
cairn logs latest --services
cairn logs latest --service web-api
cairn logs previous --service workerWhen ref is omitted, service lookup uses latest. Cairntrace checks the selected run first. If it has no run-local service pack (or no matching tmux window), the command falls back to the legacy pane logs under ~/.cairntrace/services (or CAIRN_SERVICES_LOG_ROOT). This compatibility fallback keeps older runs inspectable while new captures move into run directories managed by the normal retention policy.
Skipping and per-environment overrides
cairn run flows/x.yml --no-services # skip the whole lifecycle
cairn run flows/x.yml --services-dry-run # print the plan and exit; do not run specs--services-dry-run never reads the vault: it resolves which secret names a real run would ask tvault for (secrets.keys / secrets.required, the ${env.X} / ${secrets.X} names of the spec, its actions and the run: policy, the environment's auth: names for a use: login flow) and prints them as a secrets: line, names only. No tvault process is started, not even --version, so a plan is safe to print on a machine without vault access; secrets.required is not checked, and a suite's requires.vars is not judged (a var the vault provides cannot be known).
The printed plan keeps commands readable while replacing interpolated environment and selected-vault secret values with [redacted]. It lists every phase of the environment's effective block: the environment, the suite and the names of its processEnv, the provisioner's up / down (and whether down is critical) and its export names, each tunnel with its ready gate, docker, each file, the seed with how many post-commands run, which ones the suite skips and which ones a when leaves out, tmux, how many teardown entries there are and how many are critical, and the services up lock line. An environment without services prints a line saying so. cairn docs run-policy lists the same knobs as a wrapper-to-config table.
Per-environment overrides replace --no-services for remote envs:
version: 1
services:
tmux:
session: myapp
windows:
- name: web
command: "bun run dev"
environments:
dev:
services: false # disable all services (app is already deployed remotely)
staging:
services: # partial block deep-merges over the top-level one
seed:
command: "bun run seed:staging"
ttlSeconds: 3600
secrets: # an env-level secrets block REPLACES the top-level one
provider: tvault
tvault:
project: myapp-staging
remote:
services: # keep docker/seed phases, drop inherited local tmux windows
tmux: falseA partial services: block deep-merges over the top-level one; without a top-level block, an environment's services: stands alone (only that environment boots them — a provisioner for the one environment that pays for a machine, say). An env-level secrets: block replaces the top-level one entirely. Inside a partial services: block, tmux: false removes only the inherited local tmux windows while keeping the docker and seed phases — for apps running remotely over a tunnel that still own provisioning and seeding.
TinyVault seeding
secrets.provider: tvault resolves only the invocation's explicit secrets.keys, required, and root-spec/imported-action placeholder names, then supplies that scoped set to the seed command. It never exports a whole project or mutates global process.env; publisher-only and TinyVault client-control variables are removed from service children. The tvault: block supports direct (project) or inheritance (group + env) mode. See Secrets for the status command and the cairn secrets diagnostic.
Validation
cairn config validate --json validates the config file — the zod schema plus cross-field .refine() rules: unique window names, readyOn constraints (at least one of url, text or gate), gate definitions (exactly one probe each, no unknown references or cycles in gates:), and tvault provider requires a tvault: block with either project or group+env. Run it before relying on a services block in CI.
See also
- Configuration — the
services:schema and env resolution - Secrets — the TinyVault integration the seed step uses
- Stash —
services.stashpersists session artifacts to fcheap