opencode-longrun-harness 1.2.22
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/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import * as C from "../src/controller.js";
|
|
8
|
+
|
|
9
|
+
const CONTROLLER = path.resolve(import.meta.dirname, "..", "src", "controller.js");
|
|
10
|
+
process.env.LONGRUN_CONTROLLER_FILE = CONTROLLER;
|
|
11
|
+
const PLUG_URL = "../plugin/longrun.js";
|
|
12
|
+
const PLUG_FILE = path.resolve(import.meta.dirname, "..", "plugin", "longrun.js").split(path.sep).join("/");
|
|
13
|
+
const { F } = await import("./helper.mjs");
|
|
14
|
+
|
|
15
|
+
function freshState() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "tb-st-")); process.env.LONGRUN_STATE_DIR = d; return d; }
|
|
16
|
+
|
|
17
|
+
// 1 + 2: help + schema are self-describing; NO action-name guessing is ever required.
|
|
18
|
+
test("help is self-describing (actions + params + version + continuation OFF)", async () => {
|
|
19
|
+
freshState();
|
|
20
|
+
const h = await F(PLUG_URL, { client: null });
|
|
21
|
+
const r = JSON.parse(await h.tool.longrun.execute({ action: "help" }, { sessionID: "s", directory: os.tmpdir(), worktree: os.tmpdir() }));
|
|
22
|
+
assert.ok(r.actions.every((a) => r.params[a] !== undefined), "every action documents its params");
|
|
23
|
+
assert.ok(r.actions.includes("start") && r.actions.includes("memory_init"));
|
|
24
|
+
assert.equal(r.harnessVersion, C.LIFECYCLE_SCHEMA_VERSION, "help reports the harness lifecycle version");
|
|
25
|
+
});
|
|
26
|
+
test("a rejected action tells the model to read the schema, never to guess", async () => {
|
|
27
|
+
freshState();
|
|
28
|
+
const h = await F(PLUG_URL, { client: null });
|
|
29
|
+
const r = JSON.parse(await h.tool.longrun.execute({ action: "frobnicate" }, { sessionID: "s", directory: os.tmpdir(), worktree: os.tmpdir() }));
|
|
30
|
+
assert.equal(r.error, "unknown_action");
|
|
31
|
+
assert.match(r.detail, /Do NOT brute-force/, "explicitly discourages action-name guessing");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// 3: probes/tests can never contaminate live evidence (import != load; non-host != host).
|
|
35
|
+
const CHILD_ENV = (state) => { const e = { ...process.env, LONGRUN_STATE_DIR: state }; delete e.LONGRUN_TEST; delete e.NODE_TEST_CONTEXT; delete e.OPENCODE_CLIENT; return e; };
|
|
36
|
+
|
|
37
|
+
test("importing the plugin creates no live evidence, even with a state dir set", () => {
|
|
38
|
+
const state = fs.mkdtempSync(path.join(os.tmpdir(), "tb-noev-"));
|
|
39
|
+
const r = spawnSync(process.execPath, ["--input-type=module", "-e", `await import("file://${PLUG_FILE}");`], { env: CHILD_ENV(state) });
|
|
40
|
+
assert.equal(r.status, 0, "child import ran: " + r.stderr);
|
|
41
|
+
assert.deepEqual(fs.readdirSync(state), [], "a bare import writes nothing");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a plain-node probe driving real tool actions cannot forge a load record or touch default state", () => {
|
|
45
|
+
const state = fs.mkdtempSync(path.join(os.tmpdir(), "tb-probe-"));
|
|
46
|
+
const proj = fs.mkdtempSync(path.join(os.tmpdir(), "tb-proj-"));
|
|
47
|
+
fs.writeFileSync(path.join(proj, "a.js"), "x");
|
|
48
|
+
// A non-host child (no OPENCODE_CLIENT/electron): even a full start+verify must not create a
|
|
49
|
+
// live "load" record, and must not write into the DEFAULT production state dir.
|
|
50
|
+
const code = `
|
|
51
|
+
const m = await import("file://${PLUG_FILE}");
|
|
52
|
+
const C = await import("file://${CONTROLLER}");
|
|
53
|
+
const hooks = await m.default.server({ client: null });
|
|
54
|
+
const ctx = { sessionID: "probe", agent: "longrun", directory: "${proj}", worktree: "${proj}" };
|
|
55
|
+
await hooks.tool.longrun.execute({ action: "start", request: "x", criteria: [{ id: "c1", checks: ["t"] }] }, ctx);
|
|
56
|
+
const fs = await import("node:fs"); const path = await import("node:path");
|
|
57
|
+
const st = process.env.LONGRUN_STATE_DIR;
|
|
58
|
+
process.stdout.write(JSON.stringify({
|
|
59
|
+
hasLoadDir: fs.existsSync(path.join(st, "load")),
|
|
60
|
+
stateKeys: fs.readdirSync(st),
|
|
61
|
+
}));
|
|
62
|
+
`;
|
|
63
|
+
const r = spawnSync(process.execPath, ["--input-type=module", "-e", code], { env: CHILD_ENV(state) });
|
|
64
|
+
assert.equal(r.status, 0, "child ran: " + r.stderr);
|
|
65
|
+
const out = JSON.parse(r.stdout);
|
|
66
|
+
assert.equal(out.hasLoadDir, false, "a non-host probe must not create live-load evidence");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the longrun tool never accepts arbitrary shell / undeclared work", async () => {
|
|
70
|
+
freshState();
|
|
71
|
+
const h = await F(PLUG_URL, { client: null });
|
|
72
|
+
// verify still refuses anything outside the declared catalogue (no shell bypass)
|
|
73
|
+
const res = JSON.parse(await h.tool.longrun_verify.execute({ checkId: "rm -rf /" }, { sessionID: "ghost", directory: os.tmpdir(), worktree: os.tmpdir() }));
|
|
74
|
+
assert.equal(res.error, "NO_RUN", "no run -> nothing to run (still no shell path)");
|
|
75
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { reviewProjectFixture } from "./helper.mjs";
|
|
2
|
+
// v1.2.1 commissioning fixes, verified deterministically.
|
|
3
|
+
// There is NO live OpenCode host in this environment and the plugin deliberately refuses to treat
|
|
4
|
+
// a test/probe process as a host (isHostProcess), so a REAL live-tool run cannot occur here.
|
|
5
|
+
// These tests drive the REAL plugin tool factory + the REAL controller (the harness's own
|
|
6
|
+
// equivalent of the live tool surface) through the full lifecycle, with LONGRUN_TEST armed so no
|
|
7
|
+
// evidence can ever be written into a production state dir. Nothing here touches production
|
|
8
|
+
// source, OpenCode config, model/sampler/context, permissions, compaction or continuation.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import * as C from "../src/controller.js";
|
|
15
|
+
import { VERSION as INSTALL_VERSION } from "../src/install.mjs";
|
|
16
|
+
|
|
17
|
+
const CONTROLLER = path.resolve(import.meta.dirname, "..", "src", "controller.js");
|
|
18
|
+
process.env.LONGRUN_CONTROLLER_FILE = CONTROLLER;
|
|
19
|
+
const PLUG_URL = "../plugin/longrun.js";
|
|
20
|
+
const { F } = await import("./helper.mjs");
|
|
21
|
+
|
|
22
|
+
function freshState() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "v121-st-")); process.env.LONGRUN_STATE_DIR = d; return d; }
|
|
23
|
+
function projWithFile() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "v121-proj-")); fs.writeFileSync(path.join(d, "a.js"), "x"); return d; }
|
|
24
|
+
async function tools() { const h = await F(PLUG_URL, { client: null }); return h.tool; }
|
|
25
|
+
const ctx = (d) => ({ sessionID: "v121", agent: "longrun", directory: d, worktree: d });
|
|
26
|
+
|
|
27
|
+
// ---- version alignment: single source of truth, no drift (controller/package/plugin/installer) --
|
|
28
|
+
test("v1.2.1 version alignment (package == controller == plugin == installer)", () => {
|
|
29
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "..", "..", "package.json"), "utf8"));
|
|
30
|
+
const plugSrc = fs.readFileSync(path.resolve(import.meta.dirname, "..", "plugin", "longrun.js"), "utf8");
|
|
31
|
+
assert.equal(pkg.version, "1.2.22", "package.json bumped");
|
|
32
|
+
assert.equal(C.LIFECYCLE_SCHEMA_VERSION, pkg.version, "controller lifecycle schema version tracks package");
|
|
33
|
+
assert.equal(INSTALL_VERSION, pkg.version, "installer VERSION tracks package");
|
|
34
|
+
assert.ok(plugSrc.includes(`const VERSION = "${pkg.version}"`), "plugin VERSION literal matches package");
|
|
35
|
+
assert.ok(plugSrc.includes(`in v${pkg.version}`), "help continuation note not stale");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---- BUG 1: an incomplete contract is refused at START, and no run is created ------------------
|
|
39
|
+
test("start refuses a required criterion with no mapped check (INVALID_CONTRACT, no run created)", async () => {
|
|
40
|
+
freshState();
|
|
41
|
+
const t = await tools();
|
|
42
|
+
const d = projWithFile();
|
|
43
|
+
const res = JSON.parse(await t.longrun.execute({ action: "start", request: "lifecycle check", criteria: [{ id: "LC-001", evidenceClass: "STATIC", weight: 1 }] }, ctx(d)));
|
|
44
|
+
assert.equal(res.error, "INVALID_CONTRACT", "no run for an unverifiable criterion");
|
|
45
|
+
assert.ok(Array.isArray(res.problems) && res.problems.length === 1 && res.problems[0].criterionId === "LC-001", "names the incomplete criterion");
|
|
46
|
+
// no run was written -> a later status is still NO_RUN (prompt alone is not a run)
|
|
47
|
+
const st = JSON.parse(await t.longrun.execute({ action: "status" }, ctx(d)));
|
|
48
|
+
assert.equal(st.state, "NO_RUN", "incomplete contract created NO tracked run");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("start refuses a criterion whose checks are absent from checkCatalogue (INVALID_CONTRACT)", async () => {
|
|
52
|
+
freshState();
|
|
53
|
+
const t = await tools();
|
|
54
|
+
const d = projWithFile();
|
|
55
|
+
const res = JSON.parse(await t.longrun.execute({ action: "start", request: "x", criteria: [{ id: "c1", checks: ["ghost"] }], checkCatalogue: { other: { command: ["node", "-e", "0"], kind: "cmd" } } }, ctx(d)));
|
|
56
|
+
assert.equal(res.error, "INVALID_CONTRACT");
|
|
57
|
+
assert.deepEqual(res.problems[0].missingChecks, ["ghost"], "names the missing declared check");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---- memory subsystem works WITHOUT a run, into an isolated fixture only ----------------------
|
|
61
|
+
test("memory_status returns success at run=null (was NO_RUN in v1.2.0)", async () => {
|
|
62
|
+
freshState();
|
|
63
|
+
const t = await tools();
|
|
64
|
+
const d = projWithFile();
|
|
65
|
+
const res = JSON.parse(await t.longrun.execute({ action: "memory_status" }, ctx(d)));
|
|
66
|
+
assert.notEqual(res.state, "NO_RUN", "memory_status is a structural readout, not a run check");
|
|
67
|
+
assert.equal(res.status, "NO_MEMORY");
|
|
68
|
+
assert.equal(res.run, null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("memory_init at run=null runs; dryRun writes nothing into the fixture", async () => {
|
|
72
|
+
freshState();
|
|
73
|
+
const t = await tools();
|
|
74
|
+
const d = projWithFile();
|
|
75
|
+
const before = fs.readdirSync(d);
|
|
76
|
+
const dry = JSON.parse(await t.longrun.execute({ action: "memory_init", dryRun: true }, ctx(d)));
|
|
77
|
+
assert.equal(dry.dryRun, true);
|
|
78
|
+
assert.equal(dry.state, "MEMORY");
|
|
79
|
+
assert.deepEqual(fs.readdirSync(d), before, "dryRun wrote nothing");
|
|
80
|
+
// and a real memory_init still does NOT start a tracked run
|
|
81
|
+
await t.longrun.execute({ action: "memory_init" }, ctx(d));
|
|
82
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "status" }, ctx(d))).state, "NO_RUN", "memory seeding is not a tracked run");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- the tool contract: verify readout never executes; verify execution is longrun_verify only --
|
|
86
|
+
test("longrun action=verify is a read-only readout and longrun_verify has no shell bypass", async () => {
|
|
87
|
+
freshState();
|
|
88
|
+
const t = await tools();
|
|
89
|
+
const d = projWithFile();
|
|
90
|
+
// no run -> verify readout is not even reached for run state; assert the no-shell invariant holds
|
|
91
|
+
const v = JSON.parse(await t.longrun_verify.execute({ checkId: "rm -rf /" }, ctx(d)));
|
|
92
|
+
assert.equal(v.error, "NO_RUN", "verify tool runs nothing without a declared check + run");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---- FULL lifecycle through the real tool surface ---------------------------------------------
|
|
96
|
+
test("full lifecycle: memory -> start -> status -> checkpoint -> pause -> resume -> verify -> loss0 -> complete (same runId)", async () => {
|
|
97
|
+
freshState();
|
|
98
|
+
const t = await tools();
|
|
99
|
+
const d = projWithFile();
|
|
100
|
+
const c = ctx(d);
|
|
101
|
+
|
|
102
|
+
// memory gate (works at run=null), isolated fixture
|
|
103
|
+
const ms = JSON.parse(await t.longrun.execute({ action: "memory_status" }, c));
|
|
104
|
+
assert.equal(ms.status, "NO_MEMORY");
|
|
105
|
+
await t.longrun.execute({ action: "memory_init" }, c);
|
|
106
|
+
|
|
107
|
+
// start a valid, completable contract: one STATIC criterion mapped to a declared check
|
|
108
|
+
const start = JSON.parse(await t.longrun.execute({
|
|
109
|
+
action: "start",
|
|
110
|
+
request: "Verify that longrun v1.2.1 can create, persist, pause, resume, checkpoint and complete a native tracked run.",
|
|
111
|
+
criteria: [{ id: "LC-001", required: true, weight: 1, evidenceClass: "STATIC", checks: ["lc-static"] }],
|
|
112
|
+
checkCatalogue: { "lc-static": { command: ["node", "-e", "process.exit(0)"], kind: "cmd", timeoutMs: 15000 } },
|
|
113
|
+
candidateBudget: 3, timeBudgetHours: 0.25, sameFailureThreshold: 2, noProgressThreshold: 2, autoContinue: false,
|
|
114
|
+
}, c));
|
|
115
|
+
assert.ok(start.runId, "start returned a real runId");
|
|
116
|
+
const RUN = start.runId;
|
|
117
|
+
assert.equal(start.initialLoss, 1);
|
|
118
|
+
assert.equal(start.budgets.iterations, 3, "candidate budget honoured");
|
|
119
|
+
assert.equal(start.budgets.sameFailureLimit, 2);
|
|
120
|
+
assert.equal(start.budgets.noProgressLimit, 2);
|
|
121
|
+
|
|
122
|
+
const key = C.stateKey(C.projectIdentity(d), RUN);
|
|
123
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR);
|
|
124
|
+
const readRun = () => store.readJSON(key, "run.json");
|
|
125
|
+
const budgetsOf = () => { const r = readRun(); return JSON.stringify({ it: r.budget.iterations, sf: r.budget.sameFailureLimit, np: r.budget.noProgressLimit, cand: C.candidateCount(r) }); };
|
|
126
|
+
|
|
127
|
+
const s1 = JSON.parse(await t.longrun.execute({ action: "status" }, c));
|
|
128
|
+
assert.equal(s1.runId, RUN); assert.equal(s1.state, "IMPLEMENTING");
|
|
129
|
+
const budgetBefore = budgetsOf();
|
|
130
|
+
|
|
131
|
+
assert.equal(await t.longrun.execute({ action: "checkpoint" }, c), "checkpointed");
|
|
132
|
+
|
|
133
|
+
assert.equal(await t.longrun.execute({ action: "pause" }, c), "paused");
|
|
134
|
+
const sPaused = JSON.parse(await t.longrun.execute({ action: "status" }, c));
|
|
135
|
+
assert.equal(sPaused.state, "PAUSED", "pause persisted");
|
|
136
|
+
assert.equal(sPaused.runId, RUN, "runId unchanged by pause");
|
|
137
|
+
|
|
138
|
+
const sRes = JSON.parse(await t.longrun.execute({ action: "resume" }, c));
|
|
139
|
+
assert.equal(sRes.runId, RUN, "runId unchanged by resume");
|
|
140
|
+
assert.equal(sRes.state, "IMPLEMENTING", "resume returns to an active state");
|
|
141
|
+
const budgetAfter = budgetsOf();
|
|
142
|
+
assert.equal(budgetBefore, budgetAfter, "budgets + candidate accounting preserved across pause/resume");
|
|
143
|
+
|
|
144
|
+
// record a STATIC PASS via the dedicated verify tool; then completion is possible
|
|
145
|
+
const vr = JSON.parse(await t.longrun_verify.execute({ checkId: "lc-static", evidenceClass: "STATIC", mode: "normal" }, c));
|
|
146
|
+
assert.equal(vr.status, "PASS", "declared STATIC check recorded a PASS receipt");
|
|
147
|
+
|
|
148
|
+
const sVerified = JSON.parse(await t.longrun.execute({ action: "status" }, c));
|
|
149
|
+
assert.equal(sVerified.runId, RUN);
|
|
150
|
+
assert.equal(sVerified.criterionStates.find((x) => x.id === "LC-001").status, "PASS", "criterion derived from a real receipt without rewriting the contract");
|
|
151
|
+
assert.deepEqual(sVerified.remaining, [], "no required criteria outstanding");
|
|
152
|
+
reviewProjectFixture(d, RUN);
|
|
153
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "complete" }, c)).complete, true, "loss reached 0 only after verification");
|
|
154
|
+
assert.equal(readRun().status, "COMPLETE");
|
|
155
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "status" }, c)).state, "COMPLETE");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---- completion is blocked while the sole required check is unverified (no fabricated pass) -----
|
|
159
|
+
test("complete refuses while the required check is unverified; no fabricated PASS", async () => {
|
|
160
|
+
freshState();
|
|
161
|
+
const t = await tools();
|
|
162
|
+
const d = projWithFile();
|
|
163
|
+
const c = ctx(d);
|
|
164
|
+
const start = JSON.parse(await t.longrun.execute({
|
|
165
|
+
action: "start", request: "x",
|
|
166
|
+
criteria: [{ id: "LC-001", required: true, checks: ["lc-static"] }],
|
|
167
|
+
checkCatalogue: { "lc-static": { command: ["node", "-e", "process.exit(1)"], kind: "cmd" } },
|
|
168
|
+
}, c));
|
|
169
|
+
assert.ok(start.runId);
|
|
170
|
+
const noGo = JSON.parse(await t.longrun.execute({ action: "complete" }, c));
|
|
171
|
+
assert.equal(noGo.complete, false);
|
|
172
|
+
assert.equal(noGo.reason, "required_unverified", "blocked on the outstanding required criterion");
|
|
173
|
+
// a FAIL receipt must not satisfy it either
|
|
174
|
+
const vr = JSON.parse(await t.longrun_verify.execute({ checkId: "lc-static", evidenceClass: "STATIC" }, c));
|
|
175
|
+
assert.equal(vr.status, "FAIL");
|
|
176
|
+
assert.equal(JSON.parse(await t.longrun.execute({ action: "complete" }, c)).complete, false, "still blocked after a FAIL");
|
|
177
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as C from '../src/controller.js';
|
|
7
|
+
import { F } from './helper.mjs';
|
|
8
|
+
|
|
9
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
10
|
+
async function setup(t) {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1210-'));
|
|
12
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
13
|
+
const dir = path.join(root, 'project'), state = path.join(root, 'state');
|
|
14
|
+
fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'source.txt'), 'deadline fixture');
|
|
15
|
+
process.env.LONGRUN_STATE_DIR = state;
|
|
16
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
17
|
+
const ctx = { sessionID: 'owner', directory: dir, worktree: dir };
|
|
18
|
+
const marker = path.join(dir, 'must-not-execute');
|
|
19
|
+
const started = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Offline deadline admission fixture',
|
|
20
|
+
criteria: [{ id: 'c', evidenceClass: 'STATIC', checks: ['check'] }],
|
|
21
|
+
checkCatalogue: { check: { kind: 'cmd', command: [process.execPath, '-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)},'executed')`] } } }, ctx));
|
|
22
|
+
const store = new C.Store(state), key = C.stateKey(C.projectIdentity(dir), started.runId);
|
|
23
|
+
const file = store._file(key, 'run.json');
|
|
24
|
+
const guard = (tool, args = {}, sessionID = ctx.sessionID) => hooks['tool.execute.before']({ tool, sessionID }, { args });
|
|
25
|
+
const expire = status => store.mutate(key, r => { r.createdAt = Date.now() - r.budget.deadlineSeconds * 1000 - 1000; if (status) r.status = status; return { ok: true }; });
|
|
26
|
+
return { root, dir, state, hooks, ctx, marker, store, key, file, guard, expire, runId: started.runId };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('expired ordinary tool admission blocks reads, edits, shell and delegation without changing evidence', async t => {
|
|
30
|
+
const s = await setup(t); s.expire();
|
|
31
|
+
const before = fs.readFileSync(s.file);
|
|
32
|
+
for (const tool of ['bash', 'write', 'edit', 'apply_patch', 'task', 'batch', 'read', 'glob', 'grep', 'skill', 'some_mcp_tool']) {
|
|
33
|
+
await assert.rejects(s.guard(tool), /LONGRUN_DEADLINE_EXPIRED/);
|
|
34
|
+
}
|
|
35
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
36
|
+
assert.equal(fs.existsSync(s.marker), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('deadline admission uses canonical time at the exact boundary, not cached session flags', async t => {
|
|
40
|
+
const s = await setup(t), run = s.store.readJSON(s.key, 'run.json');
|
|
41
|
+
const deadline = run.createdAt + run.budget.deadlineSeconds * 1000, clock = Date.now;
|
|
42
|
+
try {
|
|
43
|
+
Date.now = () => deadline - 1; await s.guard('bash');
|
|
44
|
+
Date.now = () => deadline; await assert.rejects(s.guard('bash'), /LONGRUN_DEADLINE_EXPIRED/);
|
|
45
|
+
} finally { Date.now = clock; }
|
|
46
|
+
s.expire('PAUSED');
|
|
47
|
+
const bindings = JSON.parse(fs.readFileSync(path.join(s.state, 'runs.json')));
|
|
48
|
+
bindings.owner.paused = true; bindings.owner.disabled = true;
|
|
49
|
+
fs.writeFileSync(path.join(s.state, 'runs.json'), JSON.stringify(bindings));
|
|
50
|
+
await assert.rejects(s.guard('edit'), /LONGRUN_DEADLINE_EXPIRED/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('fresh sessions cannot bypass a project deadline when routing indices are absent', async t => {
|
|
54
|
+
const s = await setup(t); s.expire();
|
|
55
|
+
fs.unlinkSync(path.join(s.state, 'runs.json')); fs.unlinkSync(path.join(s.state, 'projects.json'));
|
|
56
|
+
const before = fs.readFileSync(s.file);
|
|
57
|
+
await assert.rejects(s.guard('bash', {}, 'fresh-session'), /LONGRUN_DEADLINE_EXPIRED/);
|
|
58
|
+
await assert.rejects(s.guard('task', {}, 'fresh-session'), /LONGRUN_DEADLINE_EXPIRED/);
|
|
59
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
60
|
+
const foreign = path.join(s.root, 'other'); fs.mkdirSync(foreign);
|
|
61
|
+
const other = await F('../plugin/longrun.js', { client: null, directory: foreign, worktree: foreign });
|
|
62
|
+
await other['tool.execute.before']({ tool: 'bash', sessionID: 'untracked-other' }, { args: {} });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('expired native bookkeeping and explicit check refusal remain available, while memory writes and replacement start are refused', async t => {
|
|
66
|
+
const s = await setup(t); s.expire();
|
|
67
|
+
const before = s.store.readJSON(s.key, 'run.json');
|
|
68
|
+
for (const action of ['help', 'status', 'next', 'resume-context', 'verify', 'checkpoint', 'pause', 'cancel', 'complete', 'resume', 'reconcile', 'memory_status']) await s.guard('longrun', { action, runId: s.runId });
|
|
69
|
+
for (const action of ['memory_init', 'memory_refresh', 'start']) await assert.rejects(s.guard('longrun', { action }), /LONGRUN_DEADLINE_EXPIRED/);
|
|
70
|
+
await s.guard('longrun_verify', { checkId: 'check', runId: s.runId });
|
|
71
|
+
const refusal = JSON.parse(await s.hooks.tool.longrun_verify.execute({ checkId: 'check', runId: s.runId, evidenceClass: 'STATIC' }, s.ctx));
|
|
72
|
+
assert.equal(refusal.error, 'BUDGET_EXHAUSTED'); assert.equal(refusal.spent.deadline, true);
|
|
73
|
+
await s.hooks.tool.longrun.execute({ action: 'checkpoint', runId: s.runId, progress: { nextAction: 'Expired fixture; preserve incomplete evidence.' } }, s.ctx);
|
|
74
|
+
assert.equal(await s.hooks.tool.longrun.execute({ action: 'pause', runId: s.runId }, s.ctx), 'paused');
|
|
75
|
+
const after = s.store.readJSON(s.key, 'run.json');
|
|
76
|
+
for (const key of ['runId', 'contract', 'contractHash', 'budget', 'createdAt', 'state', 'receipts', 'execution']) assert.deepEqual(after[key], before[key], key);
|
|
77
|
+
assert.equal(after.status, 'PAUSED'); assert.equal(after.autoEnabled, false); assert.equal(fs.existsSync(s.marker), false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('unreadable canonical state cannot silently disable admission enforcement for a bound session', async t => {
|
|
81
|
+
const s = await setup(t);
|
|
82
|
+
for (const bytes of ['{ corrupt canonical bytes', 'null']) {
|
|
83
|
+
fs.writeFileSync(s.file, bytes);
|
|
84
|
+
await assert.rejects(s.guard('bash'), /ROUTING_STORE_ERROR/);
|
|
85
|
+
assert.equal(fs.readFileSync(s.file, 'utf8'), bytes);
|
|
86
|
+
}
|
|
87
|
+
fs.unlinkSync(s.file);
|
|
88
|
+
await assert.rejects(s.guard('bash'), /ROUTING_STORE_ERROR/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('recovery and status expose authoritative wall-clock timing without mutating the persisted run', async t => {
|
|
92
|
+
const s = await setup(t), run = s.store.readJSON(s.key, 'run.json'), before = structuredClone(run);
|
|
93
|
+
const deadline = run.createdAt + run.budget.deadlineSeconds * 1000;
|
|
94
|
+
const view = C.deriveRunView(run, { now: deadline - 1250 });
|
|
95
|
+
assert.deepEqual(view.timing, { observedAt: deadline - 1250, startedAt: run.createdAt, deadlineAt: deadline, remainingMs: 1250, expired: false, scope: 'absolute_wall_clock_since_run_creation' });
|
|
96
|
+
const packet = C.buildRecoveryPacket(run, 1500, view).packet;
|
|
97
|
+
assert.ok(packet.includes(new Date(deadline - 1250).toISOString()));
|
|
98
|
+
assert.ok(packet.includes(new Date(deadline).toISOString()));
|
|
99
|
+
assert.match(packet, /DEADLINE REMAINING: 1250 ms/);
|
|
100
|
+
const expired = C.deriveRunView(run, { now: deadline });
|
|
101
|
+
assert.equal(expired.timing.expired, true); assert.equal(expired.timing.remainingMs, 0);
|
|
102
|
+
assert.match(C.buildRecoveryPacket(run, 1500, expired).packet, /DEADLINE EXPIRED.*checkpoint.*pause/);
|
|
103
|
+
assert.deepEqual(run, before);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('legacy unknown timestamps are explicit rather than manufactured, including in recovery context', async t => {
|
|
107
|
+
const s = await setup(t), run = s.store.readJSON(s.key, 'run.json'); delete run.createdAt;
|
|
108
|
+
const view = C.deriveRunView(run, { now: 1700000000000 });
|
|
109
|
+
assert.equal(view.timing.deadlineAt, null); assert.equal(view.timing.remainingMs, null); assert.equal(view.timing.expired, null);
|
|
110
|
+
const packet = C.buildRecoveryPacket(run, 1500, view).packet;
|
|
111
|
+
assert.match(packet, /DEADLINE: UNKNOWN/); assert.match(packet, /DEADLINE REMAINING: UNKNOWN/);
|
|
112
|
+
assert.equal(Object.hasOwn(run, 'createdAt'), false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an expired foreign-project binding cannot impose its deadline on the actual host project', async t => {
|
|
116
|
+
const s = await setup(t); s.expire();
|
|
117
|
+
const foreign = path.join(s.root, 'foreign-project'); fs.mkdirSync(foreign);
|
|
118
|
+
const other = await F('../plugin/longrun.js', { client: null, directory: foreign, worktree: foreign });
|
|
119
|
+
await other['tool.execute.before']({ tool: 'bash', sessionID: s.ctx.sessionID }, { args: {} });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('native status and recovery observe time afresh, without changing the deadline or source ledger', async t => {
|
|
123
|
+
const s = await setup(t), run = s.store.readJSON(s.key, 'run.json');
|
|
124
|
+
let now = run.createdAt + 1000; t.mock.method(Date, 'now', () => now);
|
|
125
|
+
const first = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'status', runId: s.runId }, s.ctx));
|
|
126
|
+
now += 8000;
|
|
127
|
+
const second = JSON.parse(await s.hooks.tool.longrun.execute({ action: 'status', runId: s.runId }, s.ctx));
|
|
128
|
+
assert.equal(second.timing.observedAt - first.timing.observedAt, 8000);
|
|
129
|
+
assert.equal(first.timing.remainingMs - second.timing.remainingMs, 8000);
|
|
130
|
+
assert.equal(first.timing.deadlineAt, second.timing.deadlineAt);
|
|
131
|
+
const packet = await s.hooks.tool.longrun.execute({ action: 'resume-context', runId: s.runId }, s.ctx);
|
|
132
|
+
assert.ok(packet.includes(new Date(now).toISOString()));
|
|
133
|
+
assert.deepEqual(s.store.readJSON(s.key, 'run.json'), run);
|
|
134
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { reviewProjectFixture } from "./helper.mjs";
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as C from '../src/controller.js';
|
|
8
|
+
import { F } from './helper.mjs';
|
|
9
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
10
|
+
|
|
11
|
+
async function setup(t) {
|
|
12
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1211-'));
|
|
13
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
14
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'value.txt'), 'unchanged');
|
|
15
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
16
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
17
|
+
const ctx = { sessionID: 'owner', directory: dir, worktree: dir }, other = { ...ctx, sessionID: 'other' };
|
|
18
|
+
const run = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Isolated pause admission fixture',
|
|
19
|
+
criteria: [{ id: 'check', evidenceClass: 'STATIC', checks: ['check'] }],
|
|
20
|
+
checkCatalogue: { check: { kind: 'cmd', command: [process.execPath, '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('value.txt','utf8'),'unchanged')"] } } }, ctx));
|
|
21
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), run.runId), file = store._file(key, 'run.json');
|
|
22
|
+
const call = (action, context = ctx) => hooks.tool.longrun.execute({ action, runId: run.runId }, context);
|
|
23
|
+
const guard = (tool, args = {}, sessionID = ctx.sessionID) => hooks['tool.execute.before']({ tool, sessionID }, { args });
|
|
24
|
+
return { hooks, ctx, other, call, guard, store, key, file, runId: run.runId };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('pause from another host blocks ordinary execution despite the old session active flag', async t => {
|
|
28
|
+
const s = await setup(t); await s.call('pause', s.other);
|
|
29
|
+
const before = fs.readFileSync(s.file);
|
|
30
|
+
for (const tool of ['bash', 'edit', 'write', 'apply_patch', 'task', 'batch', 'unknown_mcp_tool']) {
|
|
31
|
+
await assert.rejects(s.guard(tool), /LONGRUN_RUN_PAUSED/);
|
|
32
|
+
}
|
|
33
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('paused inspection, native checkpoint and authorized resume remain usable without resetting evidence', async t => {
|
|
37
|
+
const s = await setup(t); await s.call('pause');
|
|
38
|
+
const before = s.store.readJSON(s.key, 'run.json');
|
|
39
|
+
for (const tool of ['read', 'glob', 'grep', 'list', 'skill', 'question', 'todowrite', 'longrun_verify']) await s.guard(tool);
|
|
40
|
+
for (const action of ['help', 'status', 'next', 'resume-context', 'verify', 'checkpoint', 'pause', 'resume', 'cancel', 'reconcile']) await s.guard('longrun', { action });
|
|
41
|
+
for (const action of ['memory_init', 'memory_refresh']) await assert.rejects(s.guard('longrun', { action }), /LONGRUN_RUN_PAUSED/);
|
|
42
|
+
assert.equal(JSON.parse(await s.call('resume')).resumed, true);
|
|
43
|
+
await s.guard('edit'); await s.guard('bash');
|
|
44
|
+
const after = s.store.readJSON(s.key, 'run.json');
|
|
45
|
+
for (const field of ['runId', 'createdAt', 'contract', 'contractHash', 'budget', 'state', 'receipts', 'execution']) assert.deepEqual(after[field], before[field], field);
|
|
46
|
+
assert.equal(after.autoEnabled, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a fresh session in the paused project cannot mutate before explicit resume', async t => {
|
|
50
|
+
const s = await setup(t); await s.call('pause');
|
|
51
|
+
await assert.rejects(s.guard('write', {}, 'fresh'), /LONGRUN_RUN_PAUSED/);
|
|
52
|
+
await s.guard('read', {}, 'fresh');
|
|
53
|
+
assert.equal(JSON.parse(await s.call('resume', { ...s.ctx, sessionID: 'fresh' })).resumed, true);
|
|
54
|
+
await s.guard('write', {}, 'fresh');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a tracked terminal run cannot continue ordinary implementation, but native new-task admission stays available', async t => {
|
|
58
|
+
for (const action of ['cancel', 'complete']) {
|
|
59
|
+
const s = await setup(t);
|
|
60
|
+
if (action === 'complete') {
|
|
61
|
+
const checked = JSON.parse(await s.hooks.tool.longrun_verify.execute({ runId: s.runId, checkId: 'check', evidenceClass: 'STATIC' }, s.ctx));
|
|
62
|
+
assert.equal(checked.status, 'PASS');
|
|
63
|
+
reviewProjectFixture(s.ctx.directory, s.runId);
|
|
64
|
+
}
|
|
65
|
+
const result = await s.call(action); if (action === 'complete') assert.equal(JSON.parse(result).complete, true);
|
|
66
|
+
const before = fs.readFileSync(s.file);
|
|
67
|
+
await assert.rejects(s.guard('bash'), action === 'complete' ? /LONGRUN_RUN_COMPLETE/ : /LONGRUN_RUN_CANCELLED/);
|
|
68
|
+
await s.guard('longrun', { action: 'start' }); await s.guard('read');
|
|
69
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('non-executing canonical states fail closed while existing compaction recovery keeps its diagnostic', async t => {
|
|
74
|
+
for (const state of ['READY', 'BLOCKED', 'COMPACTING', 'RECOVERY_REQUIRED', 'INVALID_STATE']) {
|
|
75
|
+
const s = await setup(t); s.store.mutate(s.key, r => { r.status = state; return { ok: true }; });
|
|
76
|
+
const before = fs.readFileSync(s.file);
|
|
77
|
+
await assert.rejects(s.guard('edit'), state === 'RECOVERY_REQUIRED' ? /LONGRUN_RECOVERY_REQUIRED/ : /LONGRUN_RUN_STALLED/);
|
|
78
|
+
await s.guard('longrun', { action: 'status' }); await s.guard('read');
|
|
79
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import * as C from '../src/controller.js';
|
|
8
|
+
import { install, VERSION } from '../src/install.mjs';
|
|
9
|
+
|
|
10
|
+
function fixture(t) {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1212-'));
|
|
12
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
13
|
+
const project = path.join(root, 'project'), config = path.join(root, 'config'), state = path.join(root, 'state');
|
|
14
|
+
fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'value.txt'), 'fixture');
|
|
15
|
+
install({ configDir: config });
|
|
16
|
+
const store = new C.Store(state), runId = 'maintenance-fixture', key = C.stateKey(C.projectIdentity(project), runId);
|
|
17
|
+
const run = { runId, directory: project, status: 'IMPLEMENTING', autoEnabled: false, controlGeneration: 4,
|
|
18
|
+
createdAt: Date.now(), budget: { iterations: 2, deadlineSeconds: 600 },
|
|
19
|
+
contract: { criteria: [], gates: [], lossTarget: 0 }, contractHash: 'retained-mapping',
|
|
20
|
+
state: { candidates: [] }, receipts: [], execution: { commandAttempts: 0, verificationMs: 0, inFlight: null },
|
|
21
|
+
agentProgress: { fields: { nextAction: 'preserve this plan' } } };
|
|
22
|
+
store.writeJSON(key, 'run.json', run);
|
|
23
|
+
const file = store._file(key, 'run.json');
|
|
24
|
+
const invoke = action => {
|
|
25
|
+
const result = spawnSync(process.execPath, [path.join(config, 'longrun-harness', 'releases', VERSION, 'bin', 'longrun.mjs'),
|
|
26
|
+
action, '--json', '--project', project, '--run', runId], {
|
|
27
|
+
cwd: root, env: { ...process.env, LONGRUN_TEST: '1', LONGRUN_STATE_DIR: state, OPENCODE_CONFIG_DIR: config },
|
|
28
|
+
encoding: 'utf8', timeout: 10000,
|
|
29
|
+
});
|
|
30
|
+
assert.equal(result.error, undefined);
|
|
31
|
+
return { exit: result.status, output: JSON.parse(result.stdout) };
|
|
32
|
+
};
|
|
33
|
+
return { run, store, key, file, invoke };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('installed maintenance pause preserves terminal state instead of resurrecting it', t => {
|
|
37
|
+
const s = fixture(t);
|
|
38
|
+
for (const status of ['COMPLETE', 'CANCELLED']) {
|
|
39
|
+
s.store.writeJSON(s.key, 'run.json', { ...s.run, status });
|
|
40
|
+
const before = fs.readFileSync(s.file), result = s.invoke('pause');
|
|
41
|
+
assert.equal(result.exit, 2);
|
|
42
|
+
assert.equal(result.output.error, `RUN_${status}`);
|
|
43
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('installed maintenance pause respects an active verifier writer lock', t => {
|
|
48
|
+
const s = fixture(t); assert.equal(s.store.tryLock(s.key, 'verifier'), true);
|
|
49
|
+
const before = fs.readFileSync(s.file), holder = s.store.whoHoldsLock(s.key);
|
|
50
|
+
try {
|
|
51
|
+
const result = s.invoke('pause');
|
|
52
|
+
assert.equal(result.exit, 2); assert.equal(result.output.error, 'STATE_BUSY');
|
|
53
|
+
assert.deepEqual(fs.readFileSync(s.file), before);
|
|
54
|
+
assert.deepEqual(s.store.whoHoldsLock(s.key), holder);
|
|
55
|
+
} finally { s.store.releaseLock(s.key); }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('installed maintenance pause advances the control generation and preserves all evidence', t => {
|
|
59
|
+
const s = fixture(t), result = s.invoke('pause');
|
|
60
|
+
assert.equal(result.exit, 0); assert.equal(result.output.state, 'PAUSED');
|
|
61
|
+
const after = s.store.readJSON(s.key, 'run.json');
|
|
62
|
+
assert.equal(after.controlGeneration, 5);
|
|
63
|
+
assert.deepEqual(after, { ...s.run, status: 'PAUSED', autoEnabled: false, controlGeneration: 5 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('installed maintenance cannot call corrupt canonical state an absent run', t => {
|
|
67
|
+
const s = fixture(t);
|
|
68
|
+
for (const bytes of ['{broken', 'null', '[]']) {
|
|
69
|
+
fs.writeFileSync(s.file, bytes);
|
|
70
|
+
for (const action of ['status', 'pause']) {
|
|
71
|
+
const result = s.invoke(action);
|
|
72
|
+
assert.equal(result.exit, 2); assert.equal(result.output.error, 'STATE_CORRUPT');
|
|
73
|
+
assert.equal(fs.readFileSync(s.file, 'utf8'), bytes);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as C from '../src/controller.js';
|
|
7
|
+
import { F } from './helper.mjs';
|
|
8
|
+
|
|
9
|
+
async function setup() {
|
|
10
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr1213-'));
|
|
11
|
+
const project = path.join(base, 'project'), sd = path.join(base, 'state');
|
|
12
|
+
fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'app.txt'), 'isolated readout');
|
|
13
|
+
process.env.LONGRUN_STATE_DIR = sd;
|
|
14
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
15
|
+
const run = JSON.parse(fs.readFileSync(new URL('./fixtures/presets-readout-run.json', import.meta.url)));
|
|
16
|
+
run.directory = project;
|
|
17
|
+
const store = new C.Store(sd), key = C.stateKey(C.projectIdentity(project), run.runId);
|
|
18
|
+
store.writeJSON(key, 'run.json', run);
|
|
19
|
+
fs.writeFileSync(path.join(sd, 'runs.json'), JSON.stringify({ offline: { runKey: key, directory: project, runId: run.runId } }));
|
|
20
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
21
|
+
const ctx = { sessionID: 'offline', directory: project, worktree: project };
|
|
22
|
+
return { run, project, store, key, hooks, ctx, call: args => hooks.tool.longrun.execute({ runId: run.runId, ...args }, ctx) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('observed 13-receipt readout stays usable without hiding stale or failed evidence', async () => {
|
|
26
|
+
const s = await setup(), before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
27
|
+
const full = C.deriveRunView(s.run, { currentFingerprint: C.sourceFingerprint(s.project) });
|
|
28
|
+
const raw = await s.call({ action: 'status' }), status = JSON.parse(raw);
|
|
29
|
+
assert.ok(Buffer.byteLength(raw) < 24000, `status was ${Buffer.byteLength(raw)} bytes`);
|
|
30
|
+
assert.ok(Math.max(...raw.split('\n').map(x => Buffer.byteLength(x))) < 8192);
|
|
31
|
+
for (const k of ['state', 'currentLoss', 'bestLoss', 'remaining', 'candidateCount', 'historicalReceiptCount', 'completionBlocked', 'hardGateBlockers']) assert.deepEqual(status[k], full[k]);
|
|
32
|
+
assert.deepEqual(status.checks.map(c => [c.checkId, c.effectiveStatus, c.selectedReceiptId, c.staleReason, c.historicalReceiptCount]),
|
|
33
|
+
full.checks.map(c => [c.checkId, c.effectiveStatus, c.selectedReceiptId, c.staleReason, c.historicalReceiptCount]));
|
|
34
|
+
assert.equal(status.receiptDetails.action, 'receipts');
|
|
35
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('receipt pages cover all genuine history once; individual retrieval retains actual failure output', async () => {
|
|
39
|
+
const s = await setup(), before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
40
|
+
const seen = []; let offset = 0;
|
|
41
|
+
do {
|
|
42
|
+
const page = JSON.parse(await s.call({ action: 'receipts', offset, limit: 4 }));
|
|
43
|
+
assert.equal(page.ok, true); assert.equal(page.total, 13);
|
|
44
|
+
assert.ok(page.receipts.length <= 4);
|
|
45
|
+
seen.push(...page.receipts); offset = page.nextOffset;
|
|
46
|
+
} while (offset !== null);
|
|
47
|
+
assert.equal(seen.length, 13); assert.equal(new Set(seen.map(r => r.receiptId)).size, 13);
|
|
48
|
+
const failure = seen.find(r => r.historicalStatus === 'FAIL' && r.checkId === 'c-presets-e2e'); assert.ok(failure);
|
|
49
|
+
const detail = JSON.parse(await s.call({ action: 'receipts', receiptId: failure.receiptId }));
|
|
50
|
+
assert.equal(detail.receipt.exitCode, 1);
|
|
51
|
+
assert.equal(detail.receipt.outputTail, s.run.receipts[failure.order - 1].outputTail.slice(-6000));
|
|
52
|
+
assert.equal(detail.receipt.classification, 'SOURCE_FINGERPRINT_MISMATCH');
|
|
53
|
+
assert.equal(detail.receipt.historicalStatus, 'FAIL');
|
|
54
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('receipt selection and pagination fail clearly; detailed reads remain allowed while paused', async () => {
|
|
58
|
+
const s = await setup();
|
|
59
|
+
for (const args of [{ offset: -1 }, { limit: 0 }, { limit: 21 }, { offset: 0.5 }])
|
|
60
|
+
assert.equal(JSON.parse(await s.call({ action: 'receipts', ...args })).error, 'INVALID_RECEIPT_PAGE');
|
|
61
|
+
assert.equal(JSON.parse(await s.call({ action: 'receipts', receiptId: 'missing' })).error, 'RECEIPT_NOT_FOUND');
|
|
62
|
+
const page = JSON.parse(await s.call({ action: 'receipts', checkId: 'c-presets-server' }));
|
|
63
|
+
assert.equal(page.total, 2); assert.equal(page.receipts.length, 2);
|
|
64
|
+
const before = s.store.readJSON(s.key, 'run.json'); before.status = 'PAUSED'; s.store.writeJSON(s.key, 'run.json', before);
|
|
65
|
+
await s.hooks['tool.execute.before']({ tool: 'longrun', sessionID: s.ctx.sessionID }, { args: { action: 'receipts', runId: s.run.runId } });
|
|
66
|
+
assert.equal(JSON.parse(await s.call({ action: 'receipts' })).ok, true);
|
|
67
|
+
assert.equal(s.store.readJSON(s.key, 'run.json').status, 'PAUSED');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('paging reaches history older than twenty receipts and never invents missing legacy fields', () => {
|
|
71
|
+
// Synthetic offline ledger exercises pagination, not application acceptance.
|
|
72
|
+
const run = { runId: 'offline-pages', sourceFingerprint: 'now', receipts: Array.from({ length: 47 }, (_, i) => ({
|
|
73
|
+
checkId: 'legacy', status: 'FAIL', finishedAt: i + 1, command: 'offline fixture', exitCode: 1,
|
|
74
|
+
})) };
|
|
75
|
+
const before = JSON.stringify(run), ids = [];
|
|
76
|
+
for (let offset = 0; offset < 47; offset += 20) ids.push(...C.receiptReadout(run, { offset, limit: 20 }).receipts.map(r => r.receiptId));
|
|
77
|
+
assert.equal(ids.length, 47); assert.equal(new Set(ids).size, 47);
|
|
78
|
+
const oldest = C.receiptReadout(run, { receiptId: ids[0] }).receipt;
|
|
79
|
+
assert.equal(oldest.classification, 'LEGACY_STALE_MISSING_FINGERPRINT');
|
|
80
|
+
assert.equal(oldest.receiptFingerprint, null); assert.equal(oldest.outputTail, null);
|
|
81
|
+
assert.equal(JSON.stringify(run), before);
|
|
82
|
+
});
|