@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.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/authority.d.ts +37 -0
- package/lib/authority.js +161 -0
- package/lib/budget.d.ts +48 -0
- package/lib/budget.js +154 -0
- package/lib/contracts.d.ts +643 -0
- package/lib/contracts.js +86 -0
- package/lib/guard.d.ts +25 -0
- package/lib/guard.js +213 -0
- package/lib/harness.d.ts +197 -0
- package/lib/harness.js +731 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/journal.d.ts +86 -0
- package/lib/journal.js +712 -0
- package/lib/mailbox.d.ts +6 -0
- package/lib/mailbox.js +422 -0
- package/lib/policies/admission.d.ts +48 -0
- package/lib/policies/admission.js +118 -0
- package/lib/policies/groups.d.ts +47 -0
- package/lib/policies/groups.js +91 -0
- package/lib/policies/index.d.ts +5 -0
- package/lib/policies/index.js +5 -0
- package/lib/policies/live.d.ts +28 -0
- package/lib/policies/live.js +44 -0
- package/lib/policies/markers.d.ts +23 -0
- package/lib/policies/markers.js +42 -0
- package/lib/policies/merge.d.ts +49 -0
- package/lib/policies/merge.js +101 -0
- package/package.json +39 -3
- package/src/authority.ts +194 -0
- package/src/budget.ts +181 -0
- package/src/contracts.ts +762 -0
- package/src/guard.ts +305 -0
- package/src/harness.ts +975 -0
- package/src/index.ts +8 -0
- package/src/journal.ts +833 -0
- package/src/mailbox.ts +467 -0
- package/src/policies/admission.ts +164 -0
- package/src/policies/groups.ts +137 -0
- package/src/policies/index.ts +5 -0
- package/src/policies/live.ts +68 -0
- package/src/policies/markers.ts +58 -0
- 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
|
+
}
|