@framers/agentos 0.13.74 → 0.13.75

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 (29) hide show
  1. package/dist/io/channels/meetings/MeetingManager.d.ts +257 -0
  2. package/dist/io/channels/meetings/MeetingManager.d.ts.map +1 -0
  3. package/dist/io/channels/meetings/MeetingManager.js +1173 -0
  4. package/dist/io/channels/meetings/MeetingManager.js.map +1 -0
  5. package/dist/io/channels/meetings/MeetingRoom.d.ts +86 -0
  6. package/dist/io/channels/meetings/MeetingRoom.d.ts.map +1 -0
  7. package/dist/io/channels/meetings/MeetingRoom.js +51 -0
  8. package/dist/io/channels/meetings/MeetingRoom.js.map +1 -0
  9. package/dist/io/channels/meetings/MeetingTranscriptSink.d.ts +29 -0
  10. package/dist/io/channels/meetings/MeetingTranscriptSink.d.ts.map +1 -0
  11. package/dist/io/channels/meetings/MeetingTranscriptSink.js +47 -0
  12. package/dist/io/channels/meetings/MeetingTranscriptSink.js.map +1 -0
  13. package/dist/io/channels/meetings/signing.d.ts +7 -4
  14. package/dist/io/channels/meetings/signing.d.ts.map +1 -1
  15. package/dist/io/channels/meetings/signing.js +18 -6
  16. package/dist/io/channels/meetings/signing.js.map +1 -1
  17. package/dist/io/channels/meetings/stores/memory.d.ts +31 -2
  18. package/dist/io/channels/meetings/stores/memory.d.ts.map +1 -1
  19. package/dist/io/channels/meetings/stores/memory.js +102 -9
  20. package/dist/io/channels/meetings/stores/memory.js.map +1 -1
  21. package/dist/io/channels/meetings/stores/types.d.ts +29 -10
  22. package/dist/io/channels/meetings/stores/types.d.ts.map +1 -1
  23. package/dist/io/channels/meetings/stores/types.js.map +1 -1
  24. package/dist/io/channels/meetings/types.d.ts +10 -0
  25. package/dist/io/channels/meetings/types.d.ts.map +1 -1
  26. package/dist/io/channels/meetings/types.js.map +1 -1
  27. package/dist/io/voice-pipeline/types.d.ts +13 -8
  28. package/dist/io/voice-pipeline/types.d.ts.map +1 -1
  29. package/package.json +1 -1
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * @module channels/meetings/MeetingManager
3
+ *
4
+ * Owns a run's life: the state machine, the capture interlock, the stop
5
+ * command, the purge sequence behind its fence, the limits, the meter and the
6
+ * reconciliation. Keeps nothing about a run in process: every row, and every
7
+ * timer the limits run on, is in an injected store, so several manager
8
+ * processes may share one database and one that restarts carries on.
9
+ */
10
+ import { randomUUID } from 'node:crypto';
11
+ import { MeetingTranscriptSink } from './MeetingTranscriptSink.js';
12
+ import { hashWorkerToken, mintWorkerToken } from './signing.js';
13
+ import { DuplicateIdempotencyKeyError } from './stores/types.js';
14
+ import { canTransition, contentFreeRun, DEFAULT_RUN_LIMITS, isBilledEnd, TERMINAL_RUN_STATES, } from './types.js';
15
+ /** Thrown by a provider's `dispatch` when no worker has a slot. */
16
+ export class NoCapacityError extends Error {
17
+ /** @param message - Why no worker took the run. */
18
+ constructor(message = 'no worker has a free slot') {
19
+ super(message);
20
+ this.name = 'NoCapacityError';
21
+ }
22
+ }
23
+ const ONCE_EVENTS = new Set([
24
+ 'bot:requested',
25
+ 'bot:scheduled',
26
+ 'bot:dispatching',
27
+ 'bot:joining',
28
+ 'bot:in_call',
29
+ 'bot:notice_posted',
30
+ 'bot:left',
31
+ 'bot:purged',
32
+ 'bot:done',
33
+ ]);
34
+ const ACTIVE_STATES = ['dispatching', 'joining', 'waiting_room', 'in_call_notice_pending', 'in_call', 'leaving', 'left', 'purging'];
35
+ /** States a purge may start from: everything that is not terminal. */
36
+ const PURGEABLE_STATES = ['requested', 'scheduled', ...ACTIVE_STATES.filter((s) => s !== 'purging')];
37
+ /** Reports that carry what a person said or did; behind the fence they are neither stored nor forwarded. */
38
+ const CONTENT_REPORTS = new Set(['chat_message', 'stop_command', 'participant_joined', 'participant_left']);
39
+ /**
40
+ * A run's limits: the defaults, each replaced by the request's own value where it gives one. A limit given as
41
+ * `undefined` keeps its default (a spread would copy the `undefined` over it and leave the run without that limit).
42
+ *
43
+ * @throws RangeError when a limit is given that is not a finite number of milliseconds, zero or more.
44
+ */
45
+ function runLimits(requested) {
46
+ const limits = { ...DEFAULT_RUN_LIMITS };
47
+ for (const key of ['waitingRoomMs', 'aloneMs', 'totalMs', 'pageAwayMs']) {
48
+ const value = requested?.[key];
49
+ if (value === undefined)
50
+ continue;
51
+ // NaN would turn off the limit it sets: every comparison with it is false
52
+ if (!Number.isFinite(value) || value < 0)
53
+ throw new RangeError(`limits.${key} must be a finite number of milliseconds, zero or more`);
54
+ limits[key] = value;
55
+ }
56
+ return limits;
57
+ }
58
+ /**
59
+ * The meeting-participant channel's manager: sends bots, dispatches them under
60
+ * a lease, applies the providers' reports and enforces each run's lifecycle
61
+ * over the injected stores. Several managers may share one database; each
62
+ * takes dispatch leases under its own `managerId`.
63
+ */
64
+ export class MeetingManager {
65
+ constructor(config) {
66
+ this.config = config;
67
+ this.providers = new Map();
68
+ this.handlers = [];
69
+ this.paused = new Set();
70
+ this.purgeHooks = [];
71
+ this.lastReconcileAt = 0;
72
+ this.id = config.managerId ?? randomUUID();
73
+ this.leaseTtlMs = config.leaseTtlMs ?? 60000;
74
+ this.noticeConfirmMs = config.noticeConfirmMs ?? 30000;
75
+ this.monitorHeartbeatMs = config.monitorHeartbeatMs ?? 10000;
76
+ this.monitorMissedBeats = config.monitorMissedBeats ?? 3;
77
+ this.monitorRecoverMs = config.monitorRecoverMs ?? 30000;
78
+ this.dispatchRetryMs = config.dispatchRetryMs ?? 300000;
79
+ this.dispatchRetryDelayMs = config.dispatchRetryDelayMs ?? 15000;
80
+ this.killAfterMs = config.killAfterMs ?? 30000;
81
+ this.minReservationSeconds = config.minReservationSeconds ?? 600;
82
+ this.purgeHooks.push(...(config.purgeHooks ?? []));
83
+ }
84
+ get stores() {
85
+ return this.config.stores;
86
+ }
87
+ now() {
88
+ return this.stores.clock.now();
89
+ }
90
+ // ── Providers and platforms ──
91
+ /** Registers a provider under its name; a later provider with the same name replaces it. */
92
+ registerProvider(provider) {
93
+ this.providers.set(provider.name, provider);
94
+ }
95
+ /** The provider registered under `name`, if there is one. */
96
+ getProvider(name) {
97
+ return this.providers.get(name);
98
+ }
99
+ /** The provider a send uses: the one named, or else the first registered provider that declares the platform. */
100
+ providerFor(platform, name) {
101
+ if (name)
102
+ return this.providers.get(name);
103
+ for (const p of this.providers.values())
104
+ if (p.platforms.includes(platform))
105
+ return p;
106
+ return undefined;
107
+ }
108
+ /** Refuses new sends to the platform with `platform_paused`; runs already sent go on. */
109
+ pausePlatform(platform) {
110
+ this.paused.add(platform);
111
+ }
112
+ /** Takes new sends to the platform again. */
113
+ resumePlatform(platform) {
114
+ this.paused.delete(platform);
115
+ }
116
+ /** Whether new sends to the platform are refused. */
117
+ isPaused(platform) {
118
+ return this.paused.has(platform);
119
+ }
120
+ // ── Events ──
121
+ /** Adds a handler for every event the manager emits; the function returned removes it. */
122
+ on(handler) {
123
+ this.handlers.push(handler);
124
+ return () => {
125
+ const i = this.handlers.indexOf(handler);
126
+ if (i >= 0)
127
+ this.handlers.splice(i, 1);
128
+ };
129
+ }
130
+ async emit(type, run, data, eventId, at) {
131
+ const time = at ?? this.now();
132
+ const id = eventId ?? (ONCE_EVENTS.has(type) ? `${run.runId}:${type}` : `${run.runId}:${type}:${time}:${randomUUID().slice(0, 8)}`);
133
+ await this.deliver(await this.storeEvent({ id, type, at: time, data }, run.runId), run);
134
+ }
135
+ /** Stores the event in its run's sequence; undefined when an event with its id is stored already, which is then delivered no second time. */
136
+ async storeEvent(event, runId) {
137
+ const { inserted, seq } = await this.stores.events.append({ eventId: event.id, runId, type: event.type, at: event.at, data: event.data });
138
+ return inserted ? { ...event, seq } : undefined;
139
+ }
140
+ /** Delivers a stored event, with the run as it stands: a delivery to each of the owner's destinations, then every handler. */
141
+ async deliver(stored, run) {
142
+ if (!stored)
143
+ return;
144
+ const { id, type, at, seq, data } = stored;
145
+ const event = { type, runId: run.runId, run, seq, data };
146
+ if (this.config.destinationsFor) {
147
+ const payload = JSON.stringify({ id, type, runId: run.runId, at, seq, data });
148
+ for (const destinationRef of await this.config.destinationsFor(run.ownerRef)) {
149
+ await this.stores.outbox.enqueue({ id: `${id}@${destinationRef}`, runId: run.runId, destinationRef, eventId: id, type, payload }, at);
150
+ }
151
+ }
152
+ for (const h of this.handlers) {
153
+ try {
154
+ const r = h(event);
155
+ if (r instanceof Promise)
156
+ r.catch((err) => this.config.logger?.error(`[MeetingManager] handler: ${String(err)}`));
157
+ }
158
+ catch (err) {
159
+ this.config.logger?.error(`[MeetingManager] handler: ${String(err)}`);
160
+ }
161
+ }
162
+ }
163
+ // ── Sending ──
164
+ /**
165
+ * Sends a bot: answers the run an earlier send by the same owner with the same idempotency key made, checks the link
166
+ * against the platform, reserves the run's time, stores the run and schedules its dispatch (now, or at `joinAt`). A
167
+ * reservation that covers less than `minReservationSeconds` is released and the send refused. A limit the request
168
+ * leaves out, or gives as `undefined`, takes its default. A send whose job store fails after the run is stored rejects
169
+ * with that error; {@link MeetingManager.reconcile} then gives the run its job, so a send repeated with the same key
170
+ * answers a run that is dispatched.
171
+ *
172
+ * @throws RangeError when `joinAt` or a limit the request gives is not a finite number (a limit must also be zero or more); nothing is reserved.
173
+ */
174
+ async sendBot(request) {
175
+ // a repeated send answers the run its key made, whatever changed since (a platform paused in between included)
176
+ if (request.idempotencyKey !== undefined) {
177
+ const existing = await this.stores.runs.findByIdempotencyKey(request.ownerRef, request.idempotencyKey);
178
+ if (existing)
179
+ return { ok: true, run: existing, repeated: true };
180
+ }
181
+ const provider = this.providerFor(request.platform, request.providerName);
182
+ if (!provider)
183
+ return { ok: false, reason: 'no_provider', message: `no provider for ${request.platform}` };
184
+ const url = provider.checkJoinUrl(request.meetingUrl);
185
+ if (!url.ok)
186
+ return { ok: false, reason: 'invalid_url', message: url.message };
187
+ if (url.platform !== request.platform)
188
+ return { ok: false, reason: 'invalid_url', message: `the link is a ${url.platform} link` };
189
+ if (this.paused.has(request.platform))
190
+ return { ok: false, reason: 'platform_paused', message: `${request.platform} is paused today` };
191
+ const now = this.now();
192
+ const joinAt = request.joinAt ?? now;
193
+ // a NaN join time is never due: the run would wait for ever with its reservation held
194
+ if (!Number.isFinite(joinAt))
195
+ throw new RangeError('joinAt must be a finite time in milliseconds');
196
+ const limits = runLimits(request.limits);
197
+ const runId = randomUUID();
198
+ const reserved = await this.stores.entitlement.reserve({ ownerRef: request.ownerRef, runId, platform: request.platform, maxSeconds: Math.ceil(limits.totalMs / 1000) });
199
+ if (!reserved.ok)
200
+ return { ok: false, reason: reserved.reason, message: reserved.message };
201
+ // written as "not at least" so that an answer that is not a number is refused too, rather than leaving the run without a cap
202
+ if (!(reserved.maxSeconds >= this.minReservationSeconds)) {
203
+ await this.stores.entitlement.release(reserved.reservationId);
204
+ return { ok: false, reason: 'allowance', message: `under ${Math.round(this.minReservationSeconds / 60)} minutes left` };
205
+ }
206
+ limits.totalMs = Math.min(limits.totalMs, reserved.maxSeconds * 1000);
207
+ const run = {
208
+ runId,
209
+ ownerRef: request.ownerRef,
210
+ providerName: provider.name,
211
+ platform: request.platform,
212
+ state: joinAt > now ? 'scheduled' : 'requested',
213
+ keep: request.keep,
214
+ meetingUrl: request.meetingUrl,
215
+ botName: request.botName,
216
+ notice: request.notice,
217
+ stopPhrase: request.stopPhrase,
218
+ limits,
219
+ idempotencyKey: request.idempotencyKey,
220
+ transcriber: request.transcriber,
221
+ requestedAt: now,
222
+ joinAt,
223
+ billedSeconds: 0,
224
+ reservationId: reserved.reservationId,
225
+ consentRecordId: request.consentRecordId,
226
+ pageSeenAt: limits.pageAwayMs === undefined ? undefined : now,
227
+ };
228
+ try {
229
+ await this.stores.runs.insert(run);
230
+ }
231
+ catch (err) {
232
+ await this.stores.entitlement.release(reserved.reservationId);
233
+ if (err instanceof DuplicateIdempotencyKeyError && request.idempotencyKey !== undefined) {
234
+ const existing = await this.stores.runs.findByIdempotencyKey(request.ownerRef, request.idempotencyKey);
235
+ if (existing)
236
+ return { ok: true, run: existing, repeated: true };
237
+ }
238
+ throw err;
239
+ }
240
+ await this.stores.jobs.schedule({ runId, dispatchAt: joinAt });
241
+ await this.emit('bot:requested', run, { platform: run.platform, joinAt });
242
+ if (run.state === 'scheduled')
243
+ await this.emit('bot:scheduled', run, { joinAt });
244
+ return { ok: true, run, repeated: false };
245
+ }
246
+ // ── The loop ──
247
+ /** Runs `tick()` every second until `stop()`. A host with its own scheduler calls `tick()` itself. */
248
+ start(intervalMs = 1000) {
249
+ if (this.timer)
250
+ return;
251
+ this.timer = setInterval(() => {
252
+ void this.tick().catch((err) => this.config.logger?.error(`[MeetingManager] tick: ${String(err)}`));
253
+ }, intervalMs);
254
+ this.timer.unref?.();
255
+ }
256
+ /** Stops the loop `start()` began; a pass already running finishes. */
257
+ stop() {
258
+ if (this.timer)
259
+ clearInterval(this.timer);
260
+ this.timer = undefined;
261
+ }
262
+ /**
263
+ * One pass: due jobs dispatched, limits enforced, kills issued, purges resumed, a reconcile every minute. Each step runs
264
+ * even when an earlier one throws, so a dispatch that fails on one run never holds back the limits of the others (the
265
+ * stop monitor's suspension among them); the first error is thrown once the pass is over.
266
+ */
267
+ async tick() {
268
+ let failure;
269
+ const step = async (pass) => {
270
+ try {
271
+ await pass();
272
+ }
273
+ catch (error) {
274
+ failure ?? (failure = { error });
275
+ }
276
+ };
277
+ await step(() => this.dispatchDue());
278
+ await step(() => this.enforceLimits());
279
+ await step(() => this.cleanupExpired());
280
+ if (this.now() - this.lastReconcileAt >= 60000) {
281
+ this.lastReconcileAt = this.now();
282
+ await step(() => this.reconcile());
283
+ }
284
+ if (failure)
285
+ throw failure.error;
286
+ }
287
+ async dispatchDue() {
288
+ for (;;) {
289
+ const now = this.now();
290
+ const job = await this.stores.jobs.lease(this.id, now, this.leaseTtlMs);
291
+ if (!job)
292
+ return;
293
+ const run = await this.stores.runs.get(job.runId);
294
+ if (!run || TERMINAL_RUN_STATES.has(run.state) || run.state === 'purging') {
295
+ await this.stores.jobs.remove(job.runId);
296
+ continue;
297
+ }
298
+ if (run.cleanupPending && ['requested', 'scheduled', 'dispatching'].includes(run.state)) {
299
+ await this.fail(run, run.reason ?? 'crashed');
300
+ continue;
301
+ }
302
+ if (run.state === 'dispatching') {
303
+ // the lease of another manager expired in the middle of a dispatch: find out whether the bot is out
304
+ await this.recoverDispatch(run);
305
+ continue;
306
+ }
307
+ if (run.state === 'scheduled' && run.reservationId && !(await this.stores.entitlement.revalidate(run.reservationId))) {
308
+ await this.stores.jobs.remove(job.runId);
309
+ await this.fail(run, 'allowance');
310
+ continue;
311
+ }
312
+ const dispatching = await this.stores.runs.update(run.runId, { state: 'dispatching', dispatchedAt: now }, ['requested', 'scheduled']);
313
+ if (!dispatching) {
314
+ // cancelled or purged between the lease and now: the job is finished
315
+ await this.stores.jobs.remove(job.runId);
316
+ continue;
317
+ }
318
+ await this.dispatch(dispatching);
319
+ }
320
+ }
321
+ /** A run left in `dispatching` by a lease that expired: the bot is out (the provider knows the run) or it is not (dispatch again). */
322
+ async recoverDispatch(run) {
323
+ const provider = this.providers.get(run.providerName);
324
+ if (!provider) {
325
+ await this.fail(run, 'crashed', 'provider not registered');
326
+ return;
327
+ }
328
+ let alive = false;
329
+ try {
330
+ alive = (await provider.usage({ runIds: [run.runId] })).some((u) => u.runId === run.runId && u.alive);
331
+ }
332
+ catch (err) {
333
+ this.config.logger?.warn(`[MeetingManager] usage of ${provider.name} unavailable during recovery of ${run.runId}: ${String(err)}`);
334
+ if (await this.stores.jobs.release(run.runId, this.id, this.now() + this.dispatchRetryDelayMs)) {
335
+ const job = await this.stores.jobs.get(run.runId);
336
+ if (job?.cancelled) {
337
+ const current = await this.stores.runs.get(run.runId);
338
+ if (current)
339
+ await this.fail(current, current.reason ?? 'stopped_by_owner');
340
+ }
341
+ }
342
+ return;
343
+ }
344
+ if (alive) {
345
+ const { recorded, cancelled } = await this.stores.jobs.markDispatched(run.runId, this.id, run.dispatch?.workerId);
346
+ if (!recorded)
347
+ return;
348
+ if (cancelled)
349
+ await this.leaveCancelled(run.runId);
350
+ return;
351
+ }
352
+ const job = await this.stores.jobs.get(run.runId);
353
+ if (!job || job.leaseHolder !== this.id)
354
+ return;
355
+ if (job.cancelled) {
356
+ const current = await this.stores.runs.get(run.runId);
357
+ if (current)
358
+ await this.fail(current, current.reason ?? 'stopped_by_owner');
359
+ return;
360
+ }
361
+ if (run.dispatch?.roomName)
362
+ await this.config.room.delete(run.dispatch.roomName);
363
+ const again = await this.stores.runs.update(run.runId, { dispatch: undefined }, 'dispatching');
364
+ if (again)
365
+ await this.dispatch(again);
366
+ }
367
+ async dispatch(run) {
368
+ const provider = this.providers.get(run.providerName);
369
+ if (!provider) {
370
+ await this.fail(run, 'crashed', 'provider not registered');
371
+ return;
372
+ }
373
+ // the lease must still be ours before anything leaves this process: a holder that lost it stops here
374
+ if (!(await this.stores.jobs.renew(run.runId, this.id, this.now(), this.leaseTtlMs))) {
375
+ this.config.logger?.warn(`[MeetingManager] lease of ${run.runId} lost before dispatch`);
376
+ return;
377
+ }
378
+ let room;
379
+ try {
380
+ room = await this.config.room.create(run.runId);
381
+ }
382
+ catch (err) {
383
+ // a room that cannot be made fails the dispatch as a provider's refusal does: tried again after the pause, ended at the deadline
384
+ await this.dispatchFailed(run, err, undefined);
385
+ return;
386
+ }
387
+ const workerToken = mintWorkerToken();
388
+ const withDispatch = await this.stores.runs.update(run.runId, { dispatch: { roomName: room.name, workerTokenHash: hashWorkerToken(workerToken) } }, 'dispatching');
389
+ if (!withDispatch)
390
+ return;
391
+ let result;
392
+ let leaseLost = false;
393
+ let renewal = Promise.resolve();
394
+ const timer = setInterval(() => {
395
+ renewal = renewal.then(async () => {
396
+ if (!(await this.stores.jobs.renew(run.runId, this.id, this.now(), this.leaseTtlMs)))
397
+ leaseLost = true;
398
+ }).catch(() => { leaseLost = true; });
399
+ }, Math.max(1, Math.floor(this.leaseTtlMs / 3)));
400
+ timer.unref?.();
401
+ try {
402
+ result = await provider.dispatch({ run: withDispatch, room: { url: room.url, name: room.name, publishToken: room.publishToken }, workerToken, reportUrl: this.config.reportUrlFor(run.runId, provider.name) });
403
+ }
404
+ catch (err) {
405
+ clearInterval(timer);
406
+ await renewal;
407
+ if (leaseLost)
408
+ return;
409
+ await this.dispatchFailed(run, err, room.name);
410
+ return;
411
+ }
412
+ finally {
413
+ clearInterval(timer);
414
+ }
415
+ await renewal;
416
+ if (leaseLost)
417
+ return;
418
+ const { recorded, cancelled } = await this.stores.jobs.markDispatched(run.runId, this.id, result.workerId);
419
+ if (!recorded)
420
+ return;
421
+ const dispatched = await this.stores.runs.update(run.runId, { dispatch: { ...withDispatch.dispatch, workerId: result.workerId, providerRunRef: result.providerRunRef } }, 'dispatching');
422
+ if (dispatched)
423
+ await this.emit('bot:dispatching', dispatched, { workerId: result.workerId });
424
+ if (cancelled)
425
+ await this.leaveCancelled(run.runId);
426
+ }
427
+ /**
428
+ * A dispatch that failed before the bot was out, while this manager still holds the job: the room goes, then the run
429
+ * ends (no capacity, a cancel that landed meanwhile, or the retry window past its due time) or its job is released to
430
+ * be tried again after the pause.
431
+ */
432
+ async dispatchFailed(run, err, roomName) {
433
+ const job = await this.stores.jobs.get(run.runId);
434
+ if (!job || job.leaseHolder !== this.id)
435
+ return;
436
+ if (roomName)
437
+ await this.config.room.delete(roomName);
438
+ if (err instanceof NoCapacityError || this.now() - run.joinAt > this.dispatchRetryMs) {
439
+ const current = await this.stores.runs.get(run.runId);
440
+ if (current)
441
+ await this.fail(current, job.cancelled ? current.reason ?? 'stopped_by_owner' : err instanceof NoCapacityError ? 'no_capacity' : 'crashed');
442
+ return;
443
+ }
444
+ // Keep dispatching: the forward-only graph allows retrying the same state.
445
+ // Preserve the dispatch record so recovery checks whether the provider started a bot before its reply failed.
446
+ if (!(await this.stores.jobs.release(run.runId, this.id, this.now() + this.dispatchRetryDelayMs)))
447
+ return;
448
+ const released = await this.stores.jobs.get(run.runId);
449
+ if (released?.cancelled) {
450
+ const current = await this.stores.runs.get(run.runId);
451
+ if (current)
452
+ await this.fail(current, current.reason ?? 'stopped_by_owner');
453
+ }
454
+ }
455
+ /**
456
+ * A cancel learned at `markDispatched`: the bot is out, so it is told to leave with the cancel's reason. The run is read
457
+ * again first, since the bot's own first report (`joining`) may have moved it on while the dispatch was in flight.
458
+ */
459
+ async leaveCancelled(runId) {
460
+ const current = await this.stores.runs.get(runId);
461
+ if (current)
462
+ await this.leave(current, current.reason ?? 'stopped_by_owner');
463
+ }
464
+ // ── Reports ──
465
+ /** A provider's report, from the product's events route. Verified by the provider, parsed, deduplicated, applied. */
466
+ async processProviderEvent(providerName, ctx) {
467
+ const provider = this.providers.get(providerName);
468
+ if (!provider)
469
+ return { accepted: false, error: 'unknown provider', applied: 0 };
470
+ const run = ctx.runId ? await this.stores.runs.get(ctx.runId) : undefined;
471
+ const verification = provider.verifyEvent(ctx, run);
472
+ if (!verification.valid)
473
+ return { accepted: false, error: verification.error ?? 'refused', applied: 0 };
474
+ let parsed;
475
+ try {
476
+ parsed = provider.parseEvent(ctx);
477
+ }
478
+ catch {
479
+ // a fixed answer: the parser's error may quote the report (a JSON.parse error quotes the input around the bad token), and a report carries what people said
480
+ return { accepted: false, error: 'unparseable', applied: 0 };
481
+ }
482
+ let applied = 0;
483
+ for (const event of parsed.events) {
484
+ if (run && event.runId !== run.runId)
485
+ continue;
486
+ if (await this.apply(event))
487
+ applied += 1;
488
+ }
489
+ return { accepted: true, applied };
490
+ }
491
+ /**
492
+ * The event a report is, whatever the run's state, for a report that is one: stored before the report is applied, it
493
+ * is the report's record. A state report has none (the move it makes emits its event), and a heartbeat,
494
+ * capture_started, done and crashed emit nothing of their own.
495
+ */
496
+ ownEvent(event) {
497
+ switch (event.type) {
498
+ case 'participant_joined': return { type: 'bot:participant_joined', data: { participantRef: event.data?.participantRef } };
499
+ case 'participant_left': return { type: 'bot:participant_left', data: { participantRef: event.data?.participantRef } };
500
+ case 'chat_message': return { type: 'bot:chat_message', data: { text: event.data?.text, sender: event.data?.sender } };
501
+ case 'stop_command': return { type: 'bot:stop_command', data: { sender: event.data?.sender } };
502
+ case 'capture_suspended': return { type: 'bot:capture_suspended', data: {} };
503
+ case 'usage': return { type: 'bot:usage', data: { seconds: event.data?.seconds ?? 0 } };
504
+ case 'error': return { type: 'bot:error', data: { message: event.data?.message } };
505
+ default: return undefined;
506
+ }
507
+ }
508
+ async apply(event) {
509
+ const run = await this.stores.runs.get(event.runId);
510
+ if (!run || TERMINAL_RUN_STATES.has(run.state))
511
+ return false;
512
+ const fenced = run.state === 'purging';
513
+ // behind the fence nothing a person said or did is stored or forwarded: a late content report is dropped whole
514
+ if (fenced && CONTENT_REPORTS.has(event.type))
515
+ return false;
516
+ if (event.type === 'capture_started' && !this.captureReady(run)) {
517
+ await this.emit('bot:error', run, { message: 'capture_started outside the capture interlock' }, `${event.eventId}#interlock`, event.at);
518
+ return false;
519
+ }
520
+ // One row per report, the event the destinations and the handlers receive. A report that is an event of its own is
521
+ // stored as that event before anything is applied, and the row is its record: a repeated one is stored nowhere and
522
+ // changes nothing. A state report stores the event of the move it makes, and a repeated one moves nothing; a
523
+ // heartbeat (360 rows an hour would be noise), capture_started, done and crashed store nothing, and applying one
524
+ // twice changes nothing.
525
+ const own = this.ownEvent(event);
526
+ const stored = own ? await this.storeEvent({ id: `${event.eventId}#${own.type}`, type: own.type, at: event.at, data: own.data }, run.runId) : undefined;
527
+ if (own && !stored)
528
+ return false;
529
+ switch (event.type) {
530
+ case 'joining':
531
+ await this.move(run, 'joining', {}, 'bot:joining', undefined, event);
532
+ return true;
533
+ case 'waiting_room':
534
+ await this.move(run, 'waiting_room', {}, 'bot:waiting_room', undefined, event);
535
+ return true;
536
+ case 'in_call':
537
+ await this.move(run, 'in_call_notice_pending', { joinedAt: event.at, participantCount: event.data?.participantCount, ...this.aloneTimer(run, event.data?.participantCount, event.at) }, 'bot:in_call', undefined, event);
538
+ return true;
539
+ case 'notice_posted': {
540
+ // the notice alone does not open capture: the stop monitor must report a real observation after it (the interlock)
541
+ const moved = await this.move(run, 'in_call', { noticePostedAt: event.at }, 'bot:notice_posted', { version: run.notice.version }, event);
542
+ if (moved)
543
+ await this.startCaptureIfReady(moved);
544
+ return true;
545
+ }
546
+ case 'monitor_heartbeat': {
547
+ // the stop monitor's own observation: the first after the notice opens capture, one after a suspension resumes it (the method holds every guard)
548
+ const updated = await this.stores.runs.update(run.runId, { monitorSeenAt: event.at });
549
+ if (updated)
550
+ await this.startCaptureIfReady(updated);
551
+ return true;
552
+ }
553
+ case 'capture_started':
554
+ if (run.captureStartedAt === undefined)
555
+ await this.stores.runs.update(run.runId, { captureStartedAt: event.at }, 'in_call');
556
+ return true;
557
+ case 'capture_suspended':
558
+ await this.deliver(stored, run);
559
+ return true;
560
+ case 'participant_joined': {
561
+ const updated = (await this.stores.runs.update(run.runId, { participantCount: event.data?.participantCount, ...this.aloneTimer(run, event.data?.participantCount, event.at) })) ?? run;
562
+ await this.deliver(stored, updated);
563
+ const provider = this.providers.get(run.providerName);
564
+ if (updated.state === 'in_call' && provider?.capabilities.postsChat && !fenced) {
565
+ await provider.command(run.runId, { name: 'postChat', text: run.notice.lateArrivalText });
566
+ }
567
+ return true;
568
+ }
569
+ case 'participant_left': {
570
+ const updated = (await this.stores.runs.update(run.runId, { participantCount: event.data?.participantCount, ...this.aloneTimer(run, event.data?.participantCount, event.at) })) ?? run;
571
+ await this.deliver(stored, updated);
572
+ return true;
573
+ }
574
+ case 'chat_message': {
575
+ await this.deliver(stored, run);
576
+ if (!fenced && this.isStopPhrase(run, event.data?.text))
577
+ await this.purge(run, 'stopped_by_participant');
578
+ return true;
579
+ }
580
+ case 'stop_command':
581
+ await this.deliver(stored, run);
582
+ if (!fenced)
583
+ await this.purge(run, 'stopped_by_participant');
584
+ return true;
585
+ case 'usage': {
586
+ const updated = await this.stores.runs.update(run.runId, { lastUsageAt: event.at });
587
+ if (run.reservationId && run.captureStartedAt !== undefined)
588
+ await this.stores.entitlement.meter(run.reservationId, event.data?.seconds ?? 0);
589
+ await this.deliver(stored, updated ?? run);
590
+ return true;
591
+ }
592
+ case 'left': {
593
+ const reason = run.reason ?? event.data?.reason ?? 'ended';
594
+ if (run.state === 'purging') {
595
+ await this.stores.runs.update(run.runId, { leftAt: event.at, reason, reportedSeconds: event.data?.seconds ?? run.reportedSeconds });
596
+ return true;
597
+ }
598
+ // a bot that left on its own (not admitted, alone, the cap, a crash before a command) reaches `left` from any state after dispatch, with its own reason
599
+ const left = await this.move(run, 'left', { leftAt: event.at, reason, reportedSeconds: event.data?.seconds ?? run.reportedSeconds, cleanupPending: true }, 'bot:left', { reason }, event);
600
+ if (left)
601
+ await this.complete(left, event.data?.seconds);
602
+ return true;
603
+ }
604
+ case 'done':
605
+ if (run.state === 'purging')
606
+ await this.stores.runs.update(run.runId, { reportedSeconds: event.data?.seconds ?? run.reportedSeconds });
607
+ else if (run.state === 'left' || run.state === 'leaving')
608
+ await this.complete(run, event.data?.seconds);
609
+ return true;
610
+ case 'crashed':
611
+ await this.crash(run, event.at, event.data?.seconds);
612
+ return true;
613
+ case 'error':
614
+ await this.deliver(stored, run);
615
+ return true;
616
+ }
617
+ }
618
+ isStopPhrase(run, text) {
619
+ return !!text && text.toLowerCase().includes(run.stopPhrase.toLowerCase());
620
+ }
621
+ /** The alone timer's patch for a report of `count` participants besides the bot: started by the first report of none, kept while there is none, cleared when one is reported. */
622
+ aloneTimer(run, count, at) {
623
+ if (count === undefined)
624
+ return {};
625
+ return { aloneSince: count === 0 ? run.aloneSince ?? at : undefined };
626
+ }
627
+ /** Moves the run forward when the transition is allowed; a repeated state does nothing; a disallowed one is logged and dropped. */
628
+ async move(run, to, patch, type, data, event) {
629
+ if (run.state === to)
630
+ return run;
631
+ if (!canTransition(run.state, to)) {
632
+ this.config.logger?.warn(`[MeetingManager] ${run.runId}: ${run.state} -> ${to} refused`);
633
+ return undefined;
634
+ }
635
+ const updated = await this.stores.runs.update(run.runId, { ...patch, state: to }, run.state);
636
+ if (!updated)
637
+ return undefined;
638
+ await this.emitFrom(updated, type, data, event);
639
+ return updated;
640
+ }
641
+ emitFrom(run, type, data, event) {
642
+ return this.emit(type, run, data, `${event.eventId}#${type}`, event.at);
643
+ }
644
+ captureReady(run) {
645
+ return run.state === 'in_call'
646
+ && run.noticePostedAt !== undefined
647
+ && run.monitorSeenAt !== undefined
648
+ && run.monitorSeenAt >= run.noticePostedAt
649
+ && this.now() - run.monitorSeenAt < this.monitorHeartbeatMs * this.monitorMissedBeats
650
+ && run.captureSuspendedAt === undefined;
651
+ }
652
+ /** The capture interlock: `in_call`, the notice confirmed, and the stop monitor's own observation reported after the notice, no fence. */
653
+ async startCaptureIfReady(run) {
654
+ if (run.state !== 'in_call' || run.noticePostedAt === undefined || run.monitorSeenAt === undefined)
655
+ return;
656
+ if (run.monitorSeenAt < run.noticePostedAt)
657
+ return; // a heartbeat from before the notice proves nothing about the chat being watched now
658
+ if (this.now() - run.monitorSeenAt >= this.monitorHeartbeatMs * this.monitorMissedBeats)
659
+ return;
660
+ if (run.captureStartedAt !== undefined && run.captureSuspendedAt === undefined)
661
+ return; // already capturing
662
+ const provider = this.providers.get(run.providerName);
663
+ if (!provider)
664
+ return;
665
+ await provider.command(run.runId, { name: 'startCapture' });
666
+ // the suspension is on the run, so the manager that hears the monitor recover resumes capture, whichever one suspended it
667
+ const started = await this.stores.runs.update(run.runId, { captureStartedAt: run.captureStartedAt ?? this.now(), captureSuspendedAt: undefined }, 'in_call');
668
+ if (started)
669
+ await this.emit('bot:capture_started', started, { resumed: run.captureStartedAt !== undefined });
670
+ }
671
+ // ── Owner commands ──
672
+ /** The bot leaves; the run's data stays under its Keep. Before dispatch the run ends; during a dispatch the cancel becomes a leave. */
673
+ async leaveBot(runId, reason = 'stopped_by_owner') {
674
+ const run = await this.stores.runs.get(runId);
675
+ if (!run)
676
+ return false;
677
+ if (run.state === 'dispatching') {
678
+ await this.stores.runs.update(runId, { reason }, 'dispatching');
679
+ const result = await this.stores.jobs.cancel(runId);
680
+ if (result === 'already_dispatched') {
681
+ const current = await this.stores.runs.get(runId);
682
+ return current ? this.leave(current, reason) : false;
683
+ }
684
+ const job = await this.stores.jobs.get(runId);
685
+ if (job && job.leaseHolder === undefined)
686
+ await this.fail(run, reason);
687
+ return true;
688
+ }
689
+ if (run.state === 'requested' || run.state === 'scheduled') {
690
+ const cancelled = await this.stores.jobs.cancel(runId);
691
+ if (cancelled === 'already_dispatched') {
692
+ // the job was dispatched after the read above, so the run is past `requested`, which cannot move to `leaving`: leave from the state it is in now
693
+ const current = await this.stores.runs.get(runId);
694
+ return current ? this.leave(current, reason) : false;
695
+ }
696
+ const stillWaiting = await this.stores.runs.update(runId, { reason }, ['requested', 'scheduled']);
697
+ if (!stillWaiting) {
698
+ // a dispatcher took the run between the read and the cancel: it learns the cancel at markDispatched and tells the bot to leave with this reason
699
+ await this.stores.runs.update(runId, { reason }, 'dispatching');
700
+ return true;
701
+ }
702
+ await this.stores.jobs.remove(runId);
703
+ await this.fail(stillWaiting, reason);
704
+ return true;
705
+ }
706
+ return this.leave(run, reason);
707
+ }
708
+ /** The bot leaves and everything it took is deleted (the purge sequence). On a run that is already done, the kept content is deleted (a session's deletion). */
709
+ async purgeRun(runId, reason = 'stopped_by_owner') {
710
+ const run = await this.stores.runs.get(runId);
711
+ if (!run)
712
+ return false;
713
+ if (run.state === 'done')
714
+ return this.purgeKept(run);
715
+ if (run.state === 'failed')
716
+ return false;
717
+ await this.purge(run, reason);
718
+ return true;
719
+ }
720
+ /** A kept run's content removed after `done`: the fences, the stores, the hooks, the provider's copies, the content-free row, `bot:purged`. A step that throws is the caller's to retry (the product's one purge path retries). */
721
+ async purgeKept(run) {
722
+ if (run.purgedAt !== undefined)
723
+ return true;
724
+ await this.stores.transcript.fence(run.runId);
725
+ await this.stores.events.fence(run.runId);
726
+ await this.stores.outbox.purgeContent(run.runId);
727
+ await this.stores.transcript.purge(run.runId);
728
+ await this.stores.events.purgePayloads(run.runId);
729
+ await this.stores.outbox.purgeContent(run.runId);
730
+ for (const hook of this.purgeHooks)
731
+ await hook(run.runId);
732
+ await this.providers.get(run.providerName)?.deleteRun({ runId: run.runId });
733
+ const free = { ...contentFreeRun(run), purgedAt: this.now() };
734
+ await this.stores.runs.replace(free);
735
+ await this.emit('bot:purged', free, { reason: 'deleted' });
736
+ return true;
737
+ }
738
+ /** Posts to the meeting's chat through the run's bot; false unless the run is in the call and its provider posts to chat. */
739
+ async postChat(runId, text) {
740
+ const run = await this.stores.runs.get(runId);
741
+ const provider = run && this.providers.get(run.providerName);
742
+ if (!run || !provider || !provider.capabilities.postsChat || run.state !== 'in_call')
743
+ return false;
744
+ await provider.command(runId, { name: 'postChat', text });
745
+ return true;
746
+ }
747
+ /** A run sent from a page: the page is still there. */
748
+ async pageSeen(runId) {
749
+ await this.stores.runs.update(runId, { pageSeenAt: this.now() });
750
+ }
751
+ /** Adds a step to the purge sequence, as {@link MeetingManagerConfig.purgeHooks} does. */
752
+ addPurgeHook(hook) {
753
+ this.purgeHooks.push(hook);
754
+ }
755
+ /** The run's row, or undefined when there is none. */
756
+ getRun(runId) {
757
+ return this.stores.runs.get(runId);
758
+ }
759
+ /** The runs the filter selects, newest first, as the run store's {@link IRunStore.list} answers them. */
760
+ listRuns(filter) {
761
+ return this.stores.runs.list(filter);
762
+ }
763
+ // ── Ends ──
764
+ /**
765
+ * Moves the run to `leaving` (a run behind the fence stays `purging`), arms the kill and tells the bot to leave. The
766
+ * kill's deadline is armed on the run before the command goes, so a leave the bot never receives still ends in the
767
+ * kill, and whichever manager runs the limits, after a restart too, sends it.
768
+ */
769
+ async leave(run, reason) {
770
+ if (!canTransition(run.state, 'leaving') && run.state !== 'purging')
771
+ return false;
772
+ const armed = run.state === 'purging' ? { leaveCommandedAt: this.now() } : { state: 'leaving', reason, leaveCommandedAt: this.now() };
773
+ if (!(await this.stores.runs.update(run.runId, armed, run.state)))
774
+ return false;
775
+ await this.sendCommand(run, { name: 'leave', reason });
776
+ return true;
777
+ }
778
+ /**
779
+ * Sends a command on the way out (suspend, leave, kill) to the run's bot. A command that fails, its worker unreachable
780
+ * for a moment, is logged and not thrown: the kill armed before it, and the end recorded after the kill, cover a bot
781
+ * that never received it.
782
+ */
783
+ async sendCommand(run, command) {
784
+ try {
785
+ await this.providers.get(run.providerName)?.command(run.runId, command);
786
+ }
787
+ catch (err) {
788
+ this.config.logger?.warn(`[MeetingManager] ${command.name} to the bot of ${run.runId} failed: ${String(err)}`);
789
+ }
790
+ }
791
+ /** A run that ends before it joined (a failed revalidation, no capacity, a cancel): never billed. */
792
+ async fail(run, reason, message) {
793
+ const current = await this.stores.runs.get(run.runId);
794
+ if (!current || !['requested', 'scheduled', 'dispatching'].includes(current.state))
795
+ return;
796
+ // update re-reads under the anchor and checks this exact non-purging state.
797
+ const pending = await this.stores.runs.update(current.runId, { reason: current.reason ?? reason, cleanupPending: true }, current.state);
798
+ if (!pending)
799
+ return;
800
+ await this.stores.transcript.fence(run.runId);
801
+ await this.stores.events.fence(run.runId);
802
+ await this.stores.outbox.purgeContent(run.runId);
803
+ await this.stores.transcript.purge(run.runId);
804
+ await this.stores.events.purgePayloads(run.runId);
805
+ if (pending.reservationId)
806
+ await this.stores.entitlement.release(pending.reservationId);
807
+ if (pending.dispatch?.roomName)
808
+ await this.config.room.delete(pending.dispatch.roomName);
809
+ await this.stores.jobs.remove(run.runId);
810
+ const now = this.now();
811
+ const failed = await this.stores.runs.update(run.runId, { ...contentFreeRun(pending), dispatch: undefined, transcriber: undefined, pageSeenAt: undefined, lastUsageAt: undefined, reportedSeconds: undefined, monitorSeenAt: undefined, participantCount: undefined, leaveCommandedAt: undefined, captureSuspendedAt: undefined, aloneSince: undefined, state: 'failed', leftAt: now, doneAt: now, billedSeconds: 0, cleanupPending: false }, pending.state);
812
+ if (!failed)
813
+ return;
814
+ await this.emit('bot:left', failed, { reason: failed.reason, message });
815
+ await this.emit('bot:done', failed, { reason: failed.reason, billedSeconds: 0 });
816
+ }
817
+ /**
818
+ * After `left` (no purge): settle or release, drop what is not kept, delete the room, remove the job, and only then
819
+ * `done`. A step that throws leaves the run in `left` with `cleanupPending`, and the next tick calls this again; the
820
+ * entitlement hook's `settle` and `release` are idempotent, so a retry after a half-done cleanup changes nothing twice.
821
+ */
822
+ async complete(run, reportedSeconds) {
823
+ const current = await this.stores.runs.get(run.runId);
824
+ if (!current || (current.state !== 'left' && current.state !== 'leaving'))
825
+ return;
826
+ if (current.state === 'leaving') {
827
+ const left = await this.stores.runs.update(run.runId, { state: 'left', leftAt: current.leftAt ?? this.now(), cleanupPending: true, reportedSeconds: reportedSeconds ?? current.reportedSeconds }, 'leaving');
828
+ if (!left)
829
+ return;
830
+ await this.emit('bot:left', left, { reason: left.reason });
831
+ run = left;
832
+ }
833
+ else
834
+ run = current;
835
+ const now = this.now();
836
+ const seconds = reportedSeconds ?? run.reportedSeconds ?? Math.max(0, Math.round(((run.leftAt ?? now) - (run.dispatchedAt ?? run.joinAt)) / 1000));
837
+ const billed = isBilledEnd(run) ? seconds : 0;
838
+ try {
839
+ await this.stores.transcript.fence(run.runId);
840
+ if (run.reservationId) {
841
+ if (billed > 0)
842
+ await this.stores.entitlement.settle(run.reservationId, billed);
843
+ else
844
+ await this.stores.entitlement.release(run.reservationId);
845
+ }
846
+ if (run.keep === 'none') {
847
+ await this.stores.events.fence(run.runId);
848
+ await this.stores.outbox.purgeContent(run.runId);
849
+ await this.stores.transcript.purge(run.runId);
850
+ await this.stores.events.purgePayloads(run.runId);
851
+ }
852
+ if (run.dispatch?.roomName)
853
+ await this.config.room.delete(run.dispatch.roomName);
854
+ await this.stores.jobs.remove(run.runId);
855
+ }
856
+ catch (err) {
857
+ this.config.logger?.warn(`[MeetingManager] cleanup of ${run.runId} retried on the next tick: ${String(err)}`);
858
+ await this.stores.runs.update(run.runId, { reportedSeconds: seconds, cleanupPending: true }, 'left');
859
+ return;
860
+ }
861
+ const free = contentFreeRun(run);
862
+ const kept = run.keep === 'none' ? free : { ...free, botName: run.botName, notice: run.notice };
863
+ const done = await this.stores.runs.update(run.runId, { ...kept, dispatch: undefined, transcriber: undefined, pageSeenAt: undefined, lastUsageAt: undefined, reportedSeconds: undefined, monitorSeenAt: undefined, participantCount: undefined, leaveCommandedAt: undefined, captureSuspendedAt: undefined, aloneSince: undefined, state: 'done', doneAt: now, billedSeconds: billed, cleanupPending: false }, 'left');
864
+ if (!done)
865
+ return;
866
+ await this.emit('bot:done', done, { reason: done.reason, billedSeconds: billed });
867
+ }
868
+ async crash(run, at, seconds) {
869
+ const current = await this.stores.runs.get(run.runId);
870
+ if (!current || TERMINAL_RUN_STATES.has(current.state))
871
+ return;
872
+ if (current.state === 'purging') {
873
+ await this.stores.runs.update(run.runId, { leftAt: at, reportedSeconds: seconds ?? current.reportedSeconds }, 'purging');
874
+ return;
875
+ }
876
+ const expected = ACTIVE_STATES.filter((state) => state !== 'purging');
877
+ if (!expected.includes(current.state))
878
+ return;
879
+ // The store re-reads under the anchor; a purge between get and update fails this exact-state guard.
880
+ const left = await this.stores.runs.update(run.runId, { state: 'left', leftAt: at, reason: current.reason ?? 'crashed', reportedSeconds: seconds ?? current.reportedSeconds, cleanupPending: true }, current.state);
881
+ if (!left) {
882
+ const fenced = await this.stores.runs.get(run.runId);
883
+ if (fenced?.state === 'purging')
884
+ await this.stores.runs.update(run.runId, { leftAt: at, reportedSeconds: seconds ?? fenced.reportedSeconds }, 'purging');
885
+ return;
886
+ }
887
+ await this.emit('bot:left', left, { reason: left.reason });
888
+ await this.complete(left, seconds);
889
+ }
890
+ // ── The purge sequence ──
891
+ /** (a) the fence; (b) the purge, each step retried until it resolves; (c) the content-free record. */
892
+ async purge(run, reason) {
893
+ if (run.state !== 'purging') {
894
+ // a bot that may be out is told to leave below: the kill's deadline goes on the run with the fence, before the commands
895
+ const fenced = await this.stores.runs.update(run.runId, { state: 'purging', reason, ...(run.dispatch ? { leaveCommandedAt: this.now() } : {}) }, PURGEABLE_STATES);
896
+ if (!fenced)
897
+ return;
898
+ await this.stores.transcript.fence(run.runId);
899
+ await this.stores.events.fence(run.runId);
900
+ await this.stores.outbox.purgeContent(run.runId);
901
+ // decided on the row the fence wrote, not on the read before it: a dispatcher may have taken the run in between,
902
+ // written its dispatch record and started a bot. With no dispatch record no bot can start (the dispatcher's write of
903
+ // one expects `dispatching`); with one, the bot is told at once unless it has already left.
904
+ if (fenced.dispatch === undefined) {
905
+ await this.stores.jobs.cancel(run.runId);
906
+ await this.stores.jobs.remove(run.runId);
907
+ }
908
+ else if (fenced.leftAt === undefined) {
909
+ // the kill is armed first, so a suspend or a leave the bot never receives still ends in the kill; a fence written
910
+ // without its deadline (a dispatcher took the run after the read above) has it written here, before the commands
911
+ if (fenced.leaveCommandedAt === undefined)
912
+ await this.stores.runs.update(run.runId, { leaveCommandedAt: this.now() }, 'purging');
913
+ await this.sendCommand(run, { name: 'suspendCapture' });
914
+ await this.sendCommand(run, { name: 'leave', reason });
915
+ }
916
+ }
917
+ await this.continuePurge(run.runId);
918
+ }
919
+ /** The steps after the fence, in order; a step that throws is retried on the next tick, and a restart resumes here through `reconcile`. */
920
+ async continuePurge(runId) {
921
+ const run = await this.stores.runs.get(runId);
922
+ if (!run || run.state !== 'purging')
923
+ return;
924
+ const provider = this.providers.get(run.providerName);
925
+ try {
926
+ // the stores' content goes at once: behind the fence nothing new is written, so these steps need not wait for the bot
927
+ await this.stores.transcript.purge(runId);
928
+ await this.stores.events.purgePayloads(runId);
929
+ await this.stores.outbox.purgeContent(runId);
930
+ const stillInCall = run.dispatch !== undefined && run.leftAt === undefined;
931
+ if (stillInCall)
932
+ return; // the bot has not left yet; the kill timer and the left report bring us back
933
+ for (const hook of this.purgeHooks)
934
+ await hook(runId);
935
+ if (provider)
936
+ await provider.deleteRun({ runId });
937
+ if (run.dispatch?.roomName)
938
+ await this.config.room.delete(run.dispatch.roomName);
939
+ }
940
+ catch (err) {
941
+ this.config.logger?.warn(`[MeetingManager] purge of ${runId} retried: ${String(err)}`);
942
+ return;
943
+ }
944
+ const now = this.now();
945
+ const seconds = run.reportedSeconds ?? Math.max(0, Math.round(((run.leftAt ?? now) - (run.dispatchedAt ?? run.joinAt)) / 1000));
946
+ const billed = isBilledEnd(run) ? seconds : 0;
947
+ if (run.reservationId) {
948
+ if (billed > 0)
949
+ await this.stores.entitlement.settle(run.reservationId, billed);
950
+ else
951
+ await this.stores.entitlement.release(run.reservationId);
952
+ }
953
+ await this.stores.jobs.remove(runId);
954
+ // one write, `done` and the content-free row together as complete() and fail() write theirs: a failure after a first
955
+ // write of `done` would leave a done row holding the url, the bot's name and the notice, and nothing returns to a done run
956
+ const done = await this.stores.runs.update(runId, { ...contentFreeRun(run), dispatch: undefined, transcriber: undefined, pageSeenAt: undefined, lastUsageAt: undefined, reportedSeconds: undefined, monitorSeenAt: undefined, participantCount: undefined, leaveCommandedAt: undefined, captureSuspendedAt: undefined, aloneSince: undefined, state: 'done', doneAt: now, purgedAt: now, billedSeconds: billed, cleanupPending: false }, 'purging');
957
+ if (!done)
958
+ return;
959
+ await this.emit('bot:purged', done, { reason: done.reason });
960
+ await this.emit('bot:done', done, { reason: done.reason, billedSeconds: billed });
961
+ }
962
+ // ── The limits ──
963
+ /**
964
+ * Each run's limits, kills, resumed purges and retried cleanups, in turn. A run whose step throws (a cleanup, a settle,
965
+ * a command) holds back no other run's, the stop monitor's suspension among them: it is tried again on the next pass,
966
+ * and the first error is thrown once every run has had its turn.
967
+ */
968
+ async enforceLimits() {
969
+ const now = this.now();
970
+ const runs = await this.stores.runs.list({ states: ['requested', 'scheduled', ...ACTIVE_STATES], limit: Number.MAX_SAFE_INTEGER });
971
+ let failure;
972
+ for (const run of runs) {
973
+ try {
974
+ if (run.cleanupPending && ['requested', 'scheduled', 'dispatching'].includes(run.state)) {
975
+ await this.fail(run, run.reason ?? 'crashed');
976
+ continue;
977
+ }
978
+ if (run.state === 'requested' || run.state === 'scheduled')
979
+ continue;
980
+ const provider = this.providers.get(run.providerName);
981
+ // the kill's deadline is on the run, so a bot that has not left is killed whichever manager told it to leave, after a
982
+ // restart too, and the bot's leaving (`leftAt`) disarms it. The end is recorded whether or not the kill reaches the
983
+ // bot: the cleanup that follows deletes its room
984
+ if (run.leaveCommandedAt !== undefined && run.leftAt === undefined && now - run.leaveCommandedAt > this.killAfterMs) {
985
+ await this.sendCommand(run, { name: 'kill' });
986
+ await this.crash(run, now, undefined);
987
+ continue;
988
+ }
989
+ if (run.state === 'purging') {
990
+ await this.continuePurge(run.runId);
991
+ continue;
992
+ }
993
+ if (run.state === 'left') {
994
+ // a cleanup that threw last time is tried again here, until `done` is written
995
+ await this.complete(run, undefined);
996
+ continue;
997
+ }
998
+ if (run.state === 'leaving')
999
+ continue;
1000
+ // a cancel that leaveBot wrote during a dispatch, with no dispatcher left to act on it: the dispatcher stopped, so the
1001
+ // cancelled job is never leased again and no markDispatched learns it. The leave goes from here, from whatever state
1002
+ // the bot's own reports have moved the run to; a dispatch still under a live lease learns it at markDispatched.
1003
+ if (run.reason !== undefined && !(run.state === 'dispatching' && (await this.dispatchLeaseLive(run.runId, now)))) {
1004
+ await this.leave(run, run.reason);
1005
+ continue;
1006
+ }
1007
+ const since = run.dispatchedAt ?? run.joinAt;
1008
+ if (now - since > run.limits.totalMs) {
1009
+ await this.leave(run, 'cap');
1010
+ continue;
1011
+ }
1012
+ if (run.state === 'waiting_room' && now - (run.joinedAt ?? since) > run.limits.waitingRoomMs) {
1013
+ await this.leave(run, 'not_admitted');
1014
+ continue;
1015
+ }
1016
+ if (run.state === 'in_call_notice_pending' && run.joinedAt !== undefined && now - run.joinedAt > this.noticeConfirmMs) {
1017
+ await this.leave(run, 'notice_failed');
1018
+ continue;
1019
+ }
1020
+ if (run.state === 'in_call') {
1021
+ // quiet once monitorMissedBeats intervals have passed since the last heartbeat, the boundary captureReady and startCaptureIfReady use
1022
+ const monitorStale = run.monitorSeenAt !== undefined && now - run.monitorSeenAt >= this.monitorHeartbeatMs * this.monitorMissedBeats;
1023
+ if (monitorStale) {
1024
+ if (run.captureSuspendedAt === undefined) {
1025
+ // kept on the run: the manager that hears the monitor recover resumes capture, and the recovery window holds after a restart
1026
+ const suspended = await this.stores.runs.update(run.runId, { captureSuspendedAt: now }, 'in_call');
1027
+ if (suspended) {
1028
+ await provider?.command(run.runId, { name: 'suspendCapture' });
1029
+ await this.emit('bot:capture_suspended', suspended, { reason: 'stop monitor quiet' });
1030
+ }
1031
+ }
1032
+ else if (now - run.captureSuspendedAt > this.monitorRecoverMs) {
1033
+ await this.leave(run, 'stop_monitor_failed');
1034
+ continue;
1035
+ }
1036
+ }
1037
+ if (run.aloneSince !== undefined && now - run.aloneSince > run.limits.aloneMs) {
1038
+ await this.leave(run, 'alone');
1039
+ continue;
1040
+ }
1041
+ if (run.limits.pageAwayMs !== undefined && run.pageSeenAt !== undefined && now - run.pageSeenAt > run.limits.pageAwayMs) {
1042
+ await this.leave(run, 'page_away');
1043
+ continue;
1044
+ }
1045
+ }
1046
+ }
1047
+ catch (error) {
1048
+ failure ?? (failure = { error });
1049
+ }
1050
+ }
1051
+ if (failure)
1052
+ throw failure.error;
1053
+ }
1054
+ /** Whether a dispatcher still holds the run's job under a lease that has not run out. */
1055
+ async dispatchLeaseLive(runId, now) {
1056
+ const job = await this.stores.jobs.get(runId);
1057
+ return job !== undefined && job.leaseHolder !== undefined && job.leaseExpiresAt !== undefined && job.leaseExpiresAt > now;
1058
+ }
1059
+ /** Evicts completed in-memory runs after the grace. Durable stores omit forget and keep their terminal records. */
1060
+ async cleanupExpired() {
1061
+ const { runs, jobs, transcript, events, outbox, entitlement } = this.stores;
1062
+ if (!runs.forget || !jobs.forget || !transcript.forget || !events.forget || !outbox.forget)
1063
+ return;
1064
+ const grace = Math.max(86400000, this.config.forgetAfterMs ?? 172800000);
1065
+ for (const run of await runs.list({ states: ['done', 'failed'], limit: Number.MAX_SAFE_INTEGER })) {
1066
+ if (run.doneAt === undefined || this.now() - Math.max(run.doneAt, run.purgedAt ?? 0) < grace)
1067
+ continue;
1068
+ if (run.keep === 'account' && run.purgedAt === undefined && run.state !== 'failed')
1069
+ continue;
1070
+ const deliveries = await outbox.listByRun(run.runId);
1071
+ if (deliveries.some((d) => d.state === 'pending'))
1072
+ continue;
1073
+ await transcript.forget(run.runId);
1074
+ await events.forget(run.runId);
1075
+ await outbox.forget(run.runId);
1076
+ await jobs.forget(run.runId);
1077
+ await entitlement.forget?.(run.runId);
1078
+ await runs.forget(run.runId);
1079
+ }
1080
+ }
1081
+ // ── Reconcile ──
1082
+ /**
1083
+ * Compares dispatched jobs with the providers' usage: a run whose container is gone is ended as crashed; an unfinished
1084
+ * purge is resumed. A run still waiting for its dispatch whose job is missing (a send whose job could not be stored)
1085
+ * is given its job again, and one a cancel left without its job (the cancel's reason is on the run) is ended, so
1086
+ * neither waits for ever holding its reservation. As in the limits, a run whose step throws holds back no other, and
1087
+ * the first error is thrown at the end.
1088
+ */
1089
+ async reconcile() {
1090
+ const now = this.now();
1091
+ let failure;
1092
+ const byProvider = new Map();
1093
+ for (const job of await this.stores.jobs.listDispatched()) {
1094
+ try {
1095
+ const run = await this.stores.runs.get(job.runId);
1096
+ if (!run) {
1097
+ await this.stores.jobs.remove(job.runId);
1098
+ continue;
1099
+ }
1100
+ if (run.state === 'purging') {
1101
+ await this.continuePurge(run.runId);
1102
+ continue;
1103
+ }
1104
+ if (!ACTIVE_STATES.includes(run.state) || run.state === 'dispatching')
1105
+ continue;
1106
+ const list = byProvider.get(run.providerName) ?? [];
1107
+ list.push(run.runId);
1108
+ byProvider.set(run.providerName, list);
1109
+ }
1110
+ catch (error) {
1111
+ failure ?? (failure = { error });
1112
+ }
1113
+ }
1114
+ for (const [providerName, runIds] of byProvider) {
1115
+ const provider = this.providers.get(providerName);
1116
+ if (!provider)
1117
+ continue;
1118
+ let usage;
1119
+ try {
1120
+ usage = await provider.usage({ runIds });
1121
+ }
1122
+ catch (err) {
1123
+ this.config.logger?.warn(`[MeetingManager] usage of ${providerName} unavailable: ${String(err)}`);
1124
+ continue;
1125
+ }
1126
+ for (const u of usage) {
1127
+ if (u.alive)
1128
+ continue;
1129
+ try {
1130
+ const run = await this.stores.runs.get(u.runId);
1131
+ if (run && (run.state === 'joining' || run.state === 'waiting_room' || run.state === 'in_call_notice_pending' || run.state === 'in_call' || run.state === 'leaving')) {
1132
+ this.config.logger?.warn(`[MeetingManager] ${run.runId}: worker reports no container; ending as crashed`);
1133
+ await this.crash(run, now, u.seconds || undefined);
1134
+ }
1135
+ }
1136
+ catch (error) {
1137
+ failure ?? (failure = { error });
1138
+ }
1139
+ }
1140
+ }
1141
+ // only a job is ever dispatched: a waiting run without one would hold its reservation for ever. A run whose end has
1142
+ // begun (cleanupPending) is the limits pass's to finish.
1143
+ for (const run of await this.stores.runs.list({ states: ['requested', 'scheduled'], limit: Number.MAX_SAFE_INTEGER })) {
1144
+ try {
1145
+ if (run.cleanupPending || (await this.stores.jobs.get(run.runId)))
1146
+ continue;
1147
+ if (run.reason !== undefined)
1148
+ await this.fail(run, run.reason);
1149
+ else
1150
+ await this.stores.jobs.schedule({ runId: run.runId, dispatchAt: run.joinAt });
1151
+ }
1152
+ catch (error) {
1153
+ failure ?? (failure = { error });
1154
+ }
1155
+ }
1156
+ if (failure)
1157
+ throw failure.error;
1158
+ }
1159
+ // ── The transcript ──
1160
+ /** A turn the listen session wrote: a `bot:transcript` event, stored and forwarded, unless the run is behind the fence or over. */
1161
+ async recordTranscript(turn) {
1162
+ const run = await this.stores.runs.get(turn.runId);
1163
+ if (!run || run.state === 'purging' || TERMINAL_RUN_STATES.has(run.state) || await this.stores.transcript.isFenced(turn.runId))
1164
+ return false;
1165
+ await this.emit('bot:transcript', run, { seq: turn.seq, turnId: turn.turnId, text: turn.text, speaker: turn.speaker, startMs: turn.startMs, endMs: turn.endMs }, `${turn.runId}:transcript:${turn.seq}`, turn.at);
1166
+ return true;
1167
+ }
1168
+ /** The sink a listen session writes to for one run: the transcript store behind the fence, then the event above. */
1169
+ transcriptSink(runId) {
1170
+ return new MeetingTranscriptSink({ manager: this, transcript: this.stores.transcript, runId });
1171
+ }
1172
+ }
1173
+ //# sourceMappingURL=MeetingManager.js.map