@zhin.js/harness 0.0.0-stage → 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/lib/authority.d.ts +37 -0
  4. package/lib/authority.js +161 -0
  5. package/lib/budget.d.ts +48 -0
  6. package/lib/budget.js +154 -0
  7. package/lib/contracts.d.ts +643 -0
  8. package/lib/contracts.js +86 -0
  9. package/lib/guard.d.ts +25 -0
  10. package/lib/guard.js +213 -0
  11. package/lib/harness.d.ts +197 -0
  12. package/lib/harness.js +731 -0
  13. package/lib/index.d.ts +8 -0
  14. package/lib/index.js +8 -0
  15. package/lib/journal.d.ts +86 -0
  16. package/lib/journal.js +712 -0
  17. package/lib/mailbox.d.ts +6 -0
  18. package/lib/mailbox.js +422 -0
  19. package/lib/policies/admission.d.ts +48 -0
  20. package/lib/policies/admission.js +118 -0
  21. package/lib/policies/groups.d.ts +47 -0
  22. package/lib/policies/groups.js +91 -0
  23. package/lib/policies/index.d.ts +5 -0
  24. package/lib/policies/index.js +5 -0
  25. package/lib/policies/live.d.ts +28 -0
  26. package/lib/policies/live.js +44 -0
  27. package/lib/policies/markers.d.ts +23 -0
  28. package/lib/policies/markers.js +42 -0
  29. package/lib/policies/merge.d.ts +49 -0
  30. package/lib/policies/merge.js +101 -0
  31. package/package.json +39 -3
  32. package/src/authority.ts +194 -0
  33. package/src/budget.ts +181 -0
  34. package/src/contracts.ts +762 -0
  35. package/src/guard.ts +305 -0
  36. package/src/harness.ts +975 -0
  37. package/src/index.ts +8 -0
  38. package/src/journal.ts +833 -0
  39. package/src/mailbox.ts +467 -0
  40. package/src/policies/admission.ts +164 -0
  41. package/src/policies/groups.ts +137 -0
  42. package/src/policies/index.ts +5 -0
  43. package/src/policies/live.ts +68 -0
  44. package/src/policies/markers.ts +58 -0
  45. package/src/policies/merge.ts +121 -0
