@canonmsg/agent-sdk 10.2.0 → 10.3.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.
@@ -0,0 +1,573 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { ApprovalManager, CanonApiError, CanonClient, CanonStream, NativeWorkSessionCreateError, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, verifyCanonRuntimeConnection, } from '@canonmsg/core';
3
+ import { createCanonAttachedSession, } from './attached-session.js';
4
+ import { createWorkSessionJournal } from './work-session-state.js';
5
+ import { createWorkSessionInteractionBridge } from './work-session-interactions.js';
6
+ /** One authenticated transport and liveness publisher, with durable per-room native routes. */
7
+ export function createCanonWorkSessionHost(options) {
8
+ const connection = { ...options.connection };
9
+ const runtime = resolveCanonRuntimeConnection({ environmentId: connection.environmentId,
10
+ apiBaseUrl: connection.baseUrl, streamUrl: connection.streamUrl,
11
+ rtdbUrl: connection.rtdbUrl, firebaseWebApiKey: connection.firebaseApiKey });
12
+ const client = new CanonClient(connection.apiKey, runtime.apiBaseUrl);
13
+ const journal = createWorkSessionJournal(options.store, {
14
+ environmentId: connection.environmentId, agentId: connection.agentId, hostId: options.hostId,
15
+ });
16
+ const pollMs = options.pollIntervalMs ?? 2_000;
17
+ const heartbeatMs = options.heartbeatIntervalMs ?? 25_000;
18
+ const connectTimeoutMs = options.connectTimeoutMs ?? 20_000;
19
+ if (![pollMs, heartbeatMs, connectTimeoutMs].every((n) => Number.isFinite(n) && n > 0)
20
+ || heartbeatMs > 30_000 || !options.hostId.trim())
21
+ throw new Error('Invalid work-session host configuration.');
22
+ const runtimeEpoch = randomUUID();
23
+ const rooms = new Map();
24
+ const handlers = new Set();
25
+ const pending = new Set();
26
+ let broker;
27
+ let manager;
28
+ let publisher;
29
+ let heartbeat;
30
+ let transport;
31
+ let stream;
32
+ let connected = false;
33
+ let stopped = false;
34
+ let startPromise;
35
+ let stopPromise;
36
+ let cleanupPromise;
37
+ let pollTimer;
38
+ let leaseTimer;
39
+ let expiryTimer;
40
+ let processing;
41
+ let renewing;
42
+ let rejectConnection;
43
+ let resolveConnection;
44
+ const report = (error, operation) => { try {
45
+ options.onError?.(error, operation);
46
+ }
47
+ catch { /* observer */ } };
48
+ const emit = (event) => {
49
+ try {
50
+ options.onStatus?.(event);
51
+ }
52
+ catch (error) {
53
+ report(error, 'status');
54
+ }
55
+ };
56
+ function auth() {
57
+ if (!broker)
58
+ throw new Error('Work-session runtime is not registered.');
59
+ return { hostId: options.hostId, runtimeEpoch, leaseToken: broker.lease.leaseToken };
60
+ }
61
+ function fence() {
62
+ if (stopped || !broker || broker.lease.expiresAt - Date.now() < 5_000)
63
+ throw new Error('Work-session runtime no longer has a fresh lease.');
64
+ }
65
+ function retainLease(state) {
66
+ if (state.lease.hostId !== options.hostId || state.lease.runtimeEpoch !== runtimeEpoch
67
+ || (broker && state.ownerId !== broker.ownerId))
68
+ throw new Error('Work-session runtime identity changed.');
69
+ broker = state;
70
+ if (expiryTimer)
71
+ clearTimeout(expiryTimer);
72
+ expiryTimer = setTimeout(() => fail(new Error('Work-session runtime lease expired.'), 'lease-expired'), Math.max(1, state.lease.expiresAt - Date.now() - 5_000));
73
+ expiryTimer.unref?.();
74
+ }
75
+ function updatePresence() {
76
+ const ready = connected && !stopped && [...rooms.values()].some((room) => !room.stopped && room.acknowledged && ['ready', 'uncertain'].includes(room.session.getState().status));
77
+ if (ready)
78
+ heartbeat?.connect();
79
+ else
80
+ void heartbeat?.disconnect().catch((error) => report(error, 'presence'));
81
+ }
82
+ function fail(error, operation) {
83
+ report(error, operation);
84
+ void stop().catch((stopError) => report(stopError, 'stop'));
85
+ }
86
+ function fanout(name, ...args) {
87
+ for (const handler of [...handlers]) {
88
+ try {
89
+ handler[name]?.(...args);
90
+ }
91
+ catch (error) {
92
+ report(error, `stream-${name}`);
93
+ }
94
+ }
95
+ }
96
+ async function removeRoom(conversationId, releaseBinding) {
97
+ const room = rooms.get(conversationId);
98
+ if (!room)
99
+ return;
100
+ rooms.delete(conversationId);
101
+ room.stopped = true;
102
+ room.controller.abort();
103
+ try {
104
+ await room.session.stop();
105
+ }
106
+ finally {
107
+ room.releaseNative();
108
+ updatePresence();
109
+ }
110
+ if (releaseBinding && !stopped) {
111
+ fence();
112
+ retainLease(await client.releaseWorkSessionBinding({ ...auth(), conversationId,
113
+ workSessionId: room.binding.workSessionId }));
114
+ }
115
+ }
116
+ function activate(conversationId) {
117
+ const room = rooms.get(conversationId);
118
+ if (!room || stopped)
119
+ return;
120
+ room.acknowledged = true;
121
+ room.activate();
122
+ room.session.refreshRuntimeState();
123
+ updatePresence();
124
+ }
125
+ async function attach(binding, confirmed = false) {
126
+ fence();
127
+ const existing = rooms.get(binding.conversationId);
128
+ if (existing && existing.binding.nativeSessionId === binding.nativeSessionId && !existing.stopped) {
129
+ if (confirmed)
130
+ activate(binding.conversationId);
131
+ return;
132
+ }
133
+ if (existing)
134
+ throw new Error('This conversation already has a work session.');
135
+ if ([...rooms.values()].some((room) => room.binding.nativeSessionId === binding.nativeSessionId))
136
+ throw new Error('This native session already has a different Canon audience.');
137
+ const conversation = (await client.getConversations()).find((entry) => entry.id === binding.conversationId);
138
+ if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
139
+ throw new Error('The owner and agent must both be members of this conversation.');
140
+ fence();
141
+ const nativeBinding = { ...binding, environmentId: connection.environmentId, agentId: connection.agentId };
142
+ const releaseNative = await options.acquireNativeSession(nativeBinding);
143
+ const controller = new AbortController();
144
+ let activateRoute;
145
+ const routeReady = new Promise((resolve) => { activateRoute = resolve; });
146
+ controller.signal.addEventListener('abort', activateRoute, { once: true });
147
+ let session;
148
+ let adapter;
149
+ try {
150
+ fence();
151
+ const bridge = createWorkSessionInteractionBridge({ manager: manager, journal,
152
+ hostId: options.hostId, conversationId: binding.conversationId, ownerId: broker.ownerId,
153
+ onPendingChange: (value) => {
154
+ if (value)
155
+ pending.add(binding.conversationId);
156
+ else
157
+ pending.delete(binding.conversationId);
158
+ session?.refreshRuntimeState();
159
+ } });
160
+ adapter = await options.provider.open(binding.nativeSessionId, {
161
+ request: async (request, nativeOptions) => {
162
+ const combined = new AbortController();
163
+ const abort = () => combined.abort();
164
+ controller.signal.addEventListener('abort', abort, { once: true });
165
+ nativeOptions.signal.addEventListener('abort', abort, { once: true });
166
+ if (controller.signal.aborted || nativeOptions.signal.aborted || stopped)
167
+ combined.abort();
168
+ try {
169
+ // A resumed native approval can arrive during attachment. Wait for
170
+ // Canon's binding acknowledgement before exposing its contents.
171
+ const nativeAborted = new Promise((resolve) => combined.signal.addEventListener('abort', () => resolve(), { once: true }));
172
+ if (combined.signal.aborted)
173
+ return { kind: 'cancelled' };
174
+ await Promise.race([routeReady, nativeAborted]);
175
+ return await bridge.request(request, { signal: combined.signal });
176
+ }
177
+ finally {
178
+ controller.signal.removeEventListener('abort', abort);
179
+ nativeOptions.signal.removeEventListener('abort', abort);
180
+ }
181
+ },
182
+ });
183
+ fence();
184
+ session = createCanonAttachedSession({ connection, binding: nativeBinding, native: adapter,
185
+ store: options.createSessionStore(nativeBinding), transport,
186
+ onError: (error, operation) => report(error, `session-${operation}`),
187
+ onStatus: (status) => {
188
+ emit({ status: 'session', conversationId: binding.conversationId, session: status });
189
+ if (status.status === 'stopped' && !stopped) {
190
+ // Defer until the child's stop promise settles; never await it from its own callback.
191
+ queueMicrotask(() => { void removeRoom(binding.conversationId, true).catch((error) => fail(error, 'detach')); });
192
+ }
193
+ updatePresence();
194
+ },
195
+ });
196
+ rooms.set(binding.conversationId, { binding, session, controller, releaseNative, stopped: false, acknowledged: confirmed, activate: activateRoute });
197
+ if (confirmed)
198
+ activateRoute();
199
+ await session.start();
200
+ fence();
201
+ if (session.getState().status !== 'ready')
202
+ throw new Error('The native session is not ready for Canon input.');
203
+ updatePresence();
204
+ }
205
+ catch (error) {
206
+ controller.abort();
207
+ rooms.delete(binding.conversationId);
208
+ try {
209
+ if (session)
210
+ await session.stop();
211
+ else
212
+ await adapter?.close();
213
+ }
214
+ finally {
215
+ releaseNative();
216
+ updatePresence();
217
+ }
218
+ throw error;
219
+ }
220
+ }
221
+ const fingerprint = (request) => createHash('sha256').update(JSON.stringify([
222
+ request.agentId, request.conversationId, request.requestId, request.hostId,
223
+ request.runtimeEpoch, request.catalogRevision, request.selection, request.requestedBy,
224
+ ])).digest('hex');
225
+ const errorResult = (status, code, message) => ({ status, error: { code, message } });
226
+ async function processRequest(request, replayed) {
227
+ if (request.status !== 'claimed' && request.status !== 'uncertain')
228
+ return;
229
+ fence();
230
+ const hash = fingerprint(request);
231
+ let operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
232
+ if (operation && operation.fingerprint !== hash)
233
+ throw new Error('Work-session request changed during recovery.');
234
+ if (!operation) {
235
+ const completion = replayed ? errorResult('uncertain', 'local_state_missing', 'This request was already claimed, but its local record is unavailable. Select the existing native session to recover.') : undefined;
236
+ operation = { request, fingerprint: hash, phase: completion ? 'complete' : request.selection.mode === 'create' ? 'creating' : 'created',
237
+ ...(completion ? { completion } : request.selection.mode === 'attach' ? { nativeSessionId: request.selection.sessionId, settings: {} } : {}) };
238
+ await journal.mutate((state) => { state.operations.push(operation); });
239
+ if (!completion && request.selection.mode === 'create') {
240
+ let created;
241
+ try {
242
+ fence();
243
+ created = await options.provider.create(request.selection, {
244
+ requestId: request.requestId, conversationId: request.conversationId, agentId: connection.agentId,
245
+ });
246
+ }
247
+ catch (error) {
248
+ const definite = error instanceof NativeWorkSessionCreateError && error.outcome === 'not_created';
249
+ const result = errorResult(definite ? 'failed' : 'uncertain', definite ? 'native_create_failed' : 'native_create_uncertain', definite ? 'The native runtime could not create this session. Refresh setup and try again.'
250
+ : 'The native runtime may have created this session. Inspect existing sessions before trying again.');
251
+ await saveCompletion(request.requestId, result);
252
+ report(error, 'native-create');
253
+ operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
254
+ }
255
+ if (created) {
256
+ // The exact ID must reach durable storage before any observer or Canon input can attach.
257
+ await journal.mutate((state) => {
258
+ const entry = state.operations.find((item) => item.request.requestId === request.requestId);
259
+ Object.assign(entry, { phase: 'created', nativeSessionId: created.nativeSessionId, settings: created.settings });
260
+ });
261
+ operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
262
+ }
263
+ }
264
+ }
265
+ else if (operation.phase === 'creating') {
266
+ await saveCompletion(request.requestId, errorResult('uncertain', 'native_create_uncertain', 'The previous process stopped during native creation. Inspect existing sessions before trying again.'));
267
+ operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
268
+ }
269
+ fence();
270
+ if (!operation.completion) {
271
+ const nativeSessionId = operation.nativeSessionId;
272
+ if (!nativeSessionId)
273
+ throw new Error('Work-session journal has no native session to attach.');
274
+ const catalog = await options.provider.catalog();
275
+ const settings = request.selection.mode === 'attach'
276
+ ? catalog.sessions.find((entry) => entry.id === nativeSessionId)?.settings : operation.settings;
277
+ if (!settings) {
278
+ await saveCompletion(request.requestId, errorResult('failed', 'native_session_unavailable', 'The selected native session is no longer loaded. Load it and select it again.'));
279
+ }
280
+ else {
281
+ await journal.mutate((state) => { state.operations.find((entry) => entry.request.requestId === request.requestId).phase = 'attaching'; });
282
+ try {
283
+ await attach({ workSessionId: request.requestId, conversationId: request.conversationId,
284
+ hostId: options.hostId, provider: catalog.provider, nativeSessionId, settings, createdAt: Date.now() });
285
+ await saveCompletion(request.requestId, { status: 'attached', nativeSessionId, settings });
286
+ }
287
+ catch (error) {
288
+ await removeRoom(request.conversationId, false);
289
+ report(error, 'native-attach');
290
+ await saveCompletion(request.requestId, errorResult('failed', 'native_attach_failed', 'Canon could not attach this native session. The native session is retained; select it again after reconnecting.'));
291
+ }
292
+ }
293
+ operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
294
+ }
295
+ fence();
296
+ // A previous process may have saved its successful result before Canon
297
+ // received it. Reestablish that exact route before confirming readiness.
298
+ if (operation.completion?.status === 'attached' && !rooms.has(request.conversationId)) {
299
+ try {
300
+ await attach({ workSessionId: request.requestId, conversationId: request.conversationId,
301
+ hostId: options.hostId, provider: (await options.provider.catalog()).provider,
302
+ nativeSessionId: operation.completion.nativeSessionId, settings: operation.completion.settings,
303
+ createdAt: request.createdAt });
304
+ }
305
+ catch (error) {
306
+ await removeRoom(request.conversationId, false);
307
+ report(error, 'recover-attachment');
308
+ // The explicit claim says Canon has no successful binding yet. Preserve
309
+ // the native ID for inspection, but do not claim this route is ready.
310
+ await saveCompletion(request.requestId, errorResult('failed', 'native_restore_failed', 'The saved native session could not be reconnected. Load it and select it again.'));
311
+ operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
312
+ }
313
+ }
314
+ try {
315
+ const completed = await client.completeWorkSessionRequest({ ...auth(), requestId: request.requestId, result: operation.completion });
316
+ if (completed.status !== operation.completion.status) {
317
+ await removeRoom(request.conversationId, false);
318
+ throw new Error('Canon did not acknowledge the requested work-session outcome.');
319
+ }
320
+ await journal.mutate((state) => { state.operations.find((entry) => entry.request.requestId === request.requestId).acknowledged = true; });
321
+ if (completed.status === 'attached')
322
+ activate(request.conversationId);
323
+ }
324
+ catch (error) {
325
+ // An unacknowledged bridge must not accept fresh work. The durable completion can be retried.
326
+ await removeRoom(request.conversationId, false);
327
+ throw error;
328
+ }
329
+ }
330
+ async function saveCompletion(requestId, completion) {
331
+ await journal.mutate((state) => {
332
+ const entry = state.operations.find((item) => item.request.requestId === requestId);
333
+ entry.phase = 'complete';
334
+ entry.completion = completion;
335
+ });
336
+ }
337
+ async function doReconcile() {
338
+ fence();
339
+ if (!connected)
340
+ return;
341
+ // Finish durable outcomes before taking more work. Explicit claim allows same-host recovery.
342
+ const outstanding = journal.read().operations.find((entry) => !entry.acknowledged && !entry.quarantined);
343
+ let claimed;
344
+ try {
345
+ claimed = await client.claimWorkSessionRequest({ ...auth(), ...(outstanding ? { requestId: outstanding.request.requestId } : {}) });
346
+ }
347
+ catch (error) {
348
+ if (outstanding && error instanceof CanonApiError && error.code === 'WORK_SESSION_MEMBERSHIP_REQUIRED') {
349
+ await quarantine(outstanding.request.requestId, 'Conversation membership is no longer available.');
350
+ return;
351
+ }
352
+ if (error instanceof CanonApiError && (error.status === 401 || error.status === 403 || error.code === 'WORK_SESSION_LEASE_STALE'))
353
+ fail(error, 'request-authority');
354
+ throw error;
355
+ }
356
+ fence();
357
+ if (!claimed.request) {
358
+ if (outstanding)
359
+ await quarantine(outstanding.request.requestId, 'This saved request is no longer available to this runtime.');
360
+ return;
361
+ }
362
+ if (['attached', 'failed', 'expired'].includes(claimed.request.status)) {
363
+ if (outstanding) {
364
+ if (claimed.request.status === 'attached' && claimed.request.binding) {
365
+ try {
366
+ await attach(claimed.request.binding, true);
367
+ }
368
+ catch (error) {
369
+ report(error, 'recover-confirmed-attachment');
370
+ fence();
371
+ retainLease(await client.releaseWorkSessionBinding({ ...auth(), conversationId: claimed.request.conversationId,
372
+ workSessionId: claimed.request.binding.workSessionId }));
373
+ }
374
+ }
375
+ else
376
+ await removeRoom(outstanding.request.conversationId, false);
377
+ await journal.mutate((state) => { state.operations.find((entry) => entry.request.requestId === outstanding.request.requestId).acknowledged = true; });
378
+ }
379
+ return;
380
+ }
381
+ await processRequest(claimed.request, claimed.replayed);
382
+ }
383
+ async function quarantine(requestId, reason) {
384
+ const operation = journal.read().operations.find((entry) => entry.request.requestId === requestId);
385
+ if (!operation)
386
+ return;
387
+ await removeRoom(operation.request.conversationId, false);
388
+ await journal.mutate((state) => { state.operations.find((entry) => entry.request.requestId === requestId).quarantined = reason; });
389
+ report(new Error(reason), 'request-quarantined');
390
+ }
391
+ function reconcile() {
392
+ if (stopped)
393
+ return Promise.resolve();
394
+ processing ??= doReconcile().finally(() => { processing = undefined; });
395
+ return processing;
396
+ }
397
+ async function renew() {
398
+ fence();
399
+ const conversations = await client.getConversations();
400
+ for (const room of [...rooms.values()]) {
401
+ const conversation = conversations.find((entry) => entry.id === room.binding.conversationId);
402
+ if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
403
+ await removeRoom(room.binding.conversationId, true);
404
+ }
405
+ // Catalog has its own freshness deadline; it is refreshed independently of public presence.
406
+ const catalog = await options.provider.catalog();
407
+ fence();
408
+ const state = await client.heartbeatWorkSessionRuntime({ ...auth(), catalog });
409
+ if (!stopped)
410
+ retainLease(state);
411
+ }
412
+ function start() {
413
+ if (stopped)
414
+ return Promise.reject(new Error('Work-session host has stopped.'));
415
+ startPromise ??= (async () => {
416
+ await journal.load();
417
+ await verifyCanonRuntimeConnection(runtime);
418
+ const identity = await client.getAuthToken();
419
+ if (identity.agentId !== connection.agentId)
420
+ throw new Error('Canon credential belongs to a different agent.');
421
+ if (stopped)
422
+ return;
423
+ const catalog = await options.provider.catalog();
424
+ if (stopped)
425
+ return;
426
+ retainLease(await client.registerWorkSessionRuntime({ hostId: options.hostId, runtimeEpoch, displayName: options.displayName, catalog }));
427
+ if (stopped) {
428
+ fenceShutdown();
429
+ return;
430
+ }
431
+ // Native history inspection/restoration can be slower than one lease.
432
+ // Keep the acquired lease alive throughout startup, not only after every
433
+ // room has finished attaching. This timer advertises no public presence.
434
+ leaseTimer = setInterval(() => {
435
+ if (renewing || stopped)
436
+ return;
437
+ renewing = renew().catch((error) => fail(error, 'lease-renewal')).finally(() => { renewing = undefined; });
438
+ }, heartbeatMs);
439
+ leaseTimer.unref?.();
440
+ manager = new ApprovalManager(client, connection.agentId, broker.ownerId);
441
+ publisher = createRuntimeStatePublisher({ agentId: connection.agentId, clientType: connection.clientType ?? 'generic', hostMode: false,
442
+ rtdb: initRTDBAuth(client, { rtdbUrl: runtime.rtdbUrl, firebaseApiKey: runtime.firebaseWebApiKey }) });
443
+ heartbeat = createRuntimeHeartbeat({ publisher, onError: report,
444
+ getRuntime: () => ({ runtimeDescriptor: { coreControls: [], runtimeControls: [], commands: [],
445
+ supportsInterrupt: false, supportsInputInterrupt: false, streamingTextMode: 'snapshot' } }) });
446
+ transport = { agentId: connection.agentId, environmentId: connection.environmentId, client, publisher,
447
+ hasPendingInteraction: (id) => pending.has(id),
448
+ isRouteActive: (id) => !stopped && rooms.get(id)?.acknowledged === true,
449
+ subscribe: (handler) => { handlers.add(handler); if (connected && !stopped)
450
+ handler.onConnected?.(); return () => { handlers.delete(handler); }; } };
451
+ const connectionReady = new Promise((resolve, reject) => { resolveConnection = resolve; rejectConnection = reject; });
452
+ const connectionTimer = setTimeout(() => rejectConnection?.(new Error('Canon stream did not connect; work-session setup was not started.')), connectTimeoutMs);
453
+ stream = new CanonStream({ apiKey: connection.apiKey, agentId: connection.agentId, streamUrl: runtime.streamUrl, handler: {
454
+ onMessage: (payload) => {
455
+ if (stopped || !connected)
456
+ return;
457
+ manager?.handleMessage(payload.conversationId, payload.message);
458
+ fanout('onMessage', payload);
459
+ },
460
+ onAgentContext: (context) => {
461
+ if (context.agentId !== connection.agentId) {
462
+ fail(new Error('Canon stream identity changed.'), 'stream-identity');
463
+ return;
464
+ }
465
+ fanout('onAgentContext', context);
466
+ },
467
+ onConversationUpdated: (payload) => {
468
+ const members = payload.changes.memberIds;
469
+ if ((Array.isArray(members) && !members.includes(broker.ownerId))
470
+ || payload.membershipChange?.removedMemberIds.includes(broker.ownerId)) {
471
+ void removeRoom(payload.conversationId, true).catch((error) => fail(error, 'owner-membership'));
472
+ }
473
+ fanout('onConversationUpdated', payload);
474
+ },
475
+ onParticipationSuppressed: (payload) => fanout('onParticipationSuppressed', payload),
476
+ onReplayExpired: (payload) => fanout('onReplayExpired', payload),
477
+ onConnected: () => { if (stopped)
478
+ return; connected = true; resolveConnection?.(); fanout('onConnected'); updatePresence(); },
479
+ onDisconnected: () => { connected = false; fanout('onDisconnected'); updatePresence(); },
480
+ onError: (error) => report(error, 'stream'),
481
+ } });
482
+ void stream.start().catch((error) => { rejectConnection?.(error); report(error, 'stream-start'); });
483
+ try {
484
+ await connectionReady;
485
+ }
486
+ finally {
487
+ clearTimeout(connectionTimer);
488
+ resolveConnection = undefined;
489
+ rejectConnection = undefined;
490
+ }
491
+ fence();
492
+ // Confirmed same-host routes survive a graceful stop; subscriptions/history remain durable.
493
+ for (const binding of broker.bindings) {
494
+ try {
495
+ await attach(binding, true);
496
+ }
497
+ catch (error) {
498
+ report(error, 'restore-session');
499
+ fence();
500
+ retainLease(await client.releaseWorkSessionBinding({ ...auth(), conversationId: binding.conversationId, workSessionId: binding.workSessionId }));
501
+ }
502
+ }
503
+ fence();
504
+ pollTimer = setInterval(() => { void reconcile().catch((error) => report(error, 'request')); }, pollMs);
505
+ pollTimer.unref?.();
506
+ emit({ status: 'available' });
507
+ await reconcile();
508
+ })().catch(async (error) => {
509
+ // Cleanup cannot await the start promise that is currently unwinding.
510
+ fenceShutdown();
511
+ await cleanup().catch((cleanupError) => report(cleanupError, 'startup-cleanup'));
512
+ throw error;
513
+ });
514
+ return startPromise;
515
+ }
516
+ function fenceShutdown() {
517
+ stopped = true;
518
+ connected = false;
519
+ if (pollTimer)
520
+ clearInterval(pollTimer);
521
+ if (leaseTimer)
522
+ clearInterval(leaseTimer);
523
+ if (expiryTimer)
524
+ clearTimeout(expiryTimer);
525
+ rejectConnection?.(new Error('Work-session host stopped during startup.'));
526
+ stream?.stop();
527
+ manager?.dispose();
528
+ for (const room of rooms.values()) {
529
+ room.controller.abort();
530
+ // Stop input/publication immediately even if another room's create RPC is in flight.
531
+ void room.session.stop().catch((error) => report(error, 'session-stop'));
532
+ }
533
+ }
534
+ function cleanup() {
535
+ cleanupPromise ??= (async () => {
536
+ await Promise.allSettled([processing, renewing].filter(Boolean));
537
+ await Promise.allSettled([...rooms.keys()].map((id) => removeRoom(id, false)));
538
+ try {
539
+ await heartbeat?.dispose();
540
+ }
541
+ finally {
542
+ try {
543
+ if (broker)
544
+ await client.releaseWorkSessionRuntime(auth());
545
+ }
546
+ catch (error) {
547
+ report(error, 'release-runtime');
548
+ }
549
+ try {
550
+ await options.provider.close();
551
+ }
552
+ finally {
553
+ await journal.close();
554
+ }
555
+ }
556
+ handlers.clear();
557
+ pending.clear();
558
+ emit({ status: 'stopped' });
559
+ })();
560
+ return cleanupPromise;
561
+ }
562
+ function stop() {
563
+ if (stopPromise)
564
+ return stopPromise;
565
+ fenceShutdown();
566
+ stopPromise = (async () => {
567
+ await startPromise?.catch(() => { });
568
+ await cleanup();
569
+ })();
570
+ return stopPromise;
571
+ }
572
+ return { start, stop, reconcile };
573
+ }
@@ -0,0 +1,12 @@
1
+ import type { ApprovalManager, NativeSessionInteractionBridge } from '@canonmsg/core';
2
+ import type { createWorkSessionJournal } from './work-session-state.js';
3
+ export interface WorkSessionInteractionBridgeOptions {
4
+ manager: ApprovalManager;
5
+ journal: ReturnType<typeof createWorkSessionJournal>;
6
+ hostId: string;
7
+ conversationId: string;
8
+ ownerId: string;
9
+ onPendingChange?: (pending: boolean) => void;
10
+ }
11
+ /** Audience-scoped durability around the shared request manager; it owns all Canon polling and cancellation. */
12
+ export declare function createWorkSessionInteractionBridge(options: WorkSessionInteractionBridgeOptions): NativeSessionInteractionBridge;