@tachikomagundam/abathur 0.1.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 (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,154 @@
1
+ // Canonical fingerprints + id construction (plan todo 2, AC a).
2
+ // Digest discipline mirrors hr/bench/manifest.py: sha256 over JSON with
3
+ // recursively sorted keys and no whitespace, so two producers of the same
4
+ // logical value always agree byte-for-byte.
5
+ import { createHash } from "node:crypto";
6
+ import { readdirSync, readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ function sha256Hex(data) {
9
+ return createHash("sha256").update(data).digest("hex");
10
+ }
11
+ function encode(value, top) {
12
+ if (value === null)
13
+ return "null";
14
+ switch (typeof value) {
15
+ case "string":
16
+ case "boolean":
17
+ return JSON.stringify(value);
18
+ case "number":
19
+ // JSON.stringify would silently map non-finite to null: a digest
20
+ // collision we refuse instead of trusting callers to pre-filter.
21
+ if (!Number.isFinite(value)) {
22
+ throw new TypeError(`canonicalJson: non-finite number ${String(value)}`);
23
+ }
24
+ return JSON.stringify(value);
25
+ case "undefined":
26
+ if (top)
27
+ throw new TypeError("canonicalJson: undefined has no canonical encoding");
28
+ return "null"; // JSON.stringify parity inside arrays; object props are dropped
29
+ case "function":
30
+ case "symbol":
31
+ case "bigint":
32
+ throw new TypeError(`canonicalJson: unsupported ${typeof value}`);
33
+ case "object": {
34
+ if (Array.isArray(value))
35
+ return `[${value.map((el) => encode(el, false)).join(",")}]`;
36
+ const proto = Object.getPrototypeOf(value);
37
+ if (proto !== Object.prototype && proto !== null) {
38
+ throw new TypeError("canonicalJson: only plain JSON objects are accepted (convert Date etc. first)");
39
+ }
40
+ const parts = [];
41
+ for (const key of Object.keys(value).sort()) {
42
+ const child = value[key];
43
+ if (child === undefined)
44
+ continue;
45
+ parts.push(`${JSON.stringify(key)}:${encode(child, false)}`);
46
+ }
47
+ return `{${parts.join(",")}}`;
48
+ }
49
+ }
50
+ throw new TypeError(`canonicalJson: unsupported input of type ${typeof value}`);
51
+ }
52
+ /** Sorted-key, whitespace-free JSON — the canonical form every fingerprint hashes. */
53
+ export function canonicalJson(value) {
54
+ return encode(value, true);
55
+ }
56
+ export function fingerprint(value) {
57
+ return sha256Hex(canonicalJson(value));
58
+ }
59
+ /**
60
+ * Content-only tree digest: sha256 over [{path, sha256(content)}] sorted by
61
+ * path. Modes/mtimes/ownership are excluded BY CONSTRUCTION (never read), so
62
+ * clone, checkout and chmod can never change the digest — only bytes can.
63
+ */
64
+ export function fileTreeDigest(files) {
65
+ const seen = new Set();
66
+ const rows = [];
67
+ for (const { path: filePath, content } of files) {
68
+ if (seen.has(filePath))
69
+ throw new Error(`fileTreeDigest: duplicate path '${filePath}'`);
70
+ seen.add(filePath);
71
+ rows.push({ path: filePath, sha256: sha256Hex(content) });
72
+ }
73
+ rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
74
+ return sha256Hex(canonicalJson(rows));
75
+ }
76
+ /** Regular files only (symlinks/dirs excluded), forward-slash paths relative to root. */
77
+ export function walkTree(root) {
78
+ const out = [];
79
+ const visit = (dir) => {
80
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
81
+ const full = path.join(dir, entry.name);
82
+ if (entry.isDirectory())
83
+ visit(full);
84
+ else if (entry.isFile()) {
85
+ out.push({
86
+ path: path.relative(root, full).split(path.sep).join("/"),
87
+ content: readFileSync(full),
88
+ });
89
+ }
90
+ }
91
+ };
92
+ visit(root);
93
+ out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
94
+ return out;
95
+ }
96
+ export function treeDigestAt(root) {
97
+ return fileTreeDigest(walkTree(root));
98
+ }
99
+ /** Compact path-safe UTC stamp: 2026-09-09T14:25:30.000Z -> 20260909T142530Z. */
100
+ export function compactUtc(at) {
101
+ return at.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
102
+ }
103
+ /** Generation id: g-<ulctime>-<first8 of the generation content fingerprint>. */
104
+ export function genId(contentFingerprint, at) {
105
+ if (contentFingerprint.length === 0) {
106
+ throw new TypeError("genId: fingerprint seed must be a non-empty string");
107
+ }
108
+ return `g-${compactUtc(at ?? new Date())}-${contentFingerprint.slice(0, 8)}`;
109
+ }
110
+ /** Run id: r-<genId>-<unitId>-<repIdx> — one benchmark repeat of one unit. */
111
+ export function runId(genId, unitId, repIdx) {
112
+ if (genId.length === 0)
113
+ throw new TypeError("runId: genId must be non-empty");
114
+ if (unitId.length === 0)
115
+ throw new TypeError("runId: unitId must be non-empty");
116
+ if (!Number.isInteger(repIdx) || repIdx < 0) {
117
+ throw new TypeError(`runId: repIdx must be an integer >= 0, got ${String(repIdx)}`);
118
+ }
119
+ return `r-${genId}-${unitId}-${repIdx}`;
120
+ }
121
+ /**
122
+ * Version of the BenchAdapter interface contract (todo 5 `src/bench/adapter.ts`:
123
+ * reset/seed/run/score + RunResult/ScoreOutcome shapes). The todo 5 toy adapter and
124
+ * todo 6 fixture adapter both implement this iface; any breaking change to that
125
+ * surface must bump this constant so benchDigest values stop comparing across
126
+ * incompatible adapter generations.
127
+ */
128
+ export const ADAPTER_IFACE_VERSION = "abathur-bench-adapter-v1";
129
+ function sha256Of(data) {
130
+ return createHash("sha256").update(data).digest("hex");
131
+ }
132
+ /**
133
+ * Deterministic bench-configuration digest: sha256 over canonical JSON of the
134
+ * unit list (sorted by unitId), script contents (sorted by path), the command
135
+ * templates, models, timeout and the adapter iface version. Reordering units or
136
+ * scripts never moves it; any content or config change always does.
137
+ */
138
+ export function benchDigest(input) {
139
+ return fingerprint({
140
+ adapterIface: ADAPTER_IFACE_VERSION,
141
+ agentModel: input.agentModel ?? null,
142
+ graderCommand: input.graderCommand,
143
+ judgeCommand: input.judgeCommand ?? null,
144
+ judgeModel: input.judgeModel ?? null,
145
+ runCommand: input.runCommand,
146
+ scripts: [...input.scripts]
147
+ .sort((a, b) => (a.path < b.path ? -1 : 1))
148
+ .map((s) => ({ path: s.path, sha256: sha256Of(s.content) })),
149
+ timeoutS: input.timeoutS,
150
+ units: [...input.units]
151
+ .sort((a, b) => (a.unitId < b.unitId ? -1 : 1))
152
+ .map((u) => ({ unitId: u.unitId, sha256: sha256Of(u.content) })),
153
+ });
154
+ }
@@ -0,0 +1,46 @@
1
+ // Fast-forward-only incumbent promotion (todo 3 helper; todo 10 consumes it).
2
+ // The ref may only move FORWARD: `update-ref <ref> <new> <old>` compare-and-swap
3
+ // refuses a rewind or a concurrent move by exiting non-zero — abathur never
4
+ // rewrites history and never updates a ref while the branch is checked out.
5
+ import path from "node:path";
6
+ import { blocked } from "../exit.js";
7
+ import { tryGit } from "../util/git.js";
8
+ import { gitOpts, requireSha } from "./genome-paths.js";
9
+ /** Promotion branch — moved by fast-forward CAS only (todo 10), created if absent. */
10
+ export const INCUMBENT_BRANCH = "abathur/incumbent";
11
+ /**
12
+ * Move INCUMBENT_BRANCH to `commitSha` iff it is a descendant of the current
13
+ * tip (or the branch is absent and gets created). A no-op when already there.
14
+ */
15
+ export async function fastForwardIncumbent(genome, commitSha, opts = {}) {
16
+ const sha = requireSha(commitSha);
17
+ const gopts = gitOpts(opts, path.resolve(genome.repoPath));
18
+ const current = await tryGit(["symbolic-ref", "--short", "-q", "HEAD"], gopts);
19
+ if (current.ok && current.stdout.trim() === INCUMBENT_BRANCH) {
20
+ blocked(`worktree: ${INCUMBENT_BRANCH} is checked out in ${gopts.cwd} — promotion needs it idle`, "switch the genome repo to another branch before promoting");
21
+ }
22
+ const ref = `refs/heads/${INCUMBENT_BRANCH}`;
23
+ const existing = await tryGit(["rev-parse", "--verify", "-q", ref], gopts);
24
+ if (!existing.ok) {
25
+ const created = await tryGit(["branch", INCUMBENT_BRANCH, sha], gopts);
26
+ if (!created.ok) {
27
+ const why = created.error.stderr.trim().split("\n")[0] ?? created.error.message;
28
+ blocked(`worktree: cannot create ${INCUMBENT_BRANCH} at ${sha}: ${why}`, "is the commit reachable in the genome repo?");
29
+ }
30
+ return { branch: INCUMBENT_BRANCH, fromSha: null, toSha: sha };
31
+ }
32
+ const fromSha = existing.stdout.trim();
33
+ if (fromSha === sha)
34
+ return { branch: INCUMBENT_BRANCH, fromSha, toSha: sha };
35
+ const ancestor = await tryGit(["merge-base", "--is-ancestor", fromSha, sha], gopts);
36
+ if (!ancestor.ok) {
37
+ blocked(`worktree: ${INCUMBENT_BRANCH} (${fromSha.slice(0, 12)}) is not an ancestor of ${sha} — only fast-forward moves are allowed`, "point the promotion at a descendant of the current incumbent");
38
+ }
39
+ // CAS: the old-value argument makes this a pure fast-forward — if the ref
40
+ // moved concurrently, update-ref exits non-zero instead of clobbering it.
41
+ const moved = await tryGit(["update-ref", ref, sha, fromSha], gopts);
42
+ if (!moved.ok) {
43
+ blocked(`worktree: ${INCUMBENT_BRANCH} moved concurrently (expected ${fromSha}) — rerun the promotion`);
44
+ }
45
+ return { branch: INCUMBENT_BRANCH, fromSha, toSha: sha };
46
+ }
@@ -0,0 +1,112 @@
1
+ // Kernel manifest (todo 4): sha256 seal of every working-tree file matching the
2
+ // genome's immutableGlobs, stored out-of-tree at <configDir>/kernels/<fp16>.json.
3
+ // This module is READ-ONLY by design — resealing belongs exclusively to promote
4
+ // (todo 10): the oracle Critical #2 rule that tampering can never be laundered by
5
+ // a plain re-add. auditKernel() is the reuse seam for todos 9/10.
6
+ import { createHash } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { z } from "zod";
10
+ import { cannotAnswer } from "../exit.js";
11
+ import { compileGlob, listFiles } from "./glob.js";
12
+ import { effectiveRepoPath, errorText } from "./spec.js";
13
+ export const manifestEntrySchema = z.strictObject({
14
+ glob: z.string().min(1),
15
+ /** Repo-root-relative POSIX path. */
16
+ path: z.string().min(1),
17
+ sha256: z.string().regex(/^[0-9a-f]{64}$/, "expected 64-char lowercase sha256 hex"),
18
+ });
19
+ const manifestFileSchema = z.strictObject({ entries: z.array(manifestEntrySchema) });
20
+ export function kernelsDirOf(configDir) {
21
+ return path.join(configDir, "kernels");
22
+ }
23
+ export function manifestPathFor(configDir, fingerprint16) {
24
+ return path.join(kernelsDirOf(configDir), `${fingerprint16}.json`);
25
+ }
26
+ function sha256File(absPath) {
27
+ return createHash("sha256").update(readFileSync(absPath)).digest("hex");
28
+ }
29
+ /**
30
+ * One entry per sealed file; `glob` attributes the FIRST matching pattern in
31
+ * declaration order (spec fingerprint pins that order, so bytes are stable per
32
+ * fingerprint). listFiles() is sorted ⇒ entries are sorted by path.
33
+ */
34
+ export function buildManifest(repoRoot, globs) {
35
+ const matchers = globs.map((glob) => ({ glob, re: compileGlob(glob) }));
36
+ const entries = [];
37
+ for (const rel of listFiles(repoRoot)) {
38
+ const hit = matchers.find((matcher) => matcher.re.test(rel));
39
+ if (hit !== undefined) {
40
+ entries.push({ glob: hit.glob, path: rel, sha256: sha256File(path.join(repoRoot, rel)) });
41
+ }
42
+ }
43
+ return entries;
44
+ }
45
+ /** Deterministic manifest bytes: key order fixed by the type, entries sorted by path. */
46
+ export function serializeManifest(entries) {
47
+ const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
48
+ return `${JSON.stringify({ entries: sorted })}\n`;
49
+ }
50
+ /** Parse stored manifest text; throws a plain Error (callers map to exit codes). */
51
+ export function parseManifestText(text, origin) {
52
+ let document;
53
+ try {
54
+ document = JSON.parse(text);
55
+ }
56
+ catch (cause) {
57
+ throw new Error(`malformed kernel manifest in ${origin}: ${errorText(cause)}`);
58
+ }
59
+ const result = manifestFileSchema.safeParse(document);
60
+ if (!result.success) {
61
+ throw new Error(`kernel manifest rejected in ${origin}: ${result.error.issues[0]?.message ?? "schema"}`);
62
+ }
63
+ return result.data.entries;
64
+ }
65
+ export function readManifestFile(filePath) {
66
+ let text;
67
+ try {
68
+ text = readFileSync(filePath, "utf8");
69
+ }
70
+ catch {
71
+ throw new Error(`kernel manifest is missing: ${filePath}`);
72
+ }
73
+ return parseManifestText(text, filePath);
74
+ }
75
+ /** Set-diff of two manifests → deterministic drift list (sorted by path, then kind). */
76
+ export function compareManifest(saved, fresh) {
77
+ const savedByPath = new Map(saved.map((entry) => [entry.path, entry]));
78
+ const freshByPath = new Map(fresh.map((entry) => [entry.path, entry]));
79
+ const drifted = [];
80
+ for (const [entryPath, entry] of savedByPath) {
81
+ const now = freshByPath.get(entryPath);
82
+ if (now === undefined)
83
+ drifted.push({ path: entryPath, kind: "missing", glob: entry.glob });
84
+ else if (now.sha256 !== entry.sha256) {
85
+ drifted.push({ path: entryPath, kind: "modified", glob: entry.glob });
86
+ }
87
+ }
88
+ for (const [entryPath, entry] of freshByPath) {
89
+ if (!savedByPath.has(entryPath)) {
90
+ drifted.push({ path: entryPath, kind: "added", glob: entry.glob });
91
+ }
92
+ }
93
+ return drifted.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
94
+ }
95
+ /**
96
+ * Re-seal the genome's repo in memory and diff against the stored manifest.
97
+ * Missing/corrupt manifest ⇒ exit 2 (cannot answer); any drift ⇒ ok:false with
98
+ * every affected file named — consumed by `kernel audit` now, todos 9/10 later.
99
+ */
100
+ export function auditKernel(entry, configDir) {
101
+ const filePath = manifestPathFor(configDir, entry.fingerprint);
102
+ let saved;
103
+ try {
104
+ saved = readManifestFile(filePath);
105
+ }
106
+ catch (cause) {
107
+ return cannotAnswer(`kernel audit: ${errorText(cause)} — genome '${entry.spec.label}' (${entry.fingerprint})`);
108
+ }
109
+ const fresh = buildManifest(effectiveRepoPath(entry.spec.repoPath), entry.spec.kernel.immutableGlobs);
110
+ const drifted = compareManifest(saved, fresh);
111
+ return { ok: drifted.length === 0, drifted };
112
+ }
@@ -0,0 +1,198 @@
1
+ // Append-only per-genome ledger + crash resume + friction queue (plan todo 2).
2
+ // One JSONL file per genome at <genomeRepo>/.state/abathur/ledger.jsonl.
3
+ // Discipline (border/README.md precedent): lines are only ever appended, never
4
+ // rewritten or truncated — the sole exception is the corrupt-TAIL quarantine
5
+ // at open, which MOVES the original bytes to ledger.corrupt-<ts> and keeps the
6
+ // intact prefix byte-for-byte. Every read re-validates every line with zod;
7
+ // mid-file corruption is an integrity error, never a silent skip.
8
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, writeSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { z } from "zod";
11
+ import { canonicalJson, compactUtc } from "./ids.js";
12
+ import { acquireLock } from "./locks.js";
13
+ export { acquireLock, lockDirFor } from "./locks.js";
14
+ export const LEDGER_KIND_LOCK_TAKEOVER = "lock_takeover";
15
+ export const LEDGER_KIND_GENERATION_COMPLETE = "generation_complete";
16
+ export const ledgerRecordSchema = z.strictObject({
17
+ v: z.literal(1),
18
+ ts: z.string().min(1),
19
+ kind: z.string().min(1),
20
+ genId: z.string().min(1).optional(),
21
+ runId: z.string().min(1).optional(),
22
+ data: z.record(z.string(), z.unknown()),
23
+ });
24
+ export class LedgerError extends Error {
25
+ kind;
26
+ constructor(kind, message) {
27
+ super(message);
28
+ this.kind = kind;
29
+ this.name = "LedgerError";
30
+ }
31
+ }
32
+ export function ledgerPath(genomeRepo) {
33
+ return path.join(genomeRepo, ".state", "abathur", "ledger.jsonl");
34
+ }
35
+ function buildRecord(input, now) {
36
+ const candidate = {
37
+ v: 1,
38
+ ts: input.ts ?? now().toISOString(),
39
+ kind: input.kind,
40
+ data: input.data ?? {},
41
+ };
42
+ if (input.genId !== undefined)
43
+ candidate.genId = input.genId;
44
+ if (input.runId !== undefined)
45
+ candidate.runId = input.runId;
46
+ return ledgerRecordSchema.parse(candidate); // invalid input never reaches the file
47
+ }
48
+ /** ONE write() per record: fully serialized line, O_APPEND, partial-write loop is the only retry. */
49
+ function appendSerialized(filePath, record) {
50
+ const bytes = Buffer.from(`${canonicalJson(record)}\n`, "utf8");
51
+ const fd = openSync(filePath, "a");
52
+ try {
53
+ let written = 0;
54
+ while (written < bytes.length)
55
+ written += writeSync(fd, bytes, written);
56
+ }
57
+ finally {
58
+ closeSync(fd);
59
+ }
60
+ }
61
+ function parseLine(line, filePath, lineNo) {
62
+ let json;
63
+ try {
64
+ json = JSON.parse(line);
65
+ }
66
+ catch (error) {
67
+ throw new LedgerError("integrity", `${filePath}: line ${lineNo} is not valid JSON: ${error.message}`);
68
+ }
69
+ const parsed = ledgerRecordSchema.safeParse(json);
70
+ if (!parsed.success) {
71
+ throw new LedgerError("integrity", `${filePath}: line ${lineNo} failed schema validation: ${parsed.error.message}`);
72
+ }
73
+ return parsed.data;
74
+ }
75
+ function validateCompleteLines(prefix, filePath) {
76
+ if (prefix.length === 0)
77
+ return [];
78
+ const lines = prefix.toString("utf8").split("\n");
79
+ if (lines.at(-1) === "")
80
+ lines.pop();
81
+ const records = [];
82
+ lines.forEach((line, index) => {
83
+ records.push(parseLine(line, filePath, index + 1));
84
+ });
85
+ return records;
86
+ }
87
+ /**
88
+ * Load + validate, repairing a crash-truncated tail first. A file not ending
89
+ * in \n means the last record died mid-write (the kernel only splits a single
90
+ * write() on signals, and SIGKILL between mkdir/append leaves byte-prefixed
91
+ * debris too). Repair keeps prior lines byte-identical: rename the original
92
+ * to <name>.corrupt-<ts> (full bytes preserved), then recreate the file with
93
+ * only the newline-terminated prefix.
94
+ */
95
+ function loadValidated(filePath, now) {
96
+ let buf;
97
+ try {
98
+ buf = readFileSync(filePath);
99
+ }
100
+ catch (error) {
101
+ if (error.code === "ENOENT")
102
+ return [];
103
+ throw error;
104
+ }
105
+ const cut = buf.lastIndexOf(0x0a) + 1;
106
+ if (cut === buf.length)
107
+ return validateCompleteLines(buf, filePath);
108
+ const ext = path.extname(filePath);
109
+ const archive = path.join(path.dirname(filePath), `${path.basename(filePath, ext)}.corrupt-${compactUtc(now())}-${process.pid}${ext}`);
110
+ renameSync(filePath, archive);
111
+ writeFileSync(filePath, buf.subarray(0, cut));
112
+ return validateCompleteLines(buf.subarray(0, cut), filePath);
113
+ }
114
+ export class Ledger {
115
+ genomeRepo;
116
+ filePath;
117
+ now;
118
+ constructor(genomeRepo, filePath, now) {
119
+ this.genomeRepo = genomeRepo;
120
+ this.filePath = filePath;
121
+ this.now = now;
122
+ }
123
+ /** Opens, ensures the state dir, quarantines a corrupt tail and validates every line. */
124
+ static open(genomeRepo, opts = {}) {
125
+ const filePath = ledgerPath(genomeRepo);
126
+ mkdirSync(path.dirname(filePath), { recursive: true });
127
+ const ledger = new Ledger(genomeRepo, filePath, opts.now ?? (() => new Date()));
128
+ ledger.readAll();
129
+ return ledger;
130
+ }
131
+ append(input) {
132
+ const record = buildRecord(input, this.now);
133
+ appendSerialized(this.filePath, record);
134
+ return record;
135
+ }
136
+ readAll() {
137
+ return loadValidated(this.filePath, this.now);
138
+ }
139
+ /** Resume point for the evolution loop: genId of the last completed generation. */
140
+ lastCompleteGeneration() {
141
+ let last = null;
142
+ for (const record of this.readAll()) {
143
+ if (record.kind === LEDGER_KIND_GENERATION_COMPLETE && record.genId !== undefined) {
144
+ last = record.genId;
145
+ }
146
+ }
147
+ return last;
148
+ }
149
+ }
150
+ /**
151
+ * Single-flight per genome. The lock lives under config home keyed by the
152
+ * genome FINGERPRINT (not repo path) so parallel checkouts of the same genome
153
+ * still contend; a dead holder is replaced and the takeover is booked into
154
+ * the genome's own ledger before the lease is handed out.
155
+ */
156
+ export function acquireGenomeLock(opts) {
157
+ const { ledger, configDir, genomeFp } = opts;
158
+ const lease = acquireLock({
159
+ configDir,
160
+ key: genomeFp,
161
+ label: "genome lock",
162
+ waitMs: opts.waitMs,
163
+ now: opts.now,
164
+ });
165
+ if (lease.tookOverFrom !== null) {
166
+ ledger.append({
167
+ kind: LEDGER_KIND_LOCK_TAKEOVER,
168
+ data: { lockKey: genomeFp, stalePid: lease.tookOverFrom.stalePid, takenOverByPid: process.pid },
169
+ });
170
+ }
171
+ return lease;
172
+ }
173
+ /** Global friction queue (todo 11 consumes): <configDir>/friction.jsonl. */
174
+ export function frictionQueuePath(configDir) {
175
+ return path.join(configDir, "friction.jsonl");
176
+ }
177
+ /** Guarded by .locks/friction.lock; every genome can append without corrupting another's line. */
178
+ export function appendFriction(configDir, input, opts = {}) {
179
+ const now = opts.now ?? (() => new Date());
180
+ const record = buildRecord(input, now);
181
+ const lease = acquireLock({
182
+ configDir,
183
+ key: "friction",
184
+ label: "friction lock",
185
+ waitMs: opts.waitMs ?? 15_000,
186
+ now,
187
+ });
188
+ try {
189
+ appendSerialized(frictionQueuePath(configDir), record);
190
+ }
191
+ finally {
192
+ lease.release();
193
+ }
194
+ return record;
195
+ }
196
+ export function readFriction(configDir) {
197
+ return loadValidated(frictionQueuePath(configDir), () => new Date());
198
+ }
@@ -0,0 +1,172 @@
1
+ // mkdir-atomic advisory locks (plan todo 2). Lives under CONFIG HOME
2
+ // (<configDir>/.locks/<key>.lock) on purpose: keyed by genome fingerprint and
3
+ // shared across clones/machine-local paths, so single-flight survives the
4
+ // genome repo being cloned or moved.
5
+ //
6
+ // Protocol:
7
+ // - acquire = mkdirSync(lockDir) — exclusive at the kernel level; the winner
8
+ // immediately writes owner.json {pid, createdAt, label}.
9
+ // - EEXIST = read owner.json and probe liveness via process.kill(pid, 0):
10
+ // ESRCH -> dead, EPERM -> alive (foreign user), success -> alive.
11
+ // - stale takeover uses renameSync(lockDir, <lockDir>.stale-<nonce>) rather
12
+ // than rm+mkdir: rename is atomic, so when two acquirers race to reclaim,
13
+ // exactly one succeeds and the loser re-runs acquisition against the
14
+ // freshly-mkdir'd winner. rm-then-mkdir would let both "win".
15
+ // - release removes the dir only while owner.json still names us, so a lease
16
+ // we no longer hold can never clobber a legitimate new holder.
17
+ //
18
+ // - an unreadable owner.json alone does NOT prove staleness: a fresh holder
19
+ // is microseconds from writing it, so a lock younger than STALE_GRACE_MS
20
+ // (by dir mtime) is still honoured; only after the grace is a missing
21
+ // owner treated as "holder died between mkdir and owner-write". Without
22
+ // the grace, a racer can rename away a lock a live holder just created —
23
+ // caught by the two-writer friction test on the first run.
24
+ // Remaining accepted race: Linux PID reuse can mislabel a dead holder as
25
+ // live until the lock is manually cleared.
26
+ import { randomUUID } from "node:crypto";
27
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { cannotAnswer } from "../exit.js";
30
+ import { canonicalJson } from "./ids.js";
31
+ const OWNER_FILE = "owner.json";
32
+ export function lockDirFor(configDir, key) {
33
+ if (key.length === 0 || key.includes("/") || key.includes(path.sep) || key === "..") {
34
+ throw new TypeError(`lock key '${key}' is not a safe directory name`);
35
+ }
36
+ return path.join(configDir, ".locks", `${key}.lock`);
37
+ }
38
+ function readOwner(lockDir) {
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(readFileSync(path.join(lockDir, OWNER_FILE), "utf8"));
42
+ }
43
+ catch {
44
+ return null; // absent or unparseable — see protocol note above
45
+ }
46
+ if (typeof raw.pid !== "number" || !Number.isInteger(raw.pid) || raw.pid <= 0)
47
+ return null;
48
+ return {
49
+ pid: raw.pid,
50
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "unknown",
51
+ label: typeof raw.label === "string" ? raw.label : "unknown",
52
+ };
53
+ }
54
+ const STALE_GRACE_MS = 250;
55
+ function lockDirIsFresh(lockDir) {
56
+ try {
57
+ return Date.now() - statSync(lockDir).mtimeMs < STALE_GRACE_MS;
58
+ }
59
+ catch {
60
+ return false; // dir vanished mid-observe; the next mkdir/rename resolves the race
61
+ }
62
+ }
63
+ function pidAlive(pid) {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ return error.code === "EPERM";
70
+ }
71
+ }
72
+ function sleepSync(ms) {
73
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
74
+ }
75
+ function writeOwner(lockDir, label, now) {
76
+ const owner = canonicalJson({ createdAt: now().toISOString(), label, pid: process.pid });
77
+ writeFileSync(path.join(lockDir, OWNER_FILE), `${owner}\n`, "utf8");
78
+ }
79
+ export function acquireLock(opts) {
80
+ const { configDir, key } = opts;
81
+ const lockDir = lockDirFor(configDir, key);
82
+ mkdirSync(path.dirname(lockDir), { recursive: true }); // the .locks/ parent, not the lock itself
83
+ const label = opts.label ?? "lock";
84
+ const now = opts.now ?? (() => new Date());
85
+ const deadline = Date.now() + (opts.waitMs ?? 0);
86
+ let takeovers = 0;
87
+ let reclaimed = null;
88
+ for (;;) {
89
+ try {
90
+ mkdirSync(lockDir);
91
+ }
92
+ catch (error) {
93
+ if (error.code !== "EEXIST")
94
+ throw error;
95
+ const owner = readOwner(lockDir);
96
+ if (owner === null && lockDirIsFresh(lockDir)) {
97
+ sleepSync(2); // a live holder may be mid-owner-write; re-observe next pass
98
+ continue;
99
+ }
100
+ if (owner !== null && pidAlive(owner.pid)) {
101
+ reclaimed = null; // a fresh occupant took over: our old reclaim is not ours to book
102
+ if (Date.now() < deadline) {
103
+ sleepSync(2);
104
+ continue;
105
+ }
106
+ cannotAnswer(`${label} '${key}' is held by live pid ${owner.pid} (since ${owner.createdAt})`, `wait for it to finish; remove ${lockDir} only once that pid is gone`);
107
+ }
108
+ takeovers += 1;
109
+ if (takeovers > 8) {
110
+ cannotAnswer(`lock '${key}' is churn: 8 takeover races lost`, lockDir);
111
+ }
112
+ const quarantine = `${lockDir}.stale-${process.pid}-${randomUUID().slice(0, 8)}`;
113
+ try {
114
+ renameSync(lockDir, quarantine);
115
+ }
116
+ catch (race) {
117
+ reclaimed = null; // another taker won the rename — re-observe their lock normally
118
+ continue;
119
+ }
120
+ rmSync(quarantine, { recursive: true, force: true });
121
+ reclaimed = { stalePid: owner?.pid ?? null };
122
+ continue; // retry mkdir; if a competitor wins it, the claim check below resets us
123
+ }
124
+ // mkdir won. Claim, then PROVE the claim: a holder descheduled past the
125
+ // grace can have its brand-new dir stolen between mkdir and owner-write,
126
+ // so verify owner pid AND directory inode (rename+remkdir changes ino).
127
+ // A lost claim retries instead of silently double-holding the lock.
128
+ let wonInode;
129
+ try {
130
+ wonInode = statSync(lockDir).ino;
131
+ }
132
+ catch {
133
+ reclaimed = null;
134
+ continue; // dir renamed away before we could even claim it
135
+ }
136
+ try {
137
+ writeOwner(lockDir, label, now);
138
+ }
139
+ catch (error) {
140
+ if (error.code === "ENOENT") {
141
+ reclaimed = null;
142
+ continue; // dir was renamed away mid-claim — re-observe
143
+ }
144
+ throw error;
145
+ }
146
+ const claim = readOwner(lockDir);
147
+ let dirInode;
148
+ try {
149
+ dirInode = statSync(lockDir).ino;
150
+ }
151
+ catch {
152
+ dirInode = null;
153
+ }
154
+ const ours = claim !== null && claim.pid === process.pid && dirInode === wonInode;
155
+ if (ours)
156
+ return makeLease(lockDir, key, reclaimed);
157
+ reclaimed = null;
158
+ }
159
+ }
160
+ function makeLease(lockDir, key, tookOverFrom) {
161
+ return {
162
+ lockDir,
163
+ key,
164
+ tookOverFrom,
165
+ release() {
166
+ const owner = readOwner(lockDir);
167
+ if (owner !== null && owner.pid === process.pid) {
168
+ rmSync(lockDir, { recursive: true, force: true });
169
+ }
170
+ },
171
+ };
172
+ }