@botiverse/k-carrier 0.1.0 → 0.1.5

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.
package/README.md CHANGED
@@ -6,34 +6,12 @@ CLI self-update libraries stop at replacing bytes; fleet updaters assume a machi
6
6
 
7
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.
8
8
 
9
- ## Delivery and guarantee are different axes
10
-
11
- Most updaters are compared on one vague axis called "complexity". There are
12
- two, and they are independent:
13
-
14
- | | delivery — how hard is it to put the bytes in place | guarantee what is promised afterwards |
15
- |-----------------------|---------------------------------------|-----------------------------|
16
- | `rustup self update` | low: replace one file, exit | low: none |
17
- | `electron-updater` | **high** | low: none |
18
- | **K** | low: one binary | **high**: transaction, readback, rollback |
19
-
20
- Measured, not asserted: of electron-updater 6.8.9's ~4,200 lines, ~1,170 are
21
- per-platform installation (Squirrel.Mac, NSIS, deb/rpm/pacman, AppImage),
22
- ~1,200 orchestration and policy, ~960 feed providers, and ~860 differential
23
- download. Downloading is not the hard part — **installing is, because
24
- installing is not yours to do**: you hand off to a system component with its
25
- own rules (Squirrel.Mac accepts only a URL, so the updater serves the file it
26
- already downloaded back to itself over a local HTTP server; NSIS may need
27
- elevation; dpkg needs root). Grep that codebase for rollback and you find none,
28
- and nothing checks health after the install.
29
-
30
- K deliberately does not compete on the delivery axis — platform packaging
31
- belongs to platform tools. It exists on the other one, and specifically for the
32
- consequence those tools all share and none of them handle: **because something
33
- else installs your bytes, something else replaces and restarts your process.**
34
- The process driving the upgrade dies on the *success* path, so the successor
35
- must be able to tell "the handover worked" from "we crashed" — by evidence,
36
- never by a flag saying the restart was planned.
9
+ ## What K owns
10
+
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.
37
15
 
38
16
  ## Start here
39
17
 
