@titan-design/queue-mirror 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Jewkes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @titan-design/queue-mirror
2
+
3
+ Projects a local queue of human-actionable items (agent approvals, endorsements, questions,
4
+ notices) into a Matrix `#queue` room, and folds the owner's reactions and replies back into
5
+ verdicts on that queue.
6
+
7
+ Tier 2 of the titan-platform DAG. Depends on `@titan-design/matrix-bus`. The root entry has
8
+ no `node:` import.
9
+
10
+ Reference: [site/reference/queue-mirror.md](../../site/reference/queue-mirror.md).
@@ -0,0 +1,143 @@
1
+ import { ItemKind, TitanItem, Verdict, ItemInput } 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
+
81
+ /** A base64-like run is a secret only if it mixes digits and both cases and no path segment is a plain word. */
82
+ declare function looksSecret(run: string): boolean;
83
+ /** Section 10 decision 3: masks auth headers, key=value secrets and long hex or base64 runs. */
84
+ declare function redactPreview(text: string): {
85
+ text: string;
86
+ redacted: boolean;
87
+ };
88
+
89
+ /** Halves `inputPreview` and `text` until the encoded content fits, marking the item truncated. */
90
+ declare function fitItem(input: ItemInput): ItemInput;
91
+ /** Redacts before fitting, so a cut never strands the tail of a secret the patterns no longer see whole. */
92
+ declare function toItemInput(item: QueueItem): ItemInput;
93
+ /** A /sync filter that returns only the queue room's timeline, with no presence, receipts or account data. */
94
+ declare function syncFilter(roomId: string): Record<string, unknown>;
95
+
96
+ /** An in-memory QueueSource for tests; cursors are the 1-based position in its event log. */
97
+ declare class MemoryQueueSource implements QueueSource {
98
+ readonly kinds: readonly ItemKind[];
99
+ readonly resolutions: Array<{
100
+ id: string;
101
+ } & VerdictInput>;
102
+ private readonly pending;
103
+ private readonly log;
104
+ private readonly waiters;
105
+ constructor(kinds?: readonly ItemKind[]);
106
+ add(item: QueueItem): void;
107
+ close(id: string, outcome: CloseOutcome, label?: string): void;
108
+ open(): Promise<QueueItem[]>;
109
+ resolve(id: string, verdict: VerdictInput): Promise<ResolveResult>;
110
+ /** "Now" is fixed when tail is called, not at the first next(), so nothing added in between is missed. */
111
+ tail(cursor: string | undefined, signal: AbortSignal): AsyncIterable<SourceEvent>;
112
+ private stream;
113
+ private nextCursor;
114
+ private append;
115
+ private nextAppend;
116
+ }
117
+
118
+ interface MirrorSnapshot {
119
+ sourceCursor?: string;
120
+ syncToken?: string;
121
+ posted: PostedItem[];
122
+ applied: string[];
123
+ }
124
+ /** An in-memory MirrorState; `snapshot` and `restore` stand in for a restart in replay tests. */
125
+ declare class MemoryMirrorState implements MirrorState {
126
+ private cursor;
127
+ private token;
128
+ private readonly items;
129
+ private readonly eventIndex;
130
+ private readonly applied;
131
+ static restore(snapshot: MirrorSnapshot): MemoryMirrorState;
132
+ sourceCursor(): string | undefined;
133
+ syncToken(): string | undefined;
134
+ bySourceId(id: string): PostedItem | undefined;
135
+ byEventId(eventId: string): PostedItem | undefined;
136
+ openItems(): PostedItem[];
137
+ hasApplied(resolutionEventId: string): boolean;
138
+ /** Applies posted before closed, so one change may post and close the same item. */
139
+ commit(change: StateChange): void;
140
+ snapshot(): MirrorSnapshot;
141
+ }
142
+
143
+ export { type CloseOutcome, MemoryMirrorState, MemoryQueueSource, type MirrorSnapshot, type MirrorState, type PostedItem, type QueueItem, type QueueSource, type ResolveResult, type SourceEvent, type StateChange, type VerdictInput, fitItem, looksSecret, redactPreview, syncFilter, toItemInput };
package/dist/index.js ADDED
@@ -0,0 +1,201 @@
1
+ // src/redact.ts
2
+ var PLACEHOLDER = "[REDACTED]";
3
+ var AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
4
+ var KEY_VALUE = /\b([A-Za-z0-9_-]*(?:token|key|password))(["']?\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&"',;]+)/gi;
5
+ var HEX_RUN = /\b(?:[0-9a-fA-F]{32,39}|[0-9a-fA-F]{41,})\b/g;
6
+ var B64_RUN = /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_=-])/g;
7
+ var PLAIN_WORD = /^(?:[a-z]{3,}|[A-Z]{3,})$/;
8
+ function looksSecret(run) {
9
+ if (!/\d/.test(run) || !/[A-Z]/.test(run) || !/[a-z]/.test(run)) return false;
10
+ return !run.split("/").some((segment) => PLAIN_WORD.test(segment));
11
+ }
12
+ function redactPreview(text) {
13
+ let hits = 0;
14
+ const hit = (replacement) => {
15
+ hits += 1;
16
+ return replacement;
17
+ };
18
+ const out = text.replace(AUTH_HEADER, (_match, scheme) => hit(`${scheme} ${PLACEHOLDER}`)).replace(KEY_VALUE, (_match, name, separator) => hit(`${name}${separator}${PLACEHOLDER}`)).replace(HEX_RUN, () => hit(PLACEHOLDER)).replace(B64_RUN, (run) => looksSecret(run) ? hit(PLACEHOLDER) : run);
19
+ return { text: out, redacted: hits > 0 };
20
+ }
21
+
22
+ // src/render.ts
23
+ import { MAX_CONTENT_BYTES, contentBytes, encodeItem } from "@titan-design/matrix-bus";
24
+ var MAX_FIT_ROUNDS = 16;
25
+ function fits(input) {
26
+ return contentBytes(encodeItem(input)) <= MAX_CONTENT_BYTES;
27
+ }
28
+ function shrink(value, round) {
29
+ if (value === void 0) return void 0;
30
+ const points = Array.from(value);
31
+ return `${points.slice(0, Math.floor(points.length / 2 ** round)).join("")}\u2026`;
32
+ }
33
+ function fitItem(input) {
34
+ if (fits(input)) return input;
35
+ for (let round = 1; round <= MAX_FIT_ROUNDS; round += 1) {
36
+ const cut = { ...input, input_preview: shrink(input.input_preview, round), text: shrink(input.text, round), truncated: true };
37
+ if (fits(cut)) return cut;
38
+ }
39
+ return { ...input, input_preview: void 0, text: void 0, truncated: true };
40
+ }
41
+ function redactFields(item) {
42
+ if (item.kind === "approval_request" && item.inputPreview !== void 0) {
43
+ const { text, redacted } = redactPreview(item.inputPreview);
44
+ return { inputPreview: text, text: item.text, redacted };
45
+ }
46
+ if (item.kind === "endorse_request" && item.text !== void 0) {
47
+ const { text, redacted } = redactPreview(item.text);
48
+ return { inputPreview: item.inputPreview, text, redacted };
49
+ }
50
+ return { inputPreview: item.inputPreview, text: item.text, redacted: false };
51
+ }
52
+ function toItemInput(item) {
53
+ const { inputPreview, text, redacted } = redactFields(item);
54
+ return fitItem({
55
+ kind: item.kind,
56
+ machine: item.machine,
57
+ session: item.session,
58
+ agent_id: item.agentId,
59
+ msg_id: item.id,
60
+ at: item.at,
61
+ tool_name: item.toolName,
62
+ input_preview: inputPreview,
63
+ recipient: item.recipient,
64
+ text,
65
+ truncated: false,
66
+ redacted
67
+ });
68
+ }
69
+ function syncFilter(roomId) {
70
+ const none = { types: [] };
71
+ return {
72
+ presence: none,
73
+ account_data: none,
74
+ room: { rooms: [roomId], timeline: { limit: 50 }, state: none, ephemeral: none, account_data: none }
75
+ };
76
+ }
77
+
78
+ // src/memory-source.ts
79
+ import { ITEM_KINDS } from "@titan-design/matrix-bus";
80
+ var MemoryQueueSource = class {
81
+ constructor(kinds = ITEM_KINDS) {
82
+ this.kinds = kinds;
83
+ }
84
+ kinds;
85
+ resolutions = [];
86
+ pending = /* @__PURE__ */ new Map();
87
+ log = [];
88
+ waiters = /* @__PURE__ */ new Set();
89
+ add(item) {
90
+ this.pending.set(item.id, item);
91
+ this.append({ type: "opened", item, cursor: this.nextCursor() });
92
+ }
93
+ close(id, outcome, label) {
94
+ if (!this.pending.delete(id)) return;
95
+ this.append({ type: "closed", id, outcome, label, cursor: this.nextCursor() });
96
+ }
97
+ async open() {
98
+ return [...this.pending.values()];
99
+ }
100
+ async resolve(id, verdict) {
101
+ if (!this.pending.has(id)) return { ok: false, reason: "closed" };
102
+ this.resolutions.push({ id, ...verdict });
103
+ this.close(id, "resolved", verdict.verdict);
104
+ return { ok: true };
105
+ }
106
+ /** "Now" is fixed when tail is called, not at the first next(), so nothing added in between is missed. */
107
+ tail(cursor, signal) {
108
+ return this.stream(cursor === void 0 ? this.log.length : Number(cursor), signal);
109
+ }
110
+ async *stream(start, signal) {
111
+ let next = start;
112
+ while (!signal.aborted) {
113
+ while (next < this.log.length) yield this.log[next++];
114
+ await this.nextAppend(signal);
115
+ }
116
+ }
117
+ nextCursor() {
118
+ return String(this.log.length + 1);
119
+ }
120
+ append(event) {
121
+ this.log.push(event);
122
+ for (const wake of [...this.waiters]) wake();
123
+ }
124
+ nextAppend(signal) {
125
+ return new Promise((resolve) => {
126
+ if (signal.aborted) return resolve();
127
+ const done = () => {
128
+ this.waiters.delete(done);
129
+ signal.removeEventListener("abort", done);
130
+ resolve();
131
+ };
132
+ this.waiters.add(done);
133
+ signal.addEventListener("abort", done);
134
+ });
135
+ }
136
+ };
137
+
138
+ // src/memory-state.ts
139
+ var MemoryMirrorState = class _MemoryMirrorState {
140
+ cursor;
141
+ token;
142
+ items = /* @__PURE__ */ new Map();
143
+ eventIndex = /* @__PURE__ */ new Map();
144
+ applied = /* @__PURE__ */ new Set();
145
+ static restore(snapshot) {
146
+ const state = new _MemoryMirrorState();
147
+ state.commit({ ...snapshot, posted: snapshot.posted.map((item) => ({ ...item })) });
148
+ return state;
149
+ }
150
+ sourceCursor() {
151
+ return this.cursor;
152
+ }
153
+ syncToken() {
154
+ return this.token;
155
+ }
156
+ bySourceId(id) {
157
+ return this.items.get(id);
158
+ }
159
+ byEventId(eventId) {
160
+ const sourceId = this.eventIndex.get(eventId);
161
+ return sourceId === void 0 ? void 0 : this.items.get(sourceId);
162
+ }
163
+ openItems() {
164
+ return [...this.items.values()].filter((item) => item.status === "open");
165
+ }
166
+ hasApplied(resolutionEventId) {
167
+ return this.applied.has(resolutionEventId);
168
+ }
169
+ /** Applies posted before closed, so one change may post and close the same item. */
170
+ commit(change) {
171
+ for (const item of change.posted ?? []) {
172
+ this.items.set(item.sourceId, { ...item });
173
+ this.eventIndex.set(item.eventId, item.sourceId);
174
+ }
175
+ for (const id of change.closed ?? []) {
176
+ const item = this.items.get(id);
177
+ if (item) item.status = "closed";
178
+ }
179
+ for (const id of change.applied ?? []) this.applied.add(id);
180
+ if (change.sourceCursor !== void 0) this.cursor = change.sourceCursor;
181
+ if (change.syncToken !== void 0) this.token = change.syncToken;
182
+ }
183
+ snapshot() {
184
+ return {
185
+ sourceCursor: this.cursor,
186
+ syncToken: this.token,
187
+ posted: [...this.items.values()].map((item) => ({ ...item })),
188
+ applied: [...this.applied]
189
+ };
190
+ }
191
+ };
192
+ export {
193
+ MemoryMirrorState,
194
+ MemoryQueueSource,
195
+ fitItem,
196
+ looksSecret,
197
+ redactPreview,
198
+ syncFilter,
199
+ toItemInput
200
+ };
201
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@titan-design/queue-mirror",
3
+ "version": "0.0.0",
4
+ "description": "Projects a local queue of human-actionable items into a Matrix room and folds the owner's verdicts back",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "author": "Henry Jewkes",
19
+ "license": "MIT",
20
+ "homepage": "https://github.com/HJewkes/titan-platform#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/HJewkes/titan-platform.git",
24
+ "directory": "packages/queue-mirror"
25
+ },
26
+ "bugs": "https://github.com/HJewkes/titan-platform/issues",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@titan-design/matrix-bus": "^0.0.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "typecheck": "tsc --noEmit",
39
+ "lint": "eslint src"
40
+ }
41
+ }