@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
@@ -10,22 +10,31 @@
10
10
  * equivalent — including when resume() happens on the ROLLED-BACK slot.
11
11
  * - healthProbe(): evidence must be bound to one live process (same-PID /
12
12
  * startId), never assembled from files or caches. A probe that cannot
13
- * prove which process answered is not a probe.
14
- * - start(): may return BEFORE the process exists. Some hosts cannot start
15
- * themselves at all -- a service that is replaced by exiting, letting its
16
- * supervisor respawn it from the new bytes, is started by that supervisor,
17
- * asynchronously. So start() means "the successor has been asked for",
18
- * never "the successor is running": only healthProbe() can say that.
13
+ * prove which process answered is not a probe. K probes once before
14
+ * handover and refuses readback evidence that repeats that startId.
15
+ * - start(): may return before readiness. The external controller asks
16
+ * the service manager to start the selected artifact; healthProbe()
17
+ * then proves that the requested incarnation is actually running.
19
18
  *
20
- * A consequence worth stating, because it decides who finishes an upgrade:
21
- * on such hosts the process driving the transaction DIES on the success path.
22
- * The successor finds a journal that stops mid-handover -- indistinguishable
23
- * from a crash -- and K resolves it by EVIDENCE (a live process reporting the
24
- * experiment version from a different incarnation), never by a flag saying the
25
- * restart was planned. A crash could set that flag just as easily.
19
+ * K runs outside the resident process and service unit, so stopping the
20
+ * application does not kill the transaction owner. A runner crash before
21
+ * durable promote intent restores stable; after that intent, recovery
22
+ * replays the commit. A live candidate alone never authorizes promotion.
26
23
  */
