// l3-restore.mts —— L3 画像备份的列举与还原(查看器写盘桥 + 人工运维)
//
// 这是本轮**唯一新写的还原实现**(此前 `backups/user-backups/` 有生成者也有陈列者,
// 但没有还原入口 —— 全系统搜不到任何 user-backup 的还原代码)。还原逻辑在
// `memory/l3/user-file.ts` 的 `restoreFromBackup`,本脚本只解析参数。
//
// 语义要点(写在这里也写在界面上,因为它反直觉):
// · **还原到 `USER-temp.md`(提议稿),不是 live `USER.md`**。原因:备份文件名只记时间戳,
// 不记"当时捕获的是 temp 还是 live"(`ensureRunBackup` 捕的是 `readL3TargetFile` = temp ?? live),
// 既然分不清就统一落到 L3 唯一的写目标 temp;要上 live 再走一次并入。
// · 还原前会把**当前 temp** 另存一份(`-pre-restore` 后缀),所以还原本身也可回退。
// · 空备份(0 条)而当前有内容 ⇒ 默认拒绝,要 `--allow-empty` 显式放行。
//
// 为什么要在这里取 L3 run-lock:还原和正在跑的 L3 子代理都在写同一份提议稿,交错会互相覆盖。
// 锁取在 CLI 层而不是函数里,是因为 `restoreFromBackup` 是个"文件级"操作(要能脱锁离线测);
// 而 `applyL3Edit` 要照顾模型调用的工具路径,只能在实现内部取 —— 两种取法的差别仅是调用方是谁。
//
// 用法:
// node --experimental-strip-types scripts/l1-admin/l3-restore.mts --list [--profile
]
// node --experimental-strip-types scripts/l1-admin/l3-restore.mts --file [--allow-empty] [--confirm <同名>] [--profile ] [--apply]
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { listUserBackups, restoreFromBackup, userBackupsDirPath } from "../../memory/l3/user-file.ts";
import { acquireRunLock, runLockBusyMessage } from "../../memory/pipeline/run-lock.ts";
import { fail, finish, flagValue, hasFlag, profileArg, run, usageError } from "./viewer-cli.mts";
const USAGE =
"node --experimental-strip-types scripts/l1-admin/l3-restore.mts --list | " +
"--file [--allow-empty] [--confirm <同名>] [--profile ] [--apply]";
run(() => {
const profilePath = profileArg();
if (!profilePath) usageError(USAGE);
if (hasFlag("--list")) {
const dir = userBackupsDirPath(profilePath);
const files = listUserBackups(profilePath);
finish({
success: true,
dir,
count: files.length,
backups: files.map((file) => {
// size 用文件系统的大小;条目数按 `§` 分节粗算即可(展示用,不与解析口径争权威)。
let size = 0;
let entries: number | null = null;
try {
const raw = readFileSync(join(dir, file), "utf-8");
size = Buffer.byteLength(raw, "utf-8");
const parts = raw.split(/\r?\n§\r?\n/).map((s) => s.trim()).filter(Boolean);
entries = parts.length;
} catch {
// 读不了就留 null:宁可少一个数字,不让整次列举失败
}
return { file, size, entries };
}),
});
}
const file = flagValue("--file");
if (!file) usageError(USAGE);
if (!existsSync(join(userBackupsDirPath(profilePath), file))) fail(`备份不存在:${file}`);
// 实写前要求把文件名原样打一遍(防止误点/误粘贴)。
if (hasFlag("--apply") && flagValue("--confirm") !== file) {
fail(`还原会覆盖当前提议稿,需要显式确认:--confirm ${file}`);
}
const lock = hasFlag("--apply") ? acquireRunLock(profilePath, "l3") : undefined;
if (lock && !lock.acquired) fail(runLockBusyMessage("l3", lock.owner));
try {
const out = restoreFromBackup(profilePath, file, { dryRun: !hasFlag("--apply"), allowEmpty: hasFlag("--allow-empty") });
if (!out.ok) fail(out.error ?? "还原失败");
finish({
...out,
success: true,
note: out.dryRun ? "预演;加 --apply --confirm <同名> 才写盘" : `已还原到 ${out.file}(写前已把当前提议稿另存备份)`,
});
} finally {
lock?.release();
}
});