plasalid 0.7.0 → 0.7.2
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 +3 -4
- package/dist/ai/agent.d.ts +6 -7
- package/dist/ai/agent.js +27 -11
- package/dist/ai/personas.js +48 -46
- package/dist/ai/system-prompt.js +1 -1
- package/dist/ai/tools/account-mutex.d.ts +1 -0
- package/dist/ai/tools/account-mutex.js +16 -0
- package/dist/ai/tools/index.js +4 -12
- package/dist/ai/tools/ingest.d.ts +1 -1
- package/dist/ai/tools/ingest.js +282 -242
- package/dist/ai/tools/merchants.js +1 -28
- package/dist/ai/tools/read.js +8 -8
- package/dist/ai/tools/record.js +3 -36
- package/dist/ai/tools/resolve.js +25 -22
- package/dist/ai/tools/scan.js +0 -1
- package/dist/ai/tools/types.d.ts +14 -21
- package/dist/cli/commands/record.js +1 -82
- package/dist/cli/commands/resolve.d.ts +5 -2
- package/dist/cli/commands/resolve.js +36 -5
- package/dist/cli/commands/revert.js +4 -2
- package/dist/cli/commands/rules.js +2 -2
- package/dist/cli/commands/scan.js +199 -128
- package/dist/cli/commands/status.js +5 -5
- package/dist/cli/index.js +8 -29
- package/dist/cli/ink/ScanDashboard.d.ts +49 -0
- package/dist/cli/ink/ScanDashboard.js +214 -0
- package/dist/cli/ink/scan_dashboard.d.ts +40 -25
- package/dist/cli/ink/scan_dashboard.js +139 -44
- package/dist/db/queries/account-balance.d.ts +1 -1
- package/dist/db/queries/questions.d.ts +62 -0
- package/dist/db/queries/questions.js +110 -0
- package/dist/db/queries/transactions.d.ts +1 -1
- package/dist/db/queries/unknowns.d.ts +17 -15
- package/dist/db/queries/unknowns.js +35 -39
- package/dist/db/schema.js +6 -28
- package/dist/scanner/audit/auditor.d.ts +31 -0
- package/dist/scanner/audit/auditor.js +72 -0
- package/dist/scanner/audit/engine.d.ts +10 -0
- package/dist/scanner/audit/engine.js +98 -0
- package/dist/scanner/audit/eventBus.d.ts +60 -0
- package/dist/scanner/audit/eventBus.js +35 -0
- package/dist/scanner/audit/passes/index.d.ts +11 -0
- package/dist/scanner/audit/passes/index.js +9 -0
- package/dist/scanner/audit/passes/types.d.ts +23 -0
- package/dist/scanner/audit/passes/types.js +1 -0
- package/dist/scanner/audit/types.d.ts +27 -0
- package/dist/scanner/audit/types.js +1 -0
- package/dist/scanner/auditor.d.ts +51 -0
- package/dist/scanner/auditor.js +80 -0
- package/dist/scanner/buffer/engine.d.ts +9 -0
- package/dist/scanner/buffer/engine.js +110 -0
- package/dist/scanner/buffer/sharedBuffer.d.ts +78 -0
- package/dist/scanner/buffer/sharedBuffer.js +130 -0
- package/dist/scanner/buffer/types.d.ts +67 -0
- package/dist/scanner/buffer/types.js +1 -0
- package/dist/scanner/buffer.d.ts +45 -38
- package/dist/scanner/buffer.js +93 -61
- package/dist/scanner/bus/engine.d.ts +11 -0
- package/dist/scanner/bus/engine.js +42 -0
- package/dist/scanner/bus/types.d.ts +53 -0
- package/dist/scanner/bus/types.js +1 -0
- package/dist/scanner/bus.d.ts +38 -0
- package/dist/scanner/bus.js +37 -0
- package/dist/scanner/chunk-worker.d.ts +19 -0
- package/dist/scanner/chunk-worker.js +67 -0
- package/dist/scanner/chunkWorker.d.ts +20 -0
- package/dist/scanner/chunkWorker.js +59 -0
- package/dist/scanner/chunker/chunker.d.ts +7 -0
- package/dist/scanner/chunker/chunker.js +60 -0
- package/dist/scanner/chunker.d.ts +7 -0
- package/dist/scanner/chunker.js +60 -0
- package/dist/scanner/converge.d.ts +29 -0
- package/dist/scanner/converge.js +15 -0
- package/dist/scanner/decrypt.d.ts +10 -0
- package/dist/scanner/decrypt.js +80 -0
- package/dist/scanner/engine/scanEngine.d.ts +24 -0
- package/dist/scanner/engine/scanEngine.js +87 -0
- package/dist/scanner/engine/types.d.ts +90 -0
- package/dist/scanner/engine/types.js +1 -0
- package/dist/scanner/engine.d.ts +90 -0
- package/dist/scanner/engine.js +84 -0
- package/dist/scanner/file-worker.d.ts +33 -0
- package/dist/scanner/file-worker.js +28 -0
- package/dist/scanner/fileWorker.d.ts +33 -0
- package/dist/scanner/fileWorker.js +22 -0
- package/dist/scanner/hooks/types.d.ts +25 -0
- package/dist/scanner/hooks/types.js +1 -0
- package/dist/scanner/hooks.d.ts +23 -0
- package/dist/scanner/hooks.js +1 -0
- package/dist/scanner/parse.d.ts +10 -0
- package/dist/scanner/parse.js +47 -0
- package/dist/scanner/passes/index.d.ts +8 -0
- package/dist/scanner/passes/index.js +6 -0
- package/dist/scanner/passes/types.d.ts +22 -0
- package/dist/scanner/passes/types.js +1 -0
- package/dist/scanner/pdf/chunker.d.ts +7 -0
- package/dist/scanner/pdf/chunker.js +60 -0
- package/dist/scanner/pdf/password-store.d.ts +34 -0
- package/dist/scanner/pdf/password-store.js +83 -0
- package/dist/scanner/pdf/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf/pdf-unlock.js +50 -0
- package/dist/scanner/pdf/pdf.d.ts +17 -0
- package/dist/scanner/pdf/pdf.js +36 -0
- package/dist/scanner/pdf/state-machine.d.ts +60 -0
- package/dist/scanner/pdf/state-machine.js +64 -0
- package/dist/scanner/pdf/unlock.d.ts +22 -0
- package/dist/scanner/pdf/unlock.js +121 -0
- package/dist/scanner/phase-decrypt.d.ts +10 -0
- package/dist/scanner/phase-decrypt.js +80 -0
- package/dist/scanner/phase-parse.d.ts +10 -0
- package/dist/scanner/phase-parse.js +46 -0
- package/dist/scanner/phases/chunk.d.ts +8 -0
- package/dist/scanner/phases/chunk.js +13 -0
- package/dist/scanner/phases/commit.d.ts +12 -0
- package/dist/scanner/phases/commit.js +140 -0
- package/dist/scanner/phases/decrypt.d.ts +10 -0
- package/dist/scanner/phases/decrypt.js +80 -0
- package/dist/scanner/phases/parse.d.ts +10 -0
- package/dist/scanner/phases/parse.js +46 -0
- package/dist/scanner/phases/resolve.d.ts +10 -0
- package/dist/scanner/phases/resolve.js +17 -0
- package/dist/scanner/phases/review.d.ts +10 -0
- package/dist/scanner/phases/review.js +12 -0
- package/dist/scanner/progress.d.ts +14 -0
- package/dist/scanner/progress.js +21 -0
- package/dist/scanner/resolver-memory.d.ts +8 -0
- package/dist/scanner/resolver-memory.js +24 -0
- package/dist/scanner/resolver.d.ts +39 -0
- package/dist/scanner/resolver.js +196 -0
- package/dist/scanner/result.d.ts +17 -0
- package/dist/scanner/result.js +19 -0
- package/dist/scanner/run-passes.d.ts +30 -0
- package/dist/scanner/run-passes.js +15 -0
- package/dist/scanner/unlock.js +1 -1
- package/dist/scanner/worker.d.ts +19 -0
- package/dist/scanner/worker.js +67 -0
- package/dist/scanner/workers/chunkWorker.d.ts +20 -0
- package/dist/scanner/workers/chunkWorker.js +65 -0
- package/dist/scanner/workers/fileWorker.d.ts +32 -0
- package/dist/scanner/workers/fileWorker.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export class EventBus {
|
|
2
|
+
listeners = new Set();
|
|
3
|
+
buffered = [];
|
|
4
|
+
subscribe(fn) {
|
|
5
|
+
this.listeners.add(fn);
|
|
6
|
+
return () => { this.listeners.delete(fn); };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Publish an event to every listener. Listener errors are caught and logged —
|
|
10
|
+
* a misbehaving pass never silences the bus for the rest.
|
|
11
|
+
*/
|
|
12
|
+
publish(event) {
|
|
13
|
+
this.buffered.push(event);
|
|
14
|
+
for (const fn of this.listeners) {
|
|
15
|
+
try {
|
|
16
|
+
const r = fn(event);
|
|
17
|
+
if (r && typeof r.catch === "function") {
|
|
18
|
+
r.catch(err => console.error(`[EventBus listener] ${err.message}`));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
console.error(`[EventBus listener] ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** All events seen so far, in publish order. Useful for the review TUI summary. */
|
|
27
|
+
history() {
|
|
28
|
+
return this.buffered;
|
|
29
|
+
}
|
|
30
|
+
/** Test helper. */
|
|
31
|
+
reset() {
|
|
32
|
+
this.listeners.clear();
|
|
33
|
+
this.buffered = [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AuditPass } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Registry of all audit passes the Auditor runs against the SharedBuffer in
|
|
4
|
+
* flight. Order matters — earlier passes get first crack at each event. Add new
|
|
5
|
+
* passes here.
|
|
6
|
+
*
|
|
7
|
+
* Currently empty; passes are added in follow-up tasks (memoryRule, merchantDefault,
|
|
8
|
+
* dedupe, correlation, recurrenceLink, boundaryContinuation).
|
|
9
|
+
*/
|
|
10
|
+
export declare const AUDIT_PASSES: readonly AuditPass[];
|
|
11
|
+
export type { AuditPass };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of all audit passes the Auditor runs against the SharedBuffer in
|
|
3
|
+
* flight. Order matters — earlier passes get first crack at each event. Add new
|
|
4
|
+
* passes here.
|
|
5
|
+
*
|
|
6
|
+
* Currently empty; passes are added in follow-up tasks (memoryRule, merchantDefault,
|
|
7
|
+
* dedupe, correlation, recurrenceLink, boundaryContinuation).
|
|
8
|
+
*/
|
|
9
|
+
export const AUDIT_PASSES = [];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { BufferEvent } from "../../bus/types.js";
|
|
3
|
+
import type { ScanBuffer } from "../../buffer/types.js";
|
|
4
|
+
export interface AuditContext {
|
|
5
|
+
readonly db: Database.Database;
|
|
6
|
+
readonly buffer: ScanBuffer;
|
|
7
|
+
/** Optional tally bucket — passes increment their own count for the review summary. */
|
|
8
|
+
readonly tally: Record<string, number>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One audit pass — a deterministic, rule-based reaction to a BufferEvent.
|
|
12
|
+
* Passes mutate the Buffer (apply memory rules, dedup, link to existing
|
|
13
|
+
* recurrences, etc.) so the data lands clean before the review TUI sees it.
|
|
14
|
+
*
|
|
15
|
+
* Passes are NOT LLM agents — they're cheap deterministic code that runs on
|
|
16
|
+
* every relevant event. The LLM only runs in chunk workers (parsing one page)
|
|
17
|
+
* and in the review TUI's ask-user drill-down for residual unknowns.
|
|
18
|
+
*/
|
|
19
|
+
export interface AuditPass {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
handles(event: BufferEvent): boolean;
|
|
22
|
+
apply(event: BufferEvent, ctx: AuditContext): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { Bus } from "../bus/types.js";
|
|
3
|
+
import type { ScanBuffer } from "../buffer/types.js";
|
|
4
|
+
import type { AuditPass } from "./passes/types.js";
|
|
5
|
+
export interface AuditEngineDeps {
|
|
6
|
+
readonly db: Database.Database;
|
|
7
|
+
readonly bus: Bus;
|
|
8
|
+
readonly buffer: ScanBuffer;
|
|
9
|
+
readonly passes: readonly AuditPass[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The audit engine's public surface. The scanner engine creates one of these
|
|
13
|
+
* at the composition root, calls start() before the parse phase fans out the
|
|
14
|
+
* chunk workers, and stop() in the finally — guaranteeing every in-flight
|
|
15
|
+
* mutation gets a chance to react before commit reads the buffer snapshot.
|
|
16
|
+
*/
|
|
17
|
+
export interface AuditEngine {
|
|
18
|
+
/** Begin subscribing to the bus. */
|
|
19
|
+
start(): void;
|
|
20
|
+
/** Unsubscribe and ignore any further events. */
|
|
21
|
+
stop(): void;
|
|
22
|
+
/** Resolve once the queue empties and no pass is in flight. */
|
|
23
|
+
drain(): Promise<void>;
|
|
24
|
+
/** Per-pass mutation counts the review TUI surfaces. */
|
|
25
|
+
readonly tally: Readonly<Record<string, number>>;
|
|
26
|
+
}
|
|
27
|
+
export type { AuditPass } from "./passes/types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { Bus, BufferEvent, EventKind } from "./bus.js";
|
|
3
|
+
import type { ScanBuffer } from "./buffer.js";
|
|
4
|
+
export interface AuditContext {
|
|
5
|
+
readonly db: Database.Database;
|
|
6
|
+
readonly buffer: ScanBuffer;
|
|
7
|
+
/** Optional tally bucket — passes increment their own count for the review summary. */
|
|
8
|
+
readonly tally: Record<string, number>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic rule-based reaction to a BufferEvent. Passes mutate the
|
|
12
|
+
* ScanBuffer (apply memory rules, dedup, link to recurrences, etc.) so data
|
|
13
|
+
* lands clean before the review TUI sees it.
|
|
14
|
+
*
|
|
15
|
+
* Declare the event kinds you care about up front via `kinds` — the audit
|
|
16
|
+
* engine indexes once at startup and dispatches only the matching passes per
|
|
17
|
+
* event. Append to AUDIT_PASSES to extend; do not edit the engine.
|
|
18
|
+
*/
|
|
19
|
+
export interface AuditPass {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly kinds: readonly EventKind[];
|
|
22
|
+
apply(event: BufferEvent, ctx: AuditContext): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare const AUDIT_PASSES: readonly AuditPass[];
|
|
25
|
+
export interface AuditEngineDeps {
|
|
26
|
+
readonly db: Database.Database;
|
|
27
|
+
readonly bus: Bus;
|
|
28
|
+
readonly buffer: ScanBuffer;
|
|
29
|
+
readonly passes: readonly AuditPass[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reactive event consumer. The scanner engine creates one at the composition
|
|
33
|
+
* root, starts it before any phase runs, and stops it in the `finally` —
|
|
34
|
+
* guaranteeing every in-flight mutation is processed before commit reads
|
|
35
|
+
* the buffer snapshot.
|
|
36
|
+
*/
|
|
37
|
+
export interface AuditEngine {
|
|
38
|
+
start(): void;
|
|
39
|
+
stop(): void;
|
|
40
|
+
/** Resolves once the queue empties and no pass is in flight. */
|
|
41
|
+
drain(): Promise<void>;
|
|
42
|
+
/** Per-pass mutation counts the review TUI surfaces. */
|
|
43
|
+
readonly tally: Readonly<Record<string, number>>;
|
|
44
|
+
/** Live queue + processed counters + loaded-pass count for the dashboard pulse. */
|
|
45
|
+
readonly stats: {
|
|
46
|
+
readonly pending: number;
|
|
47
|
+
readonly processed: number;
|
|
48
|
+
readonly passCount: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export declare function createAuditEngine(deps: AuditEngineDeps): AuditEngine;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const AUDIT_PASSES = [];
|
|
2
|
+
function buildPassIndex(passes) {
|
|
3
|
+
const index = new Map();
|
|
4
|
+
for (const pass of passes) {
|
|
5
|
+
for (const kind of pass.kinds) {
|
|
6
|
+
const list = index.get(kind) ?? [];
|
|
7
|
+
list.push(pass);
|
|
8
|
+
index.set(kind, list);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return index;
|
|
12
|
+
}
|
|
13
|
+
async function dispatch(event, handlers, ctx) {
|
|
14
|
+
for (const pass of handlers) {
|
|
15
|
+
try {
|
|
16
|
+
await pass.apply(event, ctx);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.error(`[audit pass ${pass.name}] ${err instanceof Error ? err.message : String(err)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createAuditEngine(deps) {
|
|
24
|
+
const tally = {};
|
|
25
|
+
const ctx = { db: deps.db, buffer: deps.buffer, tally };
|
|
26
|
+
const passIndex = buildPassIndex(deps.passes);
|
|
27
|
+
const queue = [];
|
|
28
|
+
let unsubscribe = null;
|
|
29
|
+
let running = false;
|
|
30
|
+
let processed = 0;
|
|
31
|
+
let idleResolvers = [];
|
|
32
|
+
const tick = async () => {
|
|
33
|
+
if (running)
|
|
34
|
+
return;
|
|
35
|
+
running = true;
|
|
36
|
+
try {
|
|
37
|
+
while (queue.length > 0) {
|
|
38
|
+
const event = queue.shift();
|
|
39
|
+
const handlers = passIndex.get(event.kind);
|
|
40
|
+
if (handlers)
|
|
41
|
+
await dispatch(event, handlers, ctx);
|
|
42
|
+
processed++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
running = false;
|
|
47
|
+
const resolvers = idleResolvers;
|
|
48
|
+
idleResolvers = [];
|
|
49
|
+
for (const r of resolvers)
|
|
50
|
+
r();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
start() {
|
|
55
|
+
if (unsubscribe)
|
|
56
|
+
return;
|
|
57
|
+
unsubscribe = deps.bus.subscribe((event) => {
|
|
58
|
+
queue.push(event);
|
|
59
|
+
void tick();
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
stop() {
|
|
63
|
+
unsubscribe?.();
|
|
64
|
+
unsubscribe = null;
|
|
65
|
+
},
|
|
66
|
+
drain() {
|
|
67
|
+
if (!running && queue.length === 0)
|
|
68
|
+
return Promise.resolve();
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
idleResolvers.push(resolve);
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
get tally() {
|
|
74
|
+
return tally;
|
|
75
|
+
},
|
|
76
|
+
get stats() {
|
|
77
|
+
return { pending: queue.length, processed, passCount: deps.passes.length };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Bus } from "../bus/types.js";
|
|
2
|
+
import type { ScanBuffer } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The shared buffer factory. One instance per scan run — the scanner engine
|
|
5
|
+
* creates it once at the composition root and threads it to every chunk
|
|
6
|
+
* worker, audit pass, and commit phase. Mutations publish typed events on
|
|
7
|
+
* the bus so the auditor can react in flight.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createBuffer(scanId: string, bus: Bus): ScanBuffer;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Serial async mutex. Mutations chain so two concurrent callers don't see
|
|
4
|
+
* each other's half-written state. Reads bypass the mutex — they only ever
|
|
5
|
+
* see committed values (Map .set is atomic in single-threaded JS).
|
|
6
|
+
*/
|
|
7
|
+
function createMutex() {
|
|
8
|
+
let chain = Promise.resolve();
|
|
9
|
+
return {
|
|
10
|
+
runExclusive(fn) {
|
|
11
|
+
const next = chain.then(() => fn());
|
|
12
|
+
chain = next.catch(() => undefined);
|
|
13
|
+
return next;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function emptyState(scanId) {
|
|
18
|
+
return {
|
|
19
|
+
scanId,
|
|
20
|
+
transactions: new Map(),
|
|
21
|
+
unknowns: new Map(),
|
|
22
|
+
accountsCreated: new Set(),
|
|
23
|
+
merchantsCreated: new Set(),
|
|
24
|
+
fileIds: new Set(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function snapshotOf(state) {
|
|
28
|
+
return {
|
|
29
|
+
transactions: Array.from(state.transactions.values()),
|
|
30
|
+
unknowns: Array.from(state.unknowns.values()),
|
|
31
|
+
accountsCreated: Array.from(state.accountsCreated),
|
|
32
|
+
merchantsCreated: Array.from(state.merchantsCreated),
|
|
33
|
+
fileIds: Array.from(state.fileIds),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function sizeOf(state) {
|
|
37
|
+
let open = 0;
|
|
38
|
+
for (const u of state.unknowns.values())
|
|
39
|
+
if (u.answer === null)
|
|
40
|
+
open++;
|
|
41
|
+
return { transactions: state.transactions.size, unknowns: state.unknowns.size, openUnknowns: open };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The shared buffer factory. One instance per scan run — the scanner engine
|
|
45
|
+
* creates it once at the composition root and threads it to every chunk
|
|
46
|
+
* worker, audit pass, and commit phase. Mutations publish typed events on
|
|
47
|
+
* the bus so the auditor can react in flight.
|
|
48
|
+
*/
|
|
49
|
+
export function createBuffer(scanId, bus) {
|
|
50
|
+
const state = emptyState(scanId);
|
|
51
|
+
const mutex = createMutex();
|
|
52
|
+
const appendTransaction = (input, chunkId) => mutex.runExclusive(() => {
|
|
53
|
+
const id = `tx:${randomUUID()}`;
|
|
54
|
+
const tx = { transaction_id: id, chunkId, input };
|
|
55
|
+
state.transactions.set(id, tx);
|
|
56
|
+
bus.publish({ kind: "transaction_appended", transaction: tx, chunkId });
|
|
57
|
+
return id;
|
|
58
|
+
});
|
|
59
|
+
const updateTransaction = (id, mut) => mutex.runExclusive(() => {
|
|
60
|
+
const before = state.transactions.get(id);
|
|
61
|
+
if (!before)
|
|
62
|
+
return false;
|
|
63
|
+
const after = mut(before);
|
|
64
|
+
state.transactions.set(id, after);
|
|
65
|
+
bus.publish({ kind: "transaction_updated", transactionId: id, before, after });
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
const removeTransaction = (id, reason) => mutex.runExclusive(() => {
|
|
69
|
+
if (!state.transactions.delete(id))
|
|
70
|
+
return false;
|
|
71
|
+
bus.publish({ kind: "transaction_removed", transactionId: id, reason });
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
const appendUnknown = (input) => mutex.runExclusive(() => {
|
|
75
|
+
const id = `bu:${randomUUID()}`;
|
|
76
|
+
const u = { ...input, unknown_id: id, answer: null };
|
|
77
|
+
state.unknowns.set(id, u);
|
|
78
|
+
bus.publish({ kind: "unknown_appended", unknown: u, chunkId: u.chunkId });
|
|
79
|
+
return id;
|
|
80
|
+
});
|
|
81
|
+
const closeUnknown = (id, answer) => mutex.runExclusive(() => {
|
|
82
|
+
const u = state.unknowns.get(id);
|
|
83
|
+
if (!u || u.answer !== null)
|
|
84
|
+
return false;
|
|
85
|
+
state.unknowns.set(id, { ...u, answer });
|
|
86
|
+
bus.publish({ kind: "unknown_closed", unknownId: id, answer });
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
function* filterTransactions(predicate) {
|
|
90
|
+
for (const tx of state.transactions.values())
|
|
91
|
+
if (predicate(tx))
|
|
92
|
+
yield tx;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
scanId,
|
|
96
|
+
appendTransaction,
|
|
97
|
+
updateTransaction,
|
|
98
|
+
removeTransaction,
|
|
99
|
+
appendUnknown,
|
|
100
|
+
closeUnknown,
|
|
101
|
+
recordFile: (fileId) => { state.fileIds.add(fileId); },
|
|
102
|
+
recordAccountCreated: (accountId) => { state.accountsCreated.add(accountId); },
|
|
103
|
+
recordMerchantCreated: (merchantId) => { state.merchantsCreated.add(merchantId); },
|
|
104
|
+
snapshot: () => snapshotOf(state),
|
|
105
|
+
size: () => sizeOf(state),
|
|
106
|
+
getTransaction: (id) => state.transactions.get(id),
|
|
107
|
+
filterTransactions,
|
|
108
|
+
openUnknowns: () => Array.from(state.unknowns.values()).filter(u => u.answer === null),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TransactionInput } from "../../db/queries/transactions.js";
|
|
2
|
+
import type { EventBus } from "../audit/eventBus.js";
|
|
3
|
+
/**
|
|
4
|
+
* One in-flight transaction, owned by the SharedBuffer. The id is synthesized
|
|
5
|
+
* on append so the agent can reference it later (e.g. attach a note_unknown).
|
|
6
|
+
* `chunkId` records which chunk worker created it — audit passes use this to
|
|
7
|
+
* detect cross-chunk dedup candidates and boundary continuations.
|
|
8
|
+
*/
|
|
9
|
+
export interface BufferedTransaction {
|
|
10
|
+
readonly transaction_id: string;
|
|
11
|
+
readonly chunkId: string;
|
|
12
|
+
input: TransactionInput;
|
|
13
|
+
}
|
|
14
|
+
export interface BufferedUnknown {
|
|
15
|
+
readonly unknown_id: string;
|
|
16
|
+
readonly chunkId: string | null;
|
|
17
|
+
transaction_id: string | null;
|
|
18
|
+
account_id: string | null;
|
|
19
|
+
kind: string | null;
|
|
20
|
+
prompt: string;
|
|
21
|
+
options?: string[];
|
|
22
|
+
/** Null until the auditor or review TUI closes it. */
|
|
23
|
+
answer: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface BufferSnapshot {
|
|
26
|
+
readonly transactions: readonly BufferedTransaction[];
|
|
27
|
+
readonly unknowns: readonly BufferedUnknown[];
|
|
28
|
+
readonly accountsCreated: readonly string[];
|
|
29
|
+
readonly merchantsCreated: readonly string[];
|
|
30
|
+
readonly fileIds: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The single in-memory ledger every chunk worker writes to during a scan run.
|
|
34
|
+
* Mutex-guarded so concurrent workers and audit passes can mutate safely; reads
|
|
35
|
+
* are lock-free. Publishes a typed event on every mutation so the Auditor can
|
|
36
|
+
* react in flight.
|
|
37
|
+
*
|
|
38
|
+
* Account and merchant creates do NOT live here — they hit the DB directly
|
|
39
|
+
* through their own per-name mutexes so parallel workers see each other's
|
|
40
|
+
* creates. The buffer just records which ones were created during this run
|
|
41
|
+
* (for the review summary and revert).
|
|
42
|
+
*/
|
|
43
|
+
export declare class SharedBuffer {
|
|
44
|
+
readonly scanId: string;
|
|
45
|
+
private transactions;
|
|
46
|
+
private unknowns;
|
|
47
|
+
private accountsCreated;
|
|
48
|
+
private merchantsCreated;
|
|
49
|
+
private fileIds;
|
|
50
|
+
private mutex;
|
|
51
|
+
private bus;
|
|
52
|
+
constructor(scanId: string);
|
|
53
|
+
bindEventBus(bus: EventBus): void;
|
|
54
|
+
recordFile(fileId: string): void;
|
|
55
|
+
recordAccountCreated(accountId: string): void;
|
|
56
|
+
recordMerchantCreated(merchantId: string): void;
|
|
57
|
+
appendTransaction(input: TransactionInput, chunkId: string): Promise<string>;
|
|
58
|
+
updateTransaction(id: string, mutate: (current: BufferedTransaction) => BufferedTransaction): Promise<boolean>;
|
|
59
|
+
removeTransaction(id: string, reason: string): Promise<boolean>;
|
|
60
|
+
appendUnknown(input: Omit<BufferedUnknown, "unknown_id" | "answer">): Promise<string>;
|
|
61
|
+
closeUnknown(id: string, answer: string): Promise<boolean>;
|
|
62
|
+
snapshot(): BufferSnapshot;
|
|
63
|
+
/**
|
|
64
|
+
* Snapshot of just the open (unanswered) unknowns. The review TUI's
|
|
65
|
+
* drill-down asks the user about these.
|
|
66
|
+
*/
|
|
67
|
+
openUnknowns(): BufferedUnknown[];
|
|
68
|
+
/** Lookup by id without taking the mutex — readers don't need to serialize. */
|
|
69
|
+
getTransaction(id: string): BufferedTransaction | undefined;
|
|
70
|
+
/** Iterate transactions matching a filter without copying the whole map. */
|
|
71
|
+
filterTransactions(predicate: (tx: BufferedTransaction) => boolean): IterableIterator<BufferedTransaction>;
|
|
72
|
+
size(): {
|
|
73
|
+
transactions: number;
|
|
74
|
+
unknowns: number;
|
|
75
|
+
openUnknowns: number;
|
|
76
|
+
};
|
|
77
|
+
private emit;
|
|
78
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
function createMutex() {
|
|
3
|
+
let chain = Promise.resolve();
|
|
4
|
+
return {
|
|
5
|
+
async runExclusive(fn) {
|
|
6
|
+
const next = chain.then(() => fn());
|
|
7
|
+
chain = next.catch(() => undefined);
|
|
8
|
+
return next;
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The single in-memory ledger every chunk worker writes to during a scan run.
|
|
14
|
+
* Mutex-guarded so concurrent workers and audit passes can mutate safely; reads
|
|
15
|
+
* are lock-free. Publishes a typed event on every mutation so the Auditor can
|
|
16
|
+
* react in flight.
|
|
17
|
+
*
|
|
18
|
+
* Account and merchant creates do NOT live here — they hit the DB directly
|
|
19
|
+
* through their own per-name mutexes so parallel workers see each other's
|
|
20
|
+
* creates. The buffer just records which ones were created during this run
|
|
21
|
+
* (for the review summary and revert).
|
|
22
|
+
*/
|
|
23
|
+
export class SharedBuffer {
|
|
24
|
+
scanId;
|
|
25
|
+
transactions = new Map();
|
|
26
|
+
unknowns = new Map();
|
|
27
|
+
accountsCreated = new Set();
|
|
28
|
+
merchantsCreated = new Set();
|
|
29
|
+
fileIds = new Set();
|
|
30
|
+
mutex = createMutex();
|
|
31
|
+
bus = null;
|
|
32
|
+
constructor(scanId) {
|
|
33
|
+
this.scanId = scanId;
|
|
34
|
+
}
|
|
35
|
+
bindEventBus(bus) {
|
|
36
|
+
this.bus = bus;
|
|
37
|
+
}
|
|
38
|
+
recordFile(fileId) {
|
|
39
|
+
this.fileIds.add(fileId);
|
|
40
|
+
}
|
|
41
|
+
recordAccountCreated(accountId) {
|
|
42
|
+
this.accountsCreated.add(accountId);
|
|
43
|
+
}
|
|
44
|
+
recordMerchantCreated(merchantId) {
|
|
45
|
+
this.merchantsCreated.add(merchantId);
|
|
46
|
+
}
|
|
47
|
+
async appendTransaction(input, chunkId) {
|
|
48
|
+
return this.mutex.runExclusive(() => {
|
|
49
|
+
const id = `tx:${randomUUID()}`;
|
|
50
|
+
const tx = { transaction_id: id, chunkId, input };
|
|
51
|
+
this.transactions.set(id, tx);
|
|
52
|
+
this.emit({ kind: "transaction_appended", transaction: tx, chunkId });
|
|
53
|
+
return id;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async updateTransaction(id, mutate) {
|
|
57
|
+
return this.mutex.runExclusive(() => {
|
|
58
|
+
const before = this.transactions.get(id);
|
|
59
|
+
if (!before)
|
|
60
|
+
return false;
|
|
61
|
+
const after = mutate(before);
|
|
62
|
+
this.transactions.set(id, after);
|
|
63
|
+
this.emit({ kind: "transaction_updated", transactionId: id, before, after });
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async removeTransaction(id, reason) {
|
|
68
|
+
return this.mutex.runExclusive(() => {
|
|
69
|
+
if (!this.transactions.delete(id))
|
|
70
|
+
return false;
|
|
71
|
+
this.emit({ kind: "transaction_removed", transactionId: id, reason });
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async appendUnknown(input) {
|
|
76
|
+
return this.mutex.runExclusive(() => {
|
|
77
|
+
const id = `bu:${randomUUID()}`;
|
|
78
|
+
const u = { ...input, unknown_id: id, answer: null };
|
|
79
|
+
this.unknowns.set(id, u);
|
|
80
|
+
this.emit({ kind: "unknown_appended", unknown: u, chunkId: u.chunkId ?? "" });
|
|
81
|
+
return id;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async closeUnknown(id, answer) {
|
|
85
|
+
return this.mutex.runExclusive(() => {
|
|
86
|
+
const u = this.unknowns.get(id);
|
|
87
|
+
if (!u || u.answer !== null)
|
|
88
|
+
return false;
|
|
89
|
+
this.unknowns.set(id, { ...u, answer });
|
|
90
|
+
this.emit({ kind: "unknown_closed", unknownId: id, answer });
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
snapshot() {
|
|
95
|
+
return {
|
|
96
|
+
transactions: Array.from(this.transactions.values()),
|
|
97
|
+
unknowns: Array.from(this.unknowns.values()),
|
|
98
|
+
accountsCreated: Array.from(this.accountsCreated),
|
|
99
|
+
merchantsCreated: Array.from(this.merchantsCreated),
|
|
100
|
+
fileIds: Array.from(this.fileIds),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Snapshot of just the open (unanswered) unknowns. The review TUI's
|
|
105
|
+
* drill-down asks the user about these.
|
|
106
|
+
*/
|
|
107
|
+
openUnknowns() {
|
|
108
|
+
return Array.from(this.unknowns.values()).filter(u => u.answer === null);
|
|
109
|
+
}
|
|
110
|
+
/** Lookup by id without taking the mutex — readers don't need to serialize. */
|
|
111
|
+
getTransaction(id) {
|
|
112
|
+
return this.transactions.get(id);
|
|
113
|
+
}
|
|
114
|
+
/** Iterate transactions matching a filter without copying the whole map. */
|
|
115
|
+
*filterTransactions(predicate) {
|
|
116
|
+
for (const tx of this.transactions.values())
|
|
117
|
+
if (predicate(tx))
|
|
118
|
+
yield tx;
|
|
119
|
+
}
|
|
120
|
+
size() {
|
|
121
|
+
let open = 0;
|
|
122
|
+
for (const u of this.unknowns.values())
|
|
123
|
+
if (u.answer === null)
|
|
124
|
+
open++;
|
|
125
|
+
return { transactions: this.transactions.size, unknowns: this.unknowns.size, openUnknowns: open };
|
|
126
|
+
}
|
|
127
|
+
emit(event) {
|
|
128
|
+
this.bus?.publish(event);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { TransactionInput } from "../../db/queries/transactions.js";
|
|
2
|
+
/**
|
|
3
|
+
* One in-flight transaction owned by the Buffer. The id is synthesized on
|
|
4
|
+
* append so the chunk worker can reference it in the same agent turn (e.g.
|
|
5
|
+
* to attach a note_unknown). `chunkId` records who created it — audit passes
|
|
6
|
+
* use this to detect cross-chunk duplicates and boundary continuations.
|
|
7
|
+
*/
|
|
8
|
+
export interface BufferedTransaction {
|
|
9
|
+
readonly transaction_id: string;
|
|
10
|
+
readonly chunkId: string;
|
|
11
|
+
input: TransactionInput;
|
|
12
|
+
}
|
|
13
|
+
export interface BufferedUnknown {
|
|
14
|
+
readonly unknown_id: string;
|
|
15
|
+
readonly chunkId: string | null;
|
|
16
|
+
transaction_id: string | null;
|
|
17
|
+
account_id: string | null;
|
|
18
|
+
kind: string | null;
|
|
19
|
+
prompt: string;
|
|
20
|
+
options?: string[];
|
|
21
|
+
/** Null until the auditor or review TUI closes it. */
|
|
22
|
+
answer: string | null;
|
|
23
|
+
}
|
|
24
|
+
export interface BufferSnapshot {
|
|
25
|
+
readonly transactions: readonly BufferedTransaction[];
|
|
26
|
+
readonly unknowns: readonly BufferedUnknown[];
|
|
27
|
+
readonly accountsCreated: readonly string[];
|
|
28
|
+
readonly merchantsCreated: readonly string[];
|
|
29
|
+
readonly fileIds: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Internal state of the Buffer — held inside the createBuffer closure. Exposed
|
|
33
|
+
* for tests that need to inspect raw shape; phases use the snapshot API.
|
|
34
|
+
*/
|
|
35
|
+
export interface BufferState {
|
|
36
|
+
readonly scanId: string;
|
|
37
|
+
readonly transactions: Map<string, BufferedTransaction>;
|
|
38
|
+
readonly unknowns: Map<string, BufferedUnknown>;
|
|
39
|
+
readonly accountsCreated: Set<string>;
|
|
40
|
+
readonly merchantsCreated: Set<string>;
|
|
41
|
+
readonly fileIds: Set<string>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Public API every consumer (chunk workers, audit passes, review TUI, commit
|
|
45
|
+
* phase) talks to. Mutations are serialized through the Buffer's internal
|
|
46
|
+
* mutex; reads (snapshot, size, filterTransactions) are lock-free.
|
|
47
|
+
*/
|
|
48
|
+
export interface ScanBuffer {
|
|
49
|
+
readonly scanId: string;
|
|
50
|
+
appendTransaction(input: TransactionInput, chunkId: string): Promise<string>;
|
|
51
|
+
updateTransaction(id: string, mut: (current: BufferedTransaction) => BufferedTransaction): Promise<boolean>;
|
|
52
|
+
removeTransaction(id: string, reason: string): Promise<boolean>;
|
|
53
|
+
appendUnknown(input: Omit<BufferedUnknown, "unknown_id" | "answer">): Promise<string>;
|
|
54
|
+
closeUnknown(id: string, answer: string): Promise<boolean>;
|
|
55
|
+
recordFile(fileId: string): void;
|
|
56
|
+
recordAccountCreated(accountId: string): void;
|
|
57
|
+
recordMerchantCreated(merchantId: string): void;
|
|
58
|
+
snapshot(): BufferSnapshot;
|
|
59
|
+
size(): {
|
|
60
|
+
transactions: number;
|
|
61
|
+
unknowns: number;
|
|
62
|
+
openUnknowns: number;
|
|
63
|
+
};
|
|
64
|
+
getTransaction(id: string): BufferedTransaction | undefined;
|
|
65
|
+
filterTransactions(predicate: (tx: BufferedTransaction) => boolean): IterableIterator<BufferedTransaction>;
|
|
66
|
+
openUnknowns(): BufferedUnknown[];
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|