@staix/agent-mem 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -6
- package/package.json +4 -3
- package/src/cli.ts +126 -15
- package/src/commands-table.ts +16 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +194 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +22 -14
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +557 -0
- package/src/memory/catalog.ts +135 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +59 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +244 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +213 -0
- package/src/memory/store.ts +376 -67
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +331 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { validVaultPath } from "./generation.ts";
|
|
2
|
+
import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
|
|
3
|
+
import type { MemoryStore, View, VaultEntry } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
export interface VaultSelection { project: string; policy: MemoryPolicy; limit?: number }
|
|
6
|
+
export type { VaultEntry } from "./store.ts";
|
|
7
|
+
export { vaultLinkId, validVaultPath, validateVaultLinkRow, type VaultLinkRow, type VaultLink } from "./generation.ts";
|
|
8
|
+
const invalid = (): never => { throw new Error("invalid vault link or unavailable exact entry"); };
|
|
9
|
+
|
|
10
|
+
function selection(options: VaultSelection): MemoryPolicy {
|
|
11
|
+
if (!options || typeof options.project !== "string" || !options.project.trim() || options.project.length > 400
|
|
12
|
+
|| !options.policy || typeof options.policy.screen !== "function" || options.limit !== undefined && (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 200)) throw new Error("invalid startup-bound vault selection");
|
|
13
|
+
return createStandalonePolicy({ host: options.policy });
|
|
14
|
+
}
|
|
15
|
+
function durable(view: View): boolean {
|
|
16
|
+
if (view.tier !== "long" || view.local || view.expiresAt !== null || view.supersededBy || !["decision", "finding", "contract", "fail"].includes(view.kind)) return false;
|
|
17
|
+
const text = `${view.title ?? ""}\n${view.text}`.normalize("NFKC");
|
|
18
|
+
// ponytail: deterministic suggestions can omit durable lessons phrased as status; a separately measured classifier
|
|
19
|
+
// may improve recall later, but candidate selection never calls a model or imports capture into curated evidence.
|
|
20
|
+
return !/(?:\b(?:task|ticket)\s*#?\d+\b[^\n]*(?:completed|done|in.progress|blocked|checkpoint)|\b(?:review|ci|checks?|pipeline|build)\s*(?::|-)?\s*(?:is\s+)?(?:approved|passed|green|failed|pending|rejected)|\b(?:pr|pull request)\s*#?\d+\b[^\n]*(?:merged|closed|approved|opened|ready)|\b(?:task progress|review verdict|pr status|ci status|working on|progress checkpoint)\b)/i.test(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Policy runs over every startup-project value before durable selection, ordering, limit or returned counts. */
|
|
24
|
+
export async function vaultCandidates(store: MemoryStore, options: VaultSelection): Promise<View[]> {
|
|
25
|
+
const policy = selection(options), visible: VaultEntry[] = [];
|
|
26
|
+
for (const entry of store.vaultEntries(options.project)) if (await policy.screen(entry.view)) visible.push(entry);
|
|
27
|
+
const linked = new Set(store.vaultLinks(options.project).map(row => `${row.target}:${row.rowHash}`));
|
|
28
|
+
return visible.filter(entry => durable(entry.view) && !linked.has(`${entry.view.id}:${entry.rowHash}`)).map(entry => entry.view)
|
|
29
|
+
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, options.limit ?? 50);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Person-facing hosts authenticate intent before calling; no MCP vault-link tool is provided. */
|
|
33
|
+
export async function recordVaultLink(store: MemoryStore, id: string, path: string, options: VaultSelection): Promise<string> {
|
|
34
|
+
const policy = selection(options);
|
|
35
|
+
if (store.profile.kind !== "generation") throw new Error("vault links require a generation profile; shared-v1 is unchanged");
|
|
36
|
+
if (!/^[a-f0-9]{32}$/.test(id) || !validVaultPath(path)) return invalid();
|
|
37
|
+
const entry = store.vaultEntries(options.project).find(row => row.view.id === id);
|
|
38
|
+
if (!entry || !await policy.screen({ entry: entry.view, path }) || !durable(entry.view)) return invalid();
|
|
39
|
+
return store.recordVaultLink(id, path, options.project, entry.rowHash);
|
|
40
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
7
|
+
import { mutationDatabase } from "./mutation.ts";
|
|
8
|
+
import { canonicalPath } from "./platform.ts";
|
|
9
|
+
import { looksSecret } from "./screen.ts";
|
|
10
|
+
import { ensureShardDescriptor, resolveAlias, shardDir } from "./shard.ts";
|
|
11
|
+
import { machineId } from "./store.ts";
|
|
12
|
+
|
|
13
|
+
const SCHEMA = "agent-mem.work-list/v1", ROW_BYTES = 16_384;
|
|
14
|
+
export type WorkListFields = Record<string, string | number | boolean | null>;
|
|
15
|
+
export type WorkListPolicy = (row: unknown) => boolean | Promise<boolean>;
|
|
16
|
+
export interface WorkListSource { source: string; id: string; machine?: string; at?: number }
|
|
17
|
+
export interface WorkListView { list: string; fields: WorkListFields; items: { task: string; fields: WorkListFields }[]; closed: boolean }
|
|
18
|
+
interface WorkRow { schema: typeof SCHEMA; project: string; machine: string; at: number; nonce: string; list: string; fields: WorkListFields; source?: WorkListSource; id: string }
|
|
19
|
+
interface IndexedRow { row: WorkRow; position: number; order: string }
|
|
20
|
+
interface Head { file: string; bytes: number; hash: string }
|
|
21
|
+
const forbidden = new Set(["__proto__", "constructor", "prototype"]);
|
|
22
|
+
const bounded = (value: unknown, max: number): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
|
|
23
|
+
const invalid = (): never => { throw new Error("invalid work-list input"); };
|
|
24
|
+
function regular(path: string, directory = false): void {
|
|
25
|
+
try {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw new Error("work log path is invalid");
|
|
28
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
29
|
+
}
|
|
30
|
+
function fieldsOf(input: unknown): WorkListFields {
|
|
31
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length > 64 || Object.keys(input).length === 0) return invalid();
|
|
32
|
+
const out: WorkListFields = {};
|
|
33
|
+
for (const [key, value] of Object.entries(input)) {
|
|
34
|
+
if (!bounded(key, 100) || forbidden.has(key) || !(value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.length <= 8000 && !value.includes("\0"))) return invalid();
|
|
35
|
+
out[key] = value;
|
|
36
|
+
}
|
|
37
|
+
if (Object.hasOwn(out, "task") && !bounded(out.task, 300)) return invalid();
|
|
38
|
+
if (typeof out.task === "string" && Object.hasOwn(out, "status") && out.status !== null && !["todo", "doing", "done", "dropped"].includes(String(out.status))) return invalid();
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function sourceOf(input: unknown): WorkListSource | undefined {
|
|
42
|
+
if (input === undefined) return undefined;
|
|
43
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(key => !["source", "id", "machine", "at"].includes(key))) return invalid();
|
|
44
|
+
const source = input as WorkListSource;
|
|
45
|
+
if (!bounded(source.source, 500) || !bounded(source.id, 500) || source.machine !== undefined && !bounded(source.machine, 300) || source.at !== undefined && (!Number.isSafeInteger(source.at) || source.at < 0)) return invalid();
|
|
46
|
+
return { source: source.source, id: source.id, ...(source.machine === undefined ? {} : { machine: source.machine }), ...(source.at === undefined ? {} : { at: source.at }) };
|
|
47
|
+
}
|
|
48
|
+
const receipt = (row: Pick<WorkRow, "source">): string | null => row.source ? hashJSON({ source: row.source.source, id: row.source.id, machine: row.source.machine ?? null }) : null;
|
|
49
|
+
const importPayload = (row: Pick<WorkRow, "list" | "fields" | "source">): string => hashJSON({ list: row.list, fields: row.fields, source: row.source });
|
|
50
|
+
/** SQLite BINARY sorts UTF-8, unlike the existing JS UTF-16 replay order. Hex code units retain that order,
|
|
51
|
+
* and the terminator sorts before another code unit so a string prefix still comes first. */
|
|
52
|
+
function lexicalKey(value: string): string {
|
|
53
|
+
let out = "";
|
|
54
|
+
for (let i = 0; i < value.length; i++) out += value.charCodeAt(i).toString(16).padStart(4, "0");
|
|
55
|
+
return out + "!";
|
|
56
|
+
}
|
|
57
|
+
/** Numeric source components compare by value; leading zeroes only break an otherwise equal full source id. */
|
|
58
|
+
function sourceKey(value: string): string {
|
|
59
|
+
const parts = (value.match(/\d+|\D+/g) ?? []).map(part => {
|
|
60
|
+
if (!/^\d+$/.test(part)) return lexicalKey(part);
|
|
61
|
+
const digits = part.replace(/^0+(?=\d)/, "");
|
|
62
|
+
return `0030${digits.length.toString().padStart(3, "0")}${digits}!`;
|
|
63
|
+
});
|
|
64
|
+
return parts.join("") + " " + lexicalKey(value);
|
|
65
|
+
}
|
|
66
|
+
/** Derived only: timestamp, import/native origin, natural source id, machine, append position, row hash.
|
|
67
|
+
* Fixed-width safe integers and encoded strings let the persisted index supply the legacy replay order. */
|
|
68
|
+
function replayKey(row: WorkRow, position: number): string {
|
|
69
|
+
if (!Number.isSafeInteger(position) || position < 0) throw new Error("work index has an invalid append position");
|
|
70
|
+
const source = row.source;
|
|
71
|
+
return [String(row.at).padStart(16, "0"), source ? "0" : "1", lexicalKey(source ? source.machine ?? "" : row.machine),
|
|
72
|
+
lexicalKey(source?.source ?? ""), source ? sourceKey(source.id) : "", row.machine, String(position).padStart(16, "0"), row.id].join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stream log bytes with bounded carry; neither a long history nor an invalid oversized row is loaded at once. */
|
|
76
|
+
function scan(path: string, consume?: (text: string, end: number) => void, prefixBytes = 0): { bytes: number; hash: string; prefix: string } {
|
|
77
|
+
const stat = lstatSync(path);
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("work log is not a regular file");
|
|
79
|
+
const fd = openSync(path, "r"), buffer = Buffer.alloc(64 * 1024), full = createHash("sha256"), prefix = createHash("sha256"), decoder = new StringDecoder("utf8");
|
|
80
|
+
let bytes = 0, text = "", lineEnd = 0;
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break;
|
|
84
|
+
const chunk = buffer.subarray(0, n); full.update(chunk);
|
|
85
|
+
if (bytes < prefixBytes) prefix.update(chunk.subarray(0, Math.min(n, prefixBytes - bytes)));
|
|
86
|
+
bytes += n;
|
|
87
|
+
if (consume) {
|
|
88
|
+
text += decoder.write(chunk);
|
|
89
|
+
let end: number;
|
|
90
|
+
while ((end = text.indexOf("\n")) >= 0) {
|
|
91
|
+
const line = text.slice(0, end); lineEnd += Buffer.byteLength(line + "\n"); text = text.slice(end + 1);
|
|
92
|
+
if (!line || Buffer.byteLength(line) > ROW_BYTES) throw new Error("work log contains an invalid row");
|
|
93
|
+
consume(line, lineEnd);
|
|
94
|
+
}
|
|
95
|
+
if (Buffer.byteLength(text) > ROW_BYTES) throw new Error("work log contains an oversized row");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (consume && (text + decoder.end()).length) throw new Error("work log has an incomplete row");
|
|
99
|
+
return { bytes, hash: full.digest("hex"), prefix: prefix.digest("hex") };
|
|
100
|
+
} finally { closeSync(fd); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Project-scoped append truth; the index and writer lock are independent of generations and capture eviction. */
|
|
104
|
+
export class WorkListStore {
|
|
105
|
+
readonly dir: string;
|
|
106
|
+
readonly project: string;
|
|
107
|
+
private readonly db: Database;
|
|
108
|
+
private readonly mutation: Database;
|
|
109
|
+
private readonly machine: string;
|
|
110
|
+
private readonly policy: WorkListPolicy;
|
|
111
|
+
private readonly now: () => number;
|
|
112
|
+
private readonly readers = new Set<() => void>();
|
|
113
|
+
private closed = false;
|
|
114
|
+
/** #27: the work logs of keys aliased to this project that already have one; read beside this log through their own snapshot cursors, never written. */
|
|
115
|
+
private readonly aliased: WorkListStore[] = [];
|
|
116
|
+
|
|
117
|
+
constructor(options: { base: string; project: string; policy: WorkListPolicy; now?: () => number; aliases?: readonly string[]; exact?: boolean }) {
|
|
118
|
+
if (!bounded(options.project, 1000) || typeof options.policy !== "function") throw new Error("invalid work-list input");
|
|
119
|
+
const base = canonicalPath(options.base);
|
|
120
|
+
this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
|
|
121
|
+
regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
|
|
122
|
+
ensureShardDescriptor(base, this.project);
|
|
123
|
+
this.dir = join(shardDir(base, this.project), "work");
|
|
124
|
+
regular(this.dir, true); regular(join(this.dir, "log"), true);
|
|
125
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
|
|
126
|
+
mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
|
|
127
|
+
if (lstatSync(this.dir).isSymbolicLink() || lstatSync(join(this.dir, "log")).isSymbolicLink()) throw new Error("work log directory is invalid");
|
|
128
|
+
this.machine = machineId(base).id;
|
|
129
|
+
this.policy = options.policy; this.now = options.now ?? Date.now;
|
|
130
|
+
for (const name of ["mutation-lock.db", "index.db", "index.db-wal", "index.db-shm"]) regular(join(this.dir, name));
|
|
131
|
+
this.mutation = mutationDatabase(this.dir);
|
|
132
|
+
this.db = new Database(join(this.dir, "index.db"), { create: true }); chmodSync(join(this.dir, "index.db"), 0o600);
|
|
133
|
+
this.db.run("PRAGMA busy_timeout = 3000"); this.db.run("PRAGMA journal_mode = WAL");
|
|
134
|
+
// The index is disposable. An older derived schema is replayed without changing a log row.
|
|
135
|
+
const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
|
|
136
|
+
if (columns.length && !columns.some(column => column.name === "position")) { this.db.run("DROP TABLE work_ops"); this.db.run("DROP TABLE IF EXISTS work_heads"); }
|
|
137
|
+
this.db.run("CREATE TABLE IF NOT EXISTS work_ops (id TEXT PRIMARY KEY, at INTEGER NOT NULL, machine TEXT NOT NULL, payload TEXT NOT NULL, receipt TEXT, position INTEGER NOT NULL, replay TEXT NOT NULL DEFAULT '')");
|
|
138
|
+
this.db.run("CREATE INDEX IF NOT EXISTS work_receipts ON work_ops(receipt)");
|
|
139
|
+
this.db.run("CREATE TABLE IF NOT EXISTS work_heads (file TEXT PRIMARY KEY, bytes INTEGER NOT NULL, hash TEXT NOT NULL)");
|
|
140
|
+
this.db.transaction(() => {
|
|
141
|
+
const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
|
|
142
|
+
if (!columns.some(column => column.name === "replay")) this.db.run("ALTER TABLE work_ops ADD COLUMN replay TEXT NOT NULL DEFAULT ''");
|
|
143
|
+
this.db.run("CREATE INDEX IF NOT EXISTS work_replay ON work_ops(replay)");
|
|
144
|
+
}).immediate();
|
|
145
|
+
for (const alias of options.exact === true ? [] : options.aliases ?? []) {
|
|
146
|
+
if (alias === this.project || !existsSync(join(shardDir(base, alias), "work", "log"))) continue;
|
|
147
|
+
this.aliased.push(new WorkListStore({ base, project: alias, policy: options.policy, ...(options.now ? { now: options.now } : {}), exact: true }));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
close(): void {
|
|
152
|
+
if (this.closed) return;
|
|
153
|
+
this.closed = true;
|
|
154
|
+
let failure: unknown;
|
|
155
|
+
for (const close of [...this.readers, () => this.db.close(), () => this.mutation.close(), ...this.aliased.map(store => () => store.close())]) {
|
|
156
|
+
try { close(); } catch (error) { failure ??= error; }
|
|
157
|
+
}
|
|
158
|
+
if (failure !== undefined) throw failure;
|
|
159
|
+
}
|
|
160
|
+
private locked<T>(run: () => T): T {
|
|
161
|
+
if (this.closed) throw new Error("work-list store is closed");
|
|
162
|
+
return this.mutation.transaction(run).immediate();
|
|
163
|
+
}
|
|
164
|
+
private decode(text: string): WorkRow {
|
|
165
|
+
try {
|
|
166
|
+
const row = JSON.parse(text) as WorkRow;
|
|
167
|
+
if (!row || row.schema !== SCHEMA || row.project !== this.project || !/^[a-f0-9]{16}$/.test(row.machine) || !Number.isSafeInteger(row.at) || row.at < 0 || !/^[a-f0-9]{32}$/.test(row.nonce) || !bounded(row.list, 300) || !/^[a-f0-9]{64}$/.test(row.id) || Object.keys(row).some(key => !["schema", "project", "machine", "at", "nonce", "list", "fields", "source", "id"].includes(key))) throw new Error();
|
|
168
|
+
fieldsOf(row.fields); sourceOf(row.source);
|
|
169
|
+
const { id, ...body } = row;
|
|
170
|
+
if (hashJSON(body) !== id || Buffer.byteLength(text) > ROW_BYTES) throw new Error();
|
|
171
|
+
return row;
|
|
172
|
+
} catch { throw new Error("work log contains an invalid hashed row"); }
|
|
173
|
+
}
|
|
174
|
+
private index(row: WorkRow, position: number): void {
|
|
175
|
+
const key = receipt(row);
|
|
176
|
+
if (key) for (const prior of this.db.query("SELECT payload FROM work_ops WHERE receipt = ?").iterate(key) as Iterable<{ payload: string }>) {
|
|
177
|
+
if (importPayload(this.decode(prior.payload)) !== importPayload(row)) throw new Error("work log contains conflicting source receipts");
|
|
178
|
+
}
|
|
179
|
+
this.db.query("INSERT OR IGNORE INTO work_ops (id, at, machine, payload, receipt, position, replay) VALUES (?, ?, ?, ?, ?, ?, ?)").run(row.id, row.at, row.machine, canonicalJSON(row), key, position, replayKey(row, position));
|
|
180
|
+
}
|
|
181
|
+
private refresh(force = false): void {
|
|
182
|
+
const files = readdirSync(join(this.dir, "log")).sort();
|
|
183
|
+
if (files.some(file => !/^[a-f0-9]{16}\.jsonl$/.test(file))) throw new Error("work log has an invalid segment name");
|
|
184
|
+
const heads = new Map((this.db.query("SELECT file, bytes, hash FROM work_heads").all() as Head[]).map(head => [head.file, head]));
|
|
185
|
+
const scans = new Map(files.map(file => [file, scan(join(this.dir, "log", file), undefined, heads.get(file)?.bytes ?? 0)]));
|
|
186
|
+
const reset = force || [...heads].some(([file, head]) => !scans.has(file) || scans.get(file)!.bytes < head.bytes || scans.get(file)!.prefix !== head.hash);
|
|
187
|
+
this.db.transaction(() => {
|
|
188
|
+
if (reset) { this.db.run("DELETE FROM work_ops"); this.db.run("DELETE FROM work_heads"); }
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
const old = reset ? 0 : heads.get(file)?.bytes ?? 0, current = scans.get(file)!;
|
|
191
|
+
if (old !== current.bytes) scan(join(this.dir, "log", file), (line, end) => { if (end > old) this.index(this.decode(line), end); });
|
|
192
|
+
this.db.query("INSERT OR REPLACE INTO work_heads (file, bytes, hash) VALUES (?, ?, ?)").run(file, current.bytes, current.hash);
|
|
193
|
+
}
|
|
194
|
+
// Old readers may rebuild/append this disposable index without the additive replay column. Backfill one
|
|
195
|
+
// row at a time, including on an otherwise unchanged refresh; no payload history is materialized.
|
|
196
|
+
const missing = this.db.query("SELECT id, payload, position FROM work_ops WHERE replay = '' LIMIT 1");
|
|
197
|
+
const update = this.db.query("UPDATE work_ops SET replay = ? WHERE id = ?");
|
|
198
|
+
for (;;) {
|
|
199
|
+
const old = missing.get() as { id: string; payload: string; position: number } | null;
|
|
200
|
+
if (!old) break;
|
|
201
|
+
update.run(replayKey(this.decode(old.payload), old.position), old.id);
|
|
202
|
+
}
|
|
203
|
+
}).immediate();
|
|
204
|
+
}
|
|
205
|
+
rebuild(): void { this.locked(() => this.refresh(true)); }
|
|
206
|
+
private async permitted(row: WorkRow): Promise<boolean> {
|
|
207
|
+
if (looksSecret(canonicalJSON(row))) return false;
|
|
208
|
+
const copy = structuredClone(row); Object.freeze(copy.fields); if (copy.source) Object.freeze(copy.source); Object.freeze(copy);
|
|
209
|
+
try { return await this.policy(copy) === true; } catch { return false; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An ordered, stable read snapshot. The separate read-only connection never holds the mutation lock across
|
|
213
|
+
* a host policy await, so a concurrent append succeeds without changing this read's history frontier. */
|
|
214
|
+
private operations(): IterableIterator<IndexedRow> {
|
|
215
|
+
const state = this.locked(() => {
|
|
216
|
+
this.refresh();
|
|
217
|
+
regular(join(this.dir, "index.db"));
|
|
218
|
+
const db = new Database(join(this.dir, "index.db"), { readonly: true });
|
|
219
|
+
try {
|
|
220
|
+
db.run("PRAGMA busy_timeout = 3000"); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
|
|
221
|
+
db.run("BEGIN");
|
|
222
|
+
const rows = db.query("SELECT payload, position, replay FROM work_ops ORDER BY replay").iterate() as IterableIterator<{ payload: string; position: number; replay: string }>;
|
|
223
|
+
return { db, rows, first: rows.next() }; // Establish the snapshot before releasing the writer lock.
|
|
224
|
+
} catch (error) { db.close(); throw error; }
|
|
225
|
+
});
|
|
226
|
+
let first = true, closed = false;
|
|
227
|
+
const close = () => {
|
|
228
|
+
if (closed) return;
|
|
229
|
+
closed = true;
|
|
230
|
+
this.readers.delete(close);
|
|
231
|
+
try { state.rows.return?.(); } finally { try { state.db.run("ROLLBACK"); } finally { state.db.close(); } }
|
|
232
|
+
};
|
|
233
|
+
this.readers.add(close);
|
|
234
|
+
return {
|
|
235
|
+
next: () => {
|
|
236
|
+
if (closed) return { done: true, value: undefined };
|
|
237
|
+
try {
|
|
238
|
+
const next = first ? state.first : state.rows.next(); first = false;
|
|
239
|
+
if (next.done) { close(); return { done: true, value: undefined }; }
|
|
240
|
+
return { done: false, value: { row: this.decode(next.value.payload), position: next.value.position, order: next.value.replay } };
|
|
241
|
+
} catch (error) { close(); throw error; }
|
|
242
|
+
},
|
|
243
|
+
return: () => { close(); return { done: true, value: undefined }; },
|
|
244
|
+
[Symbol.iterator]() { return this; },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** #27: this log and the aliased keys' logs as one stream in replay order. Each store streams its own snapshot cursor ordered by the derived replay key, so a k-way merge needs no sort and no ceiling. The merge ends the cursors it opened. */
|
|
249
|
+
private merged(): IterableIterator<IndexedRow> {
|
|
250
|
+
if (!this.aliased.length) return this.operations();
|
|
251
|
+
const cursors: IterableIterator<IndexedRow>[] = [];
|
|
252
|
+
try { for (const store of [this, ...this.aliased]) cursors.push(store.operations()); }
|
|
253
|
+
catch (error) { for (const cursor of cursors) cursor.return?.(); throw error; }
|
|
254
|
+
const heads = cursors.map(cursor => cursor.next());
|
|
255
|
+
const end = () => { for (const cursor of cursors) { try { cursor.return?.(); } catch { /* the first failure is the one reported */ } } };
|
|
256
|
+
return {
|
|
257
|
+
next: () => {
|
|
258
|
+
try {
|
|
259
|
+
let pick = -1;
|
|
260
|
+
for (let i = 0; i < heads.length; i++) {
|
|
261
|
+
const head = heads[i]!;
|
|
262
|
+
if (!head.done && (pick < 0 || head.value.order < (heads[pick]! as IteratorYieldResult<IndexedRow>).value.order)) pick = i;
|
|
263
|
+
}
|
|
264
|
+
if (pick < 0) { end(); return { done: true, value: undefined }; }
|
|
265
|
+
const out = heads[pick]! as IteratorYieldResult<IndexedRow>;
|
|
266
|
+
heads[pick] = cursors[pick]!.next();
|
|
267
|
+
return { done: false, value: out.value };
|
|
268
|
+
} catch (error) { end(); throw error; }
|
|
269
|
+
},
|
|
270
|
+
return: () => { end(); return { done: true, value: undefined }; },
|
|
271
|
+
[Symbol.iterator]() { return this; },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async write(list: string, input: WorkListFields, source?: WorkListSource): Promise<{ id: string; already: boolean }> {
|
|
276
|
+
if (!bounded(list, 300)) return invalid();
|
|
277
|
+
const fields = fieldsOf(input), origin = sourceOf(source);
|
|
278
|
+
const at = origin?.at ?? this.locked(() => {
|
|
279
|
+
this.refresh();
|
|
280
|
+
const previous = this.db.query("SELECT max(at) AS at FROM work_ops WHERE machine = ?").get(this.machine) as { at: number | null };
|
|
281
|
+
return Math.max(this.now(), (previous.at ?? -1) + 1);
|
|
282
|
+
});
|
|
283
|
+
if (!Number.isSafeInteger(at) || at < 0) return invalid();
|
|
284
|
+
const body: Omit<WorkRow, "id"> = { schema: SCHEMA, project: this.project, machine: this.machine, at, nonce: randomBytes(16).toString("hex"), list, fields, ...(origin ? { source: origin } : {}) };
|
|
285
|
+
const row: WorkRow = { ...body, id: hashJSON(body) };
|
|
286
|
+
if (Buffer.byteLength(canonicalJSON(row)) > ROW_BYTES) return invalid();
|
|
287
|
+
if (!await this.permitted(row)) throw new Error("work-list privacy policy refused the write");
|
|
288
|
+
return this.locked(() => {
|
|
289
|
+
this.refresh();
|
|
290
|
+
const key = receipt(row);
|
|
291
|
+
if (key) {
|
|
292
|
+
const found = this.db.query("SELECT payload FROM work_ops WHERE receipt = ? ORDER BY id LIMIT 1").get(key) as { payload: string } | null;
|
|
293
|
+
if (found) {
|
|
294
|
+
const prior = this.decode(found.payload);
|
|
295
|
+
if (importPayload(prior) !== importPayload(row)) throw new Error("work-list source receipt conflicts with an earlier import");
|
|
296
|
+
return { id: prior.id, already: true };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// No metadata changes after screening: the derived index records same-machine append order.
|
|
300
|
+
regular(join(this.dir, "log", `${this.machine}.jsonl`));
|
|
301
|
+
appendFileSync(join(this.dir, "log", `${this.machine}.jsonl`), canonicalJSON(row) + "\n", { mode: 0o600 });
|
|
302
|
+
this.refresh();
|
|
303
|
+
return { id: row.id, already: false };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async read(list?: string, includeClosed = false): Promise<WorkListView[]> {
|
|
308
|
+
if (list !== undefined && !bounded(list, 300) || typeof includeClosed !== "boolean") return invalid();
|
|
309
|
+
const rows = this.merged();
|
|
310
|
+
const views = new Map<string, { fields: WorkListFields; items: Map<string, WorkListFields> }>();
|
|
311
|
+
// ponytail: memory scales with the current distinct lists/items/fields, not historical operations. A caller
|
|
312
|
+
// requesting an enormous current result still needs that result's memory; paged views are the upgrade path.
|
|
313
|
+
try {
|
|
314
|
+
for (const { row } of rows) {
|
|
315
|
+
const allowed = await this.permitted(row);
|
|
316
|
+
if (this.closed) throw new Error("work-list store is closed");
|
|
317
|
+
if (!allowed) continue;
|
|
318
|
+
if (list !== undefined && row.list !== list) continue;
|
|
319
|
+
let view = views.get(row.list); if (!view) { view = { fields: {}, items: new Map() }; views.set(row.list, view); }
|
|
320
|
+
const task = row.fields.task;
|
|
321
|
+
const target = typeof task === "string" ? view.items.get(task) ?? {} : view.fields;
|
|
322
|
+
if (typeof task === "string") view.items.set(task, target);
|
|
323
|
+
for (const [key, value] of Object.entries(row.fields)) if (key !== "task") { if (value === null) delete target[key]; else target[key] = value; }
|
|
324
|
+
}
|
|
325
|
+
} finally { rows.return?.(); }
|
|
326
|
+
return [...views].sort(([a], [b]) => a.localeCompare(b)).flatMap(([name, view]) => {
|
|
327
|
+
const closed = view.fields.status === "done";
|
|
328
|
+
return closed && !includeClosed ? [] : [{ list: name, fields: view.fields, items: [...view.items].sort(([a], [b]) => a.localeCompare(b)).map(([task, fields]) => ({ task, fields })), closed }];
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|