@botiverse/k-carrier 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/NOTICE +5 -2
  2. package/README.md +72 -26
  3. package/core/src/artifact/download.ts +23 -1
  4. package/core/src/artifact/gzip.ts +26 -0
  5. package/core/src/artifact/source.ts +2 -0
  6. package/core/src/{createUpgrader.ts → createRunner.ts} +15 -14
  7. package/core/src/index.ts +12 -3
  8. package/core/src/launcher/launch.ts +15 -0
  9. package/core/src/launcher/supervise.ts +170 -0
  10. package/core/src/lifecycle/commandHost.ts +111 -0
  11. package/core/src/lifecycle/hostAdapter.ts +28 -16
  12. package/core/src/operation.ts +47 -25
  13. package/core/src/operationLifecycle.ts +2 -7
  14. package/core/src/platform/ops.ts +7 -0
  15. package/core/src/platform/posix.ts +26 -6
  16. package/core/src/platform/windows.ts +8 -2
  17. package/core/src/protocol/runner.ts +81 -0
  18. package/core/src/provenance/journal.ts +1 -1
  19. package/core/src/quarantine.ts +167 -0
  20. package/core/src/runner/cli.ts +27 -0
  21. package/core/src/runner/execute.ts +68 -0
  22. package/core/src/txn/engine.ts +41 -85
  23. package/core/src/txn/fileEffects.ts +15 -1
  24. package/core/src/txn/hostCallBudget.ts +4 -1
  25. package/core/src/txn/hostCallUncertain.ts +2 -0
  26. package/core/src/txn/lock.ts +81 -37
  27. package/core/src/txn/state.ts +1 -1
  28. package/core/src/upgrade/drive.ts +31 -2
  29. package/core/src/upgrade/outcome.ts +1 -1
  30. package/core/src/upgrade/recover.ts +22 -1
  31. package/core/src/upgrade/retire.ts +1 -1
  32. package/core/src/upgrader.ts +7 -8
  33. package/docs/design.md +173 -0
  34. package/docs/guide.md +196 -0
  35. package/docs/harness-design.md +75 -170
  36. package/docs/integration.md +221 -354
  37. package/docs/prior-art/design-influences.md +26 -0
  38. package/docs/prior-art/external-runner-research.md +49 -0
  39. package/docs/reference.md +209 -0
  40. package/docs/test-plan.md +89 -92
  41. package/harness/src/adapter/releaseKnob.ts +1 -1
  42. package/harness/src/adapter/serviceChecks.ts +5 -5
  43. package/harness/src/artifact/m1.ts +8 -8
  44. package/harness/src/artifact/m1Resume.ts +2 -2
  45. package/harness/src/artifact/m3.ts +25 -104
  46. package/harness/src/artifact/m3Hosts.ts +9 -61
  47. package/harness/src/artifact/m4.ts +3 -3
  48. package/harness/src/artifact/m5.ts +5 -5
  49. package/harness/src/artifact/m6.ts +6 -6
  50. package/harness/src/artifact/m6Status.ts +1 -1
  51. package/harness/src/examples/checks.ts +10 -13
  52. package/harness/src/fixtures/cliToolSource.ts +166 -0
  53. package/harness/src/fixtures/externalCrashAdapter.ts +19 -0
  54. package/harness/src/fixtures/managedHost.ts +100 -0
  55. package/harness/src/fixtures/serviceSource.ts +181 -0
  56. package/harness/src/fixtures/supervisedAdapter.ts +57 -0
  57. package/harness/src/scenario/processScan.ts +3 -1
  58. package/harness/src/scenario/sandbox.ts +2 -2
  59. package/harness/src/teeth/artifact.ts +3 -3
  60. package/harness/src/teeth/examples.ts +1 -1
  61. package/package.json +5 -3
  62. package/docs/design-v1.md +0 -246
  63. package/docs/prior-art.md +0 -150
package/NOTICE CHANGED
@@ -5,10 +5,12 @@ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
5
5
 
6
6
  Design influences — concepts studied, no code copied
7
7
  -----------------------------------------------------
8
- k-carrier's design was informed by studying two mature, unrelated updaters.
8
+ k-carrier's design was informed by studying other installers and updaters.
9
9
  Only architectural concepts were borrowed; no source code was copied, and the
10
10
  implementation here is original.
11
11
 
12
+ - External installer boundary and self-update execution model are informed by Rustup's rustup-init and self-update flow (rust-lang/rustup, client installer/self-update). Concept only; no source code copied.
13
+
12
14
  - Two-slot upgrade transaction (stable/experiment slots with promote/rollback)
13
15
  and the "the installer is itself a managed package" fleet model are inspired
14
16
  by Datadog's fleet installer (DataDog/datadog-agent, pkg/fleet). Concept only.
@@ -19,6 +21,7 @@ implementation here is original.
19
21
  signature verification — it verifies artifact integrity via sha256 + size
