Kk-carrier docs

Runnable service upgrade walkthrough

A service with no K dependency, a command controller, and a runner built with a trusted adapter. It uses local temporary files and a data: release URL, so it needs no cloud account or published application release. Run the commands from the repository root with Node 24 and pnpm installed.

The temporary example home and the two protocols: requests go to the runner, lifecycle commands go to the controller $K_EXAMPLE_HOME runner.mjsK + adapter runner-release.jsonhash, size controller.mjsstop, start, probe release.jsonv2 as data: URL active.mjsruntime copy, not a slot k/state directory upgrade.lock journal.jsonl slots/stable/ slots/experiment/ receipts/ Runner protocolupgrade, recover, statusone JSON request on stdinone response on stdout Spoken by install.mjs and by you, to runner.mjs. Controller protocolfence, quiesce, stop,start, probe, resumeone call per command Spoken by the worker (and by you, for a live probe) to controller.mjs. service.mjsno K import, loopback health runs from active.mjs
Two different protocols. Upgrade, recover and status go to the runner; fence, probe, start, stop, quiesce and resume go to the application controller.

Prepare an installation

The temporary home contains the example controller, application data and a K state subdirectory. The runner and interpreter are outside the K slots. The setup step creates trusted v1 bytes, seeds stable, starts the service and prepares a v2 release.

pnpm install --frozen-lockfile
export K_EXAMPLE_HOME="$(mktemp -d)"
node scripts/build-runner.mjs examples/external-service/adapter.ts "$K_EXAMPLE_HOME/runner.mjs"
node --input-type=module <<'JS'
import { readFile, writeFile, copyFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { bootstrapStable, createCommandHost } from './core/src/index.ts';
const dir = process.env.K_EXAMPLE_HOME;
const stateDir = join(dir, 'k');
await copyFile('examples/external-service/controller.mjs', join(dir, 'controller.mjs'));
const template = await readFile('examples/external-service/service.mjs', 'utf8');
const initial = join(dir, 'initial.mjs');
await writeFile(initial, template.replace('VERSION_PLACEHOLDER', '1.0.0'));
await bootstrapStable({ stateDir, version: '1.0.0', artifactPath: initial });
const runner = await readFile(join(dir, 'runner.mjs'));
await writeFile(join(dir, 'runner-release.json'), JSON.stringify({ version: 'demo-runner-1',
  url: `data:application/octet-stream;base64,${runner.toString('base64')}`,
  sha256: createHash('sha256').update(runner).digest('hex'), size: runner.length }));
const candidate = Buffer.from(template.replace('VERSION_PLACEHOLDER', '2.0.0'));
await writeFile(join(dir, 'release.json'), JSON.stringify({ version: '2.0.0',
  url: `data:application/octet-stream;base64,${candidate.toString('base64')}`,
  sha256: createHash('sha256').update(candidate).digest('hex'), size: candidate.length }));
const host = createCommandHost({ stateDir, command: [process.execPath, join(dir, 'controller.mjs'), dir] });
await host.start('stable');
console.log(await host.healthProbe()); // version 1.0.0 plus pid/startId
JS

bootstrapStable only initializes the fallback. It does not represent an upgrade operation, so a status request at this point can report genesis even though v1 is installed and running.

Upgrade, retry and observe

node examples/external-service/install.mjs upgrade demo-v2 2.0.0
node examples/external-service/install.mjs upgrade demo-v2 2.0.0
printf '%s' '{"protocolVersion":1,"action":"status"}' | node "$K_EXAMPLE_HOME/runner.mjs"
CommandExpected response
First upgrade demo-v2 2.0.0result: "promoted", exitCode: 0, an operation with outcome: "promoted"
Second, same id and targetresult: "replayed" and the same operation, without restarting the application
statusThe current receipt. Not a live observation.

Probe separately for a live observation:

printf '%s' '{"protocolVersion":1,"action":"probe"}' | node "$K_EXAMPLE_HOME/controller.mjs" "$K_EXAMPLE_HOME"

The probe should report version 2.0.0 with a different pid and startId from setup.

Recovery and cleanup

The installer supervises its worker and recovers automatically. If it exits 3, keep the reported recovery file and run:

node examples/external-service/install.mjs recover /path/from/output/recovery.json

This verifies the retained runner and works without release distribution access. It recovers the original operation only. For operator-directed recovery of current unfinished work, start a runner against the same state directory and submit {"protocolVersion":1,"action":"recover"}. Recovery completes persisted intent or restores stable; it does not initiate another upgrade. See the protocol's exit-code table, including why a successful rollback is exit 1.

Stop this demo before removing its temporary home:

node --input-type=module <<'JS'
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { createCommandHost } from './core/src/index.ts';
const dir = process.env.K_EXAMPLE_HOME;
if (!dir) throw new Error('K_EXAMPLE_HOME is required');
const host = createCommandHost({ stateDir: join(dir, 'k'),
  command: [process.execPath, join(dir, 'controller.mjs'), dir] });
await host.stop('stable');
await rm(dir, { recursive: true });
JS
unset K_EXAMPLE_HOME

What this proves

pnpm test:runner automates upgrade, wrong-version rollback, current and archive replay, concurrent lock rejection, killing the runner between stop and start, recovery with the release source removed, bounded failed recovery, a controller surviving its worker, and restarting after the whole invocation is lost. The test owns and cleans its own home; it does not reuse the walkthrough's directory.

This adapter spawns its controller with process.execPath, which is only correct under an external Node. See the SEA pitfall before packaging this pattern.

This controller is a demo, not a production supervisor. Its service implements cooperative shutdown and a loopback health protocol; an unreachable endpoint is not generally proof that an arbitrary production process has died. A product controller must establish process ownership and termination through its service manager, authenticate control access, and implement workload preservation. The demo copies selected slot bytes to active.mjs so Node treats them as ESM and so the running file is never inside a slot directory that promotion will rename; that runtime copy is not a third rollback slot. Localhost is not authentication.

Source: adapter.ts, controller.mjs, install.mjs, service.mjs.