Bundling akm
This is for anyone shipping akm inside their own product: a Docker image, a
plugin whose node_modules carries its own akm, or any other install a human
never runs akm setup inside. It says exactly what to call at boot and what
each call's JSON means — nothing about akm's internals you don't need.
The contract
Three lines cover it:
- Pin an exact
akm-cliversion and install it however you install things (npm, the standalone binary, anode_modulesdependency). - At boot, run
akm migrate apply(orakm upgrade, which runs it). It is offline, idempotent, needs no root, takes its own safety copies, prints one JSON plan, and exits0unless a file is genuinely blocked. - Read
akm health --format json. Never grep error text — akm's wording is not a stable interface; the JSON fields are.
Everything below is detail in service of those three lines.
akm migrate apply
akm migrate status # read-only: what would change
akm migrate apply --dry-run
akm migrate apply
In order, every run applies (or, under status/--dry-run, plans):
- config.json in its current shape (
configFile): retired and unknown keys dropped, legacyextraParamslifted onto first-class fields, the legacystashDir/sources[]/installedlayout converted tobundles/defaultBundle. Config loading already does all of this in memory; this persists it under a backup. - Pending
state.dbmigrations, historical-destructive ones included (stateMigrations). This is the only path (besidesakm upgrade, which calls the same code) that is allowed to apply a destructive migration to an existing, unversioned or behind-generationstate.db— see One-way state.db below. - Task files: every task file at version 2 or 3, and every version 4
file still carrying the retired
schedule[].enabledkey, rewritten as task source v4 (taskFiles) under one backup directory per run. A file the planner cannot convert unambiguously is reportedblockedand left alone; the rest still convert. - Residue sweep: superseded pre-0.9.0
.akmfiles in the stash and the transaction-journal, maintenance-barrier, lock-mutex and version-stamp files older releases kept under$DATA/$STATE/$CONFIG(deadResidue).
The step list in docs/reference/cli.md is the
authoritative one; the two must agree.
The plan JSON
One JSON object on stdout, always, with one field per step. A current
install, nothing to do, as akm migrate status prints it (under apply the
per-step objects carry results instead — applied, removed — with the
same step names):
{
"schemaVersion": 1,
"status": "current",
"blockers": [],
"configFile": { "keys": [], "changed": false, "applied": false },
"stateMigrations": { "pending": [] },
"taskFiles": { "schemaVersion": 1, "changed": 0, "skipped": 0, "blocked": 0, "files": [] },
"deadResidue": { "pending": [] }
}
akm migrate status against a tree with one blocked task file:
{
"schemaVersion": 1,
"status": "blocked",
"blockers": ["tasks/nightly.yml: with-on-non-command-target"],
"stateMigrations": { "pending": [] },
"taskFiles": {
"schemaVersion": 1,
"changed": 3,
"skipped": 1,
"blocked": 1,
"files": [
{
"filePath": "tasks/nightly.yml",
"status": "blocked",
"reason": "with-on-non-command-target",
"beforeHash": "…"
}
]
}
}
status is one of:
current— no task-source file needs converting, and no config extraParams lift is pending. A no-op boot.ready— task sources have eligible changes and no config-lift conflict; a realapplywill clear them.blocked— at least one item cannot be applied automatically (a genuine authoring problem, e.g. a task file the migrator can't rewrite unambiguously, or a configextraParamsvalue that conflicts with its first-class field).blockersnames each one.applystill applies everything it can, then reportsblockedand exits non-zero.
A pending state.db migration needs no human decision, so it is never
blocked: under akm migrate status or apply --dry-run it reads as
ready (the run would change state.db) and is listed in
stateMigrations.pending; a real apply always applies it.
Key fields:
| Field | Meaning |
|---|---|
stateMigrations |
{ pending: string[] } under status/--dry-run; { applied: string[], safetyCopyPath?: string } after a real apply. safetyCopyPath is present only when a historical-destructive migration ran — see below. |
taskFiles |
changed/skipped/blocked counts and a files[] array with each file's status and reason. |
backupPath / applied |
Present after a real apply that rewrote at least one task file: the run's backup directory and how many files it rewrote. |
deadResidue |
{ pending: [...] } under a read-only run, { removed: [...] } / { recovered: [...], quarantined: [...], deferred: [...] } after apply. An untrusted journal (unreadable journal.json, or a fence violation) is quarantined to $DATA/txn-quarantine; a trusted, fenced journal whose rollback/finalize throws is deferred, left in place under $DATA/txn/<rootNs>/. Neither is thrown, and neither sets status to blocked. staleTxns.pending entries carry wouldQuarantine: { reason } when the journal's read-only fence check alone (not a rollback/finalize run) already shows it would be quarantined by a real apply. |
Backups and their retention:
- Task-file backups go to
<dataDir>/backups/tasks/<timestamp>-<uuid>/, one directory per apply run that rewrote a file; nothing prunes them. - Config backups go to
<cacheDir>/config-backups/config-<ISO-ts>.json, also capped at 5. - A
state.dbhistorical-destructive migration writes a verified sibling snapshot next tostate.dbitself:state.db.pre-<migrationId>.<UTC-digits>.<UUID>.bak. This one is never auto-pruned — it is a one-time, rare event (a single destructive ledger boundary), so clean it up yourself once you've confirmed the upgrade, if disk space matters to your image.
Exit codes:
| Exit | Meaning |
|---|---|
0 |
current or ready-and-applied — nothing left to do. |
1 |
blocked — at least one item needs a human. |
| anything else | A crash, not a migration outcome: {"ok":false,"error":"...","code"?:"...","hint"?:"..."} on stderr, with the exit code from akm's normal error-classification table (2 usage, 70 internal, 78 config). |
Idempotency: re-running apply against a current database/tree is a
no-op — safe to put in every boot, every time, on every replica.
Offline, no root: every step reads and writes files/SQLite it already
owns under your configured $DATA/$CACHE/bundle directories. Nothing here
touches the network or needs elevated privileges.
akm upgrade in a bundled install
akm upgrade # install a newer release if there is one, then migrate
akm upgrade --check # check for updates only — no install, no migration step
akm upgrade always runs the migration step after its install step — on
install success, on install failure, and when there was nothing to install.
Its result carries the same plan under migration:
{
"currentVersion": "0.9.8",
"newVersion": "0.9.9",
"upgraded": false,
"installMethod": "package-local",
"message": "akm runs as a dependency of the package at /opt/myapp; upgrade that package to move akm.",
"migration": { "schemaVersion": 1, "status": "current", "blockers": [], "stateMigrations": { "pending": [] } }
}
installMethod: "package-local" is the detection that matters for a
bundler: an akm living inside another package's node_modules (your
image's tools/ dependency, a plugin's own node_modules/akm-cli) is never
npm install -g'd over — that would silently diverge from the copy the
parent package actually executes. akm reports upgraded: false and names the
parent package to upgrade instead; its migrations still run, exactly as
in every other install method. installMethod is one of "binary",
"bun", "npm", "pnpm", "package-local", or "unknown".
Exit code: akm upgrade exits 1 when migration.status is "blocked"
or "failed", even if the install itself succeeded — an upgrade whose
migration didn't finish is not done. This makes plain akm upgrade (no
flags) a safe, idempotent container entrypoint step on every boot: on an
already-current install with nothing pending it is a fast no-op that exits 0.
--check skips the migration step entirely — it only compares versions
and reports updateAvailable. Use it for a version-drift alert, not as your
boot check.
akm health
akm health --format json
Read the JSON. Never parse the human-readable text or grep for a phrase — that wording is not a stable interface.
Exit codes: 0 (status: "pass"), 4 (status: "warn"), 1
(status: "fail").
The check that replaces grepping akm's refusal text is a hard check
named state-db-migrations. Every state.db open applies pending migrations
(copying the file to state.db.pre-<id>.bak first when one drops schema), so
after an upgrade the check passes and names what health's own open applied:
{
"name": "state-db-migrations",
"status": "pass",
"message": "Applied 11 pending state.db migration(s) on open (018-drop-dead-lane-schema … 028-improve-ledger); the pre-migration copy is /data/akm/state.db.pre-018-drop-dead-lane-schema.bak.",
"evidence": {
"path": "/data/akm/state.db",
"pending": [],
"applied": ["018-drop-dead-lane-schema", "…", "028-improve-ledger"],
"backupPath": "/data/akm/state.db.pre-018-drop-dead-lane-schema.bak"
}
}
It is fail only when a pending migration could not be applied; evidence.pending
then names what is still pending. Watch this row's status, not error text.
The akm-migrate executable
akm-migrate is the second bin entry the akm-cli npm package installs
(akm and akm-migrate side by side), and it is also embedded directly in
the compiled standalone binary — a release binary re-execs itself internally
to reach it, so it needs no separate download. Same surface either way:
akm-migrate status
akm-migrate apply --dry-run
akm-migrate apply
akm migrate status/apply (under the akm CLI) are a thin wrapper over
this same executable — same plan, same exit codes. Reach for akm-migrate
directly when you don't want akm's --format/--shape/--detail output
handling in the way, e.g. a shell script that just wants the raw JSON line
on stdout and a plain exit code.
Container entrypoint sketch
#!/bin/sh
set -e
akm --version # optional: confirm the pinned version actually landed
akm migrate apply # offline, idempotent — exits 1 only if genuinely blocked
akm task sync --rebind # only if you schedule akm tasks inside this image
akm health --format json # read this JSON; act on ok/status, never on message text
Environment variables
A bundler that controls the filesystem layout should set these explicitly
rather than rely on $HOME-derived defaults (names verified against
src/core/paths.ts and src/tasks/scheduler-invocation.ts):
| Variable | What it points at |
|---|---|
AKM_BUNDLE_DIR |
The content bundle ($STASH) — assets akm indexes and serves. |
AKM_CONFIG_DIR |
config.json's directory. |
AKM_DATA_DIR |
Durable, non-regenerable data: index.db and state.db live here. This is the directory a migration snapshot's safety copy sits beside. |
AKM_CACHE_DIR |
Regenerable cache: registry downloads, config backups, task logs. Safe to discard between image builds (not between boots of the same running install). |
AKM_STATE_DIR |
Not where state.db lives, despite the name — this is the XDG "state" directory. Holds scheduled-task invocation context, companion-plugin hook state (Claude Code / OpenCode hook logs), and, per stash, akm improve's whole-run lock (locks/) — see Storage locations. Set it anyway if you schedule akm tasks inside the image, so that context is captured consistently rather than falling back to $HOME/.local/state/akm. |
Set all five to paths that persist across container restarts (a mounted
volume), or akm migrate apply will see an empty state.db on every boot
and never actually converge.
One-way note: state.db migrations are one-way
Once a migration has run against state.db, an older akm binary that
later opens the same file refuses it outright — the migration ledger moved
forward, and there is no downgrade path. If you need the ability to roll
back a bundled akm version, take your own backup of the whole data directory
($AKM_DATA_DIR) before running akm upgrade/akm migrate apply — the
per-migration safety copy described above exists to protect that one
destructive step, not as a general rollback mechanism for your deployment.