import { DatabaseSync } from "node:sqlite"; import { mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import assert from "node:assert/strict"; import { test } from "node:test"; import { ChainStore } from "../src/chain-store.js"; import { Timeline } from "../src/timeline.js"; import { ObjectProjection } from "../src/object-projection.js"; function fixture() { const db = new DatabaseSync(":memory:"), timeline = new Timeline(db), chain = new ChainStore(db, timeline); const append = (id, text = "钱灏宇负责报销材料核对。".repeat(50), at = 123) => timeline.append({ id, kind: "user", at, text, source: {} }); const compact = () => { const request = chain.request({ request_id: `compact:${Math.random()}`, mode: "compact" }, 1000); while (chain.status({ id: request.id }).jobs[0].status === "pending") { const job = chain.claim({}, 1100).job; chain.commit({ job_id: job.id, token: job.token, summary: "工作经历已保存,待对象归档。", covered_node_ids: job.nodes.map(n => n.id) }, 1200); } }; const claim = (mode = "organize", now = 2000) => { chain.request({ request_id: `organize:${Math.random()}`, mode }, now); return chain.claim({}, now + 1).job; }; const proposal = (job, extra = {}) => ({ job_id: job.id, token: job.token, summary: "已审阅", covered_node_ids: [], objects: [], edges: [], core: [], catalog: [], reviewed_object_ids: job.review_objects.map(o => o.id), dispositions: job.events.map(e => ({ event_id: e.id, outcome: e.has_more ? "continue" : "archive_only", progress: e.has_more ? `已审阅至${e.end_offset}` : "", reason: "仅保留经历附件,无需更新对象", target_ids: [] })), ...extra }); return { db, timeline, chain, append, compact, claim, proposal }; } const person = (id = "person:qhy", source = "meeting") => ({ id, type: "person", name: "钱灏宇", summary: "负责核对报销材料的同事", body: "负责报销核对;其它职责待确认。", aliases: ["灏宇"], cues: ["报销找谁核对"], status: "active", source_refs: [source] }); test("compaction rejects object writes, enqueues atomically and archive-only retains time retrieval", () => { const f = fixture(); try { f.append("meeting"); f.chain.request({ request_id: "one" }, 1000); const job = f.chain.claim({}, 1001).job; const compact = { job_id: job.id, token: job.token, summary: "报销会议", covered_node_ids: job.nodes.map(n => n.id) }; assert.throws(() => f.chain.commit({ ...compact, objects: [person()] }, 1002), /Compaction only/); assert.equal(f.chain.organizer.counts().length, 0); f.chain.commit(compact, 1003); assert.equal(f.chain.graph.object("person:qhy"), null); const archive = f.claim(); const before = f.chain.context().nodes.map(n => n.id); const receipt = f.chain.commit(f.proposal(archive), 2002); assert.equal(f.chain.organizer.detail("meeting").status, "archive_only"); assert.deepEqual(f.chain.context().nodes.map(n => n.id), before); assert.equal(f.timeline.query({ from: 120, to: 125 }).entries[0].id, "meeting"); assert.ok(f.chain.node({ id: "meeting" }).text.includes("钱灏宇")); assert.deepEqual(f.chain.commit(f.proposal(archive), 999999), receipt); } finally { f.db.close(); } }); test("large raw event is queued only after its final hot fragment detaches", () => { const f = fixture(); try { f.append("large", "会议原文".repeat(7000)); f.chain.request({ request_id: "all", mode: "compact" }, 1000); let batches = 0; for (;;) { const job = f.chain.claim({ max_bytes: 24000 }, 1001).job; if (!job) break; f.chain.commit({ job_id: job.id, token: job.token, summary: "会议片段索引", covered_node_ids: job.nodes.map(n => n.id) }, 1002); const rawActive = f.db.prepare("SELECT count(*) AS n FROM memory_nodes WHERE event_id='large' AND active=1").get().n; assert.equal(Boolean(f.chain.organizer.detail("large")), !rawActive); batches++; } assert.ok(batches > 1); assert.equal(f.chain.organizer.detail("large").offset, 0); } finally { f.db.close(); } }); test("hourly check creates no empty work and does not restart on every heartbeat", () => { const f = fixture(); try { const now = Date.parse("2026-10-12T16:00:00Z"); f.chain.tick({}, now); assert.equal(f.chain.status().jobs.length, 0); f.append("meeting"); f.compact(); f.chain.tick({}, now + 3599999); assert.equal(f.chain.status().jobs.filter(j => j.trigger === "hourly").length, 0); f.chain.tick({}, now + 3600000); f.chain.tick({}, now + 3600001); assert.equal(f.chain.status().jobs.filter(j => j.trigger === "hourly").length, 1); const job = f.chain.claim({}, now + 3600002).job; assert.equal(job.mode, "organize"); assert.equal(job.events[0].id, "meeting"); } finally { f.db.close(); } }); test("event pages and multi-write checkpoints survive retries; final outcome cannot skip unread pages", () => { const f = fixture(); try { f.append("meeting", "会议议题与明确职责".repeat(2000)); f.compact(); let job = f.claim(); assert.ok(job.events[0].has_more); const linked = { event_id: "meeting", outcome: "linked", reason: "归入人物", progress: "", target_ids: ["person:qhy"] }; assert.throws(() => f.chain.commit(f.proposal(job, { objects: [person()], dispositions: [linked] }), 2100), /all event pages/); assert.equal(f.chain.graph.object("person:qhy"), null); const page = job.events[0]; f.chain.commit(f.proposal(job, { objects: [person()], dispositions: [{ ...linked, outcome: "continue", progress: "人物职责已建档,继续读后文" }] }), 2101); assert.equal(f.chain.organizer.detail("meeting").offset, page.end_offset); job = f.chain.claim({}, 2200).job; assert.equal(job.events[0].offset, page.end_offset); assert.match(job.events[0].progress, /职责已建档/); f.chain.fail({ job_id: job.id, token: job.token, kind: "transient", error: "temporary" }, 2201); job = f.chain.claim({}, 20000).job; assert.equal(job.events[0].offset, page.end_offset); let now = 20001; while (job.events[0]?.has_more) { f.chain.commit(f.proposal(job), now++); job = f.chain.claim({}, now++).job; } assert.throws(() => f.chain.commit(f.proposal(job), now++), /Archive-only/); f.chain.inspect({ job_id: job.id, token: job.token, kind: "object", id: "person:qhy" }, now++); f.chain.commit(f.proposal(job, { dispositions: [linked] }), now++); assert.equal(f.chain.organizer.detail("meeting").status, "linked"); assert.equal(f.chain.graph.read({ id: "person:qhy" }).total_sources, 1); } finally { f.db.close(); } }); test("existing dossier can accept an attachment without rewriting its facts", () => { const f = fixture(); try { f.append("old"); f.append("meeting"); f.chain.graph.apply({ objects: [person("person:qhy", "old")] }, 1, new Map([["old", f.timeline.read({ id: "old" })]]), 10); f.compact(); let job = f.claim(); f.chain.commit(f.proposal(job, {dispositions:[{event_id:"old",outcome:"linked",reason:"已有证据关系",progress:"",target_ids:[]}]}), 2002); job = f.chain.claim({}, 2003).job; const original = f.chain.graph.object("person:qhy"); f.chain.inspect({ job_id: job.id, token: job.token, kind: "object", id: "person:qhy" }, 2004); f.chain.commit(f.proposal(job, { dispositions: [{ event_id: "meeting", outcome: "linked", reason: "佐证已有职责", progress: "", target_ids: ["person:qhy"] }] }), 2005); assert.equal(f.chain.graph.object("person:qhy").body, original.body); assert.equal(f.chain.graph.read({ id: "person:qhy" }).total_sources, 2); } finally { f.db.close(); } }); test("terminal failure quarantines only current event, explicit recovery preserves checkpoint", () => { const f = fixture(); try { f.append("meeting"); f.append("next"); f.compact(); let job = f.claim(); const requestId = job.id; for (let attempt = 0; attempt < 5; attempt++) { if (attempt) job = f.chain.claim({}, 2000 + attempt * 400000).job; f.chain.fail({ job_id: job.id, token: job.token, kind: "schema", error: "test schema" }, 2002 + attempt * 400000); } assert.equal(f.chain.organizer.detail("meeting").status, "blocked"); assert.equal(f.chain.organizer.detail("next").status, "pending"); assert.equal(f.chain.status({ id: requestId }).jobs[0].status, "failed"); f.chain.request({ request_id: "unblock", mode: "organize", retry_blocked: true }, 2000000); assert.equal(f.chain.organizer.detail("meeting").status, "pending"); assert.equal(f.chain.claim({}, 2000001).job.events[0].id, "meeting"); } finally { f.db.close(); } }); test("threshold failure notice cannot immediately spawn another five-attempt job", () => { const f = fixture(); try { f.append("big", "有效工作".repeat(3000)); const now = Date.parse("2026-10-12T16:00:00Z"); f.chain.tick({ threshold_tokens: 8000 }, now); for (let i = 0; i < 5; i++) { const job = f.chain.claim({}, now + i * 400000).job; f.chain.fail({ job_id: job.id, token: job.token, kind: "schema", error: "invalid proposal" }, now + i * 400000 + 1); } f.timeline.append({ id: "notice", at: now + 1600002, kind: "memory_organized", text: "整理失败", source: {} }); f.chain.tick({ threshold_tokens: 8000 }, now + 1600003); assert.equal(f.chain.claim({}, now + 1600004).job, null); assert.equal(f.chain.status().jobs.length, 1); } finally { f.db.close(); } }); test("round-robin yields to deep review between compaction batches", () => { const f = fixture(); try { f.append("big", "会议原始经历".repeat(5000)); f.chain.request({ request_id: "compact", mode: "compact" }, 1000); f.chain.request({ request_id: "deep", mode: "deep" }, 1001); const first = f.chain.claim({}, 1010).job; assert.equal(first.mode, "compact"); f.chain.commit({ job_id: first.id, token: first.token, summary: "索引", covered_node_ids: first.nodes.map(n => n.id) }, 1011); assert.equal(f.chain.claim({}, 1012).job.mode, "deep"); } finally { f.db.close(); } }); test("migration backfills detached events once; completed archive-only is never requeued", () => { const f = fixture(); try { f.append("meeting"); f.compact(); f.db.exec("DELETE FROM memory_event_organization; DELETE FROM memory_chain_meta WHERE key='object_pipeline_version'"); const rebuilt = new ChainStore(f.db, f.timeline); assert.equal(rebuilt.organizer.detail("meeting").status, "pending"); const job = f.claim(); f.chain.commit(f.proposal(job), 2002); assert.equal(new ChainStore(f.db, f.timeline).organizer.detail("meeting").status, "archive_only"); assert.equal(f.db.prepare("PRAGMA integrity_check").get().integrity_check, "ok"); } finally { f.db.close(); } }); test("per-object Markdown projection is readable, links evidence, rejects symlinks and reports failure", () => { const f = fixture(), dir = mkdtempSync(join(tmpdir(), "object-projection-")); try { f.append("meeting"); f.compact(); const job = f.claim(); f.chain.commit(f.proposal(job, { objects: [person()], dispositions: [{ event_id: "meeting", outcome: "linked", reason: "人物职责", progress: "", target_ids: [] }] }), 2002); const projection = new ObjectProjection(f.chain, join(dir, "objects")); projection.refresh(); const index = readFileSync(join(dir, "objects", "README.md"), "utf8"); const path = index.match(/\[钱灏宇\]\(([^)]+)\)/)[1]; const body = readFileSync(join(dir, "objects", path), "utf8"); assert.match(body, /负责报销核对/); assert.match(body, /meeting/); assert.equal(f.chain.meta("object_projection_error"), ""); symlinkSync(dir, join(dir, "unsafe")); new ObjectProjection(f.chain, join(dir, "unsafe")).refresh(); assert.ok(f.chain.meta("object_projection_error")); assert.equal(f.chain.graph.object("person:qhy").name, "钱灏宇"); } finally { f.db.close(); rmSync(dir, { recursive: true, force: true }); } }); test("node read honors range; long dossier tail remains searchable; graph relations are typed", () => { const f = fixture(); try { f.append("meeting"); const item = { ...person(), body: "知".repeat(15980) + "罕见工作尾词锆铪" }; f.chain.graph.apply({ objects: [item] }, 1, new Map([["meeting", f.timeline.read({ id: "meeting" })]]), 1); assert.equal(f.chain.graph.find({ query: "锆铪" }).hits[0].id, item.id); f.chain.sync(); const node = f.chain.context().nodes[0]; assert.equal(f.chain.node({ id: node.id, offset: 2, limit: 4 }).text, node.text.slice(2, 6)); assert.throws(() => f.chain.graph.apply({ edges: [{ from_id: item.id, to_id: "project:daily", relation: "一大段叙述", source_refs: ["meeting"] }] }, 2, new Map(), 2), /supported directed/); } finally { f.db.close(); } }); test("object checkpoint survives database reopen, expired token and repeated commit", () => { const dir=mkdtempSync(join(tmpdir(),"object-checkpoint-")), path=join(dir,"memory.sqlite"); let db=new DatabaseSync(path); try { let timeline=new Timeline(db), chain=new ChainStore(db,timeline); timeline.append({id:"meeting",kind:"user",at:1,text:"钱灏宇参与报销。".repeat(50),source:{}}); chain.request({request_id:"compact",mode:"compact",trigger:"threshold"},10); const compact=chain.claim({},11).job; chain.commit({job_id:compact.id,token:compact.token,summary:"报销会议",covered_node_ids:compact.nodes.map(n=>n.id)},12); chain.request({request_id:"organize",mode:"organize"},20); let job=chain.claim({},21).job; const patch={job_id:job.id,token:job.token,summary:"已建人物,待关联项目",covered_node_ids:[],objects:[person()], dispositions:[{event_id:"meeting",outcome:"continue",reason:"还需检查项目",progress:"人物档案已建,下一批检查项目关系",target_ids:[]}],reviewed_object_ids:[]}; const receipt=chain.commit(patch,22); db.close(); db=new DatabaseSync(path); timeline=new Timeline(db); chain=new ChainStore(db,timeline); assert.deepEqual(chain.commit(patch,999999),receipt); job=chain.claim({},30).job; assert.equal(job.events[0].text,""); assert.match(job.events[0].progress,/人物档案已建/); const final={job_id:job.id,token:job.token,summary:"归档完成",covered_node_ids:[],dispositions:[{event_id:"meeting",outcome:"linked",reason:"已有档案,无需新项目",progress:"",target_ids:[]}],reviewed_object_ids:[]}; assert.throws(()=>chain.commit(final,200000),/Expired/); const next=chain.claim({},200001).job; chain.commit({...final,token:next.token},200002); assert.equal(chain.organizer.detail("meeting").status,"linked"); assert.equal(chain.graph.object("person:qhy").body,person().body); } finally {db.close();rmSync(dir,{recursive:true,force:true});} }); test("all-blocked work is partial, and a replayed recovery request cannot re-unblock it", () => { const f=fixture(); try { f.append("meeting"); f.compact(); f.db.exec("UPDATE memory_event_organization SET status='blocked'"); const req=f.chain.request({request_id:"check",mode:"organize"},2000); assert.equal(f.chain.claim({},2001).job,null); assert.equal(f.chain.status({id:req.id}).jobs[0].status,"partial"); const retry={request_id:"retry",mode:"organize",retry_blocked:true}; f.chain.request(retry,3000); assert.equal(f.chain.organizer.detail("meeting").status,"pending"); f.db.exec("UPDATE memory_event_organization SET status='blocked'"); f.chain.request(retry,4000); assert.equal(f.chain.organizer.detail("meeting").status,"blocked"); } finally {f.db.close();} });