274 lines
16 KiB
JavaScript
274 lines
16 KiB
JavaScript
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();}
|
|
});
|