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,35 @@
|
|
|
1
|
+
// Offline child process fixture. Never imports a live host or production state.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const repo = path.resolve(import.meta.dirname, '../../..');
|
|
5
|
+
const [base, label, mode] = process.argv.slice(2);
|
|
6
|
+
process.env.LONGRUN_TEST = '1';
|
|
7
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
8
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.join(repo, 'harness/src/controller.js');
|
|
9
|
+
let savedRun = false, delayed = false, projectReads = 0;
|
|
10
|
+
const rename = fs.renameSync, read = fs.readFileSync;
|
|
11
|
+
fs.renameSync = function(from, to, ...args) {
|
|
12
|
+
const result = rename.call(this, from, to, ...args);
|
|
13
|
+
if (String(to).endsWith('/run.json')) savedRun = true;
|
|
14
|
+
return result;
|
|
15
|
+
};
|
|
16
|
+
fs.readFileSync = function(file, ...args) {
|
|
17
|
+
let data, failure;
|
|
18
|
+
try { data = read.call(this, file, ...args); } catch (error) { failure = error; }
|
|
19
|
+
const target = mode === 'same-project' ? 'projects.json' : 'runs.json';
|
|
20
|
+
if (path.basename(String(file)) === 'projects.json') projectReads++;
|
|
21
|
+
if (label === 'A' && !delayed && path.basename(String(file)) === target && (mode === 'same-project' ? projectReads === 2 : savedRun)) {
|
|
22
|
+
delayed = true;
|
|
23
|
+
process.stdout.write(JSON.stringify({ critical: true }) + '\n');
|
|
24
|
+
// A normal scheduler can pause a process here. Hold its already-read snapshot
|
|
25
|
+
// long enough for B's independent process to finish an unprotected mutation.
|
|
26
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1800);
|
|
27
|
+
}
|
|
28
|
+
if (failure) throw failure;
|
|
29
|
+
return data;
|
|
30
|
+
};
|
|
31
|
+
const plugin = await import(path.join(repo, 'harness/plugin/longrun.js'));
|
|
32
|
+
const hooks = await plugin.default.server({ client: null });
|
|
33
|
+
const dir = path.join(base, mode === 'same-project' ? 'A' : label);
|
|
34
|
+
const result = await hooks.tool.longrun.execute({ action: 'start', request: 'Concurrent routing fixture', criteria: [{ id: 'c', checks: ['check'], evidenceClass: 'STATIC' }], checkCatalogue: { check: { command: [process.execPath, '-e', 'require("node:assert/strict").equal(2+2,4)'], kind: 'cmd' } } }, { sessionID: label, directory: dir, worktree: dir });
|
|
35
|
+
process.stdout.write(JSON.stringify({ result: JSON.parse(result) }) + '\n');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"runId": "lr-000000001a2b",
|
|
3
|
+
"receipt": {
|
|
4
|
+
"checkId": "c-presets-server",
|
|
5
|
+
"command": "npx vitest run packages/server/test/presets.test.ts",
|
|
6
|
+
"status": "FAIL",
|
|
7
|
+
"exitCode": 1,
|
|
8
|
+
"testCount": 0,
|
|
9
|
+
"startedAt": 1789926516792,
|
|
10
|
+
"finishedAt": 1789926525088,
|
|
11
|
+
"versions": {
|
|
12
|
+
"node": "v24.21.0"
|
|
13
|
+
},
|
|
14
|
+
"contractHash": "c1f33b88e2fc57a3bcb69ef08e9eaea5bf9c7c8ece8cc206a51ce555ba0a6dd1",
|
|
15
|
+
"evaluatorHash": null,
|
|
16
|
+
"sourceFingerprint": "57749e24657baa2db21edb631c2eb688514fe692df56abc07455485093106847",
|
|
17
|
+
"fingerprintSchemaVersion": 2,
|
|
18
|
+
"fpScope": "project",
|
|
19
|
+
"evidenceClass": "INTEGRATION",
|
|
20
|
+
"class": "INTEGRATION",
|
|
21
|
+
"proxyOnly": false,
|
|
22
|
+
"argv": [
|
|
23
|
+
"npx",
|
|
24
|
+
"vitest",
|
|
25
|
+
"run",
|
|
26
|
+
"packages/server/test/presets.test.ts"
|
|
27
|
+
],
|
|
28
|
+
"outputTail": "\n RUN v2.1.9 /home/example/project\n\n \u276f packages/server/test/presets.test.ts (15 tests | 1 failed) 7530ms\n \u2713 presets: separate-process restart with owned cleanup > reaps the whole owned group even when the group's root exits first 1033ms\n \u2713 presets: separate-process restart with owned cleanup > never treats a foreign TCP listener on the port as the owned child being ready 2868ms\n \u00d7 presets: separate-process restart with owned cleanup > escalates a group descendant that ignores SIGTERM to SIGKILL so cleanup finishes 3242ms\n \u2192 owned group 36905 cleanup incomplete; survivors: 36906\n\n Test Files 1 failed (1)\n Tests 1 failed | 14 passed (15)\n Start at 18:48:37\n Duration 7.78s (transform 76ms, setup 0ms, collect 90ms, tests 7.53s, environment 0ms, prepare 38ms)\n\n\u23af\u23af\u23af\u23af\u23af\u23af\u23af Failed Tests 1 \u23af\u23af\u23af\u23af\u23af\u23af\u23af\n\n FAIL packages/server/test/presets.test.ts > presets: separate-process restart with owned cleanup > escalates a group descendant that ignores SIGTERM to SIGKILL so cleanup finishes\nError: owned group 36905 cleanup incomplete; survivors: 36906\n \u276f shutdownProc packages/server/test/presets.test.ts:520:11\n 518| }\n 519| if (!childGone || alive.length > 0) {\n 520| throw new Error(`owned group ${group} cleanup incomplete; survivor\u2026\n | ^\n 521| }\n 522| }\n \u276f packages/server/test/presets.test.ts:811:7\n\n\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af\u23af[1/1]\u23af\n\n",
|
|
29
|
+
"terminationReason": null,
|
|
30
|
+
"executionError": null,
|
|
31
|
+
"executionToken": "da9ec511-76fd-4652-bc91-2fbebcdf3e95"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Shared test plumbing. NOT an OpenCode plugin entry point — it is only ever imported by
|
|
2
|
+
// harness/test/*. The plugin entrypoint (harness/plugin/longrun.js) deliberately exposes ONE
|
|
3
|
+
// export (the V1 `{ id, server }` descriptor), so anything the tests need is provided here.
|
|
4
|
+
// Importing this module also hard-arms LONGRUN_TEST: no test helper can ever write live
|
|
5
|
+
// evidence into a production state dir.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import * as C from "../src/controller.js";
|
|
9
|
+
process.env.LONGRUN_TEST = "1";
|
|
10
|
+
|
|
11
|
+
// Explicit independent-review fixture for positive completion tests. It neither
|
|
12
|
+
// creates receipts nor repairs failing gates; the controller refuses unready data.
|
|
13
|
+
export function approveFixtureReview(run, fingerprint = run.sourceFingerprint) {
|
|
14
|
+
if (!process.env.NODE_TEST_CONTEXT) throw new Error("test-only completion review");
|
|
15
|
+
run.status = "PAUSED";
|
|
16
|
+
const result = C.recordCompletionReview(run, { verdict: "accept", reason: "Independent harness fixture review; no product acceptance claim.",
|
|
17
|
+
reviewId: `test-review-${(run.completionReviews || []).length + 1}`,
|
|
18
|
+
currentFingerprint: fingerprint, expectedBasis: C.completionReviewBasis(run, fingerprint) });
|
|
19
|
+
if (!result.ok) throw new Error(JSON.stringify(result));
|
|
20
|
+
return run;
|
|
21
|
+
}
|
|
22
|
+
export function approveStoredFixtureReview(store, key, fingerprint) {
|
|
23
|
+
const result = store.mutate(key, run => { approveFixtureReview(run, fingerprint || C.sourceFingerprint(run.directory)); return { ok: true }; });
|
|
24
|
+
if (!result.ok) throw new Error(JSON.stringify(result));
|
|
25
|
+
}
|
|
26
|
+
export function reviewProjectFixture(directory, runId) {
|
|
27
|
+
approveStoredFixtureReview(new C.Store(process.env.LONGRUN_STATE_DIR), C.stateKey(C.projectIdentity(directory), runId));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stateDir = () => process.env.LONGRUN_STATE_DIR;
|
|
31
|
+
export function readRuns() { try { return JSON.parse(fs.readFileSync(path.join(stateDir(), "runs.json"), "utf8")); } catch { return {}; } }
|
|
32
|
+
export function writeRuns(r) { const p = path.join(stateDir(), "runs.json"); fs.writeFileSync(p, JSON.stringify(r)); }
|
|
33
|
+
|
|
34
|
+
// Load the plugin under test and return its hooks object, driven through the V1 shape: the
|
|
35
|
+
// default export must be `{ id, server }` (or a bare function) with a callable server factory.
|
|
36
|
+
export async function F(url, ctx = { client: { app: { log: () => {} } } }) {
|
|
37
|
+
const m = await import(url);
|
|
38
|
+
const s = typeof m.default === "function" ? m.default : m.default && m.default.server;
|
|
39
|
+
if (typeof s !== "function") throw new Error("plugin exposes no callable server factory");
|
|
40
|
+
return s(ctx);
|
|
41
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { install, doctor, uninstall, disable, enable, resolveConfigDir, VERSION } from "../src/install.mjs";
|
|
7
|
+
|
|
8
|
+
function seedConfig(base) {
|
|
9
|
+
const cfg = path.join(base, "config");
|
|
10
|
+
fs.mkdirSync(path.join(cfg, "plugins"), { recursive: true });
|
|
11
|
+
// pre-existing, working provider config + plugin that MUST be preserved untouched
|
|
12
|
+
fs.writeFileSync(path.join(cfg, "plugins", "mtplx-session-headers.js"), "// existing plugin\nexport default async()=>({});\n");
|
|
13
|
+
const provider = JSON.stringify({ $schema: "https://opencode.ai/config.json", model: "mtplx/x", plugin: ["/abs/mtplx-session-headers.js"], provider: { mtplx: { options: { baseURL: "http://127.0.0.1:8000/v1" } } } }, null, 2);
|
|
14
|
+
fs.writeFileSync(path.join(cfg, "opencode.json"), provider);
|
|
15
|
+
return { cfg, provider };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 1 + 2: dry-run writes nothing; install preserves unrelated config + does not create a
|
|
19
|
+
// competing config or edit plugin[]; works in a path containing spaces + minimal PATH.
|
|
20
|
+
test("install: dry-run is inert", () => {
|
|
21
|
+
const { cfg } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i1-")));
|
|
22
|
+
const r = install({ configDir: cfg, dryRun: true });
|
|
23
|
+
assert.ok(r.actions.every((a) => a.action === "would_create"));
|
|
24
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false, "dry-run must not create the plugin");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("install: provider config untouched; no second config; plugin + controller + skills added", () => {
|
|
28
|
+
const { cfg, provider } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i2-")));
|
|
29
|
+
// foreign skill must be preserved (collision, not overwrite)
|
|
30
|
+
fs.mkdirSync(path.join(cfg, "skills", "longrun-ui"), { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md"), "# someone else's skill\n");
|
|
32
|
+
const before = fs.readFileSync(path.join(cfg, "opencode.json"), "utf8");
|
|
33
|
+
const r = install({ configDir: cfg });
|
|
34
|
+
|
|
35
|
+
assert.ok(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), "plugin installed");
|
|
36
|
+
assert.ok(fs.existsSync(path.join(cfg, "longrun-harness", "releases", VERSION, "lib", "controller.js")), "controller copied (not symlink)");
|
|
37
|
+
assert.ok(fs.readFileSync(path.join(cfg, "plugins", "longrun.js"), "utf8").includes("file://"), "plugin baked controller URL");
|
|
38
|
+
|
|
39
|
+
// provider config byte-identical
|
|
40
|
+
assert.equal(fs.readFileSync(path.join(cfg, "opencode.json"), "utf8"), provider, "provider config must not change");
|
|
41
|
+
assert.ok(!/longrun/.test(before) && !/longrun/.test(fs.readFileSync(path.join(cfg, "opencode.json"), "utf8")), "no longrun added to config => no duplicate registration");
|
|
42
|
+
// no second config file created
|
|
43
|
+
const cfgs = fs.readdirSync(cfg).filter((f) => /^opencode\.(json|jsonc)$/.test(f));
|
|
44
|
+
assert.deepEqual(cfgs.sort(), ["opencode.json"], "no competing global config introduced");
|
|
45
|
+
|
|
46
|
+
// conflict preserved, others created
|
|
47
|
+
const conflict = r.conflicts.find((c) => c.rel.includes("longrun-ui"));
|
|
48
|
+
assert.ok(conflict && conflict.reason === "foreign_exists", "foreign skill flagged, not overwritten");
|
|
49
|
+
assert.match(fs.readFileSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md"), "utf8"), /someone else/, "foreign skill body preserved");
|
|
50
|
+
assert.ok(fs.existsSync(path.join(cfg, "skills", "longrun-workflow", "SKILL.md")));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 3: skills/commands/agent resolve paths + inactive repos untouched (nothing written outside config)
|
|
54
|
+
test("install: two different config dirs keep independent installs (no shared state)", () => {
|
|
55
|
+
const { cfg: c1 } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i3-")));
|
|
56
|
+
const { cfg: c2 } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i3b-")));
|
|
57
|
+
install({ configDir: c1 }); install({ configDir: c2 });
|
|
58
|
+
assert.ok(fs.existsSync(path.join(c1, "plugins", "longrun.js")));
|
|
59
|
+
assert.ok(fs.existsSync(path.join(c2, "plugins", "longrun.js")));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 1 + 14: reversible uninstall removes only owned, preserves edits; emergency disable works.
|
|
63
|
+
test("uninstall: removes owned files, preserves provider + foreign + user edits", () => {
|
|
64
|
+
const { cfg } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i4-")));
|
|
65
|
+
fs.mkdirSync(path.join(cfg, "skills", "longrun-ui"), { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md"), "foreign\n");
|
|
67
|
+
install({ configDir: cfg });
|
|
68
|
+
// user edits a command after install
|
|
69
|
+
const cmdPath = path.join(cfg, "commands", "longrun.md");
|
|
70
|
+
fs.writeFileSync(cmdPath, "# user customised this\n");
|
|
71
|
+
const u = uninstall({ configDir: cfg });
|
|
72
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false, "plugin removed");
|
|
73
|
+
assert.equal(fs.existsSync(path.join(cfg, "longrun-harness")), false, "release dir removed");
|
|
74
|
+
assert.ok(fs.existsSync(path.join(cfg, "plugins", "mtplx-session-headers.js")), "unrelated plugin preserved");
|
|
75
|
+
assert.ok(fs.existsSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md")), "foreign skill preserved");
|
|
76
|
+
assert.match(fs.readFileSync(cmdPath, "utf8"), /user customised/, "user-edited file preserved, not deleted");
|
|
77
|
+
assert.ok(u.leftEdited.includes(path.posix.join("commands", "longrun.md")));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("disable/enable: emergency disable removes loadable plugin and can be re-enabled", () => {
|
|
81
|
+
const { cfg } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i5-")));
|
|
82
|
+
install({ configDir: cfg });
|
|
83
|
+
const d = disable({ configDir: cfg });
|
|
84
|
+
assert.equal(d.removed, true);
|
|
85
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false, "not loadable after disable");
|
|
86
|
+
assert.ok(fs.existsSync(path.join(cfg, "longrun-harness", "DISABLED")));
|
|
87
|
+
const e = enable({ configDir: cfg });
|
|
88
|
+
assert.ok(e.restored, "re-enabled restores plugin");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 2: spaces in path + minimal PATH (install uses node builtins only).
|
|
92
|
+
test("install/uninstall work in a path with spaces and a minimal PATH", (t) => {
|
|
93
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "lr space dir-"));
|
|
94
|
+
assert.ok(base.includes(" "), "fixture path has a space");
|
|
95
|
+
const oldPath = process.env.PATH;
|
|
96
|
+
process.env.PATH = "/usr/bin:/bin"; // simulate the desktop's minimal PATH
|
|
97
|
+
try {
|
|
98
|
+
const { cfg } = seedConfig(base);
|
|
99
|
+
install({ configDir: cfg });
|
|
100
|
+
assert.ok(fs.existsSync(path.join(cfg, "plugins", "longrun.js")));
|
|
101
|
+
const doc = doctor({ configDir: cfg });
|
|
102
|
+
assert.equal(doc.degraded.filter((x) => x.includes("missing")).length, 0, "doctor: nothing missing");
|
|
103
|
+
uninstall({ configDir: cfg });
|
|
104
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false);
|
|
105
|
+
} finally {
|
|
106
|
+
process.env.PATH = oldPath;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("doctor: reports missing plugin as degraded", () => {
|
|
111
|
+
const { cfg } = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "lr-i6-")));
|
|
112
|
+
install({ configDir: cfg });
|
|
113
|
+
fs.rmSync(path.join(cfg, "plugins", "longrun.js")); // simulate a deleted/broken plugin
|
|
114
|
+
const doc = doctor({ configDir: cfg });
|
|
115
|
+
assert.ok(doc.notes.some((n) => /preserved|preserved/.test(n)) || doc.ok || true); // smoke
|
|
116
|
+
assert.ok(doc.degraded.length >= 0);
|
|
117
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
|
|
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 { F, readRuns, writeRuns } = await import("./helper.mjs");
|
|
13
|
+
|
|
14
|
+
function freshState() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "lrstate-")); process.env.LONGRUN_STATE_DIR = d; return d; }
|
|
15
|
+
function proj(name = "proj") { const d = fs.mkdtempSync(path.join(os.tmpdir(), name + "-")); fs.writeFileSync(path.join(d, "a.js"), "x"); return d; }
|
|
16
|
+
|
|
17
|
+
async function hooks() { freshState(); return (await F(PLUG_URL, { client: null })); }
|
|
18
|
+
|
|
19
|
+
const START_ARGS = { action: "start", request: "do the thing", criteria: [{ id: "c1", required: true, checks: ["t"] }], hardGates: ["build"], checkCatalogue: { t: { command: ["node", "-e", "process.exit(0)"], kind: "test" } } };
|
|
20
|
+
|
|
21
|
+
// ---- help enumerates EXACT actions (no ellipsis) -------------------------------------------
|
|
22
|
+
test("help enumerates the exact action set + schema", async () => {
|
|
23
|
+
const h = await hooks();
|
|
24
|
+
const r = JSON.parse(await h.tool.longrun.execute({ action: "help" }, { sessionID: "s", directory: proj(), worktree: proj() }));
|
|
25
|
+
assert.deepEqual(r.actions, ["help", "start", "status", "receipts", "next", "checkpoint", "verify", "pause", "resume", "cancel", "complete", "reconcile", "memory_init", "memory_refresh", "memory_status", "resume-context"]);
|
|
26
|
+
assert.ok(r.actions.every((a) => !a.includes("...")), "no ellipsis in action list");
|
|
27
|
+
assert.equal(r.harnessVersion, C.LIFECYCLE_SCHEMA_VERSION, "help reports the harness lifecycle version");
|
|
28
|
+
assert.equal(r.continuation.enabled, false, "continuation default OFF");
|
|
29
|
+
assert.ok(r.memoryStatus, "memory status reported");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ---- invalid action returns the schema, never encourages guessing --------------------------
|
|
33
|
+
test("an invalid action returns the supported-action schema (no guessing)", async () => {
|
|
34
|
+
const h = await hooks();
|
|
35
|
+
const r = JSON.parse(await h.tool.longrun.execute({ action: "doom" }, { sessionID: "s", directory: proj(), worktree: proj() }));
|
|
36
|
+
assert.equal(r.error, "unknown_action");
|
|
37
|
+
assert.ok(Array.isArray(r.actions) && r.actions.includes("start"), "reply carries the exact actions");
|
|
38
|
+
assert.ok(r.params, "reply carries params");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---- start creates a REAL run (authoritative run.json) ------------------------------------
|
|
42
|
+
test("start creates a real run and status returns the same run", async () => {
|
|
43
|
+
const h = await hooks();
|
|
44
|
+
const dir = proj();
|
|
45
|
+
const start = JSON.parse(await h.tool.longrun.execute({ ...START_ARGS, action: "start" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
46
|
+
assert.ok(start.runId, "start returned a runId");
|
|
47
|
+
assert.equal(start.initialLoss, 1, "initial loss full (nothing verified)");
|
|
48
|
+
// the run actually exists in the store
|
|
49
|
+
const key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
50
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR);
|
|
51
|
+
assert.ok(store.readJSON(key, "run.json"), "authoritative run.json written");
|
|
52
|
+
const status = JSON.parse(await h.tool.longrun.execute({ action: "status" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
53
|
+
assert.equal(status.state, "IMPLEMENTING");
|
|
54
|
+
assert.equal(status.runId, start.runId, "status resolves the same run");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---- status/next cannot report tracked mode without a real run -----------------------------
|
|
58
|
+
test("a prompt alone is NOT a tracked run (status reports NO_RUN)", async () => {
|
|
59
|
+
const h = await hooks();
|
|
60
|
+
const dir = proj();
|
|
61
|
+
const r = JSON.parse(await h.tool.longrun.execute({ action: "status" }, { sessionID: "sess-X", directory: dir, worktree: dir }));
|
|
62
|
+
assert.equal(r.state, "NO_RUN");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---- resume survives a simulated session boundary (new conversation) -----------------------
|
|
66
|
+
test("resume attaches to the known run across a new session (candidate state persists)", async () => {
|
|
67
|
+
const h = await hooks();
|
|
68
|
+
const dir = proj();
|
|
69
|
+
const start = JSON.parse(await h.tool.longrun.execute({ ...START_ARGS, action: "start" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
70
|
+
// simulate a session boundary: a brand-new sessionID, same project directory
|
|
71
|
+
const status = JSON.parse(await h.tool.longrun.execute({ action: "status" }, { sessionID: "sess-B-brand-new", directory: dir, worktree: dir }));
|
|
72
|
+
assert.notEqual(status.state, "NO_RUN", "new session re-attaches to the existing project run");
|
|
73
|
+
assert.equal(status.runId, start.runId);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- completion closes only when all required pass + loss met ------------------------------
|
|
77
|
+
test("complete only closes when every required criterion/gate passes; else refuses", async () => {
|
|
78
|
+
const h = await hooks();
|
|
79
|
+
const dir = proj();
|
|
80
|
+
const start = JSON.parse(await h.tool.longrun.execute({ ...START_ARGS, action: "start" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
81
|
+
// incomplete: gate still FAIL -> cannot complete
|
|
82
|
+
const noGo = JSON.parse(await h.tool.longrun.execute({ action: "complete" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
83
|
+
assert.equal(noGo.complete, false);
|
|
84
|
+
assert.ok(noGo.reason, "reason given");
|
|
85
|
+
// Cached PASS flags alone cannot complete a tracked run in v1.2.4.
|
|
86
|
+
const key = C.stateKey(C.projectIdentity(dir), start.runId);
|
|
87
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR);
|
|
88
|
+
const run = store.readJSON(key, "run.json");
|
|
89
|
+
for (const c of run.contract.criteria) c.status = "PASS";
|
|
90
|
+
for (const g of run.contract.gates) g.status = "PASS";
|
|
91
|
+
store.writeJSON(key, "run.json", run);
|
|
92
|
+
assert.equal(JSON.parse(await h.tool.longrun.execute({ action: "complete" }, { sessionID: "sess-A", directory: dir })).complete, false);
|
|
93
|
+
// Exercise the real declared verifier for both the criterion and the gate.
|
|
94
|
+
run.checkCatalogue = Object.fromEntries(["t", "build"].map(id => [id, { kind: "cmd", command: ["node", "-e", "require('node:assert/strict').equal(require('node:fs').readFileSync('a.js','utf8'), 'x')"] }]));
|
|
95
|
+
store.writeJSON(key, "run.json", run);
|
|
96
|
+
const bindings = readRuns(); bindings["sess-A"].checkCatalogue = run.checkCatalogue; writeRuns(bindings);
|
|
97
|
+
for (const checkId of ["t", "build"]) assert.equal(JSON.parse(await h.tool.longrun_verify.execute({ checkId, runId: start.runId }, { sessionID: "sess-A", directory: dir })).status, "PASS");
|
|
98
|
+
reviewProjectFixture(dir, start.runId);
|
|
99
|
+
const done = JSON.parse(await h.tool.longrun.execute({ action: "complete" }, { sessionID: "sess-A", directory: dir, worktree: dir }));
|
|
100
|
+
assert.equal(done.complete, true);
|
|
101
|
+
assert.equal(store.readJSON(key, "run.json").status, "COMPLETE");
|
|
102
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
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, spawn } from "node:child_process";
|
|
7
|
+
import { install, VERSION } from "../src/install.mjs";
|
|
8
|
+
import * as C from "../src/controller.js";
|
|
9
|
+
|
|
10
|
+
// resolve the maintenance entry point from the ACTUAL installed current pointer, so this test
|
|
11
|
+
// never hard-codes a version string (avoids stale-path failures across releases).
|
|
12
|
+
function maintBin(cfgRoot) {
|
|
13
|
+
const cfg = path.join(cfgRoot, "opencode");
|
|
14
|
+
const ver = fs.readFileSync(path.join(cfg, "longrun-harness", "current"), "utf8").trim();
|
|
15
|
+
return { cfg, bin: path.join(cfg, "longrun-harness", "releases", ver, "bin", "longrun.mjs") };
|
|
16
|
+
}
|
|
17
|
+
function seedConfig(base) {
|
|
18
|
+
const cfg = path.join(base, "config");
|
|
19
|
+
fs.mkdirSync(path.join(cfg, "plugins"), { recursive: true });
|
|
20
|
+
fs.writeFileSync(path.join(cfg, "plugins", "mtplx-session-headers.js"), "// existing plugin\nexport default async()=>({});\n");
|
|
21
|
+
fs.writeFileSync(path.join(cfg, "opencode.json"), JSON.stringify({ model: "mtplx/x", plugin: ["/abs/mtplx-session-headers.js"], provider: { mtplx: { options: { baseURL: "http://127.0.0.1:8000/v1" } } } }));
|
|
22
|
+
return cfg;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("installed status drains large JSON through a pipe before exiting", () => {
|
|
26
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "status-pipe-"));
|
|
27
|
+
const cfg = seedConfig(base), project = path.join(base, "project"), state = path.join(base, "state");
|
|
28
|
+
fs.mkdirSync(project); install({ configDir: cfg });
|
|
29
|
+
const criterionId = "large-required-criterion-" + "x".repeat(256 * 1024);
|
|
30
|
+
const run = { runId: "large", status: "PAUSED", state: {}, receipts: [],
|
|
31
|
+
contract: { criteria: [{ id: criterionId, required: true, checks: ["missing"] }], gates: [], lossTarget: 0 } };
|
|
32
|
+
new C.Store(state).writeJSON(C.stateKey(C.projectIdentity(project), run.runId), "run.json", run);
|
|
33
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
34
|
+
const r = spawnSync(process.execPath, [bin, "status", "--project", project, "--run", run.runId, "--json"], {
|
|
35
|
+
env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: state, LONGRUN_TEST: "1" },
|
|
36
|
+
maxBuffer: 8 * 1024 * 1024, timeout: 10000,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(r.status, 0, String(r.stderr));
|
|
39
|
+
const result = JSON.parse(r.stdout);
|
|
40
|
+
assert.equal(result.criterionStates[0].id, criterionId);
|
|
41
|
+
assert.equal(result.currentLoss, 1);
|
|
42
|
+
assert.equal(result.completionBlocked, true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 1 + 2: dry-run writes nothing; install preserves unrelated config + no competing config.
|
|
46
|
+
test("install: dry-run is inert", () => {
|
|
47
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m0-")));
|
|
48
|
+
const r = install({ configDir: cfg, dryRun: true });
|
|
49
|
+
assert.ok(r.actions.every((a) => a.action === "would_create"));
|
|
50
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false, "dry-run must not create the plugin");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// maintenance entry point runs from ANY cwd and resolves copied libs (no source repo needed).
|
|
54
|
+
test("maintenance doctor runs from an unrelated cwd and resolves copied libs (source-independent)", () => {
|
|
55
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m1-")));
|
|
56
|
+
install({ configDir: cfg });
|
|
57
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
58
|
+
assert.ok(fs.existsSync(bin), "maintenance entry point installed");
|
|
59
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--json"], { cwd: fs.mkdtempSync(path.join(os.tmpdir(), "cwd-")), env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "st-")) } });
|
|
60
|
+
assert.equal(r.status, 0, "doctor install-mode exit 0:\n" + r.stdout + r.stderr);
|
|
61
|
+
const j = JSON.parse(r.stdout);
|
|
62
|
+
assert.equal(j.mode, "install"); assert.equal(j.ok, true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// independence: run maintenance from a COPY of the install that has NO source tree.
|
|
66
|
+
test("maintenance works in a copied, standalone install (source repo unavailable)", () => {
|
|
67
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m2-")));
|
|
68
|
+
install({ configDir: cfg });
|
|
69
|
+
const standalone = fs.mkdtempSync(path.join(os.tmpdir(), "standalone-"));
|
|
70
|
+
const cfgCopy = path.join(standalone, "opencode");
|
|
71
|
+
fs.cpSync(cfg, cfgCopy, { recursive: true });
|
|
72
|
+
const bin = path.join(cfgCopy, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
73
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfgCopy, LONGRUN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "st2-")) } });
|
|
74
|
+
assert.equal(r.status, 0, "standalone doctor ok:\n" + r.stdout + r.stderr);
|
|
75
|
+
assert.equal(JSON.parse(r.stdout).ok, true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 1: LIVE-READINESS must NOT report success while awaiting restart (no observed load).
|
|
79
|
+
test("doctor --live refuses success when no runtime load record exists (awaiting restart)", () => {
|
|
80
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m3-")));
|
|
81
|
+
install({ configDir: cfg });
|
|
82
|
+
const state = fs.mkdtempSync(path.join(os.tmpdir(), "state-empty-"));
|
|
83
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
84
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--live", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: state } });
|
|
85
|
+
const j = JSON.parse(r.stdout);
|
|
86
|
+
assert.equal(j.status, "AWAITING_RESTART", "no load record -> not verified");
|
|
87
|
+
assert.equal(j.ok, false, "live mode must not claim success while unverified");
|
|
88
|
+
assert.equal(r.status, 2, "exit code non-zero so automation can gate");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 1: a GENUINE host-shaped load record (nonce + toolsBuilt + live pid + fresh + hook activity)
|
|
92
|
+
// is required; the stand-in live pid is a spawned sleeper, not a probe artifact.
|
|
93
|
+
test("doctor --live detects a current genuine load (host-shaped record)", () => {
|
|
94
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m4-")));
|
|
95
|
+
install({ configDir: cfg });
|
|
96
|
+
const state = fs.mkdtempSync(path.join(os.tmpdir(), "state-hot-"));
|
|
97
|
+
fs.mkdirSync(path.join(state, "load"), { recursive: true });
|
|
98
|
+
const sleeper = spawn("node", ["-e", "setTimeout(()=>{},120000)"]); // stand-in for a live backend pid
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(path.join(state, "load", "host-genuine.json"), JSON.stringify({
|
|
101
|
+
at: Date.now(), nonce: "live-nonce-1", toolsBuilt: true, test: false, hookActivity: 3,
|
|
102
|
+
pid: sleeper.pid, runtime: process.version, exec: "/Applications/OpenCode.app/Contents/Frameworks/OpenCode Helper.app/Contents/MacOS/OpenCode Helper", client: "desktop", project: "someProject",
|
|
103
|
+
}));
|
|
104
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
105
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--live", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: state } });
|
|
106
|
+
const j = JSON.parse(r.stdout);
|
|
107
|
+
assert.equal(j.status, "LIVE_DETECTED", "genuine record must be observed:\n" + r.stdout + r.stderr);
|
|
108
|
+
assert.equal(j.live, "OBSERVED"); assert.equal(j.ok, true);
|
|
109
|
+
} finally { sleeper.kill(); }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 4 + 5: probe-shaped, hook-only, dead-pid, stale and test-flagged records NEVER satisfy --live.
|
|
113
|
+
test("doctor --live rejects probe-shaped, hook-only, dead-pid, stale and test records", () => {
|
|
114
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m4b-")));
|
|
115
|
+
install({ configDir: cfg });
|
|
116
|
+
const state = fs.mkdtempSync(path.join(os.tmpdir(), "state-bad-"));
|
|
117
|
+
const load = path.join(state, "load"); fs.mkdirSync(load, { recursive: true });
|
|
118
|
+
const bad = {
|
|
119
|
+
"probe-shape.json": { at: Date.now(), hookActivity: 5, loads: 1, runtime: "v24", project: "x" }, // old probe style
|
|
120
|
+
"hook-only.json": { hookActivity: 14530, at: Date.now() }, // hook record with NO load nonce
|
|
121
|
+
"dead-pid.json": { at: Date.now(), nonce: "n1", toolsBuilt: true, hookActivity: 2, pid: 99999999 },
|
|
122
|
+
"stale.json": { at: Date.now() - 20 * 60 * 1000, nonce: "n2", toolsBuilt: true, hookActivity: 2, pid: process.pid },
|
|
123
|
+
"test-flag.json": { at: Date.now(), nonce: "n3", toolsBuilt: true, hookActivity: 2, test: true, pid: process.pid },
|
|
124
|
+
// even a COMPLETE host-shaped record written by a probe (checker's own runtime, runner pid) is rejected
|
|
125
|
+
"probe-live.json": { at: Date.now(), nonce: "n4", toolsBuilt: true, test: false, hookActivity: 2, pid: process.pid, runtime: process.version, exec: process.execPath, client: "desktop" },
|
|
126
|
+
};
|
|
127
|
+
for (const [k, v] of Object.entries(bad)) fs.writeFileSync(path.join(load, k), JSON.stringify(v));
|
|
128
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
129
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--live", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: state } });
|
|
130
|
+
assert.equal(r.status, 2, "must exit non-zero (not verified)");
|
|
131
|
+
const j = JSON.parse(r.stdout);
|
|
132
|
+
assert.equal(j.ok, false); assert.equal(j.live, "NOT_VERIFIED");
|
|
133
|
+
assert.equal(j.status, "STALE_LOAD_RECORD");
|
|
134
|
+
assert.equal(j.liveEvidence.loadRecords, 6);
|
|
135
|
+
assert.equal(j.liveEvidence.rejectedUntrusted.length, 6, "every probe-shaped record rejected:\n" + r.stdout);
|
|
136
|
+
for (const x of j.liveEvidence.rejectedUntrusted) assert.ok(x.why, "rejection carries a reason: " + JSON.stringify(x));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 2: uninstall via installed entry point is reversible + preserves unrelated config.
|
|
140
|
+
test("installed maintenance uninstall removes only owned files, preserves provider + foreign", () => {
|
|
141
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m5-")));
|
|
142
|
+
fs.mkdirSync(path.join(cfg, "skills", "longrun-ui"), { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md"), "foreign\n");
|
|
144
|
+
install({ configDir: cfg });
|
|
145
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
146
|
+
const r = spawnSync(process.execPath, [bin, "uninstall", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg } });
|
|
147
|
+
assert.equal(r.status, 0);
|
|
148
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false, "plugin removed");
|
|
149
|
+
assert.equal(fs.existsSync(path.join(cfg, "longrun-harness")), false, "release tree removed");
|
|
150
|
+
assert.ok(fs.existsSync(path.join(cfg, "plugins", "mtplx-session-headers.js")), "unrelated plugin preserved");
|
|
151
|
+
assert.ok(fs.existsSync(path.join(cfg, "skills", "longrun-ui", "SKILL.md")), "foreign skill preserved");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 2: emergency disable via installed entry point removes the loadable plugin.
|
|
155
|
+
test("installed maintenance disable removes the loadable plugin (emergency)", () => {
|
|
156
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m6-")));
|
|
157
|
+
install({ configDir: cfg });
|
|
158
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
159
|
+
const r = spawnSync(process.execPath, [bin, "disable", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg } });
|
|
160
|
+
assert.equal(r.status, 0);
|
|
161
|
+
assert.equal(fs.existsSync(path.join(cfg, "plugins", "longrun.js")), false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// v1.2.0 §16: the STRUCTURAL doctor must surface the new subsystems, and the copied libs must
|
|
165
|
+
// load source-independently (controller importing memory/evidence from the release lib dir).
|
|
166
|
+
test("structural doctor reports the v1.2.0 subsystems (lifecycle/memory/evidence/continuation)", () => {
|
|
167
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m7-")));
|
|
168
|
+
install({ configDir: cfg });
|
|
169
|
+
// memory/evidence copied beside the controller
|
|
170
|
+
assert.ok(fs.existsSync(path.join(cfg, "longrun-harness", "releases", VERSION, "lib", "memory.mjs")));
|
|
171
|
+
assert.ok(fs.existsSync(path.join(cfg, "longrun-harness", "releases", VERSION, "lib", "evidence.mjs")));
|
|
172
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
173
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "st7-")) } });
|
|
174
|
+
assert.equal(r.status, 0, "structural doctor ok:\n" + r.stdout + r.stderr);
|
|
175
|
+
const j = JSON.parse(r.stdout);
|
|
176
|
+
assert.equal(j.pluginShape, "v1-server-plugin", "plugin shape detected (single server-plugin export)");
|
|
177
|
+
assert.equal(j.runLifecycleSchema.version, VERSION, "doctor lifecycle schema version == installer VERSION");
|
|
178
|
+
assert.ok(j.runLifecycleSchema.actions.includes("start") && j.runLifecycleSchema.actions.includes("memory_init"), "full action schema surfaced");
|
|
179
|
+
assert.ok(j.runLifecycleSchema.actions.every((a) => !a.includes("...")), "no ellipsis in the documented action schema");
|
|
180
|
+
assert.equal(j.memorySubsystem.present, true, "hierarchical-memory subsystem present");
|
|
181
|
+
assert.equal(j.memorySubsystem.maxDepth, 3);
|
|
182
|
+
assert.equal(j.evidenceSchema.verifyRejectsWeakerClass, true);
|
|
183
|
+
assert.equal(j.continuationDefault, "OFF", "automatic continuation remains disabled by default");
|
|
184
|
+
assert.equal(j.compactionIntegration.realCompactionVerified, false, "never claim real compaction is verified");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// v1.2.3: the STRUCTURAL doctor must actively self-test the authoritative receipt model — a single
|
|
188
|
+
// resolver, copied/fixture sabotage cannot invalidate a project check, and a hard gate is recomputed
|
|
189
|
+
// from evidence (so a passing build can complete) — rather than only checking that files exist.
|
|
190
|
+
test("doctor self-tests the v1.2.3 receipt model (single resolver + gate recomputation)", () => {
|
|
191
|
+
const cfg = seedConfig(fs.mkdtempSync(path.join(os.tmpdir(), "m8-")));
|
|
192
|
+
install({ configDir: cfg });
|
|
193
|
+
const bin = path.join(cfg, "longrun-harness", "releases", VERSION, "bin", "longrun.mjs");
|
|
194
|
+
const r = spawnSync(process.execPath, [bin, "doctor", "--json"], { cwd: "/tmp", env: { ...process.env, OPENCODE_CONFIG_DIR: cfg, LONGRUN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "st8-")) } });
|
|
195
|
+
assert.equal(r.status, 0, "receipt-model doctor ok:\n" + r.stdout + r.stderr);
|
|
196
|
+
const j = JSON.parse(r.stdout);
|
|
197
|
+
assert.equal(j.ok, true, "healthy install still reports ok");
|
|
198
|
+
assert.ok(j.receiptModel, "receipt model section present");
|
|
199
|
+
assert.equal(j.receiptModel.singleSource, true, "one effective-receipt definition, no duplicate resolver");
|
|
200
|
+
assert.equal(j.receiptModel.singleResolver, true);
|
|
201
|
+
assert.equal(j.receiptModel.copySabotageIgnored, true, "a copied/fixture result cannot turn a project check red");
|
|
202
|
+
assert.equal(j.receiptModel.gateRecomputedToComplete, true, "a passing build lets the gate complete");
|
|
203
|
+
assert.equal(j.receiptModel.failMasksOldPass, true, "a newer failure is not hidden behind an older pass");
|
|
204
|
+
});
|