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