@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.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- package/package.json +42 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// --include-val operator flag (plan todo 6 wording, F1 fix): the CLI switch must
|
|
2
|
+
// parse, thread down the bench seam to the fixture adapter (val units runnable,
|
|
3
|
+
// manifest no longer opaque), and fail CLOSED with exit 2 on toy genomes —
|
|
4
|
+
// never silently ignored. Adapter-level val hiding itself is pinned by
|
|
5
|
+
// bench-fixture.test.ts AC (B); these tests cover the CLI + factory seam.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import test, {} from "node:test";
|
|
11
|
+
import { ExitSignal } from "../exit.js";
|
|
12
|
+
import { parseGenomeSpecDocument } from "../core/spec.js";
|
|
13
|
+
import { openBenchAdapter } from "../core/evolve/run-bench.js";
|
|
14
|
+
import { runEvolution } from "../core/evolve/run-loop.js";
|
|
15
|
+
import { FixtureScenariosAdapter } from "../bench/fixture.js";
|
|
16
|
+
import { parseRunFlags } from "../commands/run.js";
|
|
17
|
+
// ------------------------------------------------------------- parse seam
|
|
18
|
+
test("parseRunFlags: --include-val is a boolean operator switch", () => {
|
|
19
|
+
assert.equal(parseRunFlags(["--genome", "g", "--include-val"]).includeVal, true);
|
|
20
|
+
assert.equal(parseRunFlags(["--genome", "g"]).includeVal, false);
|
|
21
|
+
const combo = parseRunFlags(["--genome", "g", "--include-val", "--dry-run"]);
|
|
22
|
+
assert.equal(combo.includeVal, true);
|
|
23
|
+
assert.equal(combo.dryRun, true);
|
|
24
|
+
});
|
|
25
|
+
// -------------------------------------------------- adapter factory seam
|
|
26
|
+
const VAL_CANARY = "valcanary-7f41";
|
|
27
|
+
function fixtureSpec(genDir) {
|
|
28
|
+
return parseGenomeSpecDocument({
|
|
29
|
+
label: "iv-germ",
|
|
30
|
+
repoPath: genDir,
|
|
31
|
+
bench: {
|
|
32
|
+
type: "opencode-fixture-scenarios",
|
|
33
|
+
units: [
|
|
34
|
+
{ id: "one", path: "scenarios/scenarios-one.md", split: "train" },
|
|
35
|
+
{ id: "two", path: "scenarios/scenarios-two.md", split: "train" },
|
|
36
|
+
{ id: "hidden", path: `scenarios/${VAL_CANARY}.md`, split: "val" },
|
|
37
|
+
],
|
|
38
|
+
runCommand: "opencode run {unit.path}",
|
|
39
|
+
seedCommand: "seed.sh {sandbox}",
|
|
40
|
+
resetCommand: "reset.sh {sandbox}",
|
|
41
|
+
graderCommand: "node grader.mjs {unit.id}",
|
|
42
|
+
judgeCommand: "node judge.mjs {unit.id}",
|
|
43
|
+
judgeModel: "test/judge",
|
|
44
|
+
agentModel: "test/agent",
|
|
45
|
+
timeoutS: 5,
|
|
46
|
+
stats: { halfWidth: 0.1, minEffect: 0.05, nReps: { initial: 1, max: 2 } },
|
|
47
|
+
},
|
|
48
|
+
budget: { maxCandidates: 2, maxModelCalls: 10, maxTokens: 1000, maxWallS: 60 },
|
|
49
|
+
kernel: { immutableGlobs: [] },
|
|
50
|
+
requires: [{ cmd: "node", args: ["--version"], probeExit: 0 }],
|
|
51
|
+
opencodeBinVersion: { minVersion: "1.0.0" },
|
|
52
|
+
}, "<include-val-test>");
|
|
53
|
+
}
|
|
54
|
+
function fixtureEntry(t, genDir) {
|
|
55
|
+
t.after(() => rmSync(genDir, { recursive: true, force: true }));
|
|
56
|
+
return fixtureSpec(genDir);
|
|
57
|
+
}
|
|
58
|
+
function manifestOf(t, spec, includeVal) {
|
|
59
|
+
const configDir = mkdtempSync(path.join(os.tmpdir(), "abathur-iv-cfg-"));
|
|
60
|
+
t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
|
61
|
+
// ctor validates type + repoPath only; probes/locks are lazy, so no spawn here.
|
|
62
|
+
const bundle = openBenchAdapter(spec, {
|
|
63
|
+
configDir,
|
|
64
|
+
...(includeVal === undefined ? {} : { includeVal }),
|
|
65
|
+
});
|
|
66
|
+
t.after(() => bundle.release());
|
|
67
|
+
assert.ok(bundle.adapter instanceof FixtureScenariosAdapter, "fixture bench must build the fixture adapter");
|
|
68
|
+
return bundle.adapter.scenarioManifest();
|
|
69
|
+
}
|
|
70
|
+
test("openBenchAdapter: includeVal threads into the fixture adapter manifest", (t) => {
|
|
71
|
+
const spec = fixtureEntry(t, mkdtempSync(path.join(os.tmpdir(), "abathur-iv-gen-")));
|
|
72
|
+
const val = (entries) => {
|
|
73
|
+
const found = entries.find((e) => e.split === "val");
|
|
74
|
+
assert.ok(found !== undefined, "fixture spec must carry a val unit");
|
|
75
|
+
return found;
|
|
76
|
+
};
|
|
77
|
+
const hidden = manifestOf(t, spec); // default: operator did NOT pass the flag
|
|
78
|
+
const v1 = val(hidden);
|
|
79
|
+
assert.equal(v1.id, undefined, "val id must stay hidden without the operator flag");
|
|
80
|
+
assert.equal(v1.path, undefined, "val path must stay hidden without the operator flag");
|
|
81
|
+
assert.match(v1.alias, /^scenario-\d{2}$/);
|
|
82
|
+
assert.ok(!JSON.stringify(hidden).includes(VAL_CANARY), "val canary leaked into the default manifest");
|
|
83
|
+
const shown = manifestOf(t, spec, true);
|
|
84
|
+
const v2 = val(shown);
|
|
85
|
+
assert.equal(v2.id, "hidden");
|
|
86
|
+
assert.equal(v2.path, `scenarios/${VAL_CANARY}.md`);
|
|
87
|
+
});
|
|
88
|
+
// --------------------------------------------------------- toy fail-closed
|
|
89
|
+
function toyEntry(t) {
|
|
90
|
+
const repoPath = mkdtempSync(path.join(os.tmpdir(), "abathur-iv-toy-"));
|
|
91
|
+
t.after(() => rmSync(repoPath, { recursive: true, force: true }));
|
|
92
|
+
const spec = parseGenomeSpecDocument({
|
|
93
|
+
label: "iv-toy",
|
|
94
|
+
repoPath,
|
|
95
|
+
bench: {
|
|
96
|
+
type: "toy",
|
|
97
|
+
units: [
|
|
98
|
+
{ id: "add", path: "units/add.mjs", split: "train" },
|
|
99
|
+
{ id: "sub", path: "units/sub.mjs", split: "val" },
|
|
100
|
+
],
|
|
101
|
+
runCommand: "node {unit.path}",
|
|
102
|
+
graderCommand: "node grader.mjs {unit.path}",
|
|
103
|
+
timeoutS: 10,
|
|
104
|
+
stats: { halfWidth: 0.25, minEffect: 0.5, nReps: { initial: 2, max: 4 } },
|
|
105
|
+
},
|
|
106
|
+
budget: { maxCandidates: 4, maxModelCalls: 16, maxTokens: 100000, maxWallS: 300 },
|
|
107
|
+
kernel: { immutableGlobs: [] },
|
|
108
|
+
}, "<include-val-test>");
|
|
109
|
+
return { fingerprint: "0123456789abcdef", label: "iv-toy", spec, registryFile: "<test>/iv-toy.jsonc", storedText: "{}" };
|
|
110
|
+
}
|
|
111
|
+
test("runEvolution: toy genome + --include-val fails closed (exit 2), even with --dry-run", async (t) => {
|
|
112
|
+
const configDir = mkdtempSync(path.join(os.tmpdir(), "abathur-iv-cfg-"));
|
|
113
|
+
t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
|
114
|
+
await assert.rejects(runEvolution({ entry: toyEntry(t), configDir, includeVal: true, dryRun: true }), (cause) => {
|
|
115
|
+
assert.ok(cause instanceof ExitSignal, `expected ExitSignal, got ${String(cause)}`);
|
|
116
|
+
assert.equal(cause.code, 2);
|
|
117
|
+
assert.match(cause.message, /--include-val is only supported by the opencode-fixture-scenarios bench/);
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Todo 2 pins: crash-safe locks + friction queue (plan AC d, e, f).
|
|
2
|
+
// RED first. The genome lock must exit 2 naming a live holder, take over a
|
|
3
|
+
// provably-dead holder with a ledger event, and serialize cross-process
|
|
4
|
+
// friction appends without interleaving.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { canonicalJson } from "../core/ids.js";
|
|
12
|
+
import { LEDGER_KIND_LOCK_TAKEOVER, Ledger, acquireGenomeLock, acquireLock, appendFriction, frictionQueuePath, lockDirFor, readFriction, } from "../core/ledger.js";
|
|
13
|
+
import { ExitSignal } from "../exit.js";
|
|
14
|
+
import { FIXED_CLOCK, freshPair, isAlive, rawLines } from "./testutil.js";
|
|
15
|
+
function seedStaleLock(config, key, owner) {
|
|
16
|
+
const dir = lockDirFor(config, key);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
if (owner !== null) {
|
|
19
|
+
writeFileSync(path.join(dir, "owner.json"), JSON.stringify({ ...owner, createdAt: "x", label: "genome" }));
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
// -------------------------------------------------------------------- locks
|
|
24
|
+
test("live holder blocks the second acquirer: exit 2 naming holder pid (AC d)", async (t) => {
|
|
25
|
+
const { config } = await freshPair(t);
|
|
26
|
+
const lease = acquireLock({ configDir: config, key: "fp-live", ...FIXED_CLOCK });
|
|
27
|
+
assert.ok(existsSync(path.join(lease.lockDir, "owner.json")));
|
|
28
|
+
assert.throws(() => acquireLock({ configDir: config, key: "fp-live", ...FIXED_CLOCK }), (e) => e instanceof ExitSignal &&
|
|
29
|
+
e.code === 2 &&
|
|
30
|
+
e.message.includes(`pid ${process.pid}`) &&
|
|
31
|
+
e.message.includes("fp-live"));
|
|
32
|
+
lease.release();
|
|
33
|
+
assert.ok(!existsSync(lease.lockDir));
|
|
34
|
+
acquireLock({ configDir: config, key: "fp-live", ...FIXED_CLOCK }).release();
|
|
35
|
+
});
|
|
36
|
+
test("dead-PID holder is taken over; a lock_takeover event lands in the genome ledger (AC e)", async (t) => {
|
|
37
|
+
const { genome, config } = await freshPair(t);
|
|
38
|
+
const deadPid = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
39
|
+
if (deadPid === undefined)
|
|
40
|
+
throw new Error("fixture must yield a child pid");
|
|
41
|
+
assert.ok(!isAlive(deadPid), "fixture pid must be provably dead");
|
|
42
|
+
const staleDir = seedStaleLock(config, "fp-dead", { pid: deadPid });
|
|
43
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
44
|
+
const lease = acquireGenomeLock({ ledger, configDir: config, genomeFp: "fp-dead", ...FIXED_CLOCK });
|
|
45
|
+
assert.equal(lease.lockDir, staleDir);
|
|
46
|
+
assert.equal(JSON.parse(readFileSync(path.join(staleDir, "owner.json"), "utf8")).pid, process.pid);
|
|
47
|
+
const takeover = ledger.readAll().filter((r) => r.kind === LEDGER_KIND_LOCK_TAKEOVER);
|
|
48
|
+
assert.equal(takeover.length, 1);
|
|
49
|
+
assert.deepEqual(takeover[0]?.data, { lockKey: "fp-dead", stalePid: deadPid, takenOverByPid: process.pid });
|
|
50
|
+
assert.deepEqual(readdirSync(path.join(config, ".locks")), ["fp-dead.lock"], "stale quarantine must be cleaned up");
|
|
51
|
+
lease.release();
|
|
52
|
+
});
|
|
53
|
+
test("lock dir without a readable owner.json is stale (crash mid-acquire) and taken over", async (t) => {
|
|
54
|
+
const { genome, config } = await freshPair(t);
|
|
55
|
+
seedStaleLock(config, "fp-empty", null);
|
|
56
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
57
|
+
const lease = acquireGenomeLock({ ledger, configDir: config, genomeFp: "fp-empty", ...FIXED_CLOCK });
|
|
58
|
+
assert.deepEqual(ledger.readAll().map((r) => r.data.stalePid), [null]);
|
|
59
|
+
lease.release();
|
|
60
|
+
});
|
|
61
|
+
// ------------------------------------------------------------- friction queue
|
|
62
|
+
test("friction queue lives under config home and validates every append", async (t) => {
|
|
63
|
+
const { config } = await freshPair(t);
|
|
64
|
+
assert.equal(frictionQueuePath(config), path.join(config, "friction.jsonl"));
|
|
65
|
+
const rec = appendFriction(config, { kind: "friction", data: { genome: "g1", note: "slow" } }, FIXED_CLOCK);
|
|
66
|
+
assert.equal(rec.kind, "friction");
|
|
67
|
+
assert.deepEqual(readFriction(config).map((r) => canonicalJson(r)), [canonicalJson(rec)]);
|
|
68
|
+
assert.throws(() => appendFriction(config, { kind: "" }, FIXED_CLOCK), Error);
|
|
69
|
+
assert.equal(rawLines(frictionQueuePath(config)).length, 1, "failed append wrote nothing");
|
|
70
|
+
});
|
|
71
|
+
test("two genomes append 200 friction records each concurrently: 400 clean zod-valid lines (AC f)", async (t) => {
|
|
72
|
+
const { config } = await freshPair(t);
|
|
73
|
+
const writer = fileURLToPath(new URL("./fixtures/friction-writer.js", import.meta.url));
|
|
74
|
+
const run = (source) => new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(process.execPath, [writer, config, source, "200"], { stdio: "inherit" });
|
|
76
|
+
child.on("error", reject);
|
|
77
|
+
child.on("close", resolve);
|
|
78
|
+
});
|
|
79
|
+
assert.deepEqual(await Promise.all([run("genome-a"), run("genome-b")]), [0, 0]);
|
|
80
|
+
const lines = rawLines(frictionQueuePath(config));
|
|
81
|
+
assert.equal(lines.length, 400, "no interleaved/partial lines under concurrent appends");
|
|
82
|
+
const parsed = readFriction(config);
|
|
83
|
+
assert.equal(parsed.length, 400);
|
|
84
|
+
for (const source of ["genome-a", "genome-b"]) {
|
|
85
|
+
const seqs = parsed
|
|
86
|
+
.filter((r) => r.data.source === source)
|
|
87
|
+
.map((r) => Number(r.data.seq))
|
|
88
|
+
.sort((x, y) => x - y);
|
|
89
|
+
assert.deepEqual(seqs, Array.from({ length: 200 }, (_, i) => i), `${source}: all records intact`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test("friction append waits for a live holder within budget, then exits 2 naming the pid", async (t) => {
|
|
93
|
+
const { config } = await freshPair(t);
|
|
94
|
+
const held = acquireLock({ configDir: config, key: "friction", ...FIXED_CLOCK });
|
|
95
|
+
assert.throws(() => appendFriction(config, { kind: "friction", data: {} }, { ...FIXED_CLOCK, waitMs: 30 }), (e) => e instanceof ExitSignal && e.code === 2 && e.message.includes(`pid ${process.pid}`));
|
|
96
|
+
held.release();
|
|
97
|
+
appendFriction(config, { kind: "friction", data: { afterRelease: true } }, FIXED_CLOCK);
|
|
98
|
+
assert.equal(readFriction(config).length, 1);
|
|
99
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Todo 2 pins: append-only ledger file semantics (plan AC b, c + resume).
|
|
2
|
+
// Lock/friction pins live in ledger-lock.test.ts. TDD RED first.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { canonicalJson } from "../core/ids.js";
|
|
8
|
+
import { LEDGER_KIND_GENERATION_COMPLETE, Ledger, LedgerError, ledgerPath } from "../core/ledger.js";
|
|
9
|
+
import { FIXED_CLOCK, freshPair, rawLines } from "./testutil.js";
|
|
10
|
+
function seedLedgerFile(genome, text) {
|
|
11
|
+
mkdirSync(path.dirname(ledgerPath(genome)), { recursive: true });
|
|
12
|
+
writeFileSync(ledgerPath(genome), text, "utf8");
|
|
13
|
+
}
|
|
14
|
+
// --------------------------------------------------------- append + read-back
|
|
15
|
+
test("1000 appends read back byte-exact via a single O_APPEND write each (AC b)", async (t) => {
|
|
16
|
+
const { genome } = await freshPair(t);
|
|
17
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
18
|
+
const expected = [];
|
|
19
|
+
for (let seq = 0; seq < 1000; seq += 1) {
|
|
20
|
+
const rec = ledger.append({ kind: "run_result", data: { seq, note: "x".repeat(seq % 7) } });
|
|
21
|
+
expected.push(`${canonicalJson(rec)}\n`);
|
|
22
|
+
}
|
|
23
|
+
assert.equal(readFileSync(ledgerPath(genome), "utf8"), expected.join(""));
|
|
24
|
+
const all = ledger.readAll();
|
|
25
|
+
assert.equal(all.length, 1000);
|
|
26
|
+
assert.deepEqual(all.map((r) => r.data.seq), Array.from({ length: 1000 }, (_, i) => i));
|
|
27
|
+
});
|
|
28
|
+
test("append stamps ts from the injected clock and fails closed on invalid records", async (t) => {
|
|
29
|
+
const { genome } = await freshPair(t);
|
|
30
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
31
|
+
const rec = ledger.append({ kind: "k", genId: "g-1", data: { z: [1, { b: 2, a: 3 }] } });
|
|
32
|
+
assert.deepEqual(rec, {
|
|
33
|
+
v: 1,
|
|
34
|
+
ts: "2026-09-09T12:00:00.000Z",
|
|
35
|
+
kind: "k",
|
|
36
|
+
genId: "g-1",
|
|
37
|
+
data: { z: [1, { b: 2, a: 3 }] },
|
|
38
|
+
});
|
|
39
|
+
assert.throws(() => ledger.append({ kind: "" }), Error);
|
|
40
|
+
assert.throws(() => ledger.append({ kind: "k", genId: "" }), Error);
|
|
41
|
+
assert.equal(rawLines(ledgerPath(genome)).length, 1, "failed appends must write nothing");
|
|
42
|
+
});
|
|
43
|
+
// ------------------------------------------------------------- corrupt tail
|
|
44
|
+
test("pre-truncated tail is quarantined to ledger.corrupt-*, prior records preserved verbatim (AC c)", async (t) => {
|
|
45
|
+
const { genome } = await freshPair(t);
|
|
46
|
+
const good = Array.from({ length: 10 }, (_, i) => canonicalJson({
|
|
47
|
+
v: 1,
|
|
48
|
+
ts: `2026-09-09T12:00:0${i}.000Z`,
|
|
49
|
+
kind: LEDGER_KIND_GENERATION_COMPLETE,
|
|
50
|
+
genId: `g-${i}`,
|
|
51
|
+
data: {},
|
|
52
|
+
})).join("\n");
|
|
53
|
+
const partial = '{"v":1,"ts":"2026-09-09T12:00:10.000Z","kind":"gen';
|
|
54
|
+
const original = `${good}\n${partial}`;
|
|
55
|
+
seedLedgerFile(genome, original);
|
|
56
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
57
|
+
const dir = path.dirname(ledgerPath(genome));
|
|
58
|
+
const corruptName = readdirSync(dir).find((f) => f.startsWith("ledger.corrupt-"));
|
|
59
|
+
assert.ok(corruptName, "quarantine file ledger.corrupt-* must exist");
|
|
60
|
+
// The archive keeps the FULL original bytes — nothing is lost, only relocated.
|
|
61
|
+
assert.equal(readFileSync(path.join(dir, corruptName), "utf8"), original);
|
|
62
|
+
// The ledger keeps the 10 complete lines byte-identical, newline-terminated.
|
|
63
|
+
assert.equal(readFileSync(ledgerPath(genome), "utf8"), `${good}\n`);
|
|
64
|
+
assert.deepEqual(ledger.readAll().map((r) => r.genId), Array.from({ length: 10 }, (_, i) => `g-${i}`));
|
|
65
|
+
const next = ledger.append({ kind: "after_repair" });
|
|
66
|
+
assert.equal(next.kind, "after_repair");
|
|
67
|
+
assert.equal(ledger.readAll().length, 11);
|
|
68
|
+
});
|
|
69
|
+
test("entire-file partial write (no newline anywhere) quarantines to an empty valid ledger", async (t) => {
|
|
70
|
+
const { genome } = await freshPair(t);
|
|
71
|
+
seedLedgerFile(genome, '{"v":1,"ts":');
|
|
72
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
73
|
+
assert.deepEqual(ledger.readAll(), []);
|
|
74
|
+
assert.equal(readFileSync(ledgerPath(genome), "utf8"), "");
|
|
75
|
+
});
|
|
76
|
+
test("malformed line in the MIDDLE is an integrity failure, never silently skipped or repaired", async (t) => {
|
|
77
|
+
const { genome } = await freshPair(t);
|
|
78
|
+
const l1 = canonicalJson({ v: 1, ts: "t", kind: "a", data: {} });
|
|
79
|
+
const l3 = canonicalJson({ v: 1, ts: "t", kind: "c", data: {} });
|
|
80
|
+
seedLedgerFile(genome, `${l1}\n{"v":1,"ts":oops}\n${l3}\n`);
|
|
81
|
+
for (const attempt of [
|
|
82
|
+
() => Ledger.open(genome, FIXED_CLOCK),
|
|
83
|
+
() => Ledger.open(genome).readAll(),
|
|
84
|
+
]) {
|
|
85
|
+
assert.throws(attempt, (e) => e instanceof LedgerError && /line 2/.test(e.message), "mid-file corruption must name the offending line");
|
|
86
|
+
}
|
|
87
|
+
assert.ok(!readdirSync(path.dirname(ledgerPath(genome))).some((f) => f.startsWith("ledger.corrupt-")), "integrity failure must not quarantine anything");
|
|
88
|
+
});
|
|
89
|
+
// --------------------------------------------------------------- resume API
|
|
90
|
+
test("lastCompleteGeneration: null when empty or no matching kind, latest generation_complete otherwise", async (t) => {
|
|
91
|
+
const { genome } = await freshPair(t);
|
|
92
|
+
const ledger = Ledger.open(genome, FIXED_CLOCK);
|
|
93
|
+
assert.equal(ledger.lastCompleteGeneration(), null);
|
|
94
|
+
ledger.append({ kind: "run_result", genId: "g-a", data: {} });
|
|
95
|
+
assert.equal(ledger.lastCompleteGeneration(), null);
|
|
96
|
+
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: "g-a" });
|
|
97
|
+
ledger.append({ kind: "run_result", genId: "g-b" });
|
|
98
|
+
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: "g-b" });
|
|
99
|
+
assert.equal(ledger.lastCompleteGeneration(), "g-b");
|
|
100
|
+
// A fresh instance sees the same state — resume reads the file, not memory.
|
|
101
|
+
assert.equal(Ledger.open(genome, FIXED_CLOCK).lastCompleteGeneration(), "g-b");
|
|
102
|
+
});
|