// l2-archive.mts —— L2 场景块归档 / 恢复的命令行入口(查看器写盘桥与人工运维共用) // // 包装 `memory/l2/archive.ts`(`archiveSceneBlock` / `restoreSceneBlock` / `reconcileArchive` / // `listArchivedBlocks`)+ `syncSceneIndex`。之所以必须有这个包装:归档要按固定**顺序**走 // "自愈 → 搬块 → 重建索引",而且全程持 **L2 run-lock** —— 顺序或锁在桥里重写一遍就是口径分叉。 // 顺序与 `/my-memory:l2:archive` / `:restore` 命令逐行对齐(index.ts:1413 / :1454)。 // // 两道硬拦不绕过(与命令同款,人工路径只是能搬子代理搬不动的块): // · 归档必须有 `--reason`(它是归档唯一的审计凭据); // · 恢复要过 maxScenes 上限(满库时须先腾位)。 // // 用法: // node --experimental-strip-types scripts/l1-admin/l2-archive.mts list [--profile ] // node --experimental-strip-types scripts/l1-admin/l2-archive.mts archive --file <名.md> --reason "为什么搬" [--apply] // node --experimental-strip-types scripts/l1-admin/l2-archive.mts restore --file <名.md> [--reason "…"] [--apply] // // dry-run 的边界:archive/restore 的预演**不做自愈**(`reconcileArchive` 会写盘), // 只校验文件存在性、reason、上限这些能只读判定的前置条件。实写时自愈照常执行。 import { archiveSceneBlock, listArchivedBlocks, reconcileArchive, restoreSceneBlock } from "../../memory/l2/archive.ts"; import { countSceneBlocks, loadL2Config } from "../../memory/l2/config.ts"; import { syncSceneIndex } from "../../memory/l2/commit-tool.ts";import { acquireRunLock, runLockBusyMessage } from "../../memory/pipeline/run-lock.ts"; import { logEvent } from "../../util/notice.ts"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { MEMORY_DB_DIR, L2_ARCHIVE_DIR, L2_SCENE_DIR } from "../../memory/paths.ts"; import { APPLY, fail, finish, flagValue, positionals, profileArg, run, usageError } from "./viewer-cli.mts"; const USAGE = "node --experimental-strip-types scripts/l1-admin/l2-archive.mts list | archive --file <名.md> --reason \"…\" | " + "restore --file <名.md> [--reason \"…\"] [--profile ] [--apply]"; /** 人工路径一律以 `viewer`/`cli` 的身份记账(与子代理的 by 区分开,事后能看出是谁搬的)。 */ const BY = "viewer"; run(() => { const profilePath = profileArg(); const op = positionals(["--profile", "--file", "--reason"])[0]; if (!profilePath || !op) usageError(USAGE); const dbRel = (rel: string) => join(profilePath, MEMORY_DB_DIR, rel); if (op === "list") { const blocks = listArchivedBlocks(profilePath).map((b) => { const last = [...b.events].reverse().find((e) => e.op === "archive" || e.op === "ledger-missing"); return { file: b.filename, members: b.members.length, at: last?.at ?? null, reason: last?.reason ?? null, op: last?.op ?? null, }; }); finish({ success: true, count: blocks.length, live: countSceneBlocks(profilePath), maxScenes: loadL2Config(profilePath).maxScenes, blocks, }); } if (op !== "archive" && op !== "restore") usageError(USAGE); const filename = flagValue("--file"); const reason = flagValue("--reason"); if (!filename) usageError(USAGE); if (op === "archive" && !reason) fail("归档必须给 --reason(这是归档唯一的审计凭据)"); // 预演:只做能只读判定的前置检查(不跑自愈、不取锁、不写盘)。 if (!APPLY) { const liveHas = existsSync(join(dbRel(L2_SCENE_DIR), filename)); const archHas = existsSync(join(dbRel(L2_ARCHIVE_DIR), filename)); const count = countSceneBlocks(profilePath); const limit = loadL2Config(profilePath).maxScenes; if (op === "archive" && !liveHas) fail(`场景目录里没有这个块:${filename}(现有 live ${count} 个)`); if (op === "archive" && archHas) fail(`归档层里已经有同名块:${filename}(先恢复或改名,别覆盖账目)`); if (op === "restore" && !archHas) fail(`归档层里没有这个块:${filename}`); if (op === "restore" && count + 1 > limit) fail(`场景目录已满(${count}/${limit}):先归档或合并腾位,再恢复`); finish({ success: true, op, dryRun: true, filename, live: count, maxScenes: limit, note: "前置条件通过(预演不跑自愈);加 --apply 才写盘,实写会先 reconcileArchive 再搬块并重建索引", }); } runMove(profilePath, op, filename, reason); }); /** 实写路径:取 L2 运行级锁 → 自愈 → 搬块 → 重建索引(顺序与 `/my-memory:l2:archive` 命令一致)。 */ function runMove(profilePath: string, op: "archive" | "restore", filename: string, reason?: string): void { const lock = acquireRunLock(profilePath, "l2"); if (!lock.acquired) fail(runLockBusyMessage("l2", lock.owner)); try { reconcileArchive(profilePath, new Date().toISOString()); if (op === "archive") { const out = archiveSceneBlock(profilePath, { filename, reason: reason as string, by: BY }); if (!out.ok) fail(out.error ?? "归档失败"); syncSceneIndex(profilePath); logEvent("l2", `查看器归档 ${filename}(${out.members.length} 个成员):${reason}`, profilePath); finish({ success: true, op: "archive", filename, members: out.members.length, live: countSceneBlocks(profilePath), note: `已搬进归档层(${out.members.length} 个成员随之保留)。搬回来:restore --file ${filename} --apply`, }); } // 恢复会往 live 目录加回一个块 ⇒ 受同一个上限约束(满库时须先腾位,否则拒绝)。 const count = countSceneBlocks(profilePath); const limit = loadL2Config(profilePath).maxScenes; if (count + 1 > limit) fail(`场景目录已满(${count}/${limit}):先把别的块归档或合并,腾出位置再恢复`); const out = restoreSceneBlock(profilePath, { filename, by: BY, reason: reason ?? "人工恢复(查看器)" }); if (!out.ok) fail(out.error ?? "恢复失败"); syncSceneIndex(profilePath); logEvent("l2", `查看器恢复 ${filename}(${out.members.length} 个成员)`, profilePath); finish({ success: true, op: "restore", filename, members: out.members.length, live: countSceneBlocks(profilePath) }); } finally { lock.release(); } }