import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test, type TestContext } from "node:test"; import { lightChecker } from "../src/flow/task-code.ts"; import { recover } from "../src/flow/recover.ts"; import { containerName, freshDir, runPaths } from "../src/flow/paths.ts"; import type { Study } from "../src/flow/study.ts"; import { Board, protocolMetrics, type FrozenProtocol } from "../src/materials/index.ts"; import { loadTask, formalRun as runDefinition, prepareRun } from "../src/tasks/index.ts"; import { runBalance } from "../src/rules/index.ts"; import { runStudy } from "../src/flow/index.ts"; import { Ledger } from "../src/ledger/index.ts"; import { Runner } from "../src/runner/index.ts"; import { fullConfig } from "./config-helpers.ts"; // flow 碰到 Docker 的两处边界,用真实 Docker 测,不花模型的钱:走不到派模型任务就停下。 // 镜像一律 inforsi-test- 开头的固定名字;本机还有别的项目在建删容器和镜像,只动自己前缀的。 const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing"); function docker(args: string[]): { status: number | null; stdout: string; stderr: string } { return spawnSync("docker", args, { encoding: "utf8" }); } /** 删掉测试自己的镜像标签;不存在不算错,别的错误照样报。 */ function removeImage(tag: string): void { const removed = docker(["rmi", tag]); if (removed.status !== 0 && !removed.stderr.includes("No such image")) throw new Error(removed.stderr); } /** 一个测试用的研究编号:先清掉上次没删干净的同名镜像,测试结束后再删。 */ function studyId(t: TestContext, suffix: string): string { const id = `test-flow-${suffix}`; removeImage(`inforsi-${id}`); t.after(() => removeImage(`inforsi-${id}`)); return id; } function tempDir(t: TestContext): string { const dir = mkdtempSync(join(tmpdir(), "inforsi-flow-")); t.after(() => rmSync(dir, { recursive: true, force: true })); return dir; } function ledgerKinds(studyDir: string): string[] { const ledger = Ledger.open(join(studyDir, "ledger.sqlite")); try { return ledger.records().map((r) => r.kind); } finally { ledger.close(); } } test("研究编号本机用过(已有 inforsi-<研究编号> 镜像):冻结配置前报错停下,不建镜像、不写研究配置记录", async (t) => { const id = studyId(t, "dup"); // 一份旧研究冻结配置时建下的镜像。 await new Runner(null).buildImage(TASK_DIR, `inforsi-${id}`); const dir = tempDir(t); await assert.rejects( runStudy({ studyDir: dir, config: { ...fullConfig(), studyId: id }, stop: new AbortController().signal }), new RegExp(`研究编号 ${id} 已被本机用过(已有镜像 inforsi-${id})`), ); assert.deepEqual(ledgerKinds(dir), []); }); test("docker run 没启动:写结果\"没启动\"后停下,原因带 Docker 报错原文,不在本进程里起新编号重跑", { timeout: 120_000 }, async (t) => { // 任务事实里写一个本机没有的 Docker 运行时,docker run 每次都失败,也不建容器。 const task = join(tempDir(t), "task"); cpSync(TASK_DIR, task, { recursive: true }); const facts = JSON.parse(readFileSync(join(task, "task.json"), "utf8")) as { run: { runtime: string } }; facts.run.runtime = "inforsi-test-no-such-runtime"; writeFileSync(join(task, "task.json"), JSON.stringify(facts)); const dir = tempDir(t); const ending = await runStudy({ studyDir: dir, config: { ...fullConfig(), studyId: studyId(t, "not-started"), task }, stop: new AbortController().signal, }); assert.equal(ending.status, "halted"); assert.match((ending as { reason: string }).reason, /运行 base-1 没有启动.*unknown or invalid runtime name: inforsi-test-no-such-runtime/); assert.deepEqual(ledgerKinds(dir), ["config", "code", "runPlanned", "runResult", "event"]); }); /** 不派模型,只接真实任务镜像、账本和运行器,检查 flow 的两处 Docker 接线。 */ async function runtimeStudy(t: TestContext, suffix: string): Promise { const dir = tempDir(t); const id = studyId(t, suffix); const task = loadTask(TASK_DIR); const runner = new Runner(null); const image = await runner.buildImage(task.dir, `inforsi-${id}`); const ledger = Ledger.open(join(dir, "ledger.sqlite")); t.after(() => ledger.close()); return { dir, task, runner, ledger, stop: new AbortController().signal, frozen: { config: { ...fullConfig(), studyId: id, lightChecks: { maxCount: 1, seconds: 7 } }, facts: task.facts, image, seed: 1 } } as Study; } test("flow 轻量检查:每次调用各自限次和限时,真实 Docker 只检查当前目录,不登记正式运行", { timeout: 120_000 }, async (t) => { const s = await runtimeStudy(t, "light"); const work = join(s.dir, "work"); mkdirSync(work); writeFileSync(join(work, "initial.py"), "raise RuntimeError('不应执行候选代码')\ndef run_packing():\n pass\n"); for (const call of ["first-call", "second-call"]) { freshDir(join(s.dir, "calls", call)); const check = lightChecker(s, call, work); const result = JSON.parse(await check()); assert.deepEqual(result, { used: 1, maxCount: 1, seconds: 7, result: { passed: true } }); assert.match(await check(), /已用完(上限 1 次)/); assert.equal(await s.runner.status(containerName(s.frozen.config.studyId, `${call}-check-1`)), "absent"); } assert.deepEqual(s.ledger.records(), []); }); test("正向恢复:接上原容器不重跑,补启动只扣一次,引用指标沿用原登记缺失作废规则", { timeout: 120_000 }, async (t) => { const s = await runtimeStudy(t, "forward-recover"); const metric = { id: "old-c.required", meaning: "必测量", unit: "次", direction: "maximize" as const, howMeasured: "原登记定义", missing: "invalidateRun" as const }; const original: FrozenProtocol = { round: 0, baseline: "base", proposals: [], factors: [], safeguards: [], metrics: { defined: [metric], reused: [] }, clauses: [], body: "登记" }; const current: FrozenProtocol = { ...original, round: 1, metrics: { defined: [], reused: [metric.id] } }; s.ledger.append("protocol", { candidate: "old-c", protocol: original }); s.ledger.append("protocol", { candidate: "r1-c1", protocol: current }); const board = new Board(s.ledger.records()); const run = "forward-1"; const paths = runPaths(s.dir, run); const definition = runDefinition(s.task, s.frozen.image, "1"); s.ledger.append("runPlanned", { run, purpose: "forward", round: 1, object: "r1-c1", definition }); freshDir(paths.base); prepareRun(s.task, s.task.baselineDir, freshDir(paths.run), protocolMetrics(board, current)); const name = containerName(s.frozen.config.studyId, run); t.after(() => { const result = docker(["rm", "-f", name]); if (result.status !== 0 && !result.stderr.includes("No such container")) throw new Error(result.stderr); }); assert.deepEqual(await s.runner.start(name, definition, paths.run), { started: true }); // 中断点:容器已启动,账本还没写 runStarted。恢复不调用 formalRun,而是找到原容器等它。 assert.equal(await recover(s), null); const records = s.ledger.records(); assert.equal(records.filter((r) => r.kind === "runPlanned").length, 1); assert.equal(records.filter((r) => r.kind === "runStarted").length, 1); assert.equal(records.filter((r) => r.kind === "runResult").length, 1); const result = records.find((r) => r.kind === "runResult")!; if (result.kind !== "runResult") throw new Error("缺运行结果"); assert.equal(result.content.judged!.verdict, "invalid"); assert.match(result.content.judged!.verdictReason, /old-c.required/); assert.equal(result.content.judged!.metrics[metric.id]!.status, "unmeasured"); assert.deepEqual(runBalance(13, new Board(records).runStates()), { charged: 1, reserved: 0, balance: 12 }); assert.equal(await s.runner.status(name), "absent"); assert.equal(await recover(s), null); assert.equal(s.ledger.records().length, records.length); });