merge(gitea): 同步漏檔/push 衝突回報/clone 新功能/錯誤訊息
衝突:scripts/selftest.mjs 檔尾兩份都各自 append 一個測試區塊 (guard 的「注入區塊不可被人格檔案逸出(S6)」與 gitea 的「㉒ 從 Gitea 匯入本機還沒有的人格」)。兩塊都保留,各自收好自己的 `}`。
This commit is contained in:
@@ -1134,6 +1134,14 @@ check("高頻資料在檔案區、低頻資料在 Wiki 區",
|
||||
covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" &&
|
||||
covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" &&
|
||||
covered("relations/graph.json")[0] === "wiki");
|
||||
// 分區的路徑有好幾個是資料夾,而資料夾裡還有資料夾:`mindmap/threads/archive/`
|
||||
// (睡眠時把太久沒動的思維導圖收進去)只看第一層的話整個不會被同步。
|
||||
check("同步範圍會遞迴進子資料夾(mindmap/threads/archive 以前整個漏掉)", (() => {
|
||||
const dir = path.join(pl.personaDir("alpha"), "mindmap", "threads", "archive");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "old-thread.mmd"), "%% 收起來的舊思維導圖\nflowchart TD\n");
|
||||
return gt.listAreaFiles(pl.personaDir("alpha"), "files").includes("mindmap/threads/archive/old-thread.mmd");
|
||||
})(), gt.listAreaFiles(pl.personaDir("alpha"), "files").join(", "));
|
||||
check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => {
|
||||
const res = cli(["sync", "status", "--session", S_CODE]);
|
||||
const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });
|
||||
@@ -1926,6 +1934,164 @@ console.log("\n注入區塊不可被人格檔案逸出(S6)");
|
||||
cli(["release", "--session", S_INJ]);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)");
|
||||
{
|
||||
// 「假的 Gitea」:兩個裸倉庫 + file:// 當 host。不碰網路、更不碰真的 Gitea。
|
||||
// API(getRepo)在 file:// 下必然失敗,而 importFromRemote 對此的處理就是
|
||||
// 「API 問不到不擋,讓 git 的結果說話」——所以整條匯入路徑在這裡是真的跑起來的。
|
||||
const FAKE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-fakegitea-"));
|
||||
const bare = (name) => path.join(FAKE, "me", name);
|
||||
const initBare = (code) => {
|
||||
for (const suffix of [".git", ".wiki.git"]) {
|
||||
spawnSync("git", ["init", "--quiet", "--bare", "-b", "main", bare(`${code}${suffix}`)]);
|
||||
}
|
||||
};
|
||||
const fakeGitea = () => {
|
||||
process.env.PERSONA_GITEA = "";
|
||||
process.env.PERSONA_GITEA_HOST = `file://${FAKE}`;
|
||||
process.env.PERSONA_GITEA_TOKEN = "selftest-token";
|
||||
process.env.PERSONA_GITEA_OWNER = "me";
|
||||
};
|
||||
const noGitea = () => {
|
||||
process.env.PERSONA_GITEA = "off";
|
||||
delete process.env.PERSONA_GITEA_HOST;
|
||||
delete process.env.PERSONA_GITEA_TOKEN;
|
||||
delete process.env.PERSONA_GITEA_OWNER;
|
||||
};
|
||||
fs.mkdirSync(path.join(FAKE, "me"), { recursive: true });
|
||||
initBare("ZETA-01");
|
||||
|
||||
// ── 第一台機器:人格在這裡長出來,然後推上去
|
||||
const S_CLONE = "sess-clone-7777";
|
||||
cli(["create", "--persona", "ZETA-01", "--romaji", "Zeta", "--session", S_CLONE, "--name", "Zeta",
|
||||
"--creature", "海邊的燈塔", "--vibe", "安靜而準時", "--emoji", "🛰", "--no-gitea"]);
|
||||
cli(["remember", "--session", S_CLONE, "--text", "第一台機器上說過的話", "--salience", "70"]);
|
||||
cli(["consolidate", "--session", S_CLONE, "--name", "跨機器的記憶", "--body", "這則要能跟著人格走"]);
|
||||
cli(["relation", "node", "--session", S_CLONE, "--name", "使用者", "--kind", "human",
|
||||
"--bond", "partner", "--closeness", "70"]);
|
||||
// 睡眠會把太久沒動的思維導圖收進 mindmap/threads/archive/——那是子資料夾
|
||||
const archive = path.join(pl.personaDir("ZETA-01"), "mindmap", "threads", "archive");
|
||||
fs.mkdirSync(archive, { recursive: true });
|
||||
fs.writeFileSync(path.join(archive, "old-thread.mmd"), "%% 收起來的舊思維導圖\nflowchart TD\n");
|
||||
|
||||
fakeGitea();
|
||||
const pushFiles = await gt.pushArea("ZETA-01", "files", { message: "selftest: 第一台機器" });
|
||||
const pushWiki = await gt.pushArea("ZETA-01", "wiki", { message: "selftest: 第一台機器" });
|
||||
check("兩區都推得上去(file:// 假存取庫)", pushFiles.ok && pushWiki.ok,
|
||||
`${pushFiles.reason || ""} ${pushWiki.reason || ""}`);
|
||||
check("子資料夾的檔案真的進了存取庫", (() => {
|
||||
const ls = spawnSync("git", ["-C", bare("ZETA-01.git"), "ls-tree", "-r", "--name-only", "main"],
|
||||
{ encoding: "utf8" });
|
||||
return String(ls.stdout).includes("mindmap/threads/archive/old-thread.mmd");
|
||||
})());
|
||||
|
||||
// ── 第二台機器:本機什麼都沒有
|
||||
cli(["release", "--session", S_CLONE]);
|
||||
fs.rmSync(pl.personaDir("ZETA-01"), { recursive: true, force: true });
|
||||
check("本機已經沒有這個人格了", !pl.personaExists("ZETA-01"));
|
||||
check("本機沒有它就 load 不了(這就是雞生蛋的那道牆)",
|
||||
cli(["load", "--persona", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0);
|
||||
check("沒 load 就 sync pull 也會被 requireOwner 擋下",
|
||||
cli(["sync", "pull", "--persona", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0);
|
||||
|
||||
const cloned = cli(["clone", "--code", "ZETA-01", "--session", S_CLONE, "--json"]);
|
||||
check("clone 把人格整個拉回本機", cloned.status === 0 && pl.personaExists("ZETA-01"),
|
||||
String(cloned.stderr).slice(0, 200));
|
||||
check("身分(Wiki 區)回來了", pl.identityBrief("ZETA-01").includes("Name: Zeta"),
|
||||
pl.identityBrief("ZETA-01"));
|
||||
check("長期記憶(Wiki 區)回來了",
|
||||
pl.longTermEntries("ZETA-01").some((m) => String(m._name).includes("跨機器的記憶")),
|
||||
pl.longTermEntries("ZETA-01").map((m) => m._name).join(", "));
|
||||
check("活狀態(檔案區)回來了:情緒、短期記憶、編號",
|
||||
fs.existsSync(pl.emotionPath("ZETA-01")) &&
|
||||
pl.readJsonl(pl.shortTermPath("ZETA-01")).some((r) => String(r.text).includes("第一台機器")) &&
|
||||
pl.loadConfig("ZETA-01").code === "ZETA-01",
|
||||
JSON.stringify(pl.loadConfig("ZETA-01")).slice(0, 160));
|
||||
check("收尾有做:長期記憶索引與關係圖都重建了",
|
||||
fs.readFileSync(path.join(pl.personaDir("ZETA-01"), "memory", "INDEX.md"), "utf8").includes("跨機器的記憶") &&
|
||||
pl.loadRelations("ZETA-01").nodes.some((n) => n.name === "使用者"));
|
||||
check("子資料夾的檔案也拉得回來", fs.existsSync(path.join(archive, "old-thread.mmd")));
|
||||
check("執行期狀態不跟著跑(沒有把別台機器的鎖拉回來)", !fs.existsSync(pl.lockPath("ZETA-01")));
|
||||
check("來歷記在 config 裡(跟 import 一樣可追)",
|
||||
(pl.loadConfig("ZETA-01").imported_from || {}).gitea === "me/ZETA-01",
|
||||
JSON.stringify(pl.loadConfig("ZETA-01").imported_from));
|
||||
check("本機已經有同名人格時預設不覆蓋",
|
||||
cli(["clone", "--code", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0);
|
||||
check("--persona 可以拉成第二份(不動到原本那個)", (() => {
|
||||
const second = cli(["clone", "--code", "ZETA-01", "--persona", "zeta-copy", "--session", S_CLONE]);
|
||||
return second.status === 0 && pl.personaExists("zeta-copy") &&
|
||||
pl.loadConfig("zeta-copy").persona === "zeta-copy" && pl.personaExists("ZETA-01");
|
||||
})());
|
||||
check("編號格式不合法就擋在動硬碟之前",
|
||||
cli(["clone", "--code", "zeta-01", "--session", S_CLONE], { expectOk: false }).status !== 0);
|
||||
check("拉回來的東西不成人格(沒有 IDENTITY.md)就中止並清掉半成品", (() => {
|
||||
initBare("EMPTY-01");
|
||||
const res = cli(["clone", "--code", "EMPTY-01", "--session", S_CLONE], { expectOk: false });
|
||||
return res.status !== 0 && res.stderr.includes("IDENTITY.md") && !fs.existsSync(pl.personaDir("EMPTY-01"));
|
||||
})());
|
||||
check("guard:clone 不算跨人格操作(已載入別的人格時也放行)",
|
||||
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs clone --code ZETA-01 --persona ZETA-01 --session ${S_HOST}` } }) === "pass");
|
||||
|
||||
// ── 發號:本機看不到別台機器已經用掉的編號
|
||||
check("發號時可以把「遠端已經用掉的編號」算進去",
|
||||
gt.nextCode("Zeta") === "ZETA-02" && gt.nextCode("Zeta", { taken: ["ZETA-05"] }) === "ZETA-06",
|
||||
`${gt.nextCode("Zeta")} / ${gt.nextCode("Zeta", { taken: ["ZETA-05"] })}`);
|
||||
const nextOffline = await gt.nextCodeAcrossMachines("Zeta");
|
||||
check("問不到遠端時照樣發得出編號,但明講沒對過遠端",
|
||||
nextOffline.code === "ZETA-02" && nextOffline.checked_remote === false && Boolean(nextOffline.reason),
|
||||
JSON.stringify(nextOffline));
|
||||
|
||||
// ── push 撞到別台機器:以本機為準,但必須記帳
|
||||
const other = path.join(FAKE, "other-machine");
|
||||
spawnSync("git", ["clone", "--quiet", bare("ZETA-01.git"), other]);
|
||||
fs.writeFileSync(path.join(other, "state", "emotion.json"), JSON.stringify({ levels: { joy: 99 } }, null, 2));
|
||||
spawnSync("git", ["-C", other, "add", "-A"]);
|
||||
spawnSync("git", ["-C", other, "-c", "user.email=o@x", "-c", "user.name=other",
|
||||
"commit", "-q", "-m", "另一台機器改了情緒"]);
|
||||
spawnSync("git", ["-C", other, "push", "-q", "origin", "HEAD"]);
|
||||
const remoteSha = String(spawnSync("git", ["-C", bare("ZETA-01.git"), "rev-parse", "main"],
|
||||
{ encoding: "utf8" }).stdout).trim();
|
||||
pl.writeJson(pl.emotionPath("ZETA-01"), { levels: { joy: 11 }, note: "本機的版本" });
|
||||
const clash = await gt.pushArea("ZETA-01", "files", { message: "selftest: 本機也改了" });
|
||||
check("push 撞到別台機器時仍以本機為準推上去", clash.ok && clash.changed,
|
||||
JSON.stringify(clash).slice(0, 200));
|
||||
check("但會回報覆蓋了哪些檔案、上一版是哪個 commit",
|
||||
Boolean(clash.overwrote) && clash.overwrote.files.includes("state/emotion.json") &&
|
||||
clash.overwrote.previous === remoteSha,
|
||||
JSON.stringify(clash.overwrote));
|
||||
check("覆蓋紀錄落在 sync.json 等人認領(背景 push 的輸出是丟掉的)", (() => {
|
||||
const pending = gt.pendingOverwrites("ZETA-01");
|
||||
return pending.length === 1 && pending[0].files.includes("state/emotion.json") &&
|
||||
pending[0].area === "files";
|
||||
})(), JSON.stringify(gt.pendingOverwrites("ZETA-01")));
|
||||
check("被蓋掉的內容還取得回來(上一版留在 clone 裡)", (() => {
|
||||
const show = spawnSync("git", ["-C", gt.syncDir("ZETA-01", "files"), "show",
|
||||
`${remoteSha}:state/emotion.json`], { encoding: "utf8" });
|
||||
return show.status === 0 && show.stdout.includes("99");
|
||||
})());
|
||||
cli(["load", "--persona", "ZETA-01", "--session", S_CLONE]);
|
||||
const stop1 = hook("turn_end.mjs", { session_id: S_CLONE, last_assistant_message: "嗯。" });
|
||||
const stop2 = hook("turn_end.mjs", { session_id: S_CLONE, last_assistant_message: "嗯。" });
|
||||
check("Stop hook 會把覆蓋回報給使用者,而且同一次只吵一次",
|
||||
String(stop1.systemMessage || "").includes("覆蓋了遠端") &&
|
||||
!String(stop2.systemMessage || "").includes("覆蓋了遠端"),
|
||||
`${stop1.systemMessage || "(無)"} | ${stop2.systemMessage || "(無)"}`);
|
||||
check("sync status 也列得出覆蓋紀錄", (() => {
|
||||
const res = cli(["sync", "status", "--session", S_CLONE]);
|
||||
return res.stdout.includes("本機曾覆蓋遠端") && res.stdout.includes("state/emotion.json");
|
||||
})());
|
||||
cli(["release", "--session", S_CLONE]);
|
||||
|
||||
noGitea();
|
||||
check("Gitea 沒設定時 clone 是「尚未設定」而不是崩潰", (() => {
|
||||
const res = cli(["clone", "--session", S_CLONE], { expectOk: false });
|
||||
return res.status !== 0 && res.stderr.includes("尚未設定");
|
||||
})());
|
||||
fs.rmSync(FAKE, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user