@titan-design/queue-mirror 0.1.0 → 0.2.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 +8 -3
- package/dist/chunk-BGWXWM3P.js +38 -0
- package/dist/chunk-BGWXWM3P.js.map +1 -0
- package/dist/hitl.d.ts +25 -0
- package/dist/hitl.js +83 -0
- package/dist/hitl.js.map +1 -0
- package/dist/index.d.ts +38 -80
- package/dist/index.js +202 -0
- package/dist/index.js.map +1 -1
- package/dist/sqlite.d.ts +41 -0
- package/dist/sqlite.js +134 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/types-BgRpl8NH.d.ts +85 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Projects a local queue of human-actionable items (agent approvals, endorsements, questions,
|
|
4
4
|
notices) into a Matrix `#queue` room, and folds the owner's reactions and replies back into
|
|
5
|
-
verdicts on that queue.
|
|
5
|
+
verdicts on that queue. Each item is posted once, secrets in previews are redacted, and every
|
|
6
|
+
close edits the item with `m.replace`.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
- `runMirror(source, bus, state, options)`: the supervised loop.
|
|
9
|
+
- `QueueSource` and `MirrorState` ports, with `MemoryQueueSource` and `MemoryMirrorState`.
|
|
10
|
+
- `@titan-design/queue-mirror/hitl`: `hitlQueueSource(store, options)` over a hitl `GateStore`.
|
|
11
|
+
|
|
12
|
+
Tier 2 of the titan-platform DAG. Depends on `@titan-design/matrix-bus` and
|
|
13
|
+
`@titan-design/hitl`. The root entry has no `node:` import.
|
|
9
14
|
|
|
10
15
|
Reference: [site/reference/queue-mirror.md](../../site/reference/queue-mirror.md).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/supervise.ts
|
|
2
|
+
var DEFAULT_BACKOFF = { initialMs: 1e3, maxMs: 6e4 };
|
|
3
|
+
async function superviseLoop(name, body, options) {
|
|
4
|
+
const { signal, backoff, sleep, logger } = options;
|
|
5
|
+
let delay = backoff.initialMs;
|
|
6
|
+
const reset = () => {
|
|
7
|
+
delay = backoff.initialMs;
|
|
8
|
+
};
|
|
9
|
+
while (!signal.aborted) {
|
|
10
|
+
try {
|
|
11
|
+
return await body(reset);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (signal.aborted) return;
|
|
14
|
+
logger.warn(`${name} failed`, { error: err instanceof Error ? err.message : String(err), retryInMs: delay });
|
|
15
|
+
await sleep(delay, signal);
|
|
16
|
+
delay = Math.min(delay * 2, backoff.maxMs);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function abortableSleep(ms, signal) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
if (signal.aborted) return resolve();
|
|
23
|
+
const done = () => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
signal.removeEventListener("abort", done);
|
|
26
|
+
resolve();
|
|
27
|
+
};
|
|
28
|
+
const timer = setTimeout(done, ms);
|
|
29
|
+
signal.addEventListener("abort", done);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_BACKOFF,
|
|
35
|
+
superviseLoop,
|
|
36
|
+
abortableSleep
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=chunk-BGWXWM3P.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/supervise.ts"],"sourcesContent":["import type { MirrorLogger } from \"./types.js\";\n\nexport interface Backoff {\n initialMs: number;\n maxMs: number;\n}\n\nexport interface SuperviseOptions {\n signal: AbortSignal;\n backoff: Backoff;\n sleep: (ms: number, signal: AbortSignal) => Promise<void>;\n logger: MirrorLogger;\n}\n\nexport const DEFAULT_BACKOFF: Backoff = { initialMs: 1_000, maxMs: 60_000 };\n\n/** Reruns `body` after each throw with doubling delay; `reset` restores the initial delay after progress. */\nexport async function superviseLoop(name: string, body: (reset: () => void) => Promise<void>, options: SuperviseOptions): Promise<void> {\n const { signal, backoff, sleep, logger } = options;\n let delay = backoff.initialMs;\n const reset = () => {\n delay = backoff.initialMs;\n };\n while (!signal.aborted) {\n try {\n return await body(reset);\n } catch (err) {\n if (signal.aborted) return;\n logger.warn(`${name} failed`, { error: err instanceof Error ? err.message : String(err), retryInMs: delay });\n await sleep(delay, signal);\n delay = Math.min(delay * 2, backoff.maxMs);\n }\n }\n}\n\nexport function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal.aborted) return resolve();\n const done = () => {\n clearTimeout(timer);\n signal.removeEventListener(\"abort\", done);\n resolve();\n };\n const timer = setTimeout(done, ms);\n signal.addEventListener(\"abort\", done);\n });\n}\n"],"mappings":";AAcO,IAAM,kBAA2B,EAAE,WAAW,KAAO,OAAO,IAAO;AAG1E,eAAsB,cAAc,MAAc,MAA4C,SAA0C;AACtI,QAAM,EAAE,QAAQ,SAAS,OAAO,OAAO,IAAI;AAC3C,MAAI,QAAQ,QAAQ;AACpB,QAAM,QAAQ,MAAM;AAClB,YAAQ,QAAQ;AAAA,EAClB;AACA,SAAO,CAAC,OAAO,SAAS;AACtB,QAAI;AACF,aAAO,MAAM,KAAK,KAAK;AAAA,IACzB,SAAS,KAAK;AACZ,UAAI,OAAO,QAAS;AACpB,aAAO,KAAK,GAAG,IAAI,WAAW,EAAE,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG,WAAW,MAAM,CAAC;AAC3G,YAAM,MAAM,OAAO,MAAM;AACzB,cAAQ,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK;AAAA,IAC3C;AAAA,EACF;AACF;AAEO,SAAS,eAAe,IAAY,QAAoC;AAC7E,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,QAAI,OAAO,QAAS,QAAO,QAAQ;AACnC,UAAM,OAAO,MAAM;AACjB,mBAAa,KAAK;AAClB,aAAO,oBAAoB,SAAS,IAAI;AACxC,cAAQ;AAAA,IACV;AACA,UAAM,QAAQ,WAAW,MAAM,EAAE;AACjC,WAAO,iBAAiB,SAAS,IAAI;AAAA,EACvC,CAAC;AACH;","names":[]}
|
package/dist/hitl.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { GateRecord, GateStore } from '@titan-design/hitl';
|
|
2
|
+
import { V as VerdictInput, Q as QueueSource } from './types-BgRpl8NH.js';
|
|
3
|
+
import '@titan-design/matrix-bus';
|
|
4
|
+
|
|
5
|
+
type GateKind = "approval_request" | "question";
|
|
6
|
+
type GateAction = {
|
|
7
|
+
resolve: unknown;
|
|
8
|
+
} | {
|
|
9
|
+
cancel: string;
|
|
10
|
+
};
|
|
11
|
+
interface HitlSourceOptions {
|
|
12
|
+
/** TitanItem headline fields. */
|
|
13
|
+
machine: string;
|
|
14
|
+
session: string;
|
|
15
|
+
/** hitl has no change feed, so tail polls listPending at this interval. */
|
|
16
|
+
pollMs?: number;
|
|
17
|
+
kindOf?: (gate: GateRecord) => GateKind;
|
|
18
|
+
toPayload?: (gate: GateRecord, verdict: VerdictInput) => GateAction;
|
|
19
|
+
}
|
|
20
|
+
/** allow and approve resolve {approved: true}; an answer resolves its text; deny and dismiss cancel. */
|
|
21
|
+
declare function defaultGatePayload(_gate: GateRecord, { verdict, text }: VerdictInput): GateAction;
|
|
22
|
+
/** A QueueSource over a hitl GateStore: pending gates become approval_request or question items. */
|
|
23
|
+
declare function hitlQueueSource(store: GateStore, options: HitlSourceOptions): QueueSource;
|
|
24
|
+
|
|
25
|
+
export { type GateAction, type HitlSourceOptions, defaultGatePayload, hitlQueueSource };
|
package/dist/hitl.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
abortableSleep
|
|
3
|
+
} from "./chunk-BGWXWM3P.js";
|
|
4
|
+
|
|
5
|
+
// src/hitl.ts
|
|
6
|
+
var DEFAULT_POLL_MS = 1e3;
|
|
7
|
+
var CLOSED_ERRORS = /* @__PURE__ */ new Set(["GateAlreadySettled", "GateExpired", "GateNotFound"]);
|
|
8
|
+
var OUTCOMES = { resolved: "resolved", cancelled: "cancelled", expired: "expired" };
|
|
9
|
+
var defaultKind = (gate) => gate.schema ? "question" : "approval_request";
|
|
10
|
+
function defaultGatePayload(_gate, { verdict, text }) {
|
|
11
|
+
if (verdict === "allow" || verdict === "approve") return { resolve: { approved: true } };
|
|
12
|
+
if (verdict === "answer") return { resolve: text };
|
|
13
|
+
return { cancel: "denied from Matrix" };
|
|
14
|
+
}
|
|
15
|
+
function errorResult(err) {
|
|
16
|
+
const name = err instanceof Error ? err.name : "";
|
|
17
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
18
|
+
if (CLOSED_ERRORS.has(name)) return { ok: false, reason: "closed", detail };
|
|
19
|
+
if (name === "GatePayloadInvalid") return { ok: false, reason: "rejected", detail };
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
var HitlQueueSource = class {
|
|
23
|
+
constructor(store, options) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
store;
|
|
28
|
+
options;
|
|
29
|
+
kinds = ["approval_request", "question"];
|
|
30
|
+
async open() {
|
|
31
|
+
return this.store.listPending().map((gate) => this.toItem(gate));
|
|
32
|
+
}
|
|
33
|
+
async resolve(id, verdict) {
|
|
34
|
+
const gate = this.store.get(id);
|
|
35
|
+
if (gate?.status !== "pending") return { ok: false, reason: "closed" };
|
|
36
|
+
const action = (this.options.toPayload ?? defaultGatePayload)(gate, verdict);
|
|
37
|
+
try {
|
|
38
|
+
if ("resolve" in action) this.store.resolve(id, action.resolve);
|
|
39
|
+
else this.store.cancel(id, action.cancel);
|
|
40
|
+
return { ok: true };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return errorResult(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The cursor only numbers events; after a restart, reconcile over open() is what catches up. */
|
|
46
|
+
async *tail(_cursor, signal) {
|
|
47
|
+
const known = /* @__PURE__ */ new Set();
|
|
48
|
+
let seq = 0;
|
|
49
|
+
while (!signal.aborted) {
|
|
50
|
+
for (const event of this.diff(known)) yield { ...event, cursor: String(++seq) };
|
|
51
|
+
await abortableSleep(this.options.pollMs ?? DEFAULT_POLL_MS, signal);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
diff(known) {
|
|
55
|
+
const pending = this.store.listPending();
|
|
56
|
+
const pendingIds = new Set(pending.map((gate) => gate.id));
|
|
57
|
+
const closed = [...known].filter((id) => !pendingIds.has(id)).map((id) => this.closedEvent(id));
|
|
58
|
+
const opened = pending.filter((gate) => !known.has(gate.id)).map((gate) => ({ type: "opened", item: this.toItem(gate) }));
|
|
59
|
+
for (const id of pendingIds) known.add(id);
|
|
60
|
+
for (const event of closed) known.delete(event.id);
|
|
61
|
+
return [...closed, ...opened];
|
|
62
|
+
}
|
|
63
|
+
closedEvent(id) {
|
|
64
|
+
const gate = this.store.get(id);
|
|
65
|
+
const outcome = gate && gate.status !== "pending" ? OUTCOMES[gate.status] : "cancelled";
|
|
66
|
+
return { type: "closed", id, outcome, label: gate?.reason };
|
|
67
|
+
}
|
|
68
|
+
toItem(gate) {
|
|
69
|
+
const kind = (this.options.kindOf ?? defaultKind)(gate);
|
|
70
|
+
const { machine, session } = this.options;
|
|
71
|
+
const prompt = kind === "approval_request" ? { toolName: "gate", inputPreview: gate.prompt } : { text: gate.prompt };
|
|
72
|
+
const expiresAt = gate.expiresAt === void 0 ? void 0 : Date.parse(gate.expiresAt);
|
|
73
|
+
return { id: gate.id, kind, machine, session, at: Date.parse(gate.createdAt), expiresAt, ...prompt };
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function hitlQueueSource(store, options) {
|
|
77
|
+
return new HitlQueueSource(store, options);
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
defaultGatePayload,
|
|
81
|
+
hitlQueueSource
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=hitl.js.map
|
package/dist/hitl.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hitl.ts"],"sourcesContent":["import type { GateRecord, GateStatus, GateStore } from \"@titan-design/hitl\";\nimport { abortableSleep } from \"./supervise.js\";\nimport type { CloseOutcome, QueueItem, QueueSource, ResolveResult, SourceEvent, VerdictInput } from \"./types.js\";\n\ntype GateKind = \"approval_request\" | \"question\";\nexport type GateAction = { resolve: unknown } | { cancel: string };\n\nexport interface HitlSourceOptions {\n /** TitanItem headline fields. */\n machine: string;\n session: string;\n /** hitl has no change feed, so tail polls listPending at this interval. */\n pollMs?: number;\n kindOf?: (gate: GateRecord) => GateKind;\n toPayload?: (gate: GateRecord, verdict: VerdictInput) => GateAction;\n}\n\nconst DEFAULT_POLL_MS = 1_000;\n// Matched by name, so a second hitl copy in node_modules cannot break instanceof.\nconst CLOSED_ERRORS = new Set([\"GateAlreadySettled\", \"GateExpired\", \"GateNotFound\"]);\nconst OUTCOMES: Record<Exclude<GateStatus, \"pending\">, CloseOutcome> = { resolved: \"resolved\", cancelled: \"cancelled\", expired: \"expired\" };\n\nconst defaultKind = (gate: GateRecord): GateKind => (gate.schema ? \"question\" : \"approval_request\");\n\n/** allow and approve resolve {approved: true}; an answer resolves its text; deny and dismiss cancel. */\nexport function defaultGatePayload(_gate: GateRecord, { verdict, text }: VerdictInput): GateAction {\n if (verdict === \"allow\" || verdict === \"approve\") return { resolve: { approved: true } };\n if (verdict === \"answer\") return { resolve: text };\n return { cancel: \"denied from Matrix\" };\n}\n\nfunction errorResult(err: unknown): ResolveResult {\n const name = err instanceof Error ? err.name : \"\";\n const detail = err instanceof Error ? err.message : String(err);\n if (CLOSED_ERRORS.has(name)) return { ok: false, reason: \"closed\", detail };\n if (name === \"GatePayloadInvalid\") return { ok: false, reason: \"rejected\", detail };\n throw err;\n}\n\nclass HitlQueueSource implements QueueSource {\n readonly kinds = [\"approval_request\", \"question\"] as const;\n\n constructor(\n private readonly store: GateStore,\n private readonly options: HitlSourceOptions,\n ) {}\n\n async open(): Promise<QueueItem[]> {\n return this.store.listPending().map((gate) => this.toItem(gate));\n }\n\n async resolve(id: string, verdict: VerdictInput): Promise<ResolveResult> {\n const gate = this.store.get(id);\n if (gate?.status !== \"pending\") return { ok: false, reason: \"closed\" };\n const action = (this.options.toPayload ?? defaultGatePayload)(gate, verdict);\n try {\n if (\"resolve\" in action) this.store.resolve(id, action.resolve);\n else this.store.cancel(id, action.cancel);\n return { ok: true };\n } catch (err) {\n return errorResult(err);\n }\n }\n\n /** The cursor only numbers events; after a restart, reconcile over open() is what catches up. */\n async *tail(_cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent> {\n const known = new Set<string>();\n let seq = 0;\n while (!signal.aborted) {\n for (const event of this.diff(known)) yield { ...event, cursor: String(++seq) } as SourceEvent;\n await abortableSleep(this.options.pollMs ?? DEFAULT_POLL_MS, signal);\n }\n }\n\n private diff(known: Set<string>): Array<Omit<SourceEvent, \"cursor\">> {\n const pending = this.store.listPending();\n const pendingIds = new Set(pending.map((gate) => gate.id));\n const closed = [...known].filter((id) => !pendingIds.has(id)).map((id) => this.closedEvent(id));\n const opened = pending.filter((gate) => !known.has(gate.id)).map((gate) => ({ type: \"opened\" as const, item: this.toItem(gate) }));\n for (const id of pendingIds) known.add(id);\n for (const event of closed) known.delete(event.id);\n return [...closed, ...opened];\n }\n\n private closedEvent(id: string): { type: \"closed\"; id: string; outcome: CloseOutcome; label?: string } {\n const gate = this.store.get(id);\n const outcome = gate && gate.status !== \"pending\" ? OUTCOMES[gate.status] : \"cancelled\";\n return { type: \"closed\", id, outcome, label: gate?.reason };\n }\n\n private toItem(gate: GateRecord): QueueItem {\n const kind = (this.options.kindOf ?? defaultKind)(gate);\n const { machine, session } = this.options;\n const prompt = kind === \"approval_request\" ? { toolName: \"gate\", inputPreview: gate.prompt } : { text: gate.prompt };\n const expiresAt = gate.expiresAt === undefined ? undefined : Date.parse(gate.expiresAt);\n return { id: gate.id, kind, machine, session, at: Date.parse(gate.createdAt), expiresAt, ...prompt };\n }\n}\n\n/** A QueueSource over a hitl GateStore: pending gates become approval_request or question items. */\nexport function hitlQueueSource(store: GateStore, options: HitlSourceOptions): QueueSource {\n return new HitlQueueSource(store, options);\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,82 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/** One pending thing a human can act on; `id` becomes TitanItem.msg_id. */
|
|
4
|
-
interface QueueItem {
|
|
5
|
-
id: string;
|
|
6
|
-
kind: ItemKind;
|
|
7
|
-
machine: string;
|
|
8
|
-
session: string;
|
|
9
|
-
at: number;
|
|
10
|
-
agentId?: string;
|
|
11
|
-
toolName?: string;
|
|
12
|
-
inputPreview?: string;
|
|
13
|
-
recipient?: string;
|
|
14
|
-
text?: string;
|
|
15
|
-
/** Epoch ms; the mirror edits the item "expired" once the clock passes it. */
|
|
16
|
-
expiresAt?: number;
|
|
17
|
-
}
|
|
18
|
-
type CloseOutcome = "resolved" | "cancelled" | "expired";
|
|
19
|
-
type SourceEvent = {
|
|
20
|
-
type: "opened";
|
|
21
|
-
item: QueueItem;
|
|
22
|
-
cursor: string;
|
|
23
|
-
} | {
|
|
24
|
-
type: "closed";
|
|
25
|
-
id: string;
|
|
26
|
-
outcome: CloseOutcome;
|
|
27
|
-
label?: string;
|
|
28
|
-
cursor: string;
|
|
29
|
-
};
|
|
30
|
-
interface VerdictInput {
|
|
31
|
-
verdict: Verdict;
|
|
32
|
-
text?: string;
|
|
33
|
-
resolutionEventId: string;
|
|
34
|
-
}
|
|
35
|
-
type ResolveResult = {
|
|
36
|
-
ok: true;
|
|
37
|
-
} | {
|
|
38
|
-
ok: false;
|
|
39
|
-
reason: "closed" | "rejected";
|
|
40
|
-
detail?: string;
|
|
41
|
-
};
|
|
42
|
-
interface QueueSource {
|
|
43
|
-
readonly kinds: readonly ItemKind[];
|
|
44
|
-
open(): Promise<QueueItem[]>;
|
|
45
|
-
/** Events after `cursor` (undefined = from now); ends when `signal` aborts. */
|
|
46
|
-
tail(cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent>;
|
|
47
|
-
resolve(id: string, verdict: VerdictInput): Promise<ResolveResult>;
|
|
48
|
-
}
|
|
49
|
-
interface PostedItem {
|
|
50
|
-
sourceId: string;
|
|
51
|
-
eventId: string;
|
|
52
|
-
kind: ItemKind;
|
|
53
|
-
/** False when the item was redacted or truncated, so no phone verdict may resolve it. */
|
|
54
|
-
approvable: boolean;
|
|
55
|
-
status: "open" | "closed";
|
|
56
|
-
/** Epoch ms after which the sweep closes the item: the source's expiry, or the approval TTL. */
|
|
57
|
-
expiresAt?: number;
|
|
58
|
-
/** The record as posted; an edit repeats it in m.new_content and reads its redacted and truncated flags. */
|
|
59
|
-
record: TitanItem;
|
|
60
|
-
}
|
|
61
|
-
interface StateChange {
|
|
62
|
-
sourceCursor?: string;
|
|
63
|
-
syncToken?: string;
|
|
64
|
-
posted?: PostedItem[];
|
|
65
|
-
/** Source ids. */
|
|
66
|
-
closed?: string[];
|
|
67
|
-
/** Resolution event ids. */
|
|
68
|
-
applied?: string[];
|
|
69
|
-
}
|
|
70
|
-
/** Synchronous like hitl's GateStore: both implementations are local. `commit` is atomic. */
|
|
71
|
-
interface MirrorState {
|
|
72
|
-
sourceCursor(): string | undefined;
|
|
73
|
-
syncToken(): string | undefined;
|
|
74
|
-
bySourceId(id: string): PostedItem | undefined;
|
|
75
|
-
byEventId(eventId: string): PostedItem | undefined;
|
|
76
|
-
openItems(): PostedItem[];
|
|
77
|
-
hasApplied(resolutionEventId: string): boolean;
|
|
78
|
-
commit(change: StateChange): void;
|
|
79
|
-
}
|
|
1
|
+
import { a as QueueItem, Q as QueueSource, V as VerdictInput, C as CloseOutcome, R as ResolveResult, S as SourceEvent, M as MirrorState, P as PostedItem, b as StateChange, c as MirrorLogger } from './types-BgRpl8NH.js';
|
|
2
|
+
import { ItemInput, ItemKind, SyncBatch, AppserviceClient, TitanItem } from '@titan-design/matrix-bus';
|
|
80
3
|
|
|
81
4
|
/** A base64-like run is a secret only if it mixes digits and both cases and no path segment is a plain word. */
|
|
82
5
|
declare function looksSecret(run: string): boolean;
|
|
@@ -140,4 +63,39 @@ declare class MemoryMirrorState implements MirrorState {
|
|
|
140
63
|
snapshot(): MirrorSnapshot;
|
|
141
64
|
}
|
|
142
65
|
|
|
143
|
-
|
|
66
|
+
interface Backoff {
|
|
67
|
+
initialMs: number;
|
|
68
|
+
maxMs: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The slice of AppserviceClient the mirror drives; tests pass a fake hub. */
|
|
72
|
+
type MirrorBus = Pick<AppserviceClient, "send" | "syncLoop" | "userId">;
|
|
73
|
+
interface MirrorOptions {
|
|
74
|
+
ownerUserId: string;
|
|
75
|
+
roomId: string;
|
|
76
|
+
signal: AbortSignal;
|
|
77
|
+
now?: () => number;
|
|
78
|
+
/** Default expiry for approval_request items lacking expiresAt. */
|
|
79
|
+
approvalTtlMs?: number;
|
|
80
|
+
sweepIntervalMs?: number;
|
|
81
|
+
syncTimeoutMs?: number;
|
|
82
|
+
backoff?: Backoff;
|
|
83
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
84
|
+
logger?: MirrorLogger;
|
|
85
|
+
}
|
|
86
|
+
/** Steps exposed so tests drive the mirror deterministically; runMirror only loops over them. */
|
|
87
|
+
interface Mirror {
|
|
88
|
+
reconcile(): Promise<void>;
|
|
89
|
+
applySourceEvent(event: SourceEvent): Promise<void>;
|
|
90
|
+
applySyncBatch(batch: SyncBatch): Promise<void>;
|
|
91
|
+
sweepExpired(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
declare function createMirror(source: QueueSource, bus: MirrorBus, state: MirrorState, options: MirrorOptions): Mirror;
|
|
94
|
+
|
|
95
|
+
/** Runs the source tail (reconciling on each start), the sync loop and the expiry sweep until `signal` aborts. */
|
|
96
|
+
declare function runMirror(source: QueueSource, bus: MirrorBus, state: MirrorState, options: MirrorOptions): Promise<void>;
|
|
97
|
+
|
|
98
|
+
/** An m.replace edit of an item: a short headline and status body, with the original record in m.new_content. */
|
|
99
|
+
declare function encodeEdit(targetEventId: string, record: TitanItem, status: string): Record<string, unknown>;
|
|
100
|
+
|
|
101
|
+
export { type Backoff, CloseOutcome, MemoryMirrorState, MemoryQueueSource, type Mirror, type MirrorBus, MirrorLogger, type MirrorOptions, type MirrorSnapshot, MirrorState, PostedItem, QueueItem, QueueSource, ResolveResult, SourceEvent, StateChange, VerdictInput, createMirror, encodeEdit, fitItem, looksSecret, redactPreview, runMirror, syncFilter, toItemInput };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_BACKOFF,
|
|
3
|
+
abortableSleep,
|
|
4
|
+
superviseLoop
|
|
5
|
+
} from "./chunk-BGWXWM3P.js";
|
|
6
|
+
|
|
1
7
|
// src/redact.ts
|
|
2
8
|
var PLACEHOLDER = "[REDACTED]";
|
|
3
9
|
var AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
@@ -189,12 +195,208 @@ var MemoryMirrorState = class _MemoryMirrorState {
|
|
|
189
195
|
};
|
|
190
196
|
}
|
|
191
197
|
};
|
|
198
|
+
|
|
199
|
+
// src/mirror.ts
|
|
200
|
+
import { ITEM_KEY as ITEM_KEY2, encodeItem as encodeItem2, foldResolution } from "@titan-design/matrix-bus";
|
|
201
|
+
|
|
202
|
+
// src/edit.ts
|
|
203
|
+
import { ITEM_KEY, renderItemBody } from "@titan-design/matrix-bus";
|
|
204
|
+
function headline(record) {
|
|
205
|
+
return renderItemBody({ ...record, text: void 0 }).split("\n")[0] ?? "";
|
|
206
|
+
}
|
|
207
|
+
function encodeEdit(targetEventId, record, status) {
|
|
208
|
+
const body = `${headline(record)}
|
|
209
|
+
${status}`;
|
|
210
|
+
return {
|
|
211
|
+
msgtype: "m.text",
|
|
212
|
+
body: `* ${body}`,
|
|
213
|
+
"m.new_content": { msgtype: "m.text", body, [ITEM_KEY]: record },
|
|
214
|
+
"m.relates_to": { rel_type: "m.replace", event_id: targetEventId }
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/mirror.ts
|
|
219
|
+
var SILENT = { info: () => {
|
|
220
|
+
}, warn: () => {
|
|
221
|
+
} };
|
|
222
|
+
var itemTxnId = (sourceId) => `qm-${encodeURIComponent(sourceId)}`;
|
|
223
|
+
var editTxnId = (sourceId) => `qm-edit-${encodeURIComponent(sourceId)}`;
|
|
224
|
+
function closedStatus(event) {
|
|
225
|
+
if (event.outcome === "resolved") return event.label ? `resolved at the terminal: ${event.label}` : "resolved at the terminal";
|
|
226
|
+
return event.outcome;
|
|
227
|
+
}
|
|
228
|
+
var QueueMirror = class {
|
|
229
|
+
constructor(source, bus, state, options) {
|
|
230
|
+
this.source = source;
|
|
231
|
+
this.bus = bus;
|
|
232
|
+
this.state = state;
|
|
233
|
+
this.options = options;
|
|
234
|
+
this.log = options.logger ?? SILENT;
|
|
235
|
+
this.now = options.now ?? Date.now;
|
|
236
|
+
}
|
|
237
|
+
source;
|
|
238
|
+
bus;
|
|
239
|
+
state;
|
|
240
|
+
options;
|
|
241
|
+
log;
|
|
242
|
+
now;
|
|
243
|
+
/** Source ids with a phone verdict in flight; their echoed source close must not win the edit. */
|
|
244
|
+
resolving = /* @__PURE__ */ new Set();
|
|
245
|
+
async reconcile() {
|
|
246
|
+
const pending = await this.source.open();
|
|
247
|
+
const pendingIds = new Set(pending.map((item) => item.id));
|
|
248
|
+
for (const item of this.state.openItems()) {
|
|
249
|
+
if (!pendingIds.has(item.sourceId)) await this.close(item, "resolved at the terminal");
|
|
250
|
+
}
|
|
251
|
+
for (const item of pending) if (!this.state.bySourceId(item.id)) await this.post(item);
|
|
252
|
+
}
|
|
253
|
+
async applySourceEvent(event) {
|
|
254
|
+
const cursor = event.cursor;
|
|
255
|
+
if (event.type === "opened") {
|
|
256
|
+
if (this.state.bySourceId(event.item.id)) return this.state.commit({ sourceCursor: cursor });
|
|
257
|
+
return this.post(event.item, cursor);
|
|
258
|
+
}
|
|
259
|
+
const item = this.state.bySourceId(event.id);
|
|
260
|
+
if (item?.status !== "open" || this.resolving.has(event.id)) return this.state.commit({ sourceCursor: cursor });
|
|
261
|
+
await this.close(item, closedStatus(event), { sourceCursor: cursor });
|
|
262
|
+
}
|
|
263
|
+
/** Persists `since` only after every event is handled; a throw leaves it for the retry. */
|
|
264
|
+
async applySyncBatch(batch) {
|
|
265
|
+
for (const event of batch.events) await this.applyEvent(event);
|
|
266
|
+
this.state.commit({ syncToken: batch.since });
|
|
267
|
+
}
|
|
268
|
+
async sweepExpired() {
|
|
269
|
+
const now = this.now();
|
|
270
|
+
for (const item of this.state.openItems()) {
|
|
271
|
+
if (item.expiresAt !== void 0 && item.expiresAt <= now) await this.close(item, "expired");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
expiryOf(item) {
|
|
275
|
+
if (item.expiresAt !== void 0) return item.expiresAt;
|
|
276
|
+
const ttl = this.options.approvalTtlMs;
|
|
277
|
+
return item.kind === "approval_request" && ttl !== void 0 ? item.at + ttl : void 0;
|
|
278
|
+
}
|
|
279
|
+
async post(item, sourceCursor) {
|
|
280
|
+
const input = toItemInput(item);
|
|
281
|
+
const content = encodeItem2(input);
|
|
282
|
+
const { event_id: eventId } = await this.bus.send(this.options.roomId, "m.room.message", content, itemTxnId(item.id));
|
|
283
|
+
const posted = {
|
|
284
|
+
sourceId: item.id,
|
|
285
|
+
eventId,
|
|
286
|
+
kind: item.kind,
|
|
287
|
+
approvable: !input.redacted && !input.truncated,
|
|
288
|
+
status: "open",
|
|
289
|
+
expiresAt: this.expiryOf(item),
|
|
290
|
+
record: content[ITEM_KEY2]
|
|
291
|
+
};
|
|
292
|
+
this.state.commit({ posted: [posted], sourceCursor });
|
|
293
|
+
this.log.info("posted", { sourceId: item.id, eventId });
|
|
294
|
+
}
|
|
295
|
+
/** Edits first, then commits, so a crash in between replays into the same deduped edit. */
|
|
296
|
+
async close(item, status, change = {}) {
|
|
297
|
+
const edit = encodeEdit(item.eventId, item.record, status);
|
|
298
|
+
await this.bus.send(this.options.roomId, "m.room.message", edit, editTxnId(item.sourceId));
|
|
299
|
+
this.state.commit({ ...change, closed: [item.sourceId] });
|
|
300
|
+
this.log.info("closed", { sourceId: item.sourceId, status });
|
|
301
|
+
}
|
|
302
|
+
async applyEvent(event) {
|
|
303
|
+
if (event.sender === this.bus.userId || this.state.hasApplied(event.event_id)) return;
|
|
304
|
+
const open = this.state.openItems();
|
|
305
|
+
const resolution = this.fold(event, open.filter((item) => item.approvable));
|
|
306
|
+
if (resolution) return this.applyResolution(event, resolution);
|
|
307
|
+
const refused = this.fold(event, open.filter((item) => !item.approvable));
|
|
308
|
+
if (refused) return this.logRefusal(event, refused);
|
|
309
|
+
if (isResolutionShaped(event)) this.log.info("ignored", { eventId: event.event_id, sender: event.sender });
|
|
310
|
+
}
|
|
311
|
+
fold(event, items) {
|
|
312
|
+
const itemEventIds = new Map(items.map((item) => [item.eventId, item.kind]));
|
|
313
|
+
return foldResolution(event, { ownerUserId: this.options.ownerUserId, itemEventIds });
|
|
314
|
+
}
|
|
315
|
+
logRefusal(event, refused) {
|
|
316
|
+
const item = this.state.byEventId(refused.itemEventId);
|
|
317
|
+
const reason = item?.record.truncated ? "truncated" : "redacted";
|
|
318
|
+
this.log.warn(`refused: ${reason}`, { eventId: event.event_id, sourceId: item?.sourceId });
|
|
319
|
+
}
|
|
320
|
+
async applyResolution(event, { itemEventId, verdict, text }) {
|
|
321
|
+
const item = this.state.byEventId(itemEventId);
|
|
322
|
+
const applied = [event.event_id];
|
|
323
|
+
const result = await this.resolveAtSource(item.sourceId, { verdict, text, resolutionEventId: event.event_id });
|
|
324
|
+
if (result.ok) return this.close(item, `resolved: ${verdict}`, { applied });
|
|
325
|
+
if (result.reason === "closed") return this.close(item, "already resolved", { applied });
|
|
326
|
+
this.state.commit({ applied });
|
|
327
|
+
this.log.warn("rejected", { sourceId: item.sourceId, verdict, detail: result.detail });
|
|
328
|
+
}
|
|
329
|
+
async resolveAtSource(sourceId, verdict) {
|
|
330
|
+
this.resolving.add(sourceId);
|
|
331
|
+
try {
|
|
332
|
+
return await this.source.resolve(sourceId, verdict);
|
|
333
|
+
} finally {
|
|
334
|
+
this.resolving.delete(sourceId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
var RESOLUTION_TYPES = /* @__PURE__ */ new Set(["m.reaction", "m.room.message", "io.titan.resolution"]);
|
|
339
|
+
function isResolutionShaped(event) {
|
|
340
|
+
return RESOLUTION_TYPES.has(event.type) && event.state_key === void 0;
|
|
341
|
+
}
|
|
342
|
+
function createMirror(source, bus, state, options) {
|
|
343
|
+
return new QueueMirror(source, bus, state, options);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/run.ts
|
|
347
|
+
function streamEnded(name, signal) {
|
|
348
|
+
if (!signal.aborted) throw new Error(`${name} stream ended`);
|
|
349
|
+
}
|
|
350
|
+
function sourceLoop(mirror, source, state, signal) {
|
|
351
|
+
return async (reset) => {
|
|
352
|
+
const events = source.tail(state.sourceCursor(), signal);
|
|
353
|
+
await mirror.reconcile();
|
|
354
|
+
for await (const event of events) {
|
|
355
|
+
await mirror.applySourceEvent(event);
|
|
356
|
+
reset();
|
|
357
|
+
}
|
|
358
|
+
streamEnded("source", signal);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function syncLoop(mirror, bus, state, options) {
|
|
362
|
+
const { roomId, signal, syncTimeoutMs = 3e4 } = options;
|
|
363
|
+
return async (reset) => {
|
|
364
|
+
for await (const batch of bus.syncLoop({ since: state.syncToken(), filter: syncFilter(roomId), timeoutMs: syncTimeoutMs, signal })) {
|
|
365
|
+
await mirror.applySyncBatch(batch);
|
|
366
|
+
reset();
|
|
367
|
+
}
|
|
368
|
+
streamEnded("sync", signal);
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function sweepLoop(mirror, options, sleep) {
|
|
372
|
+
const { signal, sweepIntervalMs = 5e3 } = options;
|
|
373
|
+
return async (reset) => {
|
|
374
|
+
while (!signal.aborted) {
|
|
375
|
+
await mirror.sweepExpired();
|
|
376
|
+
reset();
|
|
377
|
+
await sleep(sweepIntervalMs, signal);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function runMirror(source, bus, state, options) {
|
|
382
|
+
const mirror = createMirror(source, bus, state, options);
|
|
383
|
+
const sleep = options.sleep ?? abortableSleep;
|
|
384
|
+
const supervise = { signal: options.signal, backoff: options.backoff ?? DEFAULT_BACKOFF, sleep, logger: options.logger ?? SILENT };
|
|
385
|
+
await Promise.all([
|
|
386
|
+
superviseLoop("source", sourceLoop(mirror, source, state, options.signal), supervise),
|
|
387
|
+
superviseLoop("sync", syncLoop(mirror, bus, state, options), supervise),
|
|
388
|
+
superviseLoop("sweep", sweepLoop(mirror, options, sleep), supervise)
|
|
389
|
+
]);
|
|
390
|
+
}
|
|
192
391
|
export {
|
|
193
392
|
MemoryMirrorState,
|
|
194
393
|
MemoryQueueSource,
|
|
394
|
+
createMirror,
|
|
395
|
+
encodeEdit,
|
|
195
396
|
fitItem,
|
|
196
397
|
looksSecret,
|
|
197
398
|
redactPreview,
|
|
399
|
+
runMirror,
|
|
198
400
|
syncFilter,
|
|
199
401
|
toItemInput
|
|
200
402
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/redact.ts","../src/render.ts","../src/memory-source.ts","../src/memory-state.ts"],"sourcesContent":["const PLACEHOLDER = \"[REDACTED]\";\n\nconst AUTH_HEADER = /\\b(Bearer|Basic)\\s+[A-Za-z0-9._~+/-]+=*/gi;\n// The optional quote after the name lets a JSON key such as \"password\": \"x\" match.\nconst KEY_VALUE = /\\b([A-Za-z0-9_-]*(?:token|key|password))([\"']?\\s*[=:]\\s*)(\"[^\"]*\"|'[^']*'|[^\\s&\"',;]+)/gi;\n// Exactly 40 hex characters is a git commit SHA, which the owner exempted so git previews stay approvable.\nconst HEX_RUN = /\\b(?:[0-9a-fA-F]{32,39}|[0-9a-fA-F]{41,})\\b/g;\nconst B64_RUN = /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_=-])/g;\n\nconst PLAIN_WORD = /^(?:[a-z]{3,}|[A-Z]{3,})$/;\n\n/** A base64-like run is a secret only if it mixes digits and both cases and no path segment is a plain word. */\nexport function looksSecret(run: string): boolean {\n if (!/\\d/.test(run) || !/[A-Z]/.test(run) || !/[a-z]/.test(run)) return false;\n return !run.split(\"/\").some((segment) => PLAIN_WORD.test(segment));\n}\n\n/** Section 10 decision 3: masks auth headers, key=value secrets and long hex or base64 runs. */\nexport function redactPreview(text: string): { text: string; redacted: boolean } {\n let hits = 0;\n const hit = (replacement: string) => {\n hits += 1;\n return replacement;\n };\n const out = text\n .replace(AUTH_HEADER, (_match, scheme: string) => hit(`${scheme} ${PLACEHOLDER}`))\n .replace(KEY_VALUE, (_match, name: string, separator: string) => hit(`${name}${separator}${PLACEHOLDER}`))\n .replace(HEX_RUN, () => hit(PLACEHOLDER))\n .replace(B64_RUN, (run) => (looksSecret(run) ? hit(PLACEHOLDER) : run));\n return { text: out, redacted: hits > 0 };\n}\n","import { MAX_CONTENT_BYTES, contentBytes, encodeItem, type ItemInput } from \"@titan-design/matrix-bus\";\nimport { redactPreview } from \"./redact.js\";\nimport type { QueueItem } from \"./types.js\";\n\nconst MAX_FIT_ROUNDS = 16;\n\nfunction fits(input: ItemInput): boolean {\n return contentBytes(encodeItem(input)) <= MAX_CONTENT_BYTES;\n}\n\n// Slices on code points, so an astral character is never split into a lone surrogate.\nfunction shrink(value: string | undefined, round: number): string | undefined {\n if (value === undefined) return undefined;\n const points = Array.from(value);\n return `${points.slice(0, Math.floor(points.length / 2 ** round)).join(\"\")}…`;\n}\n\n/** Halves `inputPreview` and `text` until the encoded content fits, marking the item truncated. */\nexport function fitItem(input: ItemInput): ItemInput {\n if (fits(input)) return input;\n for (let round = 1; round <= MAX_FIT_ROUNDS; round += 1) {\n const cut = { ...input, input_preview: shrink(input.input_preview, round), text: shrink(input.text, round), truncated: true };\n if (fits(cut)) return cut;\n }\n return { ...input, input_preview: undefined, text: undefined, truncated: true };\n}\n\nfunction redactFields(item: QueueItem): { inputPreview?: string; text?: string; redacted: boolean } {\n if (item.kind === \"approval_request\" && item.inputPreview !== undefined) {\n const { text, redacted } = redactPreview(item.inputPreview);\n return { inputPreview: text, text: item.text, redacted };\n }\n if (item.kind === \"endorse_request\" && item.text !== undefined) {\n const { text, redacted } = redactPreview(item.text);\n return { inputPreview: item.inputPreview, text, redacted };\n }\n return { inputPreview: item.inputPreview, text: item.text, redacted: false };\n}\n\n/** Redacts before fitting, so a cut never strands the tail of a secret the patterns no longer see whole. */\nexport function toItemInput(item: QueueItem): ItemInput {\n const { inputPreview, text, redacted } = redactFields(item);\n return fitItem({\n kind: item.kind,\n machine: item.machine,\n session: item.session,\n agent_id: item.agentId,\n msg_id: item.id,\n at: item.at,\n tool_name: item.toolName,\n input_preview: inputPreview,\n recipient: item.recipient,\n text,\n truncated: false,\n redacted,\n });\n}\n\n/** A /sync filter that returns only the queue room's timeline, with no presence, receipts or account data. */\nexport function syncFilter(roomId: string): Record<string, unknown> {\n const none = { types: [] as string[] };\n return {\n presence: none,\n account_data: none,\n room: { rooms: [roomId], timeline: { limit: 50 }, state: none, ephemeral: none, account_data: none },\n };\n}\n","import { ITEM_KINDS, type ItemKind } from \"@titan-design/matrix-bus\";\nimport type { CloseOutcome, QueueItem, QueueSource, ResolveResult, SourceEvent, VerdictInput } from \"./types.js\";\n\n/** An in-memory QueueSource for tests; cursors are the 1-based position in its event log. */\nexport class MemoryQueueSource implements QueueSource {\n readonly resolutions: Array<{ id: string } & VerdictInput> = [];\n private readonly pending = new Map<string, QueueItem>();\n private readonly log: SourceEvent[] = [];\n private readonly waiters = new Set<() => void>();\n\n constructor(readonly kinds: readonly ItemKind[] = ITEM_KINDS) {}\n\n add(item: QueueItem): void {\n this.pending.set(item.id, item);\n this.append({ type: \"opened\", item, cursor: this.nextCursor() });\n }\n\n close(id: string, outcome: CloseOutcome, label?: string): void {\n if (!this.pending.delete(id)) return;\n this.append({ type: \"closed\", id, outcome, label, cursor: this.nextCursor() });\n }\n\n async open(): Promise<QueueItem[]> {\n return [...this.pending.values()];\n }\n\n async resolve(id: string, verdict: VerdictInput): Promise<ResolveResult> {\n if (!this.pending.has(id)) return { ok: false, reason: \"closed\" };\n this.resolutions.push({ id, ...verdict });\n this.close(id, \"resolved\", verdict.verdict);\n return { ok: true };\n }\n\n /** \"Now\" is fixed when tail is called, not at the first next(), so nothing added in between is missed. */\n tail(cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent> {\n return this.stream(cursor === undefined ? this.log.length : Number(cursor), signal);\n }\n\n private async *stream(start: number, signal: AbortSignal): AsyncGenerator<SourceEvent> {\n let next = start;\n while (!signal.aborted) {\n while (next < this.log.length) yield this.log[next++] as SourceEvent;\n await this.nextAppend(signal);\n }\n }\n\n private nextCursor(): string {\n return String(this.log.length + 1);\n }\n\n private append(event: SourceEvent): void {\n this.log.push(event);\n for (const wake of [...this.waiters]) wake();\n }\n\n private nextAppend(signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal.aborted) return resolve();\n const done = () => {\n this.waiters.delete(done);\n signal.removeEventListener(\"abort\", done);\n resolve();\n };\n this.waiters.add(done);\n signal.addEventListener(\"abort\", done);\n });\n }\n}\n","import type { MirrorState, PostedItem, StateChange } from \"./types.js\";\n\nexport interface MirrorSnapshot {\n sourceCursor?: string;\n syncToken?: string;\n posted: PostedItem[];\n applied: string[];\n}\n\n/** An in-memory MirrorState; `snapshot` and `restore` stand in for a restart in replay tests. */\nexport class MemoryMirrorState implements MirrorState {\n private cursor: string | undefined;\n private token: string | undefined;\n private readonly items = new Map<string, PostedItem>();\n private readonly eventIndex = new Map<string, string>();\n private readonly applied = new Set<string>();\n\n static restore(snapshot: MirrorSnapshot): MemoryMirrorState {\n const state = new MemoryMirrorState();\n state.commit({ ...snapshot, posted: snapshot.posted.map((item) => ({ ...item })) });\n return state;\n }\n\n sourceCursor(): string | undefined {\n return this.cursor;\n }\n\n syncToken(): string | undefined {\n return this.token;\n }\n\n bySourceId(id: string): PostedItem | undefined {\n return this.items.get(id);\n }\n\n byEventId(eventId: string): PostedItem | undefined {\n const sourceId = this.eventIndex.get(eventId);\n return sourceId === undefined ? undefined : this.items.get(sourceId);\n }\n\n openItems(): PostedItem[] {\n return [...this.items.values()].filter((item) => item.status === \"open\");\n }\n\n hasApplied(resolutionEventId: string): boolean {\n return this.applied.has(resolutionEventId);\n }\n\n /** Applies posted before closed, so one change may post and close the same item. */\n commit(change: StateChange): void {\n for (const item of change.posted ?? []) {\n this.items.set(item.sourceId, { ...item });\n this.eventIndex.set(item.eventId, item.sourceId);\n }\n for (const id of change.closed ?? []) {\n const item = this.items.get(id);\n if (item) item.status = \"closed\";\n }\n for (const id of change.applied ?? []) this.applied.add(id);\n if (change.sourceCursor !== undefined) this.cursor = change.sourceCursor;\n if (change.syncToken !== undefined) this.token = change.syncToken;\n }\n\n snapshot(): MirrorSnapshot {\n return {\n sourceCursor: this.cursor,\n syncToken: this.token,\n posted: [...this.items.values()].map((item) => ({ ...item })),\n applied: [...this.applied],\n };\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/redact.ts","../src/render.ts","../src/memory-source.ts","../src/memory-state.ts","../src/mirror.ts","../src/edit.ts","../src/run.ts"],"sourcesContent":["const PLACEHOLDER = \"[REDACTED]\";\n\nconst AUTH_HEADER = /\\b(Bearer|Basic)\\s+[A-Za-z0-9._~+/-]+=*/gi;\n// The optional quote after the name lets a JSON key such as \"password\": \"x\" match.\nconst KEY_VALUE = /\\b([A-Za-z0-9_-]*(?:token|key|password))([\"']?\\s*[=:]\\s*)(\"[^\"]*\"|'[^']*'|[^\\s&\"',;]+)/gi;\n// Exactly 40 hex characters is a git commit SHA, which the owner exempted so git previews stay approvable.\nconst HEX_RUN = /\\b(?:[0-9a-fA-F]{32,39}|[0-9a-fA-F]{41,})\\b/g;\nconst B64_RUN = /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_=-])/g;\n\nconst PLAIN_WORD = /^(?:[a-z]{3,}|[A-Z]{3,})$/;\n\n/** A base64-like run is a secret only if it mixes digits and both cases and no path segment is a plain word. */\nexport function looksSecret(run: string): boolean {\n if (!/\\d/.test(run) || !/[A-Z]/.test(run) || !/[a-z]/.test(run)) return false;\n return !run.split(\"/\").some((segment) => PLAIN_WORD.test(segment));\n}\n\n/** Section 10 decision 3: masks auth headers, key=value secrets and long hex or base64 runs. */\nexport function redactPreview(text: string): { text: string; redacted: boolean } {\n let hits = 0;\n const hit = (replacement: string) => {\n hits += 1;\n return replacement;\n };\n const out = text\n .replace(AUTH_HEADER, (_match, scheme: string) => hit(`${scheme} ${PLACEHOLDER}`))\n .replace(KEY_VALUE, (_match, name: string, separator: string) => hit(`${name}${separator}${PLACEHOLDER}`))\n .replace(HEX_RUN, () => hit(PLACEHOLDER))\n .replace(B64_RUN, (run) => (looksSecret(run) ? hit(PLACEHOLDER) : run));\n return { text: out, redacted: hits > 0 };\n}\n","import { MAX_CONTENT_BYTES, contentBytes, encodeItem, type ItemInput } from \"@titan-design/matrix-bus\";\nimport { redactPreview } from \"./redact.js\";\nimport type { QueueItem } from \"./types.js\";\n\nconst MAX_FIT_ROUNDS = 16;\n\nfunction fits(input: ItemInput): boolean {\n return contentBytes(encodeItem(input)) <= MAX_CONTENT_BYTES;\n}\n\n// Slices on code points, so an astral character is never split into a lone surrogate.\nfunction shrink(value: string | undefined, round: number): string | undefined {\n if (value === undefined) return undefined;\n const points = Array.from(value);\n return `${points.slice(0, Math.floor(points.length / 2 ** round)).join(\"\")}…`;\n}\n\n/** Halves `inputPreview` and `text` until the encoded content fits, marking the item truncated. */\nexport function fitItem(input: ItemInput): ItemInput {\n if (fits(input)) return input;\n for (let round = 1; round <= MAX_FIT_ROUNDS; round += 1) {\n const cut = { ...input, input_preview: shrink(input.input_preview, round), text: shrink(input.text, round), truncated: true };\n if (fits(cut)) return cut;\n }\n return { ...input, input_preview: undefined, text: undefined, truncated: true };\n}\n\nfunction redactFields(item: QueueItem): { inputPreview?: string; text?: string; redacted: boolean } {\n if (item.kind === \"approval_request\" && item.inputPreview !== undefined) {\n const { text, redacted } = redactPreview(item.inputPreview);\n return { inputPreview: text, text: item.text, redacted };\n }\n if (item.kind === \"endorse_request\" && item.text !== undefined) {\n const { text, redacted } = redactPreview(item.text);\n return { inputPreview: item.inputPreview, text, redacted };\n }\n return { inputPreview: item.inputPreview, text: item.text, redacted: false };\n}\n\n/** Redacts before fitting, so a cut never strands the tail of a secret the patterns no longer see whole. */\nexport function toItemInput(item: QueueItem): ItemInput {\n const { inputPreview, text, redacted } = redactFields(item);\n return fitItem({\n kind: item.kind,\n machine: item.machine,\n session: item.session,\n agent_id: item.agentId,\n msg_id: item.id,\n at: item.at,\n tool_name: item.toolName,\n input_preview: inputPreview,\n recipient: item.recipient,\n text,\n truncated: false,\n redacted,\n });\n}\n\n/** A /sync filter that returns only the queue room's timeline, with no presence, receipts or account data. */\nexport function syncFilter(roomId: string): Record<string, unknown> {\n const none = { types: [] as string[] };\n return {\n presence: none,\n account_data: none,\n room: { rooms: [roomId], timeline: { limit: 50 }, state: none, ephemeral: none, account_data: none },\n };\n}\n","import { ITEM_KINDS, type ItemKind } from \"@titan-design/matrix-bus\";\nimport type { CloseOutcome, QueueItem, QueueSource, ResolveResult, SourceEvent, VerdictInput } from \"./types.js\";\n\n/** An in-memory QueueSource for tests; cursors are the 1-based position in its event log. */\nexport class MemoryQueueSource implements QueueSource {\n readonly resolutions: Array<{ id: string } & VerdictInput> = [];\n private readonly pending = new Map<string, QueueItem>();\n private readonly log: SourceEvent[] = [];\n private readonly waiters = new Set<() => void>();\n\n constructor(readonly kinds: readonly ItemKind[] = ITEM_KINDS) {}\n\n add(item: QueueItem): void {\n this.pending.set(item.id, item);\n this.append({ type: \"opened\", item, cursor: this.nextCursor() });\n }\n\n close(id: string, outcome: CloseOutcome, label?: string): void {\n if (!this.pending.delete(id)) return;\n this.append({ type: \"closed\", id, outcome, label, cursor: this.nextCursor() });\n }\n\n async open(): Promise<QueueItem[]> {\n return [...this.pending.values()];\n }\n\n async resolve(id: string, verdict: VerdictInput): Promise<ResolveResult> {\n if (!this.pending.has(id)) return { ok: false, reason: \"closed\" };\n this.resolutions.push({ id, ...verdict });\n this.close(id, \"resolved\", verdict.verdict);\n return { ok: true };\n }\n\n /** \"Now\" is fixed when tail is called, not at the first next(), so nothing added in between is missed. */\n tail(cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent> {\n return this.stream(cursor === undefined ? this.log.length : Number(cursor), signal);\n }\n\n private async *stream(start: number, signal: AbortSignal): AsyncGenerator<SourceEvent> {\n let next = start;\n while (!signal.aborted) {\n while (next < this.log.length) yield this.log[next++] as SourceEvent;\n await this.nextAppend(signal);\n }\n }\n\n private nextCursor(): string {\n return String(this.log.length + 1);\n }\n\n private append(event: SourceEvent): void {\n this.log.push(event);\n for (const wake of [...this.waiters]) wake();\n }\n\n private nextAppend(signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal.aborted) return resolve();\n const done = () => {\n this.waiters.delete(done);\n signal.removeEventListener(\"abort\", done);\n resolve();\n };\n this.waiters.add(done);\n signal.addEventListener(\"abort\", done);\n });\n }\n}\n","import type { MirrorState, PostedItem, StateChange } from \"./types.js\";\n\nexport interface MirrorSnapshot {\n sourceCursor?: string;\n syncToken?: string;\n posted: PostedItem[];\n applied: string[];\n}\n\n/** An in-memory MirrorState; `snapshot` and `restore` stand in for a restart in replay tests. */\nexport class MemoryMirrorState implements MirrorState {\n private cursor: string | undefined;\n private token: string | undefined;\n private readonly items = new Map<string, PostedItem>();\n private readonly eventIndex = new Map<string, string>();\n private readonly applied = new Set<string>();\n\n static restore(snapshot: MirrorSnapshot): MemoryMirrorState {\n const state = new MemoryMirrorState();\n state.commit({ ...snapshot, posted: snapshot.posted.map((item) => ({ ...item })) });\n return state;\n }\n\n sourceCursor(): string | undefined {\n return this.cursor;\n }\n\n syncToken(): string | undefined {\n return this.token;\n }\n\n bySourceId(id: string): PostedItem | undefined {\n return this.items.get(id);\n }\n\n byEventId(eventId: string): PostedItem | undefined {\n const sourceId = this.eventIndex.get(eventId);\n return sourceId === undefined ? undefined : this.items.get(sourceId);\n }\n\n openItems(): PostedItem[] {\n return [...this.items.values()].filter((item) => item.status === \"open\");\n }\n\n hasApplied(resolutionEventId: string): boolean {\n return this.applied.has(resolutionEventId);\n }\n\n /** Applies posted before closed, so one change may post and close the same item. */\n commit(change: StateChange): void {\n for (const item of change.posted ?? []) {\n this.items.set(item.sourceId, { ...item });\n this.eventIndex.set(item.eventId, item.sourceId);\n }\n for (const id of change.closed ?? []) {\n const item = this.items.get(id);\n if (item) item.status = \"closed\";\n }\n for (const id of change.applied ?? []) this.applied.add(id);\n if (change.sourceCursor !== undefined) this.cursor = change.sourceCursor;\n if (change.syncToken !== undefined) this.token = change.syncToken;\n }\n\n snapshot(): MirrorSnapshot {\n return {\n sourceCursor: this.cursor,\n syncToken: this.token,\n posted: [...this.items.values()].map((item) => ({ ...item })),\n applied: [...this.applied],\n };\n }\n}\n","import { ITEM_KEY, encodeItem, foldResolution, type AppserviceClient, type ItemKind, type MatrixEvent, type Resolution, type SyncBatch } from \"@titan-design/matrix-bus\";\nimport { encodeEdit } from \"./edit.js\";\nimport { toItemInput } from \"./render.js\";\nimport type { Backoff } from \"./supervise.js\";\nimport type { MirrorLogger, MirrorState, PostedItem, QueueItem, QueueSource, ResolveResult, SourceEvent, VerdictInput } from \"./types.js\";\n\n/** The slice of AppserviceClient the mirror drives; tests pass a fake hub. */\nexport type MirrorBus = Pick<AppserviceClient, \"send\" | \"syncLoop\" | \"userId\">;\n\nexport interface MirrorOptions {\n ownerUserId: string;\n roomId: string;\n signal: AbortSignal;\n now?: () => number;\n /** Default expiry for approval_request items lacking expiresAt. */\n approvalTtlMs?: number;\n sweepIntervalMs?: number;\n syncTimeoutMs?: number;\n backoff?: Backoff;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n logger?: MirrorLogger;\n}\n\n/** Steps exposed so tests drive the mirror deterministically; runMirror only loops over them. */\nexport interface Mirror {\n reconcile(): Promise<void>;\n applySourceEvent(event: SourceEvent): Promise<void>;\n applySyncBatch(batch: SyncBatch): Promise<void>;\n sweepExpired(): Promise<void>;\n}\n\nexport const SILENT: MirrorLogger = { info: () => {}, warn: () => {} };\n\n// Deterministic, so a re-send after a crash between send and commit is deduped by the homeserver.\nconst itemTxnId = (sourceId: string) => `qm-${encodeURIComponent(sourceId)}`;\nconst editTxnId = (sourceId: string) => `qm-edit-${encodeURIComponent(sourceId)}`;\n\nfunction closedStatus(event: Extract<SourceEvent, { type: \"closed\" }>): string {\n if (event.outcome === \"resolved\") return event.label ? `resolved at the terminal: ${event.label}` : \"resolved at the terminal\";\n return event.outcome;\n}\n\nclass QueueMirror implements Mirror {\n private readonly log: MirrorLogger;\n private readonly now: () => number;\n /** Source ids with a phone verdict in flight; their echoed source close must not win the edit. */\n private readonly resolving = new Set<string>();\n\n constructor(\n private readonly source: QueueSource,\n private readonly bus: MirrorBus,\n private readonly state: MirrorState,\n private readonly options: MirrorOptions,\n ) {\n this.log = options.logger ?? SILENT;\n this.now = options.now ?? Date.now;\n }\n\n async reconcile(): Promise<void> {\n const pending = await this.source.open();\n const pendingIds = new Set(pending.map((item) => item.id));\n for (const item of this.state.openItems()) {\n if (!pendingIds.has(item.sourceId)) await this.close(item, \"resolved at the terminal\");\n }\n for (const item of pending) if (!this.state.bySourceId(item.id)) await this.post(item);\n }\n\n async applySourceEvent(event: SourceEvent): Promise<void> {\n const cursor = event.cursor;\n if (event.type === \"opened\") {\n if (this.state.bySourceId(event.item.id)) return this.state.commit({ sourceCursor: cursor });\n return this.post(event.item, cursor);\n }\n const item = this.state.bySourceId(event.id);\n if (item?.status !== \"open\" || this.resolving.has(event.id)) return this.state.commit({ sourceCursor: cursor });\n await this.close(item, closedStatus(event), { sourceCursor: cursor });\n }\n\n /** Persists `since` only after every event is handled; a throw leaves it for the retry. */\n async applySyncBatch(batch: SyncBatch): Promise<void> {\n for (const event of batch.events) await this.applyEvent(event);\n this.state.commit({ syncToken: batch.since });\n }\n\n async sweepExpired(): Promise<void> {\n const now = this.now();\n for (const item of this.state.openItems()) {\n if (item.expiresAt !== undefined && item.expiresAt <= now) await this.close(item, \"expired\");\n }\n }\n\n private expiryOf(item: QueueItem): number | undefined {\n if (item.expiresAt !== undefined) return item.expiresAt;\n const ttl = this.options.approvalTtlMs;\n return item.kind === \"approval_request\" && ttl !== undefined ? item.at + ttl : undefined;\n }\n\n private async post(item: QueueItem, sourceCursor?: string): Promise<void> {\n const input = toItemInput(item);\n const content = encodeItem(input);\n const { event_id: eventId } = await this.bus.send(this.options.roomId, \"m.room.message\", content, itemTxnId(item.id));\n const posted: PostedItem = {\n sourceId: item.id,\n eventId,\n kind: item.kind,\n approvable: !input.redacted && !input.truncated,\n status: \"open\",\n expiresAt: this.expiryOf(item),\n record: content[ITEM_KEY],\n };\n this.state.commit({ posted: [posted], sourceCursor });\n this.log.info(\"posted\", { sourceId: item.id, eventId });\n }\n\n /** Edits first, then commits, so a crash in between replays into the same deduped edit. */\n private async close(item: PostedItem, status: string, change: { sourceCursor?: string; applied?: string[] } = {}): Promise<void> {\n const edit = encodeEdit(item.eventId, item.record, status);\n await this.bus.send(this.options.roomId, \"m.room.message\", edit, editTxnId(item.sourceId));\n this.state.commit({ ...change, closed: [item.sourceId] });\n this.log.info(\"closed\", { sourceId: item.sourceId, status });\n }\n\n private async applyEvent(event: MatrixEvent): Promise<void> {\n if (event.sender === this.bus.userId || this.state.hasApplied(event.event_id)) return;\n const open = this.state.openItems();\n const resolution = this.fold(event, open.filter((item) => item.approvable));\n if (resolution) return this.applyResolution(event, resolution);\n const refused = this.fold(event, open.filter((item) => !item.approvable));\n if (refused) return this.logRefusal(event, refused);\n if (isResolutionShaped(event)) this.log.info(\"ignored\", { eventId: event.event_id, sender: event.sender });\n }\n\n private fold(event: MatrixEvent, items: PostedItem[]): Resolution | null {\n const itemEventIds = new Map<string, ItemKind>(items.map((item) => [item.eventId, item.kind]));\n return foldResolution(event, { ownerUserId: this.options.ownerUserId, itemEventIds });\n }\n\n private logRefusal(event: MatrixEvent, refused: Resolution): void {\n const item = this.state.byEventId(refused.itemEventId);\n const reason = item?.record.truncated ? \"truncated\" : \"redacted\";\n this.log.warn(`refused: ${reason}`, { eventId: event.event_id, sourceId: item?.sourceId });\n }\n\n private async applyResolution(event: MatrixEvent, { itemEventId, verdict, text }: Resolution): Promise<void> {\n const item = this.state.byEventId(itemEventId) as PostedItem;\n const applied = [event.event_id];\n const result = await this.resolveAtSource(item.sourceId, { verdict, text, resolutionEventId: event.event_id });\n if (result.ok) return this.close(item, `resolved: ${verdict}`, { applied });\n if (result.reason === \"closed\") return this.close(item, \"already resolved\", { applied });\n this.state.commit({ applied });\n this.log.warn(\"rejected\", { sourceId: item.sourceId, verdict, detail: result.detail });\n }\n\n private async resolveAtSource(sourceId: string, verdict: VerdictInput): Promise<ResolveResult> {\n this.resolving.add(sourceId);\n try {\n return await this.source.resolve(sourceId, verdict);\n } finally {\n this.resolving.delete(sourceId);\n }\n }\n}\n\nconst RESOLUTION_TYPES = new Set([\"m.reaction\", \"m.room.message\", \"io.titan.resolution\"]);\n\nfunction isResolutionShaped(event: MatrixEvent): boolean {\n return RESOLUTION_TYPES.has(event.type) && event.state_key === undefined;\n}\n\nexport function createMirror(source: QueueSource, bus: MirrorBus, state: MirrorState, options: MirrorOptions): Mirror {\n return new QueueMirror(source, bus, state, options);\n}\n","import { ITEM_KEY, renderItemBody, type TitanItem } from \"@titan-design/matrix-bus\";\n\nfunction headline(record: TitanItem): string {\n return renderItemBody({ ...record, text: undefined }).split(\"\\n\")[0] ?? \"\";\n}\n\n/** An m.replace edit of an item: a short headline and status body, with the original record in m.new_content. */\nexport function encodeEdit(targetEventId: string, record: TitanItem, status: string): Record<string, unknown> {\n const body = `${headline(record)}\\n${status}`;\n return {\n msgtype: \"m.text\",\n body: `* ${body}`,\n \"m.new_content\": { msgtype: \"m.text\", body, [ITEM_KEY]: record },\n \"m.relates_to\": { rel_type: \"m.replace\", event_id: targetEventId },\n };\n}\n","import { createMirror, SILENT, type Mirror, type MirrorBus, type MirrorOptions } from \"./mirror.js\";\nimport { syncFilter } from \"./render.js\";\nimport { DEFAULT_BACKOFF, abortableSleep, superviseLoop } from \"./supervise.js\";\nimport type { MirrorState, QueueSource } from \"./types.js\";\n\ntype Body = (reset: () => void) => Promise<void>;\n\nfunction streamEnded(name: string, signal: AbortSignal): void {\n if (!signal.aborted) throw new Error(`${name} stream ended`);\n}\n\n// Opens the tail before reconciling, so an item opened during reconcile arrives through the tail.\nfunction sourceLoop(mirror: Mirror, source: QueueSource, state: MirrorState, signal: AbortSignal): Body {\n return async (reset) => {\n const events = source.tail(state.sourceCursor(), signal);\n await mirror.reconcile();\n for await (const event of events) {\n await mirror.applySourceEvent(event);\n reset();\n }\n streamEnded(\"source\", signal);\n };\n}\n\nfunction syncLoop(mirror: Mirror, bus: MirrorBus, state: MirrorState, options: MirrorOptions): Body {\n const { roomId, signal, syncTimeoutMs = 30_000 } = options;\n return async (reset) => {\n for await (const batch of bus.syncLoop({ since: state.syncToken(), filter: syncFilter(roomId), timeoutMs: syncTimeoutMs, signal })) {\n await mirror.applySyncBatch(batch);\n reset();\n }\n streamEnded(\"sync\", signal);\n };\n}\n\nfunction sweepLoop(mirror: Mirror, options: MirrorOptions, sleep: NonNullable<MirrorOptions[\"sleep\"]>): Body {\n const { signal, sweepIntervalMs = 5_000 } = options;\n return async (reset) => {\n while (!signal.aborted) {\n await mirror.sweepExpired();\n reset();\n await sleep(sweepIntervalMs, signal);\n }\n };\n}\n\n/** Runs the source tail (reconciling on each start), the sync loop and the expiry sweep until `signal` aborts. */\nexport async function runMirror(source: QueueSource, bus: MirrorBus, state: MirrorState, options: MirrorOptions): Promise<void> {\n const mirror = createMirror(source, bus, state, options);\n const sleep = options.sleep ?? abortableSleep;\n const supervise = { signal: options.signal, backoff: options.backoff ?? DEFAULT_BACKOFF, sleep, logger: options.logger ?? SILENT };\n await Promise.all([\n superviseLoop(\"source\", sourceLoop(mirror, source, state, options.signal), supervise),\n superviseLoop(\"sync\", syncLoop(mirror, bus, state, options), supervise),\n superviseLoop(\"sweep\", sweepLoop(mirror, options, sleep), supervise),\n ]);\n}\n"],"mappings":"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","names":["ITEM_KEY","encodeItem","encodeItem","ITEM_KEY"]}
|
package/dist/sqlite.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Db, Migration } from '@titan-design/store-sqlite';
|
|
2
|
+
import { M as MirrorState, P as PostedItem, b as StateChange } from './types-BgRpl8NH.js';
|
|
3
|
+
import '@titan-design/matrix-bus';
|
|
4
|
+
|
|
5
|
+
declare const DEFAULT_MIRROR_TABLE_PREFIX = "queue_mirror";
|
|
6
|
+
declare function mirrorTableDdl(prefix?: string): string;
|
|
7
|
+
/** Drop into the product's own `runMigrations` list so queue-mirror shares one database with its domain tables. */
|
|
8
|
+
declare function mirrorMigration(version: number, prefix?: string): Migration;
|
|
9
|
+
interface SqliteMirrorStateOptions {
|
|
10
|
+
tablePrefix?: string;
|
|
11
|
+
/** Run `mirrorMigration` on construction. Off when the product owns its migration list. */
|
|
12
|
+
migrate?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Durable mirror state. `commit` runs every write in one transaction, so a
|
|
16
|
+
* failure partway (e.g. a duplicate event id) leaves no partial rows behind.
|
|
17
|
+
*/
|
|
18
|
+
declare class SqliteMirrorState implements MirrorState {
|
|
19
|
+
private readonly db;
|
|
20
|
+
private readonly itemTable;
|
|
21
|
+
private readonly appliedTable;
|
|
22
|
+
private readonly cursorTable;
|
|
23
|
+
private readonly applyChange;
|
|
24
|
+
constructor(db: Db, options?: SqliteMirrorStateOptions);
|
|
25
|
+
sourceCursor(): string | undefined;
|
|
26
|
+
syncToken(): string | undefined;
|
|
27
|
+
bySourceId(id: string): PostedItem | undefined;
|
|
28
|
+
byEventId(eventId: string): PostedItem | undefined;
|
|
29
|
+
openItems(): PostedItem[];
|
|
30
|
+
hasApplied(resolutionEventId: string): boolean;
|
|
31
|
+
commit(change: StateChange): void;
|
|
32
|
+
/** Applies posted before closed, so one change may post and close the same item; matches MemoryMirrorState. */
|
|
33
|
+
private applyChangeUnsafe;
|
|
34
|
+
private readCursor;
|
|
35
|
+
private upsertCursor;
|
|
36
|
+
private insertItem;
|
|
37
|
+
private closeItem;
|
|
38
|
+
private markApplied;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { DEFAULT_MIRROR_TABLE_PREFIX, SqliteMirrorState, type SqliteMirrorStateOptions, mirrorMigration, mirrorTableDdl };
|
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// src/sqlite-state.ts
|
|
2
|
+
import { quoteIdent, runMigrations } from "@titan-design/store-sqlite";
|
|
3
|
+
var DEFAULT_MIRROR_TABLE_PREFIX = "queue_mirror";
|
|
4
|
+
function tableNames(prefix) {
|
|
5
|
+
return { item: `${prefix}_item`, applied: `${prefix}_applied`, cursor: `${prefix}_cursor` };
|
|
6
|
+
}
|
|
7
|
+
function mirrorTableDdl(prefix = DEFAULT_MIRROR_TABLE_PREFIX) {
|
|
8
|
+
const { item, applied, cursor } = tableNames(prefix);
|
|
9
|
+
return `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS ${quoteIdent(item)} (
|
|
11
|
+
source_id TEXT PRIMARY KEY,
|
|
12
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
13
|
+
kind TEXT NOT NULL,
|
|
14
|
+
approvable INTEGER NOT NULL,
|
|
15
|
+
status TEXT NOT NULL,
|
|
16
|
+
expires_at INTEGER,
|
|
17
|
+
record TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS ${quoteIdent(applied)} (
|
|
20
|
+
resolution_event_id TEXT PRIMARY KEY
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS ${quoteIdent(cursor)} (
|
|
23
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
24
|
+
source_cursor TEXT,
|
|
25
|
+
sync_token TEXT
|
|
26
|
+
);
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
function mirrorMigration(version, prefix = DEFAULT_MIRROR_TABLE_PREFIX) {
|
|
30
|
+
return { version, name: `queue-mirror:${prefix}`, up: (db) => db.exec(mirrorTableDdl(prefix)) };
|
|
31
|
+
}
|
|
32
|
+
function toPostedItem(row) {
|
|
33
|
+
return {
|
|
34
|
+
sourceId: row.source_id,
|
|
35
|
+
eventId: row.event_id,
|
|
36
|
+
kind: row.kind,
|
|
37
|
+
approvable: row.approvable === 1,
|
|
38
|
+
status: row.status,
|
|
39
|
+
expiresAt: row.expires_at ?? void 0,
|
|
40
|
+
record: JSON.parse(row.record)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
var SqliteMirrorState = class {
|
|
44
|
+
constructor(db, options = {}) {
|
|
45
|
+
this.db = db;
|
|
46
|
+
const prefix = options.tablePrefix ?? DEFAULT_MIRROR_TABLE_PREFIX;
|
|
47
|
+
const names = tableNames(prefix);
|
|
48
|
+
this.itemTable = quoteIdent(names.item);
|
|
49
|
+
this.appliedTable = quoteIdent(names.applied);
|
|
50
|
+
this.cursorTable = quoteIdent(names.cursor);
|
|
51
|
+
if (options.migrate ?? true) runMigrations(db, [mirrorMigration(1, prefix)]);
|
|
52
|
+
this.applyChange = db.transaction((change) => this.applyChangeUnsafe(change));
|
|
53
|
+
}
|
|
54
|
+
db;
|
|
55
|
+
itemTable;
|
|
56
|
+
appliedTable;
|
|
57
|
+
cursorTable;
|
|
58
|
+
applyChange;
|
|
59
|
+
sourceCursor() {
|
|
60
|
+
return this.readCursor()?.source_cursor ?? void 0;
|
|
61
|
+
}
|
|
62
|
+
syncToken() {
|
|
63
|
+
return this.readCursor()?.sync_token ?? void 0;
|
|
64
|
+
}
|
|
65
|
+
bySourceId(id) {
|
|
66
|
+
const row = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE source_id = ?`).get(id);
|
|
67
|
+
return row ? toPostedItem(row) : void 0;
|
|
68
|
+
}
|
|
69
|
+
byEventId(eventId) {
|
|
70
|
+
const row = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE event_id = ?`).get(eventId);
|
|
71
|
+
return row ? toPostedItem(row) : void 0;
|
|
72
|
+
}
|
|
73
|
+
openItems() {
|
|
74
|
+
const rows = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE status = 'open'`).all();
|
|
75
|
+
return rows.map(toPostedItem);
|
|
76
|
+
}
|
|
77
|
+
hasApplied(resolutionEventId) {
|
|
78
|
+
const row = this.db.prepare(`SELECT 1 FROM ${this.appliedTable} WHERE resolution_event_id = ?`).get(
|
|
79
|
+
resolutionEventId
|
|
80
|
+
);
|
|
81
|
+
return row !== void 0;
|
|
82
|
+
}
|
|
83
|
+
commit(change) {
|
|
84
|
+
this.applyChange(change);
|
|
85
|
+
}
|
|
86
|
+
/** Applies posted before closed, so one change may post and close the same item; matches MemoryMirrorState. */
|
|
87
|
+
applyChangeUnsafe(change) {
|
|
88
|
+
this.upsertCursor(change);
|
|
89
|
+
for (const item of change.posted ?? []) this.insertItem(item);
|
|
90
|
+
for (const id of change.closed ?? []) this.closeItem(id);
|
|
91
|
+
for (const id of change.applied ?? []) this.markApplied(id);
|
|
92
|
+
}
|
|
93
|
+
readCursor() {
|
|
94
|
+
return this.db.prepare(`SELECT source_cursor, sync_token FROM ${this.cursorTable} WHERE id = 1`).get();
|
|
95
|
+
}
|
|
96
|
+
upsertCursor(change) {
|
|
97
|
+
if (change.sourceCursor === void 0 && change.syncToken === void 0) return;
|
|
98
|
+
const current = this.readCursor();
|
|
99
|
+
const sourceCursor = change.sourceCursor ?? current?.source_cursor ?? null;
|
|
100
|
+
const syncToken = change.syncToken ?? current?.sync_token ?? null;
|
|
101
|
+
this.db.prepare(
|
|
102
|
+
`INSERT INTO ${this.cursorTable} (id, source_cursor, sync_token) VALUES (1, ?, ?)
|
|
103
|
+
ON CONFLICT(id) DO UPDATE SET source_cursor = excluded.source_cursor, sync_token = excluded.sync_token`
|
|
104
|
+
).run(sourceCursor, syncToken);
|
|
105
|
+
}
|
|
106
|
+
insertItem(item) {
|
|
107
|
+
this.db.prepare(
|
|
108
|
+
`INSERT INTO ${this.itemTable}
|
|
109
|
+
(source_id, event_id, kind, approvable, status, expires_at, record)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
111
|
+
).run(
|
|
112
|
+
item.sourceId,
|
|
113
|
+
item.eventId,
|
|
114
|
+
item.kind,
|
|
115
|
+
item.approvable ? 1 : 0,
|
|
116
|
+
item.status,
|
|
117
|
+
item.expiresAt ?? null,
|
|
118
|
+
JSON.stringify(item.record)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
closeItem(sourceId) {
|
|
122
|
+
this.db.prepare(`UPDATE ${this.itemTable} SET status = 'closed' WHERE source_id = ?`).run(sourceId);
|
|
123
|
+
}
|
|
124
|
+
markApplied(resolutionEventId) {
|
|
125
|
+
this.db.prepare(`INSERT OR IGNORE INTO ${this.appliedTable} (resolution_event_id) VALUES (?)`).run(resolutionEventId);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
export {
|
|
129
|
+
DEFAULT_MIRROR_TABLE_PREFIX,
|
|
130
|
+
SqliteMirrorState,
|
|
131
|
+
mirrorMigration,
|
|
132
|
+
mirrorTableDdl
|
|
133
|
+
};
|
|
134
|
+
//# sourceMappingURL=sqlite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/sqlite-state.ts"],"sourcesContent":["import { quoteIdent, runMigrations, type Db, type Migration } from \"@titan-design/store-sqlite\";\nimport type { ItemKind } from \"@titan-design/matrix-bus\";\nimport type { MirrorState, PostedItem, StateChange } from \"./types.js\";\n\nexport const DEFAULT_MIRROR_TABLE_PREFIX = \"queue_mirror\";\n\nfunction tableNames(prefix: string): { item: string; applied: string; cursor: string } {\n return { item: `${prefix}_item`, applied: `${prefix}_applied`, cursor: `${prefix}_cursor` };\n}\n\nexport function mirrorTableDdl(prefix: string = DEFAULT_MIRROR_TABLE_PREFIX): string {\n const { item, applied, cursor } = tableNames(prefix);\n return `\n CREATE TABLE IF NOT EXISTS ${quoteIdent(item)} (\n source_id TEXT PRIMARY KEY,\n event_id TEXT NOT NULL UNIQUE,\n kind TEXT NOT NULL,\n approvable INTEGER NOT NULL,\n status TEXT NOT NULL,\n expires_at INTEGER,\n record TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS ${quoteIdent(applied)} (\n resolution_event_id TEXT PRIMARY KEY\n );\n CREATE TABLE IF NOT EXISTS ${quoteIdent(cursor)} (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n source_cursor TEXT,\n sync_token TEXT\n );\n `;\n}\n\n/** Drop into the product's own `runMigrations` list so queue-mirror shares one database with its domain tables. */\nexport function mirrorMigration(version: number, prefix: string = DEFAULT_MIRROR_TABLE_PREFIX): Migration {\n return { version, name: `queue-mirror:${prefix}`, up: (db: Db) => db.exec(mirrorTableDdl(prefix)) };\n}\n\nexport interface SqliteMirrorStateOptions {\n tablePrefix?: string;\n /** Run `mirrorMigration` on construction. Off when the product owns its migration list. */\n migrate?: boolean;\n}\n\ninterface RawItemRow {\n source_id: string;\n event_id: string;\n kind: ItemKind;\n approvable: number;\n status: \"open\" | \"closed\";\n expires_at: number | null;\n record: string;\n}\n\ninterface RawCursorRow {\n source_cursor: string | null;\n sync_token: string | null;\n}\n\nfunction toPostedItem(row: RawItemRow): PostedItem {\n return {\n sourceId: row.source_id,\n eventId: row.event_id,\n kind: row.kind,\n approvable: row.approvable === 1,\n status: row.status,\n expiresAt: row.expires_at ?? undefined,\n record: JSON.parse(row.record),\n };\n}\n\n/**\n * Durable mirror state. `commit` runs every write in one transaction, so a\n * failure partway (e.g. a duplicate event id) leaves no partial rows behind.\n */\nexport class SqliteMirrorState implements MirrorState {\n private readonly itemTable: string;\n private readonly appliedTable: string;\n private readonly cursorTable: string;\n private readonly applyChange: (change: StateChange) => void;\n\n constructor(\n private readonly db: Db,\n options: SqliteMirrorStateOptions = {},\n ) {\n const prefix = options.tablePrefix ?? DEFAULT_MIRROR_TABLE_PREFIX;\n const names = tableNames(prefix);\n this.itemTable = quoteIdent(names.item);\n this.appliedTable = quoteIdent(names.applied);\n this.cursorTable = quoteIdent(names.cursor);\n if (options.migrate ?? true) runMigrations(db, [mirrorMigration(1, prefix)]);\n this.applyChange = db.transaction((change: StateChange) => this.applyChangeUnsafe(change));\n }\n\n sourceCursor(): string | undefined {\n return this.readCursor()?.source_cursor ?? undefined;\n }\n\n syncToken(): string | undefined {\n return this.readCursor()?.sync_token ?? undefined;\n }\n\n bySourceId(id: string): PostedItem | undefined {\n const row = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE source_id = ?`).get(id) as\n | RawItemRow\n | undefined;\n return row ? toPostedItem(row) : undefined;\n }\n\n byEventId(eventId: string): PostedItem | undefined {\n const row = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE event_id = ?`).get(eventId) as\n | RawItemRow\n | undefined;\n return row ? toPostedItem(row) : undefined;\n }\n\n openItems(): PostedItem[] {\n const rows = this.db.prepare(`SELECT * FROM ${this.itemTable} WHERE status = 'open'`).all() as RawItemRow[];\n return rows.map(toPostedItem);\n }\n\n hasApplied(resolutionEventId: string): boolean {\n const row = this.db.prepare(`SELECT 1 FROM ${this.appliedTable} WHERE resolution_event_id = ?`).get(\n resolutionEventId,\n );\n return row !== undefined;\n }\n\n commit(change: StateChange): void {\n this.applyChange(change);\n }\n\n /** Applies posted before closed, so one change may post and close the same item; matches MemoryMirrorState. */\n private applyChangeUnsafe(change: StateChange): void {\n this.upsertCursor(change);\n for (const item of change.posted ?? []) this.insertItem(item);\n for (const id of change.closed ?? []) this.closeItem(id);\n for (const id of change.applied ?? []) this.markApplied(id);\n }\n\n private readCursor(): RawCursorRow | undefined {\n return this.db.prepare(`SELECT source_cursor, sync_token FROM ${this.cursorTable} WHERE id = 1`).get() as\n | RawCursorRow\n | undefined;\n }\n\n private upsertCursor(change: StateChange): void {\n if (change.sourceCursor === undefined && change.syncToken === undefined) return;\n const current = this.readCursor();\n const sourceCursor = change.sourceCursor ?? current?.source_cursor ?? null;\n const syncToken = change.syncToken ?? current?.sync_token ?? null;\n this.db\n .prepare(\n `INSERT INTO ${this.cursorTable} (id, source_cursor, sync_token) VALUES (1, ?, ?)\n ON CONFLICT(id) DO UPDATE SET source_cursor = excluded.source_cursor, sync_token = excluded.sync_token`,\n )\n .run(sourceCursor, syncToken);\n }\n\n private insertItem(item: PostedItem): void {\n this.db\n .prepare(\n `INSERT INTO ${this.itemTable}\n (source_id, event_id, kind, approvable, status, expires_at, record)\n VALUES (?, ?, ?, ?, ?, ?, ?)`,\n )\n .run(\n item.sourceId,\n item.eventId,\n item.kind,\n item.approvable ? 1 : 0,\n item.status,\n item.expiresAt ?? null,\n JSON.stringify(item.record),\n );\n }\n\n private closeItem(sourceId: string): void {\n this.db.prepare(`UPDATE ${this.itemTable} SET status = 'closed' WHERE source_id = ?`).run(sourceId);\n }\n\n private markApplied(resolutionEventId: string): void {\n this.db\n .prepare(`INSERT OR IGNORE INTO ${this.appliedTable} (resolution_event_id) VALUES (?)`)\n .run(resolutionEventId);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Verdict, ItemKind, TitanItem } from '@titan-design/matrix-bus';
|
|
2
|
+
|
|
3
|
+
/** One pending thing a human can act on; `id` becomes TitanItem.msg_id. */
|
|
4
|
+
interface QueueItem {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: ItemKind;
|
|
7
|
+
machine: string;
|
|
8
|
+
session: string;
|
|
9
|
+
at: number;
|
|
10
|
+
agentId?: string;
|
|
11
|
+
toolName?: string;
|
|
12
|
+
inputPreview?: string;
|
|
13
|
+
recipient?: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
/** Epoch ms; the mirror edits the item "expired" once the clock passes it. */
|
|
16
|
+
expiresAt?: number;
|
|
17
|
+
}
|
|
18
|
+
type CloseOutcome = "resolved" | "cancelled" | "expired";
|
|
19
|
+
type SourceEvent = {
|
|
20
|
+
type: "opened";
|
|
21
|
+
item: QueueItem;
|
|
22
|
+
cursor: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "closed";
|
|
25
|
+
id: string;
|
|
26
|
+
outcome: CloseOutcome;
|
|
27
|
+
label?: string;
|
|
28
|
+
cursor: string;
|
|
29
|
+
};
|
|
30
|
+
interface VerdictInput {
|
|
31
|
+
verdict: Verdict;
|
|
32
|
+
text?: string;
|
|
33
|
+
resolutionEventId: string;
|
|
34
|
+
}
|
|
35
|
+
type ResolveResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: "closed" | "rejected";
|
|
40
|
+
detail?: string;
|
|
41
|
+
};
|
|
42
|
+
interface QueueSource {
|
|
43
|
+
readonly kinds: readonly ItemKind[];
|
|
44
|
+
open(): Promise<QueueItem[]>;
|
|
45
|
+
/** Events after `cursor` (undefined = from now); ends when `signal` aborts. */
|
|
46
|
+
tail(cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent>;
|
|
47
|
+
resolve(id: string, verdict: VerdictInput): Promise<ResolveResult>;
|
|
48
|
+
}
|
|
49
|
+
interface PostedItem {
|
|
50
|
+
sourceId: string;
|
|
51
|
+
eventId: string;
|
|
52
|
+
kind: ItemKind;
|
|
53
|
+
/** False when the item was redacted or truncated, so no phone verdict may resolve it. */
|
|
54
|
+
approvable: boolean;
|
|
55
|
+
status: "open" | "closed";
|
|
56
|
+
/** Epoch ms after which the sweep closes the item: the source's expiry, or the approval TTL. */
|
|
57
|
+
expiresAt?: number;
|
|
58
|
+
/** The record as posted; an edit repeats it in m.new_content and reads its redacted and truncated flags. */
|
|
59
|
+
record: TitanItem;
|
|
60
|
+
}
|
|
61
|
+
interface StateChange {
|
|
62
|
+
sourceCursor?: string;
|
|
63
|
+
syncToken?: string;
|
|
64
|
+
posted?: PostedItem[];
|
|
65
|
+
/** Source ids. */
|
|
66
|
+
closed?: string[];
|
|
67
|
+
/** Resolution event ids. */
|
|
68
|
+
applied?: string[];
|
|
69
|
+
}
|
|
70
|
+
/** Synchronous like hitl's GateStore: both implementations are local. `commit` is atomic. */
|
|
71
|
+
interface MirrorState {
|
|
72
|
+
sourceCursor(): string | undefined;
|
|
73
|
+
syncToken(): string | undefined;
|
|
74
|
+
bySourceId(id: string): PostedItem | undefined;
|
|
75
|
+
byEventId(eventId: string): PostedItem | undefined;
|
|
76
|
+
openItems(): PostedItem[];
|
|
77
|
+
hasApplied(resolutionEventId: string): boolean;
|
|
78
|
+
commit(change: StateChange): void;
|
|
79
|
+
}
|
|
80
|
+
interface MirrorLogger {
|
|
81
|
+
info(msg: string, data?: object): void;
|
|
82
|
+
warn(msg: string, data?: object): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type { CloseOutcome as C, MirrorState as M, PostedItem as P, QueueSource as Q, ResolveResult as R, SourceEvent as S, VerdictInput as V, QueueItem as a, StateChange as b, MirrorLogger as c };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@titan-design/queue-mirror",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Projects a local queue of human-actionable items into a Matrix room and folds the owner's verdicts back",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./hitl": {
|
|
14
|
+
"types": "./dist/hitl.d.ts",
|
|
15
|
+
"import": "./dist/hitl.js"
|
|
16
|
+
},
|
|
17
|
+
"./sqlite": {
|
|
18
|
+
"types": "./dist/sqlite.d.ts",
|
|
19
|
+
"import": "./dist/sqlite.js"
|
|
12
20
|
}
|
|
13
21
|
},
|
|
14
22
|
"files": [
|
|
@@ -31,7 +39,9 @@
|
|
|
31
39
|
"access": "public"
|
|
32
40
|
},
|
|
33
41
|
"dependencies": {
|
|
34
|
-
"@titan-design/
|
|
42
|
+
"@titan-design/hitl": "^0.2.1",
|
|
43
|
+
"@titan-design/matrix-bus": "^0.1.0",
|
|
44
|
+
"@titan-design/store-sqlite": "^0.3.1"
|
|
35
45
|
},
|
|
36
46
|
"scripts": {
|
|
37
47
|
"build": "tsup",
|