package/lib/journal.js ADDED
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Engagement journal implementations (ADR 0048 §3.5).
3
+ *
4
+ * Depends on `contracts.ts` only. The database store targets a narrow query
5
+ * interface (`EngagementJournalDatabase`) that `@zhin.js/database` satisfies,
6
+ * so the host registers `ENGAGEMENT_EVENT_MODEL` and passes its database in.
7
+ */
8
+ import { BUDGET_KEYS, DEFAULT_JOURNAL_RETENTION, JournalFullError, } from './contracts.js';
9
+ // ---------------------------------------------------------------------------
10
+ // Shared helpers
11
+ // ---------------------------------------------------------------------------
12
+ /** Deep-copies a JSON value, throwing TypeError for anything JSON cannot round-trip. */
13
+ function cloneJson(value, path, seen) {
14
+ if (value === null)
15
+ return null;
16
+ switch (typeof value) {
17
+ case 'string':
18
+ case 'boolean':
19
+ return value;
20
+ case 'number':
21
+ if (!Number.isFinite(value))
22
+ throw new TypeError(`Journal data at ${path} is not a finite number`);
23
+ return value;
24
+ case 'object':
25
+ break;
26
+ default:
27
+ throw new TypeError(`Journal data at ${path} is not JSON-serializable (${typeof value})`);
28
+ }
29
+ const obj = value;
30
+ if (seen.has(obj))
31
+ throw new TypeError(`Journal data at ${path} contains a cycle`);
32
+ seen.add(obj);
33
+ try {
34
+ if (Array.isArray(obj)) {
35
+ return obj.map((item, i) => cloneJson(item, `${path}[${i}]`, seen));
36
+ }
37
+ const proto = Object.getPrototypeOf(obj);
38
+ if (proto !== Object.prototype && proto !== null) {
39
+ throw new TypeError(`Journal data at ${path} is not a plain object`);
40
+ }
41
+ const out = {};
42
+ for (const [key, item] of Object.entries(obj)) {
43
+ out[key] = cloneJson(item, `${path}.${key}`, seen);
44
+ }
45
+ return out;
46
+ }
47
+ finally {
48
+ seen.delete(obj);
49
+ }
50
+ }
51
+ function deepFreeze(value) {
52
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
53
+ Object.freeze(value);
54
+ for (const item of Object.values(value))
55
+ deepFreeze(item);
56
+ }
57
+ return value;
58
+ }
59
+ function buildEvent(input, seq, data) {
60
+ return deepFreeze({
61
+ engagementId: input.engagementId,
62
+ seq,
63
+ at: input.at,
64
+ type: input.type,
65
+ data,
66
+ });
67
+ }
68
+ function validateInput(input) {
69
+ if (typeof input.engagementId !== 'string' || input.engagementId === '') {
70
+ throw new TypeError('engagementId must be a non-empty string');
71
+ }
72
+ if (!Number.isFinite(input.at))
73
+ throw new TypeError('at must be a finite number');
74
+ const data = cloneJson(input.data, 'data', new Set());
75
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
76
+ throw new TypeError('data must be a JSON object');
77
+ }
78
+ return data;
79
+ }
80
+ /** Runs tasks one at a time per key, in call order. */
81
+ class KeyedSerializer {
82
+ #tails = new Map();
83
+ run(key, task) {
84
+ const prev = this.#tails.get(key) ?? Promise.resolve();
85
+ const result = prev.then(task, task);
86
+ const tail = result.catch(() => undefined);
87
+ this.#tails.set(key, tail);
88
+ void tail.then(() => {
89
+ if (this.#tails.get(key) === tail)
90
+ this.#tails.delete(key);
91
+ });
92
+ return result;
93
+ }
94
+ }
95
+ function isPlainRecord(value) {
96
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
97
+ }
98
+ function recordPayload(record) {
99
+ const data = isPlainRecord(record.data) ? record.data : {};
100
+ const event = isPlainRecord(record.event) ? record.event : {};
101
+ return { ...event, ...data };
102
+ }
103
+ function recordType(record) {
104
+ if (typeof record.type === 'string' && record.type)
105
+ return record.type;
106
+ if (isPlainRecord(record.event) && typeof record.event.type === 'string')
107
+ return record.event.type;
108
+ return '';
109
+ }
110
+ function isActorRef(value) {
111
+ return (typeof value === 'string'
112
+ && value.length > 0
113
+ && !value.startsWith('human:')
114
+ && !value.startsWith('system:')
115
+ && value !== 'human'
116
+ && value !== 'system');
117
+ }
118
+ /** Reconstructs blackboard entries from a sequence of journal records. */
119
+ export function reconstructBoardFromEvents(records, filter) {
120
+ const byKey = new Map();
121
+ for (const record of records) {
122
+ if (recordType(record) !== 'board_written')
123
+ continue;
124
+ const payload = recordPayload(record);
125
+ const key = typeof payload.key === 'string' ? payload.key.trim() : '';
126
+ if (!key)
127
+ continue;
128
+ const prev = byKey.get(key);
129
+ const kind = payload.kind === 'artifact' || payload.kind === 'todo' || payload.kind === 'note'
130
+ ? payload.kind
131
+ : prev?.kind ?? 'note';
132
+ const content = typeof payload.content === 'string' ? payload.content : prev?.content ?? '';
133
+ const author = typeof payload.actor === 'string'
134
+ ? payload.actor
135
+ : typeof payload.author === 'string'
136
+ ? payload.author
137
+ : prev?.author ?? '';
138
+ const version = typeof payload.version === 'number' && Number.isFinite(payload.version)
139
+ ? payload.version
140
+ : (prev?.version ?? 0) + 1;
141
+ const status = payload.status === 'pending' || payload.status === 'in_progress' || payload.status === 'done'
142
+ ? payload.status
143
+ : prev?.status;
144
+ byKey.set(key, deepFreeze({
145
+ key,
146
+ kind,
147
+ content,
148
+ author,
149
+ updatedAt: record.at,
150
+ version,
151
+ ...(status !== undefined ? { status } : {}),
152
+ }));
153
+ }
154
+ const entries = [...byKey.values()].filter((entry) => {
155
+ if (filter?.key !== undefined && entry.key !== filter.key)
156
+ return false;
157
+ if (filter?.kind !== undefined && entry.kind !== filter.kind)
158
+ return false;
159
+ return true;
160
+ });
161
+ return Object.freeze(entries);
162
+ }
163
+ /** Pure helper that summarizes an engagement's journal records into a collaboration graph and metrics. */
164
+ export function summarizeEngagementEvents(engagementId, records) {
165
+ let initiator;
166
+ let venue;
167
+ let startedAt = records[0]?.at ?? 0;
168
+ let endedAt;
169
+ let closedStatus;
170
+ let reason;
171
+ let toolCallCount = 0;
172
+ const actors = [];
173
+ const seenActors = new Set();
174
+ const addActor = (actor) => {
175
+ if (isActorRef(actor) && !seenActors.has(actor)) {
176
+ seenActors.add(actor);
177
+ actors.push(actor);
178
+ }
179
+ };
180
+ const guardBlocks = [];
181
+ const escalations = [];
182
+ const projections = [];
183
+ const consumed = { modelCalls: 0, tokens: 0, wallMs: 0, sideEffects: 0 };
184
+ let closedSpent;
185
+ const hopsById = new Map();
186
+ const hopOrder = [];
187
+ const seenMailIds = new Set();
188
+ let anonymousMailCount = 0;
189
+ for (const record of records) {
190
+ const type = recordType(record);
191
+ const payload = recordPayload(record);
192
+ switch (type) {
193
+ case 'engagement_opened': {
194
+ startedAt = record.at;
195
+ if (isPlainRecord(payload.initiator) && typeof payload.initiator.id === 'string') {
196
+ initiator = payload.initiator;
197
+ }
198
+ else if (isPlainRecord(payload.principal) && typeof payload.principal.id === 'string') {
199
+ initiator = payload.principal;
200
+ }
201
+ else if (typeof payload.principal === 'string' && payload.principal) {
202
+ initiator = {
203
+ kind: 'human',
204
+ id: payload.principal,
205
+ trust: 'group_member',
206
+ };
207
+ }
208
+ if (isPlainRecord(payload.venue) && typeof payload.venue.kind === 'string' && typeof payload.venue.id === 'string') {
209
+ venue = { kind: payload.venue.kind, id: payload.venue.id };
210
+ }
211
+ addActor(payload.entry);
212
+ break;
213
+ }
214
+ case 'mail_admitted':
215
+ case 'mail_sent': {
216
+ const mailId = typeof payload.mailId === 'string' ? payload.mailId : '';
217
+ const kind = (typeof payload.kind === 'string' ? payload.kind : 'ask');
218
+ const from = typeof payload.from === 'string' ? payload.from : '';
219
+ const to = typeof payload.to === 'string' ? payload.to : '';
220
+ const causationId = typeof payload.causationId === 'string' && payload.causationId ? payload.causationId : undefined;
221
+ addActor(from);
222
+ addActor(to);
223
+ if (mailId) {
224
+ seenMailIds.add(mailId);
225
+ if (!hopsById.has(mailId)) {
226
+ hopsById.set(mailId, {
227
+ mailId,
228
+ kind,
229
+ from,
230
+ to,
231
+ ...(causationId !== undefined ? { causationId } : {}),
232
+ status: 'sent',
233
+ });
234
+ hopOrder.push(mailId);
235
+ }
236
+ if (kind === 'result' && causationId) {
237
+ const parentHop = hopsById.get(causationId);
238
+ if (parentHop) {
239
+ parentHop.status = payload.status === 'cancelled' ? 'cancelled' : 'completed';
240
+ }
241
+ }
242
+ }
243
+ else {
244
+ anonymousMailCount++;
245
+ }
246
+ break;
247
+ }
248
+ case 'turn_started':
249
+ case 'mail_delivered': {
250
+ addActor(payload.actor);
251
+ const ids = [];
252
+ if (typeof payload.mailId === 'string')
253
+ ids.push(payload.mailId);
254
+ if (Array.isArray(payload.mails)) {
255
+ for (const id of payload.mails)
256
+ if (typeof id === 'string')
257
+ ids.push(id);
258
+ }
259
+ for (const id of ids) {
260
+ const hop = hopsById.get(id);
261
+ if (hop && hop.status === 'sent')
262
+ hop.status = 'delivered';
263
+ }
264
+ break;
265
+ }
266
+ case 'turn_finished':
267
+ case 'mail_completed': {
268
+ addActor(payload.actor);
269
+ const nextStatus = payload.status === 'cancelled' ? 'cancelled' : 'completed';
270
+ const ids = [];
271
+ if (typeof payload.mailId === 'string')
272
+ ids.push(payload.mailId);
273
+ if (Array.isArray(payload.mails)) {
274
+ for (const id of payload.mails)
275
+ if (typeof id === 'string')
276
+ ids.push(id);
277
+ }
278
+ for (const id of ids) {
279
+ const hop = hopsById.get(id);
280
+ if (hop)
281
+ hop.status = nextStatus;
282
+ }
283
+ break;
284
+ }
285
+ case 'mail_cancelled': {
286
+ const mailId = typeof payload.mailId === 'string' ? payload.mailId : '';
287
+ const hop = mailId ? hopsById.get(mailId) : undefined;
288
+ if (hop)
289
+ hop.status = 'cancelled';
290
+ break;
291
+ }
292
+ case 'tool_called': {
293
+ toolCallCount++;
294
+ addActor(payload.actor);
295
+ break;
296
+ }
297
+ case 'board_written': {
298
+ addActor(payload.actor ?? payload.author);
299
+ break;
300
+ }
301
+ case 'guard_blocked':
302
+ case 'mail_denied': {
303
+ const from = typeof payload.from === 'string'
304
+ ? payload.from
305
+ : typeof payload.actor === 'string'
306
+ ? payload.actor
307
+ : '';
308
+ const to = typeof payload.to === 'string' ? payload.to : undefined;
309
+ addActor(from);
310
+ addActor(to);
311
+ const blockReason = isPlainRecord(payload.reason) && typeof payload.reason.code === 'string'
312
+ ? {
313
+ code: payload.reason.code,
314
+ ...(typeof payload.reason.detail === 'string' ? { detail: payload.reason.detail } : {}),
315
+ }
316
+ : {
317
+ code: typeof payload.code === 'string' ? payload.code : 'denied',
318
+ ...(typeof payload.reason === 'string' ? { detail: payload.reason } : {}),
319
+ };
320
+ guardBlocks.push({
321
+ from,
322
+ ...(to !== undefined ? { to } : {}),
323
+ reason: blockReason,
324
+ at: record.at,
325
+ });
326
+ break;
327
+ }
328
+ case 'escalated': {
329
+ const by = typeof payload.by === 'string'
330
+ ? payload.by
331
+ : typeof payload.from === 'string'
332
+ ? payload.from
333
+ : typeof payload.actor === 'string'
334
+ ? payload.actor
335
+ : '';
336
+ addActor(by);
337
+ const escReason = typeof payload.reason === 'string' ? payload.reason : '';
338
+ escalations.push({ by, reason: escReason, at: record.at });
339
+ break;
340
+ }
341
+ case 'venue_projected': {
342
+ addActor(payload.actor);
343
+ if (isPlainRecord(payload.venue) && typeof payload.venue.kind === 'string' && typeof payload.venue.id === 'string') {
344
+ projections.push({
345
+ venue: { kind: payload.venue.kind, id: payload.venue.id },
346
+ actor: typeof payload.actor === 'string' ? payload.actor : '',
347
+ mailId: typeof payload.mailId === 'string' ? payload.mailId : '',
348
+ mode: payload.mode === 'summary' ? 'summary' : 'visible',
349
+ at: record.at,
350
+ });
351
+ }
352
+ break;
353
+ }
354
+ case 'budget_charged': {
355
+ const usage = isPlainRecord(payload.usage)
356
+ ? payload.usage
357
+ : isPlainRecord(payload.amount)
358
+ ? payload.amount
359
+ : isPlainRecord(payload.consumed)
360
+ ? payload.consumed
361
+ : payload;
362
+ for (const key of BUDGET_KEYS) {
363
+ const val = usage[key];
364
+ if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
365
+ consumed[key] += val;
366
+ }
367
+ }
368
+ break;
369
+ }
370
+ case 'engagement_closed': {
371
+ endedAt = record.at;
372
+ if (payload.status === 'completed'
373
+ || payload.status === 'escalated'
374
+ || payload.status === 'cancelled'
375
+ || payload.status === 'blocked') {
376
+ closedStatus = payload.status;
377
+ }
378
+ if (typeof payload.reason === 'string') {
379
+ reason = payload.reason;
380
+ }
381
+ if (isPlainRecord(payload.budget) && isPlainRecord(payload.budget.spent)) {
382
+ closedSpent = payload.budget.spent;
383
+ }
384
+ else if (isPlainRecord(payload.consumed)) {
385
+ closedSpent = payload.consumed;
386
+ }
387
+ break;
388
+ }
389
+ default:
390
+ break;
391
+ }
392
+ }
393
+ if (closedSpent) {
394
+ for (const key of BUDGET_KEYS) {
395
+ const val = closedSpent[key];
396
+ if (typeof val === 'number' && Number.isFinite(val)) {
397
+ consumed[key] = Math.max(consumed[key], val);
398
+ }
399
+ }
400
+ }
401
+ const status = closedStatus
402
+ ?? (endedAt !== undefined
403
+ ? escalations.length > 0
404
+ ? 'escalated'
405
+ : 'completed'
406
+ : escalations.length > 0
407
+ ? 'escalated'
408
+ : 'open');
409
+ if (reason === undefined && escalations.length > 0) {
410
+ reason = escalations[escalations.length - 1]?.reason;
411
+ }
412
+ const boardEntries = reconstructBoardFromEvents(records);
413
+ const hops = [];
414
+ for (const id of hopOrder) {
415
+ const hop = hopsById.get(id);
416
+ if (hop)
417
+ hops.push({ ...hop });
418
+ }
419
+ return deepFreeze({
420
+ engagementId,
421
+ ...(initiator !== undefined ? { initiator } : {}),
422
+ ...(venue !== undefined ? { venue } : {}),
423
+ startedAt,
424
+ ...(endedAt !== undefined ? { endedAt } : {}),
425
+ status,
426
+ ...(reason !== undefined ? { reason } : {}),
427
+ actors,
428
+ mailCount: seenMailIds.size + anonymousMailCount,
429
+ toolCallCount,
430
+ boardEntries,
431
+ guardBlocks,
432
+ escalations,
433
+ projections,
434
+ consumed,
435
+ hops,
436
+ });
437
+ }
438
+ const COMPACTABLE_EVENT_TYPES = new Set([
439
+ 'budget_charged',
440
+ 'budget_granted',
441
+ 'mail_delivered',
442
+ 'turn_started',
443
+ ]);
444
+ function compactRecordList(engagementId, records) {
445
+ if (records.length <= 20)
446
+ return undefined;
447
+ let closedRecord;
448
+ for (const record of records) {
449
+ const type = recordType(record);
450
+ if (type === 'guard_blocked' || type === 'mail_denied' || type === 'escalated') {
451
+ return undefined;
452
+ }
453
+ if (type === 'engagement_closed') {
454
+ closedRecord = record;
455
+ }
456
+ }
457
+ if (!closedRecord)
458
+ return undefined;
459
+ const closedStatus = closedRecord.data.status;
460
+ if (closedStatus !== undefined && closedStatus !== 'completed') {
461
+ return undefined;
462
+ }
463
+ const summary = summarizeEngagementEvents(engagementId, records);
464
+ const filtered = records.filter((record) => !COMPACTABLE_EVENT_TYPES.has(recordType(record)));
465
+ if (filtered.length === records.length)
466
+ return undefined;
467
+ return filtered.map((record, index) => {
468
+ const data = record === closedRecord
469
+ ? cloneJson({
470
+ ...record.data,
471
+ status: 'completed',
472
+ consumed: summary.consumed,
473
+ }, 'data', new Set())
474
+ : cloneJson(record.data, 'data', new Set());
475
+ return buildEvent({ engagementId: record.engagementId, at: record.at, type: record.type, data }, index, data);
476
+ });
477
+ }
478
+ // ---------------------------------------------------------------------------
479
+ // In-memory
480
+ // ---------------------------------------------------------------------------
481
+ export class InMemoryEngagementJournal {
482
+ #retention;
483
+ #events = new Map();
484
+ #serial = new KeyedSerializer();
485
+ constructor(options = {}) {
486
+ this.#retention = options.retention ?? DEFAULT_JOURNAL_RETENTION;
487
+ }
488
+ append(event) {
489
+ let data;
490
+ try {
491
+ data = validateInput(event);
492
+ }
493
+ catch (error) {
494
+ return Promise.reject(error);
495
+ }
496
+ const input = { ...event };
497
+ return this.#serial.run(input.engagementId, async () => {
498
+ const list = this.#events.get(input.engagementId) ?? [];
499
+ if (list.length >= this.#retention.maxEventsPerEngagement) {
500
+ throw new JournalFullError(input.engagementId, this.#retention.maxEventsPerEngagement);
501
+ }
502
+ const stored = buildEvent(input, list.length, data);
503
+ list.push(stored);
504
+ this.#events.set(input.engagementId, list);
505
+ return stored;
506
+ });
507
+ }
508
+ async read(engagementId) {
509
+ return Object.freeze([...(this.#events.get(engagementId) ?? [])]);
510
+ }
511
+ async prune(now) {
512
+ const cutoff = now - this.#retention.maxAgeMs;
513
+ let removed = 0;
514
+ for (const [id, list] of this.#events) {
515
+ const last = list[list.length - 1];
516
+ if (last !== undefined && last.at < cutoff) {
517
+ this.#events.delete(id);
518
+ removed++;
519
+ }
520
+ }
521
+ return removed;
522
+ }
523
+ async listEngagements(limit) {
524
+ const entries = [];
525
+ let order = 0;
526
+ for (const [id, list] of this.#events) {
527
+ const last = list[list.length - 1];
528
+ if (!last)
529
+ continue;
530
+ const lastAt = last.at;
531
+ entries.push({ id, lastAt, order: order++ });
532
+ }
533
+ entries.sort((a, b) => (b.lastAt !== a.lastAt ? b.lastAt - a.lastAt : b.order - a.order));
534
+ const ids = entries.map((entry) => entry.id);
535
+ return Object.freeze(limit !== undefined ? ids.slice(0, Math.max(0, limit)) : ids);
536
+ }
537
+ async summarize(engagementId) {
538
+ const records = await this.read(engagementId);
539
+ if (records.length === 0)
540
+ return undefined;
541
+ return summarizeEngagementEvents(engagementId, records);
542
+ }
543
+ async compact(engagementId) {
544
+ await this.#serial.run(engagementId, async () => {
545
+ const list = this.#events.get(engagementId);
546
+ if (!list)
547
+ return;
548
+ const compacted = compactRecordList(engagementId, list);
549
+ if (compacted) {
550
+ this.#events.set(engagementId, [...compacted]);
551
+ }
552
+ });
553
+ }
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Database-backed
557
+ // ---------------------------------------------------------------------------
558
+ export const ENGAGEMENT_EVENTS_TABLE = 'engagement_events';
559
+ /**
560
+ * Table schema. `id` (`<engagement_id>:<seq>`) is the primary key and therefore
561
+ * the unique (engagement_id, seq) constraint: `@zhin.js/database` column
562
+ * definitions have no composite unique key.
563
+ */
564
+ export const ENGAGEMENT_EVENT_MODEL = {
565
+ id: { type: 'text', primary: true, nullable: false },
566
+ engagement_id: { type: 'text', nullable: false },
567
+ seq: { type: 'integer', nullable: false },
568
+ at: { type: 'bigint', nullable: false },
569
+ type: { type: 'text', nullable: false },
570
+ data: { type: 'text', nullable: false },
571
+ };
572
+ /** Registers the table on a `@zhin.js/database` instance. */
573
+ export function defineEngagementEventModel(database) {
574
+ database.define(ENGAGEMENT_EVENTS_TABLE, ENGAGEMENT_EVENT_MODEL);
575
+ }
576
+ export class DatabaseEngagementJournal {
577
+ #db;
578
+ #retention;
579
+ #maxRetries;
580
+ #serial = new KeyedSerializer();
581
+ constructor(database, options = {}) {
582
+ this.#db = database;
583
+ this.#retention = options.retention ?? DEFAULT_JOURNAL_RETENTION;
584
+ this.#maxRetries = options.maxConflictRetries ?? 5;
585
+ }
586
+ append(event) {
587
+ let data;
588
+ try {
589
+ data = validateInput(event);
590
+ }
591
+ catch (error) {
592
+ return Promise.reject(error);
593
+ }
594
+ const input = { ...event };
595
+ return this.#serial.run(input.engagementId, async () => {
596
+ const json = JSON.stringify(data);
597
+ let lastError;
598
+ for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
599
+ const seq = await this.#nextSeq(input.engagementId);
600
+ if (seq >= this.#retention.maxEventsPerEngagement) {
601
+ throw new JournalFullError(input.engagementId, this.#retention.maxEventsPerEngagement);
602
+ }
603
+ try {
604
+ await this.#db.insert(ENGAGEMENT_EVENTS_TABLE, {
605
+ id: `${input.engagementId}:${seq}`,
606
+ engagement_id: input.engagementId,
607
+ seq,
608
+ at: input.at,
609
+ type: input.type,
610
+ data: json,
611
+ });
612
+ return buildEvent(input, seq, JSON.parse(json));
613
+ }
614
+ catch (error) {
615
+ lastError = error;
616
+ // A unique-key conflict means another writer took this seq: re-read and retry.
617
+ // If the max seq did not advance, the failure was something else.
618
+ if ((await this.#nextSeq(input.engagementId)) <= seq)
619
+ throw error;
620
+ }
621
+ }
622
+ throw lastError;
623
+ });
624
+ }
625
+ async read(engagementId) {
626
+ const rows = await this.#db
627
+ .select(ENGAGEMENT_EVENTS_TABLE, ['engagement_id', 'seq', 'at', 'type', 'data'])
628
+ .where({ engagement_id: engagementId });
629
+ return Object.freeze(rows
630
+ .map(row => ({ row, seq: Number(row.seq) }))
631
+ .sort((a, b) => a.seq - b.seq)
632
+ .map(({ row, seq }) => buildEvent({ engagementId, at: Number(row.at), type: row.type, data: {} }, seq, JSON.parse(String(row.data)))));
633
+ }
634
+ async prune(now) {
635
+ const cutoff = now - this.#retention.maxAgeMs;
636
+ const rows = await this.#db
637
+ .select(ENGAGEMENT_EVENTS_TABLE, ['engagement_id', 'at'])
638
+ .where({});
639
+ const lastAt = new Map();
640
+ for (const row of rows) {
641
+ const id = String(row.engagement_id);
642
+ lastAt.set(id, Math.max(lastAt.get(id) ?? -Infinity, Number(row.at)));
643
+ }
644
+ let removed = 0;
645
+ for (const [id, last] of lastAt) {
646
+ if (last >= cutoff)
647
+ continue;
648
+ await this.#serial.run(id, async () => {
649
+ await this.#db.delete(ENGAGEMENT_EVENTS_TABLE, { engagement_id: id });
650
+ });
651
+ removed++;
652
+ }
653
+ return removed;
654
+ }
655
+ async listEngagements(limit) {
656
+ const rows = await this.#db
657
+ .select(ENGAGEMENT_EVENTS_TABLE, ['engagement_id', 'at', 'seq'])
658
+ .where({});
659
+ const meta = new Map();
660
+ let order = 0;
661
+ for (const row of rows) {
662
+ const id = String(row.engagement_id);
663
+ const at = Number(row.at);
664
+ const existing = meta.get(id);
665
+ if (!existing) {
666
+ meta.set(id, { lastAt: at, order: order++ });
667
+ }
668
+ else {
669
+ if (at >= existing.lastAt)
670
+ existing.lastAt = at;
671
+ existing.order = order++;
672
+ }
673
+ }
674
+ const entries = [...meta.entries()].sort(([, a], [, b]) => b.lastAt !== a.lastAt ? b.lastAt - a.lastAt : b.order - a.order);
675
+ const ids = entries.map(([id]) => id);
676
+ return Object.freeze(limit !== undefined ? ids.slice(0, Math.max(0, limit)) : ids);
677
+ }
678
+ async summarize(engagementId) {
679
+ const records = await this.read(engagementId);
680
+ if (records.length === 0)
681
+ return undefined;
682
+ return summarizeEngagementEvents(engagementId, records);
683
+ }
684
+ async compact(engagementId) {
685
+ await this.#serial.run(engagementId, async () => {
686
+ const records = await this.read(engagementId);
687
+ const compacted = compactRecordList(engagementId, records);
688
+ if (!compacted)
689
+ return;
690
+ await this.#db.delete(ENGAGEMENT_EVENTS_TABLE, { engagement_id: engagementId });
691
+ for (const record of compacted) {
692
+ await this.#db.insert(ENGAGEMENT_EVENTS_TABLE, {
693
+ id: `${engagementId}:${record.seq}`,
694
+ engagement_id: engagementId,
695
+ seq: record.seq,
696
+ at: record.at,
697
+ type: record.type,
698
+ data: JSON.stringify(record.data),
699
+ });
700
+ }
701
+ });
702
+ }
703
+ async #nextSeq(engagementId) {
704
+ const rows = await this.#db
705
+ .select(ENGAGEMENT_EVENTS_TABLE, ['seq'])
706
+ .where({ engagement_id: engagementId });
707
+ let max = -1;
708
+ for (const row of rows)
709
+ max = Math.max(max, Number(row.seq));
710
+ return max + 1;
711
+ }
712
+ }