20
22
  only. No distsign / signing code is present in this repository.
21
23
 
22
- See docs/prior-art.md for the full analysis, including what each project does
24
+ See docs/prior-art/design-influences.md and
25
+ docs/prior-art/external-runner-research.md for the full analysis, including what each project does
23
26
  and where k-carrier deliberately differs (fail-closed restart, post-upgrade
24
27
  convergence read-back, and rollback).
package/README.md CHANGED
@@ -1,38 +1,84 @@
1
1
  # K (k-carrier)
2
2
 
3
- **Self-upgrade framework for programs that must prove they came back up.**
3
+ **Reliable application upgrades, run by an independent installer.**
4
4
 
5
- CLI self-update libraries stop at replacing bytes; fleet updaters assume a machine someone else administers. K covers what neither does: **an upgrade that is a transaction and can prove it happened** two slots with rollback, crash-safe at every step, handoff of a live process with its workloads intact, and convergence proven from the live process and named OS surfaces (a version string is never accepted as proof). Consent and notification are built in, because on a machine someone owns personally, changing behaviour silently is not acceptable — but nothing here is limited to personal machines.
5
+ K is for applications you distribute yourself to end users' machines, across
6
+ operating systems, outside any package manager: desktop agents, background
7
+ services installed by `curl | sh`, CLIs that update themselves. Nobody
8
+ operates those machines. A failed upgrade means the product silently stops
9
+ working and nobody can log in to repair it. K combines a rustup-style external
10
+ installer with recoverable, verified service upgrades.
6
11
 
7
- **Two process models, defined by how many live incarnations K manages** — `swap` (**0**: K replaces bytes and touches no process; a one-shot CLI and an hours-long agent session are the same case) and `service` (**1**: K stops the old, starts the new, and proves it). OS lifecycle convergence and fleet drive are capabilities you opt into on top, not a third model. Proof is executable: a runnable example per case, and a claim without a green example does not exist.
12
+ If a package manager, container image or fleet orchestrator already owns your
13
+ installation, that manager owns upgrades too. Your adapter identifies that
14
+ ownership so K can defer; you probably do not need it.
8
15
 
9
- ## What K owns
16
+ ## Why upgrading is non-trivial
10
17
 
11
- K does not replace platform packaging or artifact delivery. It wraps an
12
- addressable release in a transaction with rollback and convergence readback.
13
- Because the process driving an upgrade may die on the success path, the
14
- successor proves the handoff from live evidence rather than trusting a flag.
18
+ Downloading a new executable is only the beginning. The application may still
19
+ be running, power may fail halfway through replacement, or the new version may
20
+ fail to start on a particular machine. Replacing the file does not prove the
21
+ service came back healthy. The installed updater may itself be too old or
22
+ broken to help.
15
23
 
16
- ## Start here
24
+ K stages verified bytes in a second slot, stops the service, starts and probes
25
+ the candidate, then commits or restores the previous executable. A durable
26
+ journal lets a subsequent installer recover interrupted work. An upgrade ends
27
+ promoted, rolled back, or explicitly unresolved with the evidence preserved;
28
+ it is never reported as a success K did not observe.
17
29
 
18
- - **[`docs/integration.md`](docs/integration.md)** — from-zero guide: the problem in plain words, concept primer, tiered adoption with code.
19
- - [`docs/design-v1.md`](docs/design-v1.md) — full design: six layers, architecture, decision record.
20
- - [`docs/harness-design.md`](docs/harness-design.md) — the test framework, designed first: harness as executable spec (teeth registry, real-process crash injection, adversarial self-verification).
21
- - [`docs/test-plan.md`](docs/test-plan.md) — executable test plan (M0–M6, must-red per cell).
22
- - [`docs/prior-art.md`](docs/prior-art.md) — the source-level survey this design stands on (Tailscale / Datadog), and the license-defense record behind `NOTICE`.
30
+ ## One installer, multiple entrypoints
23
31
 
24
- ## Repo layout
32
+ `install.sh` and your application's `self upgrade` launch the same external
33
+ installer, built from K and your product adapter. The installer owns the
34
+ upgrade transaction and exits when finished. It can be updated independently
35
+ of the application and run even when the installed application cannot start.
25
36
 
26
- ```
27
- core/ the framework zero host-specific concepts (shells live in their
28
- product's repo and consume core as a dependency)
29
- harness/ generic acceptance bed: fake-host daemon + profile-tiered teeth
30
- examples/ one runnable demo per profile (swap-tool / service-daemon / hosted-service)
31
- docs/ guides + design + test plan + prior art
32
- ```
37
+ You distribute **three things**: the bootstrap script, the installer, and the
38
+ application release. They can share a hosting location. You supply the
39
+ release source and service lifecycle operations; K supplies the transaction
40
+ machinery. The runner uses Node 24, either as an external runtime or bundled
41
+ into a Node single executable. A fully runtime-independent installer must also
42
+ package its supervisor and controller dependencies.
33
43
 