27
24
  export interface HostAdapter {
28
- /** Park all hosted workloads durably. Idempotent. */
25
+ /**
26
+ * Drain or fence effects left by an earlier worker/controller before replay.
27
+ * Called under K's transaction lock. Throw if isolation cannot be proved.
28
+ * Required for adapters whose effects can outlive their worker; purely
29
+ * in-process adapters without surviving effects may omit it.
30
+ */
31
+ fence?(): Promise<void>;
32
+
33
+ /**
34
+ * Park all hosted workloads durably. Idempotent. A stateless service
35
+ * acknowledges and returns; the obligation covers only workloads the
36
+ * product promises to preserve across an upgrade.
37
+ */
29
38
  quiesce(): Promise<void>;
30
39
 
31
40
  /** Stop the resident service process tree for the given slot. */
@@ -40,7 +49,10 @@ export interface HostAdapter {
40
49
  */
41
50
  healthProbe(): Promise<ProcessEvidence>;
42
51
 
43
- /** Resume workloads parked by quiesce(). Must also work after rollback. */
52
+ /**
53
+ * Resume workloads parked by quiesce(). Must also work after rollback,
54
+ * i.e. on the previous version. Trivial when quiesce() is trivial.
55
+ */
44
56
  resume(): Promise<void>;
45
57
  }
46
58
 
@@ -60,8 +72,8 @@ export interface HostAdapter {
60
72
  * reconstructed after a failure, it was never disturbed.
61
73
  *
62
74
  * ⚠️ `stable` here is a POSITION, not the name of a release channel. If your
63
- * product also has a channel called "stable" (ours does), they are unrelated:
64
- * a channel says which stream you follow, a slot says which copy on disk.
75
+ * product also has a channel called "stable", they are unrelated: a channel
76
+ * says which stream you follow, a slot says which copy on disk.
65
77
  */
66
78
  export type Slot = "stable" | "experiment";
67
79
 
@@ -4,10 +4,11 @@
4
4
  * Hosts may project this record into their own UI or transport, but they do
5
5
  * not maintain a second upgrade state machine. The operation receipt is the
6
6
  * single durable answer to: what is running, which version was requested,
7
- * what stable version can be restored, and whether the terminal receipt has
8
- * already been acknowledged by the host transport.
7
+ * what stable version can be restored, and the outcome. Transport delivery
8
+ * tracking belongs to the caller and cannot block another transaction.
9
9
  */
10
10
  import { promises as fs } from "node:fs";
11
+ import { createHash } from "node:crypto";
11
12
  import * as path from "node:path";
12
13
  import { platformOpsFor } from "./platform/index.ts";
13
14
  import type { ProvenanceIdentity } from "./upgrader.ts";
@@ -75,8 +76,6 @@ export interface OperationRecord {
75
76
  reason: string | null;
76
77
  provenance: ProvenanceIdentity | null;
77
78
  metadata: Record<string, string>;
78
- /** Host transport receipt, not a second transaction outcome. */
79
- acknowledgedAtMs: number | null;
80
79
  }
81
80
 
82
81
  export type OperationRead =
@@ -97,7 +96,7 @@ function validIdentity(value: unknown): value is ProvenanceIdentity {
97
96
  );
98
97
  }
99
98
 
100
- function parseOperation(text: string): OperationRecord {
99
+ export function parseOperation(text: string): OperationRecord {
101
100
  const parsed = JSON.parse(text) as Partial<OperationRecord>;
102
101
  if (
103
102
  parsed.formatVersion !== OPERATION_FORMAT_VERSION
@@ -122,7 +121,6 @@ function parseOperation(text: string): OperationRecord {
122
121
  || parsed.metadata === null
123
122
  || Array.isArray(parsed.metadata)
124
123
  || Object.values(parsed.metadata).some((value) => typeof value !== "string")
125
- || !(parsed.acknowledgedAtMs === null || typeof parsed.acknowledgedAtMs === "number")
126
124
  ) {
127
125
  throw new Error("operation record has an invalid shape");
128
126
  }
@@ -160,23 +158,47 @@ export async function loadOperation(stateDir: string): Promise<OperationRead> {
160
158
  }
161
159
  }
162
160
 
163
- export async function acknowledgeOperation(
164
- stateDir: string,
165
- operationId: string,
166
- acknowledgedAtMs: number,
167
- ): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed"> {
168
- const current = await loadOperation(stateDir);
169
- if (current.kind === "genesis") return "not-found";
170
- if (current.kind === "unreadable") throw new Error(current.reason);
171
- if (current.operation.id !== operationId) return "changed";
172
- if (current.operation.outcome === null) return "not-terminal";
173
- // Exact replay is idempotent. The first delivery time is part of the audit
174
- // receipt; a retry must not rewrite it or manufacture a later delivery.
175
- if (current.operation.acknowledgedAtMs !== null) return "acknowledged";
176
- await persistOperation(stateDir, {
177
- ...current.operation,
178
- updatedAtMs: acknowledgedAtMs,
179
- acknowledgedAtMs,
180
- });
181
- return "acknowledged";
161
+ /** A completed request is replayed from its durable receipt, never executed twice. */
162
+ export class OperationReplay extends Error {
163
+ readonly operation: OperationRecord;
164
+ constructor(operation: OperationRecord) {
165
+ super(`OPERATION_REPLAY: ${operation.id}`);
166
+ this.operation = operation;
167
+ this.name = "OperationReplay";
168
+ }
169
+ }
170
+
171
+ function archivePath(stateDir: string, id: string): string {
172
+ return path.join(stateDir, "receipts", `${createHash("sha256").update(id).digest("hex")}.json`);
173
+ }
174
+
175
+ /** Durable history is independent of transport acknowledgement. Called under K's lock. */
176
+ export async function archiveOperation(stateDir: string, operation: OperationRecord): Promise<void> {
177
+ if (operation.outcome === null) throw new Error("cannot archive an active operation");
178
+ const target = archivePath(stateDir, operation.id);
179
+ const existing = await loadArchivedOperation(stateDir, operation.id);
180
+ if (existing.kind === "unreadable") throw new Error(existing.reason);
181
+ if (existing.kind === "observed") {
182
+ if (existing.operation.targetVersion !== operation.targetVersion || existing.operation.outcome !== operation.outcome) {
183
+ throw new Error("OPERATION_ARCHIVE_CONFLICT");
184
+ }
185
+ return;
186
+ }
187
+ await fs.mkdir(path.dirname(target), { recursive: true });
188
+ const tmp = `${target}.tmp`;
189
+ const handle = await fs.open(tmp, "w", 0o600);
190
+ try { await handle.writeFile(JSON.stringify(operation)); await handle.sync(); }
191
+ finally { await handle.close(); }
192
+ await platformOpsFor().renamePath(tmp, target);
193
+ }
194
+
195
+ export async function loadArchivedOperation(stateDir: string, id: string): Promise<OperationRead> {
196
+ try {
197
+ const operation = parseOperation(await fs.readFile(archivePath(stateDir, id), "utf8"));
198
+ if (operation.id !== id || operation.outcome === null) throw new Error("invalid archived receipt identity/outcome");
199
+ return { kind: "observed", operation };
200
+ } catch (error) {
201
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "genesis" };
202
+ return { kind: "unreadable", reason: "cannot read archived K receipt" };
203
+ }
182
204
  }
@@ -2,7 +2,7 @@ import type { Clock } from "./clock.ts";
2
2
  import { phaseAtRest, type TxnState } from "./txn/state.ts";
3
3
  import type { ProvenanceIdentity } from "./upgrader.ts";
4
4
  import {
5
- acknowledgeOperation,
5
+ archiveOperation,
6
6
  loadOperation,
7
7
  persistOperation,
8
8
  type OperationDescriptor,
@@ -27,7 +27,6 @@ export interface OperationLifecycle {
27
27
  settleRecovery(): Promise<void>;
28
28
  reset(): void;
29
29
  read(): Promise<OperationRead>;
30
- acknowledge(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
31
30
  }
32
31
 
33
32
  export function createOperationLifecycle(
@@ -58,9 +57,7 @@ export function createOperationLifecycle(
58
57
  if (existing.operation.outcome === null) {
59
58
  throw new Error(`OPERATION_IN_PROGRESS: ${existing.operation.id}`);
60
59
  }
61
- if (existing.operation.acknowledgedAtMs === null) {
62
- throw new Error(`OPERATION_RECEIPT_PENDING: ${existing.operation.id}`);
63
- }
60
+ await archiveOperation(stateDir, existing.operation);
64
61
  }
65
62
  if (existing.kind === "observed" && existing.operation.id === descriptor.id) {
66
63
  record = existing.operation;
@@ -79,7 +76,6 @@ export function createOperationLifecycle(
79
76
  reason: null,
80
77
  provenance: descriptor.provenance ?? provenance,
81
78
  metadata: { ...descriptor.metadata },
82
- acknowledgedAtMs: null,
83
79
  };
84
80
  await persistOperation(stateDir, record);
85
81
  },
@@ -108,6 +104,5 @@ export function createOperationLifecycle(
108
104
 
109
105
  read: () => loadOperation(stateDir),
110
106
 
111
- acknowledge: (operationId) => acknowledgeOperation(stateDir, operationId, clock.nowMs()),
112
107
  };
113
108
  }
@@ -36,6 +36,13 @@ export interface PlatformOps {
36
36
  * RUNNING image; this one is plain state.
37
37
  */
38
38
  renamePath(from: string, to: string): Promise<void>;
39
+ /**
40
+ * Make directory entries durable. A rename or file creation is only
41
+ * crash-safe once the PARENT directory is fsync'd; `fh.sync()` on the file
42
+ * alone leaves the new name in the page cache. No-op where the OS does not
43
+ * support fsync on a directory handle (Windows).
44
+ */
45
+ syncDirectory(dir: string): Promise<void>;
39
46
  /** Identifier used to select a manifest target, e.g. "linux-x64". */
40
47
  platformKey(): string;
41
48
  }
@@ -3,9 +3,16 @@
3
3
  * The only place in core allowed to name rename/signals.
4
4
  */
5
5
  import { execFileSync } from "node:child_process";
6
- import { promises as fs } from "node:fs";
6
+ import { promises as fs, readFileSync } from "node:fs";
7
+ import path from "node:path";
7
8
  import type { PlatformOps } from "./ops.ts";
8
9
 
10
+ /** fsync a directory so entries created/renamed inside it survive power loss. */
11
+ async function syncDirectory(dir: string): Promise<void> {
12
+ const fh = await fs.open(dir, "r");
13
+ try { await fh.sync(); } finally { await fh.close(); }
14
+ }
15
+
9
16
  async function atomicReplace(filePath: string, data: Uint8Array): Promise<void> {
10
17
  const tmpPath = `${filePath}.tmp`;
11
18
  try {
@@ -28,6 +35,7 @@ async function atomicReplace(filePath: string, data: Uint8Array): Promise<void>
28
35
  }
29
36
  // Atomic on POSIX: a reader sees either the old file or the whole new one.
30
37
  await fs.rename(tmpPath, filePath);
38
+ await syncDirectory(path.dirname(filePath)); // the new name itself must be durable
31
39
  } catch (err) {
32
40
  await fs.unlink(tmpPath).catch(() => {});
33
41
  throw err;
@@ -77,19 +85,31 @@ export function platformKeyFor(
77
85
  export const posixOps: PlatformOps = {
78
86
  swapExecutable: atomicReplace,
79
87
  isProcessAlive(pid) {
80
- try {
81
- process.kill(pid, 0);
82
- return true;
83
- } catch {
84
- return false;
88
+ try { process.kill(pid, 0); }
89
+ catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
90
+ if (process.platform === "linux") {
91
+ try {
92
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
93
+ // A zombie may stay in a container's process table, but cannot execute.
94
+ if (stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z")) return false;
95
+ } catch (error) {
96
+ // Access restrictions are uncertainty, never proof of death.
97
+ return (error as NodeJS.ErrnoException).code !== "ENOENT";
98
+ }
85
99
  }
100
+ return true;
86
101
  },
87
102
  killProcess(pid) {
88
103
  process.kill(pid, "SIGKILL");
89
104
  },
90
105
  async renamePath(from, to) {
91
106
  await fs.rename(from, to);
107
+ const target = path.dirname(to);
108
+ await syncDirectory(target);
109
+ const source = path.dirname(from);
110
+ if (source !== target) await syncDirectory(source);
92
111
  },
112
+ syncDirectory,
93
113
  async makeExecutable(filePath) {
94
114
  await fs.chmod(filePath, 0o755);
95
115
  },
@@ -51,8 +51,8 @@ export const windowsOps: PlatformOps = {
51
51
  try {
52
52
  process.kill(pid, 0);
53
53
  return true;
54
- } catch {
55
- return false;
54
+ } catch (error) {
55
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
56
56
  }
57
57
  },
58
58
  killProcess(pid) {
@@ -63,6 +63,12 @@ export const windowsOps: PlatformOps = {
63
63
  // Plain state moves DO work on Windows (unlike replacing a running .exe).
64
64
  await fs.rename(from, to);
65
65
  },
66
+ async syncDirectory() {
67
+ // Node does not provide a portable directory flush here. This no-op
68
+ // supplies no power-loss durability guarantee; NTFS journaling alone
69
+ // is not an acknowledgement that these particular writes reached disk.
70
+ await Promise.resolve();
71
+ },
66
72
  async makeExecutable() {
67
73
  // No executable bit on Windows; nothing to do (a real no-op).
68
74
  await Promise.resolve();
@@ -0,0 +1,81 @@
1
+ import { parseOperation, type OperationRead } from "../operation.ts";
2
+
3
+ /** Wire version is independent of the helper and application release versions. */
4
+ export const RUNNER_PROTOCOL_VERSION = 1;
5
+ export type RunnerRequest =
6
+ | { protocolVersion: 1; action: "upgrade"; id: string; targetVersion: string; consented: boolean }
7
+ | { protocolVersion: 1; action: "recover"; expected?: { id: string; targetVersion: string } }
8
+ | { protocolVersion: 1; action: "status" }
9
+ ;
10
+
11
+ export interface RunnerResponse {
12
+ protocolVersion: 1;
13
+ action: RunnerRequest["action"];
14
+ /** 0 = completed command, 1 = failure/unknown, 2 = hold, 3 = recovery needed. */
15
+ exitCode: 0 | 1 | 2 | 3;
16
+ result: string;
17
+ /** Verbatim K receipt; an exception never manufactures a rollback receipt. */
18
+ operation: OperationRead;
19
+ error: string | null;
20
+ }
21
+
22
+ function objectValue(value: unknown): Record<string, unknown> {
23
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
24
+ throw new Error("RUNNER_PROTOCOL_INVALID: expected an object");
25
+ }
26
+ return value as Record<string, unknown>;
27
+ }
28
+
29
+ function textField(value: unknown, field: string): string {
30
+ if (typeof value !== "string" || value.trim() !== value || value.length === 0 || value.length > 256) {
31
+ throw new Error(`RUNNER_PROTOCOL_INVALID: invalid ${field}`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ /** Reject unknown versions and fields before loading an adapter or touching disk. */
37
+ export function parseRunnerRequest(input: unknown): RunnerRequest {
38
+ const value = objectValue(input);
39
+ if (value.protocolVersion !== RUNNER_PROTOCOL_VERSION) throw new Error("RUNNER_PROTOCOL_UNSUPPORTED");
40
+ const keys = value.action === "upgrade" ? ["protocolVersion", "action", "id", "targetVersion", "consented"] : value.action === "recover" ? ["protocolVersion", "action", "expected"] : ["protocolVersion", "action"];
41
+ if (Object.keys(value).some((key) => !keys.includes(key))) throw new Error("RUNNER_PROTOCOL_INVALID: unknown field");
42
+ switch (value.action) {
43
+ case "upgrade":
44
+ if (typeof value.consented !== "boolean") throw new Error("RUNNER_PROTOCOL_INVALID: consented must be boolean");
45
+ return { protocolVersion: 1, action: "upgrade", id: textField(value.id, "id"),
46
+ targetVersion: textField(value.targetVersion, "targetVersion"), consented: value.consented };
47
+ case "recover": {
48
+ if (value.expected === undefined) return { protocolVersion: 1, action: "recover" };
49
+ const expected = objectValue(value.expected);
50
+ if (Object.keys(expected).some((key) => !["id", "targetVersion"].includes(key))) throw new Error("RUNNER_PROTOCOL_INVALID: unknown expected field");
51
+ return { protocolVersion: 1, action: "recover", expected: {
52
+ id: textField(expected.id, "id"), targetVersion: textField(expected.targetVersion, "targetVersion"),
53
+ } };
54
+ }
55
+ case "status":
56
+ return { protocolVersion: 1, action: value.action };
57
+ default: throw new Error("RUNNER_PROTOCOL_INVALID: unknown action");
58
+ }
59
+ }
60
+
61
+ /** The supervisor accepts only a complete, typed runner response. */
62
+ export function parseRunnerResponse(input: unknown): RunnerResponse {
63
+ const value = objectValue(input);
64
+ if (value.protocolVersion !== 1 || !["upgrade", "recover", "status"].includes(String(value.action)) ||
65
+ ![0, 1, 2, 3].includes(Number(value.exitCode)) || typeof value.exitCode !== "number" ||
66
+ typeof value.result !== "string" || !(value.error === null || typeof value.error === "string")) {
67
+ throw new Error("RUNNER_RESPONSE_INVALID");
68
+ }
69
+ const read = objectValue(value.operation);
70
+ let operation: OperationRead;
71
+ switch (read.kind) {
72
+ case "genesis": operation = { kind: "genesis" }; break;
73
+ case "unreadable":
74
+ if (typeof read.reason !== "string") throw new Error("RUNNER_RESPONSE_INVALID");
75
+ operation = { kind: "unreadable", reason: read.reason }; break;
76
+ case "observed": operation = { kind: "observed", operation: parseOperation(JSON.stringify(read.operation)) }; break;
77
+ default: throw new Error("RUNNER_RESPONSE_INVALID");
78
+ }
79
+ return { protocolVersion: 1, action: value.action as RunnerRequest["action"],
80
+ exitCode: value.exitCode as RunnerResponse["exitCode"], result: value.result, error: value.error, operation };
81
+ }
@@ -174,7 +174,7 @@ export interface ProvenanceSummary {
174
174
  }
175
175
 
176
176
  /**
177
- * Record one reconcile in the journal (used by createUpgrader before the
177
+ * Record one reconcile in the journal (used by createRunner before the
178
178
  * transaction). The VERSION comes from the release the reconcile drove —
179
179
  * the journal records what was attempted, not the caller's claim. Identity
180
180
  * defaults to the local operator.
@@ -0,0 +1,167 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadOperation } from "./operation.ts";
4
+ import { acquireUpgradeLock, type UpgradeLock } from "./txn/lock.ts";
5
+ import { UpgradeLockError } from "./txn/lock.ts";
6
+ import { platformOpsFor } from "./platform/index.ts";
7
+
8
+ export type QuarantineResult =
9
+ | { status: "quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
10
+ | { status: "already-quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
11
+ | { status: "not-found"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number };
12
+
13
+ export type QuarantineErrorCode =
14
+ | "QUARANTINE_INVALID_DESTINATION"
15
+ | "QUARANTINE_DESTINATION_CONFLICT"
16
+ | "QUARANTINE_ACTIVE_OPERATION"
17
+ | "QUARANTINE_ACTIVE_LOCK"
18
+ | "QUARANTINE_STATE_UNREADABLE"
19
+ | "QUARANTINE_WRITE_FAILED";
20
+
21
+ export class QuarantineError extends Error {
22
+ readonly code: QuarantineErrorCode;
23
+
24
+ constructor(code: QuarantineErrorCode, message: string, options?: { cause?: unknown }) {
25
+ super(`[${code}] ${message}`, options);
26
+ this.name = "QuarantineError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ export interface QuarantineOptions {
32
+ /** Absolute destination. It must be outside stateDir and must not exist. */
33
+ destination: string;
34
+ /** Timestamp supplied by the host clock so the receipt is deterministic. */
35
+ timestampMs: number;
36
+ /** Host proof run while K's single-writer lock is held. */
37
+ assertActiveHandoff?: () => Promise<void>;
38
+ }
39
+
40
+ function assertDestination(stateDir: string, destination: string): void {
41
+ if (!path.isAbsolute(destination)) {
42
+ throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be absolute");
43
+ }
44
+ const source = path.resolve(stateDir);
45
+ const target = path.resolve(destination);
46
+ if (source === target || target.startsWith(`${source}${path.sep}`)) {
47
+ throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be outside stateDir");
48
+ }
49
+ }
50
+
51
+ async function exists(filePath: string): Promise<boolean> {
52
+ try {
53
+ await fs.stat(filePath);
54
+ return true;
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ interface QuarantineReceipt {
62
+ formatVersion: 1;
63
+ kind: "k-fresh-install-quarantine";
64
+ sourcePath: string;
65
+ quarantinePath: string;
66
+ operationId: string;
67
+ timestampMs: number;
68
+ }
69
+
70
+ async function readReceipt(destination: string): Promise<QuarantineReceipt | null> {
71
+ try {
72
+ const parsed = JSON.parse(await fs.readFile(path.join(destination, "fresh-install-quarantine.json"), "utf8")) as Partial<QuarantineReceipt>;
73
+ if (parsed.formatVersion !== 1 || parsed.kind !== "k-fresh-install-quarantine" || typeof parsed.sourcePath !== "string" || typeof parsed.quarantinePath !== "string" || typeof parsed.operationId !== "string" || typeof parsed.timestampMs !== "number") return null;
74
+ return parsed as QuarantineReceipt;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Move the complete K state directory to an audit-only quarantine.
82
+ *
83
+ * The state lock is acquired before the terminal check and the directory
84
+ * rename is one filesystem operation. The lock release is ownership-aware, so
85
+ * a new state directory created immediately after the rename cannot have its
86
+ * lock removed by the old holder. Active operations are never killed or
87
+ * silently detached; callers must first complete the host handoff contract.
88
+ */
89
+ export async function quarantineState(stateDir: string, options: QuarantineOptions): Promise<QuarantineResult> {
90
+ assertDestination(stateDir, options.destination);
91
+ const timestampMs = options.timestampMs;
92
+ const sourcePath = path.resolve(stateDir);
93
+ const quarantinePath = path.resolve(options.destination);
94
+ const existingDestination = await exists(quarantinePath);
95
+ const existingOperation = await loadOperation(sourcePath);
96
+ const operationId = existingOperation.kind === "observed" ? existingOperation.operation.id : "genesis";
97
+
98
+ if (existingDestination) {
99
+ if (await exists(sourcePath)) {
100
+ throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
101
+ }
102
+ const receipt = await readReceipt(quarantinePath);
103
+ if (receipt === null) throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination has no valid quarantine receipt: ${quarantinePath}`);
104
+ return { status: "already-quarantined", sourcePath: receipt.sourcePath, quarantinePath: receipt.quarantinePath, operationId: receipt.operationId, timestampMs: receipt.timestampMs };
105
+ }
106
+ if (!(await exists(sourcePath))) {
107
+ return { status: "not-found", sourcePath, quarantinePath, operationId, timestampMs };
108
+ }
109
+
110
+ let lock: UpgradeLock | null = null;
111
+ try {
112
+ lock = await acquireUpgradeLock(sourcePath, timestampMs);
113
+ const operation = await loadOperation(sourcePath);
114
+ if (operation.kind === "unreadable") {
115
+ throw new QuarantineError("QUARANTINE_STATE_UNREADABLE", operation.reason);
116
+ }
117
+ if (operation.kind === "observed" && operation.operation.outcome === null) {
118
+ if (options.assertActiveHandoff === undefined) {
119
+ throw new QuarantineError(
120
+ "QUARANTINE_ACTIVE_OPERATION",
121
+ `operation ${operation.operation.id} is active; complete host handoff before quarantine`,
122
+ );
123
+ }
124
+ try {
125
+ await options.assertActiveHandoff();
126
+ } catch (error) {
127
+ throw new QuarantineError("QUARANTINE_ACTIVE_OPERATION", `active operation ${operation.operation.id} handoff was not proven`, { cause: error });
128
+ }
129
+ }
130
+ if (await exists(quarantinePath)) {
131
+ throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
132
+ }
133
+ await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
134
+ const receipt: QuarantineReceipt = {
135
+ formatVersion: 1,
136
+ kind: "k-fresh-install-quarantine",
137
+ sourcePath,
138
+ quarantinePath,
139
+ operationId: operation.kind === "observed" ? operation.operation.id : "genesis",
140
+ timestampMs,
141
+ };
142
+ const receiptPath = path.join(sourcePath, "fresh-install-quarantine.json");
143
+ const fh = await fs.open(receiptPath, "w");
144
+ try {
145
+ await fh.writeFile(JSON.stringify(receipt));
146
+ await fh.sync();
147
+ } finally {
148
+ await fh.close();
149
+ }
150
+ await platformOpsFor().renamePath(sourcePath, quarantinePath);
151
+ return {
152
+ status: "quarantined",
153
+ sourcePath,
154
+ quarantinePath,
155
+ operationId: receipt.operationId,
156
+ timestampMs,
157
+ };
158
+ } catch (error) {
159
+ if (error instanceof QuarantineError) throw error;
160
+ if (error instanceof UpgradeLockError) {
161
+ throw new QuarantineError("QUARANTINE_ACTIVE_LOCK", error.message, { cause: error });
162
+ }
163
+ throw new QuarantineError("QUARANTINE_WRITE_FAILED", `could not quarantine ${sourcePath}`, { cause: error });
164
+ } finally {
165
+ await lock?.release();
166
+ }
167
+ }
@@ -0,0 +1,27 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { parseRunnerRequest } from "../protocol/runner.ts";
3
+ import { executeRequest, type RunnerUpgrader } from "./execute.ts";
4
+
5
+ /** Build-time adapter factory; request JSON can never name code to import. */
6
+ export async function serveRunner(
7
+ create: () => Promise<RunnerUpgrader> | RunnerUpgrader,
8
+ input: Readable = process.stdin, output: Writable = process.stdout,
9
+ ): Promise<number> {
10
+ try {
11
+ let text = "";
12
+ for await (const chunk of input) {
13
+ text += String(chunk);
14
+ if (text.length > 16_384) throw new Error("RUNNER_REQUEST_TOO_LARGE");
15
+ }
16
+ const request = parseRunnerRequest(JSON.parse(text));
17
+ const response = await executeRequest(await create(), request);
18
+ await new Promise<void>((resolve, reject) => {
19
+ output.write(`${JSON.stringify(response)}\n`, (error) => error ? reject(error) : resolve());
20
+ });
21
+ return response.exitCode;
22
+ } catch (error) {
23
+ output.write(`${JSON.stringify({ protocolVersion: 1, result: "failed", exitCode: 1,
24
+ error: error instanceof Error ? error.message : "runner failed" })}\n`);
25
+ return 1;
26
+ }
27
+ }