@@ -53,10 +31,8 @@ examples/ one runnable demo per profile (swap-tool / service-daemon / hosted-s
53
31
  docs/ guides + design + test plan + prior art
54
32
  ```
55
33
 
56
- **Platform support today:** Linux and macOS are implemented and gate CI.
57
- Windows platform operations (replacing a *running* .exe, process liveness) are
58
- deliberately unimplemented — they throw a typed `PLATFORM_UNSUPPORTED` rather
59
- than approximating POSIX behaviour and corrupting an install. The Windows CI
60
- job runs and reports, but does not gate, until those land.
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.
61
37
 
62
38
  Status: incubating. TypeScript first. License: **Apache-2.0**.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * First-adoption bootstrap for an already-running service.
3
+ *
4
+ * K normally owns both slots from the first install. An existing application
5
+ * adopting K has a different starting world: trusted bytes are running, but
6
+ * `slots/stable` does not exist yet. Starting the first transaction in that
7
+ * world would make a failed experiment roll back to an empty slot.
8
+ *
9
+ * `bootstrapStable` closes that one-time gap. It copies the application's
10
+ * current trusted executable into K's stable slot before any transaction can
11
+ * start. The publication is atomic and shares K's upgrade lock, so a crash
12
+ * leaves either no stable slot or one complete stable slot, never half of one.
13
+ */
14
+ import { promises as fs } from "node:fs";
15
+ import path from "node:path";
16
+ import { platformOpsFor } from "./platform/index.ts";
17
+ import { acquireUpgradeLock } from "./txn/lock.ts";
18
+ import { slotArtifactPath } from "./txn/fileEffects.ts";
19
+
20
+ const VERSION_FILE = "VERSION";
21
+
22
+ export type BootstrapStableResult = "bootstrapped" | "already-initialized";
23
+
24
+ export type BootstrapErrorCode =
25
+ | "BOOTSTRAP_VERSION_INVALID"
26
+ | "BOOTSTRAP_STATE_CONFLICT"
27
+ | "BOOTSTRAP_SOURCE_UNREADABLE"
28
+ | "BOOTSTRAP_WRITE_FAILED";
29
+
30
+ export class BootstrapError extends Error {
31
+ readonly code: BootstrapErrorCode;
32
+
33
+ constructor(code: BootstrapErrorCode, message: string, options?: { cause?: unknown }) {
34
+ super(`[${code}] ${message}`, options);
35
+ this.name = "BootstrapError";
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ export interface BootstrapStableOptions {
41
+ /** Directory K owns for its journal, slots, and staging area. */
42
+ stateDir: string;
43
+ /** Version of the trusted executable that is running before K adoption. */
44
+ version: string;
45
+ /** Path to those exact trusted executable bytes. */
46
+ artifactPath: string;
47
+ /** Clock seam used only for the shared upgrade-lock receipt. */
48
+ nowMs?: () => number;
49
+ }
50
+
51
+ async function pathExists(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
+ async function readInitializedStable(stateDir: string): Promise<string | null> {
62
+ const stableDir = path.join(stateDir, "slots", "stable");
63
+ if (!(await pathExists(stableDir))) return null;
64
+
65
+ let version: string;
66
+ try {
67
+ version = (await fs.readFile(path.join(stableDir, VERSION_FILE), "utf8")).trim();
68
+ await fs.stat(slotArtifactPath(stateDir, "stable"));
69
+ } catch (error) {
70
+ throw new BootstrapError(
71
+ "BOOTSTRAP_STATE_CONFLICT",
72
+ "the stable slot exists but is incomplete; refusing to overwrite recovery evidence",
73
+ { cause: error },
74
+ );
75
+ }
76
+ if (version.length === 0) {
77
+ throw new BootstrapError(
78
+ "BOOTSTRAP_STATE_CONFLICT",
79
+ "the stable slot has an empty version; refusing to overwrite recovery evidence",
80
+ );
81
+ }
82
+ return version;
83
+ }
84
+
85
+ async function assertPristineTransactionState(stateDir: string): Promise<void> {
86
+ const conflicting = [
87
+ path.join(stateDir, "journal.jsonl"),
88
+ path.join(stateDir, "slots", "experiment"),
89
+ ];
90
+ for (const candidate of conflicting) {
91
+ if (await pathExists(candidate)) {
92
+ throw new BootstrapError(
93
+ "BOOTSTRAP_STATE_CONFLICT",
94
+ `transaction state already exists at ${candidate}; refusing to invent an initial stable slot`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Seed K's stable slot exactly once from an application's current trusted
102
+ * executable. A complete existing stable slot means K is already initialized
103
+ * (possibly at a newer version), so the caller's old carrier bytes are ignored.
104
+ */
105
+ export async function bootstrapStable(opts: BootstrapStableOptions): Promise<BootstrapStableResult> {
106
+ const version = opts.version.trim();
107
+ if (version.length === 0) {
108
+ throw new BootstrapError("BOOTSTRAP_VERSION_INVALID", "version must be non-empty");
109
+ }
110
+
111
+ const lock = await acquireUpgradeLock(opts.stateDir, (opts.nowMs ?? Date.now)());
112
+ try {
113
+ if ((await readInitializedStable(opts.stateDir)) !== null) return "already-initialized";
114
+ await assertPristineTransactionState(opts.stateDir);
115
+
116
+ try {
117
+ const source = await fs.stat(opts.artifactPath);
118
+ if (!source.isFile()) throw new Error("bootstrap source is not a regular file");
119
+ } catch (error) {
120
+ throw new BootstrapError(
121
+ "BOOTSTRAP_SOURCE_UNREADABLE",
122
+ `trusted bootstrap artifact is not readable at ${opts.artifactPath}`,
123
+ { cause: error },
124
+ );
125
+ }
126
+
127
+ const slotsDir = path.join(opts.stateDir, "slots");
128
+ const stableDir = path.join(slotsDir, "stable");
129
+ const stagingDir = `${stableDir}.bootstrap`;
130
+ const stagingArtifact = path.join(stagingDir, "artifact.bin");
131
+ try {
132
+ await fs.rm(stagingDir, { recursive: true, force: true });
133
+ await fs.mkdir(stagingDir, { recursive: true });
134
+ await fs.copyFile(opts.artifactPath, stagingArtifact);
135
+ const artifactHandle = await fs.open(stagingArtifact, "r+");
136
+ try {
137
+ await artifactHandle.sync();
138
+ } finally {
139
+ await artifactHandle.close();
140
+ }
141
+ await platformOpsFor().makeExecutable(stagingArtifact);
142
+
143
+ const versionHandle = await fs.open(path.join(stagingDir, VERSION_FILE), "w");
144
+ try {
145
+ await versionHandle.writeFile(version);
146
+ await versionHandle.sync();
147
+ } finally {
148
+ await versionHandle.close();
149
+ }
150
+ await fs.mkdir(slotsDir, { recursive: true });
151
+ await platformOpsFor().renamePath(stagingDir, stableDir);
152
+ return "bootstrapped";
153
+ } catch (error) {
154
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
155
+ if (error instanceof BootstrapError) throw error;
156
+ throw new BootstrapError(
157
+ "BOOTSTRAP_WRITE_FAILED",
158
+ "could not atomically publish the initial stable slot",
159
+ { cause: error },
160
+ );
161
+ }
162
+ } finally {
163
+ await lock.release();
164
+ }
165
+ }
166
+
167
+ /** Public slot resolver used by host adapters; layout remains K-owned. */
168
+ export { slotArtifactPath } from "./txn/fileEffects.ts";
@@ -27,6 +27,7 @@ import { finishUpgradeOutcome } from "./upgrade/outcome.ts";
27
27
  import { retireReason } from "./upgrade/retire.ts";
28
28
  import { buildStatusReport, type StatusReport } from "./status/report.ts";
29
29
  import { persistReport, loadLastReport, type ReportRead } from "./status/reportStore.ts";
30
+ import { recoverUpgrade } from "./upgrade/recover.ts";
30
31
 
31
32
  export interface CreateUpgraderOptions extends UpgraderConfig {
32
33
  clock?: Clock;
@@ -65,7 +66,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
65
66
  const clock = opts.clock ?? systemClock;
66
67
  const effects = fileEffects(opts.stateDir);
67
68
  const ownership = opts.installOwnership ?? ((): "self" => "self");
68
-
69
69
  // The last predicate evidence, captured for the promote report (the
70
70
  // engine only carries pass/fail; the report needs the real results).
71
71
  let lastEvidence: ProcessEvidence | null = null;
@@ -150,7 +150,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
150
150
  const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
151
151
  try {
152
152
  await engine.recover(); // finish or undo anything a previous crash left
153
-
154
153
  const current = (await readState()).stableVersion;
155
154
  reportStage({ stage: "checking" });
156
155
  let release: Release | null;
@@ -170,7 +169,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
170
169
  throw err;
171
170
  }
172
171
  if (release === null) return { result: "up-to-date" };
173
-
174
172
  if (!consented && opts.policy === "notify-only") {
175
173
  await notify("held", { reason: "notify-only", version: release.version });
176
174
  return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
@@ -231,6 +229,8 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
231
229
  }
232
230
 
233
231
  return {
232
+ recover: () => recoverUpgrade(opts.stateDir, clock, engine),
233
+
234
234
  async check(): Promise<{ current: string; target: string | null }> {
235
235
  const current = (await readState()).stableVersion;
236
236
  const release = await opts.source.checkForUpdate({
package/core/src/index.ts CHANGED
@@ -6,10 +6,19 @@
6
6
  // The upgrader factory and its configuration.
7
7
  export * from "./createUpgrader.ts";
8
8
 
9
+ // One-time adoption of an already-running trusted binary into K's stable
10
+ // slot, plus the K-owned slot resolver host adapters use to launch it.
11
+ export * from "./bootstrap.ts";
12
+
9
13
  // Core types: Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity,
10
14
  // NotificationEvent.
11
15
  export * from "./upgrader.ts";
12
16
 
17
+ // The release-source boundary applications implement and the durable
18
+ // provenance journal they wire into createUpgrader.
19
+ export * from "./artifact/source.ts";
20
+ export * from "./provenance/journal.ts";
21
+
13
22
  // The host boundary an adopter implements: HostAdapter, Slot, ProcessEvidence.
14
23
  export * from "./lifecycle/hostAdapter.ts";
15
24
 
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { promises as fs } from "node:fs";
25
25
  import path from "node:path";
26
- import type { Clock } from "../clock.ts";
26
+ import { systemClock, type Clock } from "../clock.ts";
27
27
 
28
28
  const PROVENANCE_FILE = "provenance.jsonl";
29
29
 
@@ -84,7 +84,7 @@ export interface ProvenanceJournal {
84
84
  read(): Promise<ProvenanceRead>;
85
85
  }
86
86
 
87
- export function fileProvenanceJournal(stateDir: string, clock: Clock): ProvenanceJournal {
87
+ export function fileProvenanceJournal(stateDir: string, clock: Clock = systemClock): ProvenanceJournal {
88
88
  const filePath = path.join(stateDir, PROVENANCE_FILE);
89
89
 
90
90
  /** Read the raw file. Throws ProvenanceHistoryUnreadableError when the
@@ -0,0 +1,17 @@
1
+ import type { Clock } from "../clock.ts";
2
+ import type { UpgradeEngine } from "../txn/engine.ts";
3
+ import { acquireUpgradeLock } from "../txn/lock.ts";
4
+
5
+ /** Settle an existing transaction under the same lock as every other drive. */
6
+ export async function recoverUpgrade(
7
+ stateDir: string,
8
+ clock: Clock,
9
+ engine: UpgradeEngine,
10
+ ): Promise<void> {
11
+ const lock = await acquireUpgradeLock(stateDir, clock.nowMs());
12
+ try {
13
+ await engine.recover();
14
+ } finally {
15
+ await lock.release();
16
+ }
17
+ }
@@ -24,6 +24,17 @@ export interface ProvenanceIdentity {
24
24
  * convergence" (the class of bug this framework exists to kill).
25
25
  */
26
26
  export interface Upgrader {
27
+ /**
28
+ * Settle any transaction a previous coordinator left in flight.
29
+ *
30
+ * Recovery uses the same durable journal, host adapter, predicates and
31
+ * upgrade lock as ordinary upgrades. It never consults the release source
32
+ * or begins a new transaction; it only replays or rolls back work already
33
+ * recorded by K. Hosts should run this from a coordinator that survives
34
+ * service replacement, because recovery may stop and restart the service.
35
+ */
36
+ recover(): Promise<void>;
37
+
27
38
  /**
28
39
  * Ask the release source whether this install should move, without moving
29
40
  * it. `target: null` means nothing to do.
package/docs/design-v1.md CHANGED
@@ -239,7 +239,7 @@ K 交付低 / 保证高 单二进制;事务 + 回读 +
239
239
  3. 本仓库 = 原"release/publish 侧 spec"的上位替代;接入方视角见 `docs/integration.md`。
240
240
 
241
241
  ## 6. 决定记录(原开放问题,已拍部分)
242
- 1. **名字/仓库 ✅(08-05)**:公开名 **k-carrier**(`github.com/botiverse/k-carrier`,private 孵化),口头名 **K**。理由:单字母不可检索 + kframework/k 撞名;k-carrier 自解释。
242
+ 1. **名字/仓库 ✅(08-05)**:公开名 **k-carrier**(`github.com/botiverse/k-carrier`),口头名 **K**。理由:单字母不可检索 + kframework/k 撞名;k-carrier 自解释。
243
243
  2. **core 语言 = TS 起步 ✅(默认成立,未被否)**:与 daemon 同栈、宿主壳复用最快、测试教义全在 TS 生态;留 FFI/重写门。
244
244
  3. **并行方式 = 1.0.16 先行 ✅(默认成立)**:按本仓库接口形状写,core 骨架随后收编。
245
245
  4. **drive 协议(仍开放)**:对齐现有远程配置生态 vs 自定义最小集 —— 到 L5 动工时拍。
File without changes
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@botiverse/k-carrier",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
+ "packageManager": "pnpm@11.18.0",
4
5
  "description": "A fail-closed upgrade carrier for long-running managed services: two-slot upgrade transactions (promote/rollback), host-driven quiesce/resume handoff, and post-upgrade convergence read-back.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/botiverse/k-carrier"
9
+ },
5
10
  "license": "Apache-2.0",
6
11
  "type": "module",
7
12
  "engines": {
@@ -12,7 +17,7 @@
12
17
  "./harness": "./harness/src/index.ts"
13
18
  },
14
19
  "bin": {
15
- "k-harness": "./harness/src/cli.ts"
20
+ "k-harness": "harness/src/cli.ts"
16
21
  },
17
22
  "files": [
18
23
  "core/src/**/*.ts",
@@ -1 +0,0 @@
1
- # artifact — see docs/design-v1.md for this layer's spec. Interfaces land here next.
@@ -1 +0,0 @@
1
- # drive — see docs/design-v1.md for this layer's spec. Interfaces land here next.
@@ -1 +0,0 @@
1
- # platform — see docs/design-v1.md for this layer's spec. Interfaces land here next.
@@ -1 +0,0 @@
1
- # policy — see docs/design-v1.md for this layer's spec. Interfaces land here next.
package/harness/README.md DELETED
@@ -1,20 +0,0 @@
1
- # K harness
2
-
3
- The generic acceptance bed runs the same registered teeth against K, a real
4
- binary, or an adopter's HostAdapter. It also owns K's deterministic simulator.
5
-
6
- ```sh
7
- k-harness --list
8
- k-harness --profile service
9
- k-harness sim # fixed PR smoke corpus
10
- k-harness sim --seed 3737844653 --json # exact replay
11
- k-harness sim --start-seed 1 --seeds 50000
12
- ```
13
-
14
- Simulation uses the real `UpgradeEngine` over an in-memory `TxnEffects` and
15
- HostAdapter. Every journal, slot, host and predicate effect is a seeded fault
16
- point. A failure prints its exact replay command and is atomically merged into
17
- `.k-harness/sim-failures.json`; the nightly workflow uploads that corpus.
18
-
19
- The simulator covers transaction/convergence logic. It does not replace the
20
- real-process crash matrix or real-OS test beds.