34
- **Platform support today:** Linux and macOS gate CI. Windows platform
35
- operations are implemented; its acceptance harness and CI gate are still in
36
- progress.
44
+ The installer must survive stopping the application. If a worker crashes,
45
+ the temporary supervisor runs bounded recovery. After reboot, an operator or
46
+ OS startup hook must start installation again. K rolls back executables,
47
+ not application data; data migration compatibility remains the product's job.
48
+ Artifact hashes check integrity; your distribution channel establishes trust.
37
49
 
38
- Status: incubating. TypeScript first. License: **Apache-2.0**.
50
+ ## Status
51
+
52
+ | Area | State |
53
+ |---|---|
54
+ | Two-slot transaction, journal, lock, receipts | Done; generated crash matrix, seeded simulation, and a Lean model of all phases, rollback and crash/recovery interleavings (host honesty assumed) |
55
+ | External runner protocol, supervisor, bounded recovery | Done; Linux/macOS process tests |
56
+ | Command controller boundary | Done; demo controller only |
57
+ | Verified download with resume and gzip | Done |
58
+ | Single-executable (SEA) runner | Verified manually; build flag provided, no CI |
59
+ | Windows | Core is platform-seamed; harness port incomplete, CI informational |
60
+ | Product-ready `install.sh` template | Not yet |
61
+ | Publisher signing of installer or release metadata | Not provided; hash and size only |
62
+ | Automatic restart after machine reboot | Not provided; product OS hook |
63
+ | Receipt archive garbage collection | Not provided |
64
+
65
+ ## Documentation
66
+
67
+ Guides, written to be read in order:
68
+
69
+ - [How an upgrade works](docs/guide.md): the processes involved, one upgrade start to finish, what breaks, how to read the result
70
+ - [Runnable example](examples/external-service/README.md)
71
+ - [Integration and distribution guide](docs/integration.md)
72
+
73
+ Contracts, written to be looked up:
74
+
75
+ - [Design](docs/design.md): normative execution model, transaction, supervision and controller obligations
76
+ - [Reference](docs/reference.md): protocol, exit codes, budgets, on-disk layout
77
+ - [Test plan](docs/test-plan.md) and [harness design](docs/harness-design.md)
78
+ - [Formal model](formal/README.md)
79
+
80
+ Background:
81
+
82
+ - [Prior art](docs/prior-art/design-influences.md) and [external installer research](docs/prior-art/external-runner-research.md)
83
+
84
+ Incubating · TypeScript / Node 24 · Apache-2.0. Contributions welcome.
@@ -32,6 +32,7 @@ import { ArtifactError } from "./errors.ts";
32
32
  import type { Release } from "./source.ts";
33
33
  import { collectStream } from "./collectStream.ts";
34
34
  import { partialPathFor } from "./partialPath.ts";
35
+ import { decodeGzipArtifact } from "./gzip.ts";
35
36
  import type { DownloadOptions } from "./transferPolicy.ts";
36
37
  export { partialPathFor } from "./partialPath.ts";
37
38
  export type { DownloadOptions } from "./transferPolicy.ts";
@@ -41,6 +42,27 @@ export async function downloadVerified(
41
42
  release: Release,
42
43
  opts: DownloadOptions = {},
43
44
  ): Promise<Uint8Array> {
45
+ if (release.gzip !== undefined) {
46
+ const gzip = release.gzip;
47
+ if (!gzip || typeof gzip.url !== "string" || !gzip.url ||
48
+ !/^[a-f0-9]{64}$/.test(gzip.sha256) || !Number.isSafeInteger(gzip.size) || gzip.size <= 0 ||
49
+ !Number.isSafeInteger(release.size) || release.size < 0 ||
50
+ !/^[a-f0-9]{64}$/.test(release.sha256)) {
51
+ throw new ArtifactError("MANIFEST_INVALID", "invalid gzip artifact identity");
52
+ }
53
+ // Range and partial files refer to compressed bytes, never decoded offsets.
54
+ const compressed = await downloadVerified({
55
+ version: release.version, url: gzip.url, size: gzip.size, sha256: gzip.sha256,
56
+ }, opts);
57
+ try {
58
+ return await decodeGzipArtifact(compressed, release);
59
+ } catch (error) {
60
+ if (opts.resumeDir) {
61
+ await fs.rm(partialPathFor(opts.resumeDir, gzip.url), { force: true }).catch(() => {});
62
+ }
63
+ throw error;
64
+ }
65
+ }
44
66
  const url = release.url;
45
67
  const clock = opts.clock ?? systemClock;
46
68
  const timeoutMs = opts.timeoutMs ?? 10000;
@@ -147,7 +169,7 @@ async function fetchAndAppend(
147
169
  });
148
170
  };
