@canonmsg/agent-sdk 11.0.3 → 11.1.1
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/dist/attached-session.js +22 -2
- package/dist/canon-agent.js +11 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/managed-session-host.d.ts +27 -0
- package/dist/managed-session-host.js +195 -0
- package/dist/work-session-coordination.d.ts +21 -0
- package/dist/work-session-coordination.js +53 -0
- package/dist/work-session-host.js +53 -30
- package/dist/work-session-state.d.ts +1 -1
- package/dist/work-session-state.js +1 -1
- package/package.json +2 -2
package/dist/attached-session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { buildChunkedMessagePartMetadata, buildCanonGroupContext, buildCompactGroupContextLines, CanonClient, CanonStream, createAttachedNativeSession, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
2
|
+
import { buildChunkedMessagePartMetadata, buildCanonGroupContext, buildCompactGroupContextLines, buildSelfContextPromptLines, CanonClient, CanonStream, createAttachedNativeSession, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, hydrateCanonReplyContext, selectInboundSelfContexts, resolveCanonRuntimeConnection, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
3
3
|
/**
|
|
4
4
|
* Connect one existing native session to one existing Canon conversation.
|
|
5
5
|
* This is account-level participation, not a second group member. The caller
|
|
@@ -236,9 +236,29 @@ export function createCanonAttachedSession(options) {
|
|
|
236
236
|
}
|
|
237
237
|
const roster = buildCanonGroupContext({ conversation: currentConversation, messages: [message], agentId: binding.agentId, ownerId: currentOwnerId });
|
|
238
238
|
const rosterLines = roster ? buildCompactGroupContextLines(roster, 'initial') : [];
|
|
239
|
+
const page = await client.getMessagesPage(binding.conversationId, 50).catch(() => null);
|
|
240
|
+
const replyContext = message.replyTo
|
|
241
|
+
? await hydrateCanonReplyContext({
|
|
242
|
+
client,
|
|
243
|
+
conversationId: binding.conversationId,
|
|
244
|
+
message,
|
|
245
|
+
messages: page?.messages,
|
|
246
|
+
})
|
|
247
|
+
: null;
|
|
248
|
+
const selectedSelfContext = selectInboundSelfContexts({
|
|
249
|
+
selfContexts: [...(page?.selfContexts ?? []), ...(payload.selfContexts ?? [])],
|
|
250
|
+
replyTo: message.replyTo,
|
|
251
|
+
linkedSelfContextId: page?.activeSelfContextIdByMessageId?.[message.id]
|
|
252
|
+
?? payload.activeSelfContextId,
|
|
253
|
+
messages: page?.messages,
|
|
254
|
+
replyContext,
|
|
255
|
+
});
|
|
256
|
+
const privateContextLines = buildSelfContextPromptLines(selectedSelfContext.selfContexts, {
|
|
257
|
+
activeSelfContextId: selectedSelfContext.activeSelfContextId,
|
|
258
|
+
});
|
|
239
259
|
const result = await core.submit({
|
|
240
260
|
messageId: message.id,
|
|
241
|
-
text: [...rosterLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
|
|
261
|
+
text: [...rosterLines, ...privateContextLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
|
|
242
262
|
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
243
263
|
});
|
|
244
264
|
// A conclusive provider acknowledgement settles command delivery. Native
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext,
|
|
1
|
+
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectInboundSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { Debouncer } from './debouncer.js';
|
|
4
4
|
import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
|
|
@@ -1628,12 +1628,17 @@ export class CanonAgent {
|
|
|
1628
1628
|
client: this.apiClient, conversationId, message: latestMessage, messages: history,
|
|
1629
1629
|
})
|
|
1630
1630
|
: null;
|
|
1631
|
-
const
|
|
1632
|
-
|
|
1633
|
-
|
|
1631
|
+
const selectedSelfContext = selectInboundSelfContexts({
|
|
1632
|
+
selfContexts: page.selfContexts,
|
|
1633
|
+
replyTo: latestMessage?.replyTo,
|
|
1634
|
+
linkedSelfContextId: latestMessage?.id
|
|
1635
|
+
? page.activeSelfContextIdByMessageId?.[latestMessage.id]
|
|
1636
|
+
: null,
|
|
1637
|
+
messages: page.messages,
|
|
1638
|
+
replyContext,
|
|
1634
1639
|
});
|
|
1635
|
-
const selfContexts =
|
|
1636
|
-
const activeSelfContextId =
|
|
1640
|
+
const selfContexts = selectedSelfContext.selfContexts;
|
|
1641
|
+
const activeSelfContextId = selectedSelfContext.activeSelfContextId;
|
|
1637
1642
|
const withActiveSelfContext = (options) => {
|
|
1638
1643
|
const base = { ...(options ?? {}) };
|
|
1639
1644
|
const withContext = base.selfContextId !== undefined || !activeSelfContextId
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { CanonAgent } from './canon-agent.js';
|
|
2
|
+
export { createCanonManagedSessionHost } from './managed-session-host.js';
|
|
3
|
+
export type { CanonManagedSessionHostOptions } from './managed-session-host.js';
|
|
2
4
|
export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
|
|
3
5
|
export type { EnsureAgentProfileOptions, RegistrationCredentials, RegistrationProgress, RegistrationSession, RegistrationStatus, RegistrationStore, ResumeRegistrationOptions } from '@canonmsg/core';
|
|
4
6
|
export type { AgentContactsAPI, AgentConversationsAPI, AgentDirectoryAPI, AgentUsersAPI, } from './canon-agent.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { CanonAgent } from './canon-agent.js';
|
|
2
|
+
export { createCanonManagedSessionHost } from './managed-session-host.js';
|
|
2
3
|
export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
|
|
3
4
|
export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
|
|
4
5
|
export { SessionManager } from './session-manager.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CanonClient, type WorkSessionBinding, type WorkSessionCatalog, type WorkSessionDetailFields, type WorkSessionRequest, type WorkSessionSettings } from '@canonmsg/core';
|
|
2
|
+
import { type WorkSessionHostStore } from './work-session-state.js';
|
|
3
|
+
export interface CanonManagedSessionHostOptions {
|
|
4
|
+
client: CanonClient;
|
|
5
|
+
agentId: string;
|
|
6
|
+
environmentId: string;
|
|
7
|
+
hostId: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
store: WorkSessionHostStore;
|
|
10
|
+
catalog(): Promise<WorkSessionCatalog>;
|
|
11
|
+
/** Must durably save the resolved selection before returning. May be called again after a crash. */
|
|
12
|
+
configure(request: WorkSessionRequest): Promise<{
|
|
13
|
+
settings: WorkSessionSettings;
|
|
14
|
+
fields: WorkSessionDetailFields;
|
|
15
|
+
}>;
|
|
16
|
+
onError?: (error: unknown, operation: string) => void;
|
|
17
|
+
pollIntervalMs?: number;
|
|
18
|
+
heartbeatIntervalMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Broker coordination for a managed host whose ordinary message loop owns turns. */
|
|
21
|
+
export declare function createCanonManagedSessionHost(options: CanonManagedSessionHostOptions): {
|
|
22
|
+
start: () => Promise<void>;
|
|
23
|
+
stop: () => Promise<void>;
|
|
24
|
+
reconcile: () => Promise<void>;
|
|
25
|
+
report: (conversationId: string, fields: WorkSessionDetailFields, setupState?: "configured" | "attached") => Promise<void>;
|
|
26
|
+
binding: (conversationId: string) => WorkSessionBinding | undefined;
|
|
27
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { CanonApiError, } from '@canonmsg/core';
|
|
3
|
+
import { createWorkSessionJournal } from './work-session-state.js';
|
|
4
|
+
import { createWorkSessionAuthority, fingerprintWorkSessionRequest } from './work-session-coordination.js';
|
|
5
|
+
/** Broker coordination for a managed host whose ordinary message loop owns turns. */
|
|
6
|
+
export function createCanonManagedSessionHost(options) {
|
|
7
|
+
const journal = createWorkSessionJournal(options.store, {
|
|
8
|
+
environmentId: options.environmentId, agentId: options.agentId, hostId: options.hostId,
|
|
9
|
+
});
|
|
10
|
+
const runtimeEpoch = randomUUID();
|
|
11
|
+
const pollMs = options.pollIntervalMs ?? 2_000;
|
|
12
|
+
const heartbeatMs = options.heartbeatIntervalMs ?? 25_000;
|
|
13
|
+
if (!options.hostId || !Number.isFinite(pollMs) || pollMs <= 0 || !Number.isFinite(heartbeatMs)
|
|
14
|
+
|| heartbeatMs <= 0 || heartbeatMs > 30_000)
|
|
15
|
+
throw new Error('Invalid managed-session host configuration.');
|
|
16
|
+
let broker;
|
|
17
|
+
let stopped = false;
|
|
18
|
+
const authority = createWorkSessionAuthority({ client: options.client, hostId: options.hostId,
|
|
19
|
+
runtimeEpoch, displayName: options.displayName, isStopped: () => stopped });
|
|
20
|
+
let started = false;
|
|
21
|
+
let pollTimer;
|
|
22
|
+
let heartbeatTimer;
|
|
23
|
+
let processing;
|
|
24
|
+
let renewing;
|
|
25
|
+
let starting;
|
|
26
|
+
const reportError = (error, operation) => { try {
|
|
27
|
+
options.onError?.(error, operation);
|
|
28
|
+
}
|
|
29
|
+
catch { /* observer */ } };
|
|
30
|
+
const auth = authority.auth;
|
|
31
|
+
const fence = authority.fence;
|
|
32
|
+
function retain(state) {
|
|
33
|
+
broker = authority.retain(state);
|
|
34
|
+
}
|
|
35
|
+
async function processRequest(request, replayed) {
|
|
36
|
+
if (request.status !== 'claimed' && request.status !== 'uncertain')
|
|
37
|
+
return;
|
|
38
|
+
fence();
|
|
39
|
+
const hash = fingerprintWorkSessionRequest(request);
|
|
40
|
+
let configuredFields;
|
|
41
|
+
let entry = journal.read().operations.find((operation) => operation.request.requestId === request.requestId);
|
|
42
|
+
if (entry && entry.fingerprint !== hash)
|
|
43
|
+
throw new Error('Managed-session request changed during recovery.');
|
|
44
|
+
if (!entry) {
|
|
45
|
+
entry = { request, fingerprint: hash, phase: 'configuring',
|
|
46
|
+
...(replayed ? { completion: { status: 'uncertain', error: { code: 'local_state_missing',
|
|
47
|
+
message: 'The host lost the saved setup result. Inspect this local runtime before trying again.' } }, phase: 'complete' } : {}),
|
|
48
|
+
};
|
|
49
|
+
await journal.mutate((state) => { state.operations.push(entry); });
|
|
50
|
+
}
|
|
51
|
+
if (!entry.completion) {
|
|
52
|
+
if (request.selection.mode !== 'configure') {
|
|
53
|
+
entry.completion = { status: 'failed', error: { code: 'unsupported_setup', message: 'This host only supports managed setup.' } };
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
try {
|
|
57
|
+
const result = await options.configure(request);
|
|
58
|
+
fence();
|
|
59
|
+
entry.completion = { status: 'configured', settings: result.settings };
|
|
60
|
+
configuredFields = result.fields;
|
|
61
|
+
await journal.mutate((state) => {
|
|
62
|
+
const saved = state.operations.find((operation) => operation.request.requestId === request.requestId);
|
|
63
|
+
saved.phase = 'complete';
|
|
64
|
+
saved.completion = entry.completion;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
reportError(error, 'configure');
|
|
69
|
+
entry.completion = { status: 'failed', error: { code: 'managed_configuration_failed',
|
|
70
|
+
message: (error instanceof Error ? error.message : String(error)).slice(0, 512) } };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (entry.completion.status !== 'configured') {
|
|
74
|
+
await journal.mutate((state) => {
|
|
75
|
+
const saved = state.operations.find((operation) => operation.request.requestId === request.requestId);
|
|
76
|
+
saved.phase = 'complete';
|
|
77
|
+
saved.completion = entry.completion;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
fence();
|
|
82
|
+
const completed = await authority.acknowledge(journal, request.requestId, entry.completion);
|
|
83
|
+
if (completed.binding) {
|
|
84
|
+
broker.bindings = [...broker.bindings.filter((binding) => binding.conversationId !== completed.conversationId), completed.binding];
|
|
85
|
+
}
|
|
86
|
+
if (configuredFields && completed.status === 'configured') {
|
|
87
|
+
await report(request.conversationId, configuredFields).catch((error) => reportError(error, 'report-configured'));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function doReconcile() {
|
|
91
|
+
fence();
|
|
92
|
+
const outstanding = journal.read().operations.find((entry) => !entry.acknowledged && !entry.quarantined);
|
|
93
|
+
let claimed;
|
|
94
|
+
try {
|
|
95
|
+
claimed = await authority.claim(outstanding?.request.requestId);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (outstanding && error instanceof CanonApiError && error.code === 'WORK_SESSION_MEMBERSHIP_REQUIRED') {
|
|
99
|
+
await journal.mutate((state) => {
|
|
100
|
+
state.operations.find((entry) => entry.request.requestId === outstanding.request.requestId).quarantined = 'Conversation membership was removed.';
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
fence();
|
|
107
|
+
if (!claimed.request) {
|
|
108
|
+
if (outstanding)
|
|
109
|
+
await journal.mutate((state) => {
|
|
110
|
+
state.operations.find((entry) => entry.request.requestId === outstanding.request.requestId).quarantined = 'Request no longer belongs to this runtime.';
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (['configured', 'failed', 'expired'].includes(claimed.request.status)) {
|
|
115
|
+
if (outstanding)
|
|
116
|
+
await journal.mutate((state) => {
|
|
117
|
+
state.operations.find((entry) => entry.request.requestId === outstanding.request.requestId).acknowledged = true;
|
|
118
|
+
});
|
|
119
|
+
if (claimed.request.binding) {
|
|
120
|
+
broker.bindings = [...broker.bindings.filter((binding) => binding.conversationId !== claimed.request.conversationId), claimed.request.binding];
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await processRequest(claimed.request, claimed.replayed);
|
|
125
|
+
}
|
|
126
|
+
function reconcile() {
|
|
127
|
+
if (stopped || !started)
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
processing ??= doReconcile().finally(() => { processing = undefined; });
|
|
130
|
+
return processing;
|
|
131
|
+
}
|
|
132
|
+
async function renew() {
|
|
133
|
+
fence();
|
|
134
|
+
const state = await authority.heartbeat(await options.catalog());
|
|
135
|
+
if (!stopped)
|
|
136
|
+
retain(state);
|
|
137
|
+
}
|
|
138
|
+
function start() {
|
|
139
|
+
if (stopped)
|
|
140
|
+
throw new Error('Managed-session host already stopped.');
|
|
141
|
+
if (started && broker && broker.lease.expiresAt - Date.now() >= 5_000)
|
|
142
|
+
return Promise.resolve();
|
|
143
|
+
starting ??= (async () => {
|
|
144
|
+
if (!started)
|
|
145
|
+
await journal.load();
|
|
146
|
+
retain(await authority.register(await options.catalog()));
|
|
147
|
+
if (stopped)
|
|
148
|
+
return;
|
|
149
|
+
if (!started) {
|
|
150
|
+
started = true;
|
|
151
|
+
heartbeatTimer = setInterval(() => {
|
|
152
|
+
if (!renewing)
|
|
153
|
+
renewing = renew().catch((error) => reportError(error, 'lease-renewal')).finally(() => { renewing = undefined; });
|
|
154
|
+
}, heartbeatMs);
|
|
155
|
+
heartbeatTimer.unref?.();
|
|
156
|
+
pollTimer = setInterval(() => { void reconcile().catch((error) => reportError(error, 'request')); }, pollMs);
|
|
157
|
+
pollTimer.unref?.();
|
|
158
|
+
}
|
|
159
|
+
await reconcile();
|
|
160
|
+
})().finally(() => { starting = undefined; });
|
|
161
|
+
return starting;
|
|
162
|
+
}
|
|
163
|
+
async function report(conversationId, fields, setupState) {
|
|
164
|
+
fence();
|
|
165
|
+
const binding = broker.bindings.find((entry) => entry.conversationId === conversationId);
|
|
166
|
+
await options.client.reportWorkSessionDetails({ ...auth(), conversationId,
|
|
167
|
+
...(binding ? { workSessionId: binding.workSessionId } : {}),
|
|
168
|
+
...(setupState ? { setupState } : binding ? { setupState: binding.kind === 'managed' ? 'configured' : 'attached' } : {}),
|
|
169
|
+
fields });
|
|
170
|
+
}
|
|
171
|
+
async function stop() {
|
|
172
|
+
if (stopped)
|
|
173
|
+
return;
|
|
174
|
+
stopped = true;
|
|
175
|
+
if (pollTimer)
|
|
176
|
+
clearInterval(pollTimer);
|
|
177
|
+
if (heartbeatTimer)
|
|
178
|
+
clearInterval(heartbeatTimer);
|
|
179
|
+
await Promise.allSettled([starting, processing, renewing].filter(Boolean));
|
|
180
|
+
try {
|
|
181
|
+
if (broker)
|
|
182
|
+
await options.client.releaseWorkSessionRuntime(auth());
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
reportError(error, 'release-runtime');
|
|
186
|
+
}
|
|
187
|
+
await journal.close();
|
|
188
|
+
}
|
|
189
|
+
function binding(conversationId) {
|
|
190
|
+
if (stopped || !broker || broker.lease.expiresAt - Date.now() < 5_000)
|
|
191
|
+
return undefined;
|
|
192
|
+
return broker.bindings.find((entry) => entry.conversationId === conversationId && entry.kind === 'managed');
|
|
193
|
+
}
|
|
194
|
+
return { start, stop, reconcile, report, binding };
|
|
195
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CanonClient, type WorkSessionCatalog, type WorkSessionCompletion, type WorkSessionRequest, type WorkSessionRuntimeAuth, type WorkSessionRuntimeState } from '@canonmsg/core';
|
|
2
|
+
import type { WorkSessionJournal } from './work-session-state.js';
|
|
3
|
+
/** The same request identity is used by native creation and managed setup recovery. */
|
|
4
|
+
export declare function fingerprintWorkSessionRequest(request: WorkSessionRequest): string;
|
|
5
|
+
/** Shared broker authority; each host retains its own delivery and request dispatch loop. */
|
|
6
|
+
export declare function createWorkSessionAuthority(options: {
|
|
7
|
+
client: CanonClient;
|
|
8
|
+
hostId: string;
|
|
9
|
+
runtimeEpoch: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
isStopped(): boolean;
|
|
12
|
+
}): {
|
|
13
|
+
auth: () => WorkSessionRuntimeAuth;
|
|
14
|
+
fence: () => void;
|
|
15
|
+
retain: (state: WorkSessionRuntimeState) => WorkSessionRuntimeState;
|
|
16
|
+
register: (catalog: WorkSessionCatalog) => Promise<WorkSessionRuntimeState>;
|
|
17
|
+
heartbeat: (catalog: WorkSessionCatalog) => Promise<WorkSessionRuntimeState>;
|
|
18
|
+
claim: (requestId?: string) => Promise<import("@canonmsg/backend-contracts").WorkSessionClaimResult>;
|
|
19
|
+
acknowledge: (journal: WorkSessionJournal, requestId: string, result: WorkSessionCompletion) => Promise<WorkSessionRequest>;
|
|
20
|
+
state: () => WorkSessionRuntimeState | undefined;
|
|
21
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** The same request identity is used by native creation and managed setup recovery. */
|
|
3
|
+
export function fingerprintWorkSessionRequest(request) {
|
|
4
|
+
return createHash('sha256').update(JSON.stringify([
|
|
5
|
+
request.agentId, request.conversationId, request.requestId, request.hostId,
|
|
6
|
+
request.runtimeEpoch, request.catalogRevision, request.selection, request.requestedBy,
|
|
7
|
+
])).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
/** Shared broker authority; each host retains its own delivery and request dispatch loop. */
|
|
10
|
+
export function createWorkSessionAuthority(options) {
|
|
11
|
+
let current;
|
|
12
|
+
function auth() {
|
|
13
|
+
if (!current)
|
|
14
|
+
throw new Error('Work-session runtime is not registered.');
|
|
15
|
+
return { hostId: options.hostId, runtimeEpoch: options.runtimeEpoch,
|
|
16
|
+
leaseToken: current.lease.leaseToken };
|
|
17
|
+
}
|
|
18
|
+
function fence() {
|
|
19
|
+
if (options.isStopped() || !current || current.lease.expiresAt - Date.now() < 5_000)
|
|
20
|
+
throw new Error('Work-session runtime no longer has a fresh lease.');
|
|
21
|
+
}
|
|
22
|
+
function retain(state) {
|
|
23
|
+
if (state.lease.hostId !== options.hostId || state.lease.runtimeEpoch !== options.runtimeEpoch
|
|
24
|
+
|| (current && current.ownerId !== state.ownerId))
|
|
25
|
+
throw new Error('Work-session runtime identity changed.');
|
|
26
|
+
current = state;
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
async function register(catalog) {
|
|
30
|
+
return retain(await options.client.registerWorkSessionRuntime({ hostId: options.hostId,
|
|
31
|
+
runtimeEpoch: options.runtimeEpoch, displayName: options.displayName, catalog }));
|
|
32
|
+
}
|
|
33
|
+
async function heartbeat(catalog) {
|
|
34
|
+
fence();
|
|
35
|
+
return retain(await options.client.heartbeatWorkSessionRuntime({ ...auth(), catalog }));
|
|
36
|
+
}
|
|
37
|
+
function claim(requestId) {
|
|
38
|
+
fence();
|
|
39
|
+
return options.client.claimWorkSessionRequest({ ...auth(), ...(requestId ? { requestId } : {}) });
|
|
40
|
+
}
|
|
41
|
+
async function acknowledge(journal, requestId, result) {
|
|
42
|
+
fence();
|
|
43
|
+
const completed = await options.client.completeWorkSessionRequest({ ...auth(), requestId, result });
|
|
44
|
+
if (completed.status !== result.status)
|
|
45
|
+
throw new Error('Canon did not acknowledge the requested work-session outcome.');
|
|
46
|
+
await journal.mutate((state) => {
|
|
47
|
+
state.operations.find((entry) => entry.request.requestId === requestId).acknowledged = true;
|
|
48
|
+
});
|
|
49
|
+
return completed;
|
|
50
|
+
}
|
|
51
|
+
return { auth, fence, retain, register, heartbeat, claim, acknowledge,
|
|
52
|
+
state: () => current };
|
|
53
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { ApprovalManager, CanonApiError, CanonClient, CanonStream, NativeWorkSessionCreateError, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
3
3
|
import { createCanonAttachedSession, } from './attached-session.js';
|
|
4
4
|
import { createWorkSessionJournal } from './work-session-state.js';
|
|
5
5
|
import { createWorkSessionInteractionBridge } from './work-session-interactions.js';
|
|
6
|
+
import { createWorkSessionAuthority, fingerprintWorkSessionRequest } from './work-session-coordination.js';
|
|
6
7
|
/** One authenticated transport and liveness publisher, with durable per-room native routes. */
|
|
7
8
|
export function createCanonWorkSessionHost(options) {
|
|
8
9
|
const connection = { ...options.connection };
|
|
@@ -32,6 +33,8 @@ export function createCanonWorkSessionHost(options) {
|
|
|
32
33
|
let connected = false;
|
|
33
34
|
let agentContext;
|
|
34
35
|
let stopped = false;
|
|
36
|
+
const authority = createWorkSessionAuthority({ client, hostId: options.hostId,
|
|
37
|
+
runtimeEpoch, displayName: options.displayName, isStopped: () => stopped });
|
|
35
38
|
let startPromise;
|
|
36
39
|
let stopPromise;
|
|
37
40
|
let cleanupPromise;
|
|
@@ -40,6 +43,8 @@ export function createCanonWorkSessionHost(options) {
|
|
|
40
43
|
let expiryTimer;
|
|
41
44
|
let processing;
|
|
42
45
|
let renewing;
|
|
46
|
+
let reporting;
|
|
47
|
+
let reportsRequested = false;
|
|
43
48
|
let rejectConnection;
|
|
44
49
|
let resolveConnection;
|
|
45
50
|
const report = (error, operation) => { try {
|
|
@@ -54,20 +59,10 @@ export function createCanonWorkSessionHost(options) {
|
|
|
54
59
|
report(error, 'status');
|
|
55
60
|
}
|
|
56
61
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
throw new Error('Work-session runtime is not registered.');
|
|
60
|
-
return { hostId: options.hostId, runtimeEpoch, leaseToken: broker.lease.leaseToken };
|
|
61
|
-
}
|
|
62
|
-
function fence() {
|
|
63
|
-
if (stopped || !broker || broker.lease.expiresAt - Date.now() < 5_000)
|
|
64
|
-
throw new Error('Work-session runtime no longer has a fresh lease.');
|
|
65
|
-
}
|
|
62
|
+
const auth = authority.auth;
|
|
63
|
+
const fence = authority.fence;
|
|
66
64
|
function retainLease(state) {
|
|
67
|
-
|
|
68
|
-
|| (broker && state.ownerId !== broker.ownerId))
|
|
69
|
-
throw new Error('Work-session runtime identity changed.');
|
|
70
|
-
broker = state;
|
|
65
|
+
broker = authority.retain(state);
|
|
71
66
|
if (expiryTimer)
|
|
72
67
|
clearTimeout(expiryTimer);
|
|
73
68
|
expiryTimer = setTimeout(() => fail(new Error('Work-session runtime lease expired.'), 'lease-expired'), Math.max(1, state.lease.expiresAt - Date.now() - 5_000));
|
|
@@ -122,9 +117,45 @@ export function createCanonWorkSessionHost(options) {
|
|
|
122
117
|
room.activate();
|
|
123
118
|
room.session.refreshRuntimeState();
|
|
124
119
|
updatePresence();
|
|
120
|
+
scheduleReports();
|
|
121
|
+
}
|
|
122
|
+
async function reportRoom(room) {
|
|
123
|
+
if (!room.acknowledged || room.stopped || !options.provider.describe)
|
|
124
|
+
return;
|
|
125
|
+
fence();
|
|
126
|
+
const fields = await options.provider.describe(room.binding.nativeSessionId, room.binding.settings);
|
|
127
|
+
if (stopped || rooms.get(room.binding.conversationId) !== room || room.stopped || !room.acknowledged)
|
|
128
|
+
return;
|
|
129
|
+
fence();
|
|
130
|
+
await client.reportWorkSessionDetails({ ...auth(), conversationId: room.binding.conversationId,
|
|
131
|
+
workSessionId: room.binding.workSessionId, setupState: 'attached', fields });
|
|
132
|
+
}
|
|
133
|
+
function scheduleReports() {
|
|
134
|
+
if (stopped || !options.provider.describe)
|
|
135
|
+
return;
|
|
136
|
+
reportsRequested = true;
|
|
137
|
+
if (reporting)
|
|
138
|
+
return;
|
|
139
|
+
reporting = Promise.resolve().then(async () => {
|
|
140
|
+
while (reportsRequested && !stopped) {
|
|
141
|
+
reportsRequested = false;
|
|
142
|
+
const active = [...rooms.values()].filter((room) => room.acknowledged && !room.stopped);
|
|
143
|
+
const outcomes = await Promise.allSettled(active.map(reportRoom));
|
|
144
|
+
for (const outcome of outcomes) {
|
|
145
|
+
if (outcome.status === 'rejected')
|
|
146
|
+
report(outcome.reason, 'session-details');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}).catch((error) => report(error, 'session-details')).finally(() => {
|
|
150
|
+
reporting = undefined;
|
|
151
|
+
if (reportsRequested && !stopped)
|
|
152
|
+
scheduleReports();
|
|
153
|
+
});
|
|
125
154
|
}
|
|
126
155
|
async function attach(binding, confirmed = false) {
|
|
127
156
|
fence();
|
|
157
|
+
if (typeof binding.nativeSessionId !== 'string')
|
|
158
|
+
throw new Error('This host cannot attach a managed-session binding.');
|
|
128
159
|
const existing = rooms.get(binding.conversationId);
|
|
129
160
|
if (existing && existing.binding.nativeSessionId === binding.nativeSessionId && !existing.stopped) {
|
|
130
161
|
if (confirmed)
|
|
@@ -194,7 +225,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
194
225
|
updatePresence();
|
|
195
226
|
},
|
|
196
227
|
});
|
|
197
|
-
rooms.set(binding.conversationId, { binding, session, controller, releaseNative, stopped: false, acknowledged: confirmed, activate: activateRoute });
|
|
228
|
+
rooms.set(binding.conversationId, { binding: binding, session, controller, releaseNative, stopped: false, acknowledged: confirmed, activate: activateRoute });
|
|
198
229
|
if (confirmed)
|
|
199
230
|
activateRoute();
|
|
200
231
|
await session.start();
|
|
@@ -219,16 +250,12 @@ export function createCanonWorkSessionHost(options) {
|
|
|
219
250
|
throw error;
|
|
220
251
|
}
|
|
221
252
|
}
|
|
222
|
-
const fingerprint = (request) => createHash('sha256').update(JSON.stringify([
|
|
223
|
-
request.agentId, request.conversationId, request.requestId, request.hostId,
|
|
224
|
-
request.runtimeEpoch, request.catalogRevision, request.selection, request.requestedBy,
|
|
225
|
-
])).digest('hex');
|
|
226
253
|
const errorResult = (status, code, message) => ({ status, error: { code, message } });
|
|
227
254
|
async function processRequest(request, replayed) {
|
|
228
255
|
if (request.status !== 'claimed' && request.status !== 'uncertain')
|
|
229
256
|
return;
|
|
230
257
|
fence();
|
|
231
|
-
const hash =
|
|
258
|
+
const hash = fingerprintWorkSessionRequest(request);
|
|
232
259
|
let operation = journal.read().operations.find((entry) => entry.request.requestId === request.requestId);
|
|
233
260
|
if (operation && operation.fingerprint !== hash)
|
|
234
261
|
throw new Error('Work-session request changed during recovery.');
|
|
@@ -313,12 +340,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
313
340
|
}
|
|
314
341
|
}
|
|
315
342
|
try {
|
|
316
|
-
const completed = await
|
|
317
|
-
if (completed.status !== operation.completion.status) {
|
|
318
|
-
await removeRoom(request.conversationId, false);
|
|
319
|
-
throw new Error('Canon did not acknowledge the requested work-session outcome.');
|
|
320
|
-
}
|
|
321
|
-
await journal.mutate((state) => { state.operations.find((entry) => entry.request.requestId === request.requestId).acknowledged = true; });
|
|
343
|
+
const completed = await authority.acknowledge(journal, request.requestId, operation.completion);
|
|
322
344
|
if (completed.status === 'attached')
|
|
323
345
|
activate(request.conversationId);
|
|
324
346
|
}
|
|
@@ -343,7 +365,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
343
365
|
const outstanding = journal.read().operations.find((entry) => !entry.acknowledged && !entry.quarantined);
|
|
344
366
|
let claimed;
|
|
345
367
|
try {
|
|
346
|
-
claimed = await
|
|
368
|
+
claimed = await authority.claim(outstanding?.request.requestId);
|
|
347
369
|
}
|
|
348
370
|
catch (error) {
|
|
349
371
|
if (outstanding && error instanceof CanonApiError && error.code === 'WORK_SESSION_MEMBERSHIP_REQUIRED') {
|
|
@@ -360,7 +382,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
360
382
|
await quarantine(outstanding.request.requestId, 'This saved request is no longer available to this runtime.');
|
|
361
383
|
return;
|
|
362
384
|
}
|
|
363
|
-
if (['attached', 'failed', 'expired'].includes(claimed.request.status)) {
|
|
385
|
+
if (['attached', 'configured', 'failed', 'expired'].includes(claimed.request.status)) {
|
|
364
386
|
if (outstanding) {
|
|
365
387
|
if (claimed.request.status === 'attached' && claimed.request.binding) {
|
|
366
388
|
try {
|
|
@@ -417,9 +439,10 @@ export function createCanonWorkSessionHost(options) {
|
|
|
417
439
|
// Catalog has its own freshness deadline; it is refreshed independently of public presence.
|
|
418
440
|
const catalog = await options.provider.catalog();
|
|
419
441
|
fence();
|
|
420
|
-
const state = await
|
|
442
|
+
const state = await authority.heartbeat(catalog);
|
|
421
443
|
if (!stopped)
|
|
422
444
|
retainLease(state);
|
|
445
|
+
scheduleReports();
|
|
423
446
|
}
|
|
424
447
|
function start() {
|
|
425
448
|
if (stopped)
|
|
@@ -435,7 +458,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
435
458
|
const catalog = await options.provider.catalog();
|
|
436
459
|
if (stopped)
|
|
437
460
|
return;
|
|
438
|
-
retainLease(await
|
|
461
|
+
retainLease(await authority.register(catalog));
|
|
439
462
|
if (stopped) {
|
|
440
463
|
fenceShutdown();
|
|
441
464
|
return;
|
|
@@ -2,7 +2,7 @@ import { type ExclusiveJsonFileStore, type NativeSessionInteractionResult, type
|
|
|
2
2
|
export interface WorkSessionOperation {
|
|
3
3
|
request: WorkSessionRequest;
|
|
4
4
|
fingerprint: string;
|
|
5
|
-
phase: 'creating' | 'created' | 'attaching' | 'complete';
|
|
5
|
+
phase: 'configuring' | 'creating' | 'created' | 'attaching' | 'complete';
|
|
6
6
|
nativeSessionId?: string;
|
|
7
7
|
settings?: WorkSessionSettings;
|
|
8
8
|
completion?: WorkSessionCompletion;
|
|
@@ -16,7 +16,7 @@ export function createWorkSessionJournal(store, identity) {
|
|
|
16
16
|
|| loaded.hostId !== identity.hostId || !Array.isArray(loaded.operations)
|
|
17
17
|
|| !Array.isArray(loaded.interactions)
|
|
18
18
|
|| loaded.operations.some((entry) => !entry?.request?.requestId || !entry.fingerprint
|
|
19
|
-
|| !['creating', 'created', 'attaching', 'complete'].includes(entry.phase)
|
|
19
|
+
|| !['configuring', 'creating', 'created', 'attaching', 'complete'].includes(entry.phase)
|
|
20
20
|
|| (entry.phase === 'complete' && !entry.completion))
|
|
21
21
|
|| loaded.interactions.some((entry) => !entry?.id || !entry.fingerprint || !Number.isFinite(entry.expiresAt))) {
|
|
22
22
|
throw new Error('Work-session journal cannot be verified; retain it for recovery.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.1.1",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=22.22.3"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^13.0
|
|
31
|
+
"@canonmsg/core": "^13.2.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|