149
171
  try {
150
- const headers: Record<string, string> = {};
172
+ const headers: Record<string, string> = { "Accept-Encoding": "identity" };
151
173
  if (partialSize > 0) headers["Range"] = `bytes=${partialSize}-`;
152
174
  let res: Response;
153
175
  armResponse();
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ import { gunzip } from "node:zlib";
3
+ import { ArtifactError } from "./errors.ts";
4
+ import type { Release } from "./source.ts";
5
+
6
+ /** Decode only verified transport bytes; bound expansion by the canonical size. */
7
+ export async function decodeGzipArtifact(bytes: Uint8Array, release: Release): Promise<Uint8Array> {
8
+ let decoded: Uint8Array;
9
+ try {
10
+ decoded = await new Promise<Buffer>((resolve, reject) => {
11
+ gunzip(bytes, { maxOutputLength: Math.max(1, release.size) }, (error, output) => {
12
+ if (error) reject(error);
13
+ else resolve(output);
14
+ });
15
+ });
16
+ } catch (cause) {
17
+ throw new ArtifactError("DOWNLOAD_FAILED", "gzip is invalid or exceeds the declared decoded size", { cause });
18
+ }
19
+ if (decoded.length !== release.size) {
20
+ throw new ArtifactError("SIZE_MISMATCH", "decoded gzip size does not match the release");
21
+ }
22
+ if (createHash("sha256").update(decoded).digest("hex") !== release.sha256) {
23
+ throw new ArtifactError("SHA256_MISMATCH", "decoded gzip SHA-256 does not match the release");
24
+ }
25
+ return decoded;
26
+ }
@@ -39,6 +39,8 @@ export interface Release {
39
39
  url: string;
40
40
  sha256: string;
41
41
  size: number;
42
+ /** Optional explicit gzip representation. Outer size/hash always describe installed bytes. */
43
+ gzip?: { url: string; sha256: string; size: number };
42
44
  }
43
45
 
44
46
  export interface ReleaseSource {
@@ -1,5 +1,6 @@
1
+ import { HostCallUncertain } from "./txn/hostCallBudget.ts";
1
2
  /**
2
- * createUpgrader — the one construction every entrypoint uses.
3
+ * createRunner — the one construction every entrypoint uses.
3
4
  *
4
5
  * Order of gates: lock -> ownership -> source -> policy -> compat ->
5
6
  * download -> engine. Everything before the engine can only produce `held`
@@ -27,9 +28,11 @@ import type { OperationDescriptor } from "./operation.ts";
27
28
  import { createOperationLifecycle } from "./operationLifecycle.ts";
28
29
  import { driveUpgrade } from "./upgrade/drive.ts";
29
30
  import type { ArtifactTransferPolicy } from "./artifact/transferPolicy.ts";
31
+ import { quarantineState } from "./quarantine.ts";
30
32
 
31
- export interface CreateUpgraderOptions extends UpgraderConfig {
33
+ export interface RunnerOptions extends UpgraderConfig {
32
34
  clock?: Clock;
35
+ hostCallBudgetMs?: number;
33
36
  /** Reports who owns this install; default: we own it. */
34
37
  installOwnership?: () => "self" | "managed-elsewhere";
35
38
  /** Optional host semantic gate; a string result refuses the transition. */
@@ -67,7 +70,7 @@ export interface CreateUpgraderOptions extends UpgraderConfig {
67
70
  artifactTransferPolicy?: ArtifactTransferPolicy;
68
71
  }
69
72
 
70
- export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
73
+ export function createRunner(opts: RunnerOptions): Upgrader {
71
74
  const clock = opts.clock ?? systemClock;
72
75
  const effects = fileEffects(opts.stateDir);
73
76
  const ownership = opts.installOwnership ?? ((): "self" => "self");
@@ -91,6 +94,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
91
94
  effects,
92
95
  host: opts.host,
93
96
  clock,
97
+ ...(opts.hostCallBudgetMs === undefined ? {} : { hostCallBudgetMs: opts.hostCallBudgetMs }),
94
98
  evaluatePredicates: async (evidence: ProcessEvidence, targetVersion: string) => {
95
99
  lastEvidence = evidence;
96
100
  if (evidence.version !== targetVersion) {
@@ -178,9 +182,9 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
178
182
  }, request);
179
183
 
180
184
  return {
181
- recover: async () => {
185
+ recover: async (expected) => {
182
186
  operationLifecycle.reset();
183
- await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery);
187
+ await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery, expected);
184
188
  },
185
189
 
186
190
  async check(): Promise<{ current: string; target: string | null }> {
@@ -224,6 +228,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
224
228
 
225
229
  async rollback(reason: string): Promise<"rolled-back" | { held: string }> {
226
230
  const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
231
+ let release = true;
227
232
  try {
228
233
  // Gate on the action's nature: settling K's own in-flight
229
234
  // transaction is ALWAYS allowed (a held mid-transaction is a
@@ -239,8 +244,11 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
239
244
  await effects.slots.clearExperiment();
240
245
  await opts.notificationSink({ kind: "rolled-back", detail: { reason } });
241
246
  return "rolled-back";
247
+ } catch (error) {
248
+ if (error instanceof HostCallUncertain) release = false;
249
+ throw error;
242
250
  } finally {
243
- await lock.release();
251
+ if (release) await lock.release();
244
252
  }
245
253
  },
246
254
 
@@ -259,13 +267,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
259
267
 
260
268
  operation: operationLifecycle.read,
261
269
 
262
- async acknowledgeOperation(operationId) {
263
- const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
264
- try {
265
- return await operationLifecycle.acknowledge(operationId);
266
- } finally {
267
- await lock.release();
268
- }
269
- },
270
+ quarantineState: (options) => quarantineState(opts.stateDir, options),
270
271
  };
271
272
  }
package/core/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  // This barrel is the single supported entry point for the core framework;
4
4
  // deep imports into ./core/src/** are internal and not part of the public API.
5
5
 
6
- // The upgrader factory and its configuration.
7
- export * from "./createUpgrader.ts";
6
+ // Compose the transaction engine inside a runner; no forwarding factory.
7
+ export { createRunner } from "./createRunner.ts";
8
+ export type { RunnerOptions } from "./createRunner.ts";
8
9
 
9
10
  // One-time adoption of an already-running trusted binary into K's stable
10
11
  // slot, plus the K-owned slot resolver host adapters use to launch it.
@@ -14,9 +15,10 @@ export * from "./bootstrap.ts";
14
15
  // NotificationEvent.
15
16
  export * from "./upgrader.ts";
16
17
  export * from "./operation.ts";
18
+ export * from "./quarantine.ts";
17
19
 
18
20
  // The release-source boundary applications implement and the durable
19
- // provenance journal they wire into createUpgrader.
21
+ // provenance journal they wire into createRunner.
20
22
  export * from "./artifact/source.ts";
21
23
  export * from "./artifact/transferPolicy.ts";
22
24
  export * from "./provenance/journal.ts";
@@ -26,3 +28,10 @@ export * from "./lifecycle/hostAdapter.ts";
26
28
 
27
29
  // The built-in invariants and their types (WorldSnapshot, Invariant, ...).
28
30
  export * from "./invariants.ts";
31
+ export * from "./protocol/runner.ts";
32
+ export * from "./runner/execute.ts";
33
+ export * from "./runner/cli.ts";
34
+ export * from "./launcher/launch.ts";
35
+ export * from "./lifecycle/commandHost.ts";
36
+
37
+ export { HostCallTimeout, HostCallUncertain } from "./txn/hostCallBudget.ts";
@@ -0,0 +1,15 @@
1
+ import { superviseRunner, type RunnerLaunch } from "./supervise.ts";
2
+ export { superviseRunner, resumeRunner } from "./supervise.ts";
3
+ export type { RunnerLaunch, RunnerLaunchResult } from "./supervise.ts";
4
+
5
+ /** Installer entrypoint: one final response and an honest recovery exit code. */
6
+ export async function launchRunner(input: RunnerLaunch): Promise<number> {
7
+ const result = await superviseRunner(input);
8
+ const response = result.error ? { protocolVersion: 1, action: input.request.action,
9
+ exitCode: 3, result: "recovery-required", error: result.error,
10
+ operation: result.response?.operation ?? { kind: "unreadable", reason: "worker returned no usable receipt" },
11
+ recoveryFile: result.recoveryFile } : result.response;
12
+ if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
13
+ if (result.error) process.stderr.write(`${result.error}; recovery: ${result.recoveryFile}\n`);
14
+ return result.exitCode;
15
+ }
@@ -0,0 +1,170 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import type { Release } from "../artifact/source.ts";
6
+ import { downloadVerified } from "../artifact/download.ts";
7
+ import { artifactTransferTimeouts } from "../artifact/transferPolicy.ts";
8
+ import { parseRunnerRequest, parseRunnerResponse, type RunnerRequest, type RunnerResponse } from "../protocol/runner.ts";
9
+ import { platformOpsFor } from "../platform/index.ts";
10
+ import { systemClock } from "../clock.ts";
11
+
12
+ export interface RunnerLaunch {
13
+ release: Release;
14
+ request: RunnerRequest;
15
+ scratchDir: string;
16
+ interpreter?: string;
17
+ /** Finite worker, recovery-attempt and overall execution budgets. */
18
+ executionTimeoutMs?: number;
19
+ recoveryTimeoutMs?: number;
20
+ totalTimeoutMs?: number;
21
+ recoveryAttempts?: number;
22
+ }
23
+
24
+ export interface RunnerLaunchResult {
25
+ exitCode: number;
26
+ response: RunnerResponse | null;
27
+ /** Retained verified helper + invocation; no transaction state is copied. */
28
+ recoveryFile: string | null;
29
+ error: string | null;
30
+ attempts: number;
31
+ }
32
+
33
+ interface Attempt { code: number; response: RunnerResponse | null; fenced: boolean }
34
+
35
+ function budget(value: number | undefined, fallback: number): number {
36
+ const result = value ?? fallback;
37
+ if (!Number.isSafeInteger(result) || result <= 0 || result > 2_147_483_647) throw new Error("invalid runner budget");
38
+ return result;
39
+ }
40
+
41
+ /** A worker is reaped before a successor is allowed. Controller effects are
42
+ * separately fenced by the adapter under K's lock, including after a restart. */
43
+ async function run(file: string, interpreter: string | undefined, request: RunnerRequest, timeout: number): Promise<Attempt> {
44
+ return new Promise((resolve) => {
45
+ const child = spawn(interpreter ?? file, interpreter ? [file] : [], { stdio: ["pipe", "pipe", "inherit"] });
46
+ let output = "";
47
+ let exited = false;
48
+ let expired = false;
49
+ let finished = false;
50
+ let cancelGrace: (() => void) | undefined;
51
+ const finish = (code: number, fenced: boolean): void => {
52
+ if (finished) return;
53
+ finished = true;
54
+ cancel(); cancelGrace?.();
55
+ child.stdin.destroy(); child.stdout.destroy(); child.unref();
56
+ let response: RunnerResponse | null = null;
57
+ if (!expired) {
58
+ try { response = parseRunnerResponse(JSON.parse(output)); }
59
+ catch { /* a missing/malformed response cannot settle a transaction */ }
60
+ }
61
+ if (response && (response.action !== request.action || response.exitCode !== code)) response = null;
62
+ resolve({ code, response, fenced });
63
+ };
64
+ const terminate = (): void => {
65
+ if (expired || finished) return;
66
+ expired = true;
67
+ if (child.pid && !exited) {
68
+ try { platformOpsFor().killProcess(child.pid); } catch { /* require observed exit */ }
69
+ }
70
+ child.stdin.destroy(); child.stdout.destroy();
71
+ cancelGrace = systemClock.after(1000, () => finish(3, exited));
72
+ };
73
+ const cancel = systemClock.after(timeout, terminate);
74
+ child.on("error", () => finish(1, child.pid === undefined));
75
+ child.on("exit", () => { exited = true; });
76
+ child.on("close", (code) => finish(expired ? 3 : code ?? 1, exited || child.pid === undefined));
77
+ child.stdout.on("data", (chunk: Buffer) => {
78
+ output += chunk.toString();
79
+ if (output.length > 64 * 1024) terminate();
80
+ });
81
+ child.stdin.on("error", () => {});
82
+ child.stdin.end(JSON.stringify(request));
83
+ });
84
+ }
85
+
86
+ function settled(response: RunnerResponse | null, request: RunnerRequest): boolean {
87
+ if (!response || response.error !== null) return false;
88
+ const expected = request.action === "upgrade" ? request : request.action === "recover" ? request.expected : undefined;
89
+ if (!expected) return response.exitCode !== 3 && response.operation.kind !== "unreadable" &&
90
+ (response.operation.kind === "genesis" || response.operation.operation.outcome !== null);
91
+ if (response.operation.kind !== "observed") return false;
92
+ const op = response.operation.operation;
93
+ const code = op.outcome === "promoted" || op.outcome === "up-to-date" ? 0 : op.outcome === "held" ? 2 : 1;
94
+ return op.id === expected.id && op.targetVersion === expected.targetVersion && op.outcome !== null && response.exitCode === code;
95
+ }
96
+
97
+ /** Own one install until a bound receipt or explicit unresolved recovery. */
98
+ export async function superviseRunner(input: RunnerLaunch): Promise<RunnerLaunchResult> {
99
+ const request = parseRunnerRequest(input.request);
100
+ const execution = budget(input.executionTimeoutMs, 600_000);
101
+ const recovery = budget(input.recoveryTimeoutMs, 120_000);
102
+ const total = budget(input.totalTimeoutMs, execution + recovery * 2);
103
+ const retries = input.recoveryAttempts ?? 2;
104
+ if (!Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error("invalid recovery attempts");
105
+ const transfer = artifactTransferTimeouts(input.release.size);
106
+ const bytes = await downloadVerified(input.release, { timeoutMs: transfer.overallTimeoutMs,
107
+ responseTimeoutMs: transfer.responseTimeoutMs, idleTimeoutMs: transfer.idleTimeoutMs });
108
+ await fs.mkdir(input.scratchDir, { recursive: true });
109
+ const dir = await fs.mkdtemp(path.join(input.scratchDir, "k-runner-"));
110
+ const file = path.join(dir, input.interpreter ? "runner.mjs" : "runner.bin");
111
+ const recoveryFile = path.join(dir, "recovery.json");
112
+ const expected = request.action === "upgrade" ? { id: request.id, targetVersion: request.targetVersion } :
113
+ request.action === "recover" ? request.expected : undefined;
114
+ const recoverRequest: RunnerRequest = { protocolVersion: 1, action: "recover", ...(expected ? { expected } : {}) };
115
+ // Retain enough trusted distribution metadata to verify and recover offline.
116
+ // This is an invocation descriptor, never another transaction log.
117
+ const artifact = await fs.open(file, "wx", 0o700);
118
+ try { await artifact.writeFile(bytes); await artifact.sync(); }
119
+ finally { await artifact.close(); }
120
+ await platformOpsFor().makeExecutable(file);
121
+ const handle = await fs.open(recoveryFile, "w", 0o600);
122
+ try { await handle.writeFile(JSON.stringify({ file, interpreter: input.interpreter,
123
+ sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, request: recoverRequest })); await handle.sync(); }
124
+ finally { await handle.close(); }
125
+ const deadline = systemClock.nowMs() + total;
126
+ let response: RunnerResponse | null = null;
127
+ let attempts = 0;
128
+ for (; attempts <= retries; attempts++) {
129
+ const remaining = deadline - systemClock.nowMs();
130
+ if (remaining <= 0) break;
131
+ const attempt = await run(file, input.interpreter, attempts === 0 ? request : recoverRequest,
132
+ Math.min(remaining, attempts === 0 ? execution : recovery));
133
+ response = attempt.response;
134
+ if (attempt.fenced && (request.action === "status" || settled(response, request))) {
135
+ await fs.rm(dir, { force: true, recursive: true });
136
+ const exitCode = request.action === "status" && !response && attempt.code === 0 ? 1 : attempt.code;
137
+ return { exitCode, response, recoveryFile: null, error: null, attempts: attempts + 1 };
138
+ }
139
+ if (!attempt.fenced || !expected) { attempts++; break; }
140
+ }
141
+ return { exitCode: 3, response, recoveryFile, attempts,
142
+ error: "RECOVERY_UNRESOLVED: retained helper and invocation; transaction evidence is unchanged" };
143
+ }
144
+
145
+ /** Resume the retained, hash-verified helper without distribution access. */
146
+ export async function resumeRunner(recoveryFile: string, options: Pick<RunnerLaunch,
147
+ "executionTimeoutMs" | "recoveryTimeoutMs" | "totalTimeoutMs" | "recoveryAttempts"> = {}): Promise<RunnerLaunchResult> {
148
+ const descriptor = JSON.parse(await fs.readFile(recoveryFile, "utf8")) as {
149
+ file: string; interpreter?: string; sha256: string; size: number; request: RunnerRequest;
150
+ };
151
+ const request = parseRunnerRequest(descriptor.request);
152
+ if (request.action !== "recover" || !request.expected ||
153
+ typeof descriptor.file !== "string" || (descriptor.interpreter !== undefined && typeof descriptor.interpreter !== "string")) {
154
+ throw new Error("INVALID_RECOVERY_DESCRIPTOR");
155
+ }
156
+ const bytes = await fs.readFile(descriptor.file);
157
+ if (bytes.length !== descriptor.size || createHash("sha256").update(bytes).digest("hex") !== descriptor.sha256) {
158
+ throw new Error("RECOVERY_ARTIFACT_MISMATCH");
159
+ }
160
+ const result = await superviseRunner({ ...options, request,
161
+ ...(descriptor.interpreter ? { interpreter: descriptor.interpreter } : {}),
162
+ scratchDir: path.dirname(path.dirname(recoveryFile)), release: { version: "retained-runner",
163
+ url: `data:application/octet-stream;base64,${bytes.toString("base64")}`, size: descriptor.size, sha256: descriptor.sha256 } });
164
+ if (!result.recoveryFile) {
165
+ // Remove only the files this descriptor owns, never its caller's directory.
166
+ await fs.rm(descriptor.file, { force: true });
167
+ await fs.rm(recoveryFile, { force: true });
168
+ }
169
+ return result;
170
+ }
@@ -0,0 +1,111 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { HostCallTimeout, HostCallUncertain } from "../txn/hostCallBudget.ts";
6
+ import type { HostAdapter, ProcessEvidence, Slot } from "./hostAdapter.ts";
7
+ import { slotArtifactPath } from "../bootstrap.ts";
8
+ import { systemClock } from "../clock.ts";
9
+ import { platformOpsFor } from "../platform/index.ts";
10
+ function controllerObject(value: unknown): Record<string, unknown> {
11
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
12
+ throw new Error("HOST_PROTOCOL_INVALID: expected an object");
13
+ }
14
+ return value as Record<string, unknown>;
15
+ }
16
+
17
+ export interface CommandHostOptions {
18
+ /** Trusted external controller argv. Never a shell expression. */
19
+ command: [string, ...string[]];
20
+ stateDir: string;
21
+ cwd?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ /** Controller stdin is one JSON request; stdout is one JSON response. */
26
+ export function createCommandHost(options: CommandHostOptions): HostAdapter {
27
+ const timeout = options.timeoutMs ?? 30_000;
28
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) throw new Error("invalid controller timeout");
29
+ const [file, ...args] = options.command;
30
+ const controllersDir = path.join(options.stateDir, "controllers");
31
+ async function drainControllers(): Promise<void> {
32
+ await fs.mkdir(controllersDir, { recursive: true });
33
+ const deadline = systemClock.nowMs() + timeout;
34
+ for (const name of await fs.readdir(controllersDir)) {
35
+ const controllerPath = path.join(controllersDir, name);
36
+ const pid = Number(name.split("-")[0]);
37
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("HOST_FENCE_UNRESOLVED: invalid controller evidence");
38
+ while (platformOpsFor().isProcessAlive(pid)) {
39
+ if (systemClock.nowMs() >= deadline) throw new HostCallTimeout("fence", timeout);
40
+ await new Promise<void>((resolve) => { systemClock.after(Math.min(50, timeout), resolve); });
41
+ }
42
+ await fs.rm(controllerPath, { force: true });
43
+ }
44
+ }
45
+ async function call(action: string, slot?: Slot): Promise<Record<string, unknown>> {
46
+ const input = { protocolVersion: 1, action,
47
+ ...(slot ? { slot, artifactPath: slotArtifactPath(options.stateDir, slot) } : {}) };
48
+ const child = spawn(file, args, { stdio: ["pipe", "pipe", "pipe"],
49
+ ...(options.cwd ? { cwd: options.cwd } : {}) });
50
+ const controllerPath = path.join(controllersDir, `${child.pid}-${randomUUID()}.json`);
51
+ let exited = false;
52
+ let cancel: (() => void) | undefined;
53
+ let stdout = "";
54
+ const done = new Promise<string>((resolve, reject) => {
55
+ child.on("error", reject);
56
+ child.on("exit", () => { exited = true; });
57
+ child.stdout.on("data", (chunk: Buffer) => {
58
+ stdout += chunk.toString();
59
+ if (stdout.length > 64 * 1024) reject(new HostCallUncertain("HOST_RESPONSE_TOO_LARGE"));
60
+ });
61
+ child.stderr.resume();
62
+ child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(`HOST_COMMAND_FAILED: ${action} (${code})`)));
63
+ });
64
+ // Install rejection handlers before filesystem IO or an early spawn failure.
65
+ void done.catch(() => {});
66
+ child.stdin.on("error", () => {});
67
+ try {
68
+ if (!child.pid) return await done.then(() => { throw new Error("HOST_SPAWN_FAILED"); });
69
+ await fs.mkdir(controllersDir, { recursive: true });
70
+ const handle = await fs.open(controllerPath, "wx", 0o600);
71
+ try { await handle.writeFile(JSON.stringify({ pid: child.pid })); await handle.sync(); }
72
+ finally { await handle.close(); }
73
+ // No controller gets a command before its lifetime is recorded. A worker
74
+ // dying before this point closes stdin without authorizing any effect.
75
+ child.stdin.end(JSON.stringify(input));
76
+ stdout = await Promise.race([done, new Promise<never>((_resolve, reject) => {
77
+ cancel = systemClock.after(timeout, () => reject(new HostCallTimeout(action, timeout)));
78
+ })]);
79
+ } finally {
80
+ cancel?.();
81
+ if (!exited && child.pid) {
82
+ try { platformOpsFor().killProcess(child.pid); } catch { /* fence will verify */ }
83
+ }
84
+ child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
85
+ // On uncertain termination retain the process evidence for the next fence.
86
+ if (exited) await fs.rm(controllerPath, { force: true });
87
+ }
88
+ const value = controllerObject(JSON.parse(stdout));
89
+ if (value.protocolVersion !== 1 || value.ok !== true) throw new Error(`HOST_PROTOCOL_INVALID: ${action}`);
90
+ return value;
91
+ }
92
+ return {
93
+ fence: async () => {
94
+ await drainControllers();
95
+ // The controller must also fence detached/queued service-manager effects;
96
+ // absence of its old PID alone does not establish that condition.
97
+ await call("fence");
98
+ },
99
+ quiesce: async () => { await call("quiesce"); },
100
+ stop: async (slot) => { await call("stop", slot); },
101
+ start: async (slot) => { await call("start", slot); },
102
+ resume: async () => { await call("resume"); },
103
+ healthProbe: async (): Promise<ProcessEvidence> => {
104
+ const value = controllerObject((await call("probe")).evidence);
105
+ if (typeof value.version !== "string" || !value.version || typeof value.startId !== "string" ||
106
+ !value.startId || !Number.isSafeInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0 ||
107
+ value.pid === process.pid) throw new Error("HOST_EVIDENCE_INVALID");
108
+ return { version: value.version, pid: value.pid, startId: value.startId };
109
+ },
110
+ };
111
+ }