@bliggy/chat-runtime 0.0.0-stage → 0.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.
- package/README.md +281 -2
- package/index.js +34 -0
- package/package.json +29 -4
- package/src/channels.js +72 -0
- package/src/conversation.js +154 -0
- package/src/memoryTurnStore.js +95 -0
- package/src/pendingActions.js +37 -0
- package/src/runtime.js +525 -0
- package/src/twilio.js +176 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Select from an already authorized queue without executing or changing it.
|
|
4
|
+
// Callers own confirmation interpretation, atomic claiming, authorization at
|
|
5
|
+
// execution time, and receipts. A warning requires explicit approval; a general
|
|
6
|
+
// confirmation cannot override it. Existing ids and action objects are retained.
|
|
7
|
+
function selectPendingActions(actions, { decision, actionId = null } = {}) {
|
|
8
|
+
if (!Array.isArray(actions)) throw new TypeError('actions must be an array');
|
|
9
|
+
const unchanged = reason => ({ selected: [], remaining: [...actions], reason });
|
|
10
|
+
if (actions.length === 0) return unchanged('empty');
|
|
11
|
+
if (!['confirm', 'confirm_including_flagged', 'cancel'].includes(decision)) {
|
|
12
|
+
return unchanged('unconfirmed');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (actionId !== null && actionId !== undefined) {
|
|
16
|
+
const index = actions.findIndex(action => String(action.id) === String(actionId));
|
|
17
|
+
if (index === -1) return unchanged('not_found');
|
|
18
|
+
return {
|
|
19
|
+
selected: [actions[index]],
|
|
20
|
+
remaining: actions.filter((_, actionIndex) => actionIndex !== index),
|
|
21
|
+
reason: null
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (decision === 'cancel' || decision === 'confirm_including_flagged') {
|
|
26
|
+
return { selected: [...actions], remaining: [], reason: null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const selected = [];
|
|
30
|
+
const remaining = [];
|
|
31
|
+
for (const action of actions) {
|
|
32
|
+
(action.warning ? remaining : selected).push(action);
|
|
33
|
+
}
|
|
34
|
+
return { selected, remaining, reason: selected.length === 0 ? 'all_flagged' : null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { selectPendingActions };
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const TURN_STATUSES = Object.freeze([
|
|
6
|
+
'received',
|
|
7
|
+
'authenticated',
|
|
8
|
+
'processing',
|
|
9
|
+
'awaiting_confirmation',
|
|
10
|
+
'executing',
|
|
11
|
+
'completed',
|
|
12
|
+
'cancelled',
|
|
13
|
+
'rejected',
|
|
14
|
+
'failed',
|
|
15
|
+
'expired'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const TURN_TRANSITIONS = Object.freeze({
|
|
19
|
+
received: ['authenticated', 'rejected', 'failed'],
|
|
20
|
+
authenticated: ['processing', 'rejected', 'failed'],
|
|
21
|
+
processing: ['awaiting_confirmation', 'completed', 'failed'],
|
|
22
|
+
awaiting_confirmation: ['executing', 'cancelled', 'expired'],
|
|
23
|
+
executing: ['completed', 'cancelled', 'failed'],
|
|
24
|
+
completed: [],
|
|
25
|
+
cancelled: [],
|
|
26
|
+
rejected: [],
|
|
27
|
+
failed: [],
|
|
28
|
+
expired: []
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
class ChannelRuntimeError extends Error {
|
|
32
|
+
constructor(message, { code = 'CHANNEL_RUNTIME_ERROR', statusCode = 500 } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = this.constructor.name;
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.statusCode = statusCode;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class EnvelopeValidationError extends ChannelRuntimeError {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message, { code: 'INVALID_CHANNEL_ENVELOPE', statusCode: 400 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class SecurityError extends ChannelRuntimeError {
|
|
47
|
+
constructor(message = 'Channel request could not be authenticated') {
|
|
48
|
+
super(message, { code: 'CHANNEL_AUTHENTICATION_FAILED', statusCode: 401 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class RateLimitError extends ChannelRuntimeError {
|
|
53
|
+
constructor(message = 'Channel rate limit exceeded') {
|
|
54
|
+
super(message, { code: 'CHANNEL_RATE_LIMITED', statusCode: 429 });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseDecision(message) {
|
|
59
|
+
const normalized = typeof message === 'string' ? message.trim().toLowerCase() : '';
|
|
60
|
+
if (['confirm', 'approve', 'yes'].includes(normalized)) return 'confirm';
|
|
61
|
+
if (['cancel', 'reject', 'no'].includes(normalized)) return 'cancel';
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stableValue(value) {
|
|
66
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
67
|
+
if (!value || typeof value !== 'object') return value;
|
|
68
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function actionSignature(action) {
|
|
72
|
+
return JSON.stringify(stableValue(action));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function actionIdempotencyKey(turn, action, index) {
|
|
76
|
+
return crypto.createHash('sha256')
|
|
77
|
+
.update(JSON.stringify([turn.id, index, stableValue(action)]))
|
|
78
|
+
.digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateEnvelope(channel, envelope, maxTextLength) {
|
|
82
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
83
|
+
throw new EnvelopeValidationError('Channel adapter must return an envelope object');
|
|
84
|
+
}
|
|
85
|
+
if (envelope.channel !== channel) {
|
|
86
|
+
throw new EnvelopeValidationError(`Envelope channel must be ${channel}`);
|
|
87
|
+
}
|
|
88
|
+
for (const key of ['sourceKey', 'threadKey']) {
|
|
89
|
+
if (typeof envelope[key] !== 'string' || envelope[key].trim() === '') {
|
|
90
|
+
throw new EnvelopeValidationError(`${key} is required`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (typeof envelope.text !== 'string') {
|
|
94
|
+
throw new EnvelopeValidationError('text must be a string');
|
|
95
|
+
}
|
|
96
|
+
if (envelope.text.length > maxTextLength) {
|
|
97
|
+
throw new EnvelopeValidationError(`text exceeds the ${maxTextLength} character limit`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validatePrincipal(resolution) {
|
|
102
|
+
if (!resolution || typeof resolution !== 'object') {
|
|
103
|
+
throw new SecurityError('Channel actor could not be resolved');
|
|
104
|
+
}
|
|
105
|
+
for (const key of ['tenantKey', 'actorKey']) {
|
|
106
|
+
if (typeof resolution[key] !== 'string' || resolution[key].trim() === '') {
|
|
107
|
+
throw new SecurityError('Channel actor could not be resolved');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!resolution.principal) throw new SecurityError('Channel actor could not be resolved');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sanitizeAuthentication(authentication) {
|
|
114
|
+
if (!authentication || typeof authentication !== 'object') return null;
|
|
115
|
+
const { allowed, method, provider, accountKey, reason } = authentication;
|
|
116
|
+
return { allowed: !!allowed, method, provider, accountKey, reason };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function classifyActions(actions, authorization) {
|
|
120
|
+
const approved = new Set((authorization?.approved || []).map(actionSignature));
|
|
121
|
+
const pending = new Set(
|
|
122
|
+
(authorization?.requiresConfirmation || authorization?.requiresConfirm || []).map(actionSignature)
|
|
123
|
+
);
|
|
124
|
+
const rejected = new Map((authorization?.rejected || []).map(item => {
|
|
125
|
+
const { reason = 'Action rejected by policy', ...actionWithoutReason } = item;
|
|
126
|
+
return [actionSignature(item.action || actionWithoutReason), reason];
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
return actions.map(action => {
|
|
130
|
+
const signature = actionSignature(action);
|
|
131
|
+
if (rejected.has(signature)) return { action, decision: 'reject', reason: rejected.get(signature) };
|
|
132
|
+
if (pending.has(signature)) return { action, decision: 'confirm' };
|
|
133
|
+
if (approved.has(signature)) return { action, decision: 'approve' };
|
|
134
|
+
return { action, decision: 'reject', reason: 'Action was not classified by policy' };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function defaultRender({ plan, results, pendingActions, decision }) {
|
|
139
|
+
let content = plan?.content || plan?.summary || '';
|
|
140
|
+
if (decision === 'cancel') content ||= 'The pending action was cancelled.';
|
|
141
|
+
if (decision === 'confirm') content ||= 'The pending action was confirmed.';
|
|
142
|
+
if (pendingActions.length > 0) {
|
|
143
|
+
content = `${content}${content ? '\n\n' : ''}Would you like me to go ahead with these actions?`;
|
|
144
|
+
}
|
|
145
|
+
return { content, results, pendingActions };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
class ChannelRuntime {
|
|
149
|
+
constructor({
|
|
150
|
+
adapters,
|
|
151
|
+
turnStore,
|
|
152
|
+
resolvePrincipal,
|
|
153
|
+
plan,
|
|
154
|
+
authorize,
|
|
155
|
+
execute,
|
|
156
|
+
render = defaultRender,
|
|
157
|
+
rateLimit = null,
|
|
158
|
+
parseConfirmation = parseDecision,
|
|
159
|
+
confirmationTtlMs = 24 * 60 * 60 * 1000,
|
|
160
|
+
maxTextLength = 50_000,
|
|
161
|
+
maxActions = 25,
|
|
162
|
+
now = () => new Date(),
|
|
163
|
+
onSecurityEvent = () => {},
|
|
164
|
+
onError = () => {}
|
|
165
|
+
}) {
|
|
166
|
+
if (!adapters || typeof adapters !== 'object') throw new TypeError('adapters are required');
|
|
167
|
+
for (const method of ['claim', 'transition', 'getPending', 'claimPending']) {
|
|
168
|
+
if (typeof turnStore?.[method] !== 'function') throw new TypeError(`turnStore.${method} is required`);
|
|
169
|
+
}
|
|
170
|
+
for (const [name, value] of Object.entries({ resolvePrincipal, plan, authorize, execute, render })) {
|
|
171
|
+
if (typeof value !== 'function') throw new TypeError(`${name} must be a function`);
|
|
172
|
+
}
|
|
173
|
+
if (typeof now !== 'function') throw new TypeError('now must be a function');
|
|
174
|
+
for (const [name, value] of Object.entries({ confirmationTtlMs, maxTextLength, maxActions })) {
|
|
175
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.adapters = adapters;
|
|
179
|
+
this.turnStore = turnStore;
|
|
180
|
+
this.resolvePrincipal = resolvePrincipal;
|
|
181
|
+
this.plan = plan;
|
|
182
|
+
this.authorize = authorize;
|
|
183
|
+
this.execute = execute;
|
|
184
|
+
this.render = render;
|
|
185
|
+
this.rateLimit = rateLimit;
|
|
186
|
+
this.parseConfirmation = parseConfirmation;
|
|
187
|
+
this.confirmationTtlMs = confirmationTtlMs;
|
|
188
|
+
this.maxTextLength = maxTextLength;
|
|
189
|
+
this.maxActions = maxActions;
|
|
190
|
+
this.now = now;
|
|
191
|
+
this.onSecurityEvent = onSecurityEvent;
|
|
192
|
+
this.onError = onError;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async handle({ channel, payload, context = {}, emit = () => {} }) {
|
|
196
|
+
const adapter = this.adapters[channel];
|
|
197
|
+
if (!adapter || typeof adapter.normalize !== 'function' || typeof adapter.authenticate !== 'function') {
|
|
198
|
+
throw new EnvelopeValidationError(`Unsupported channel: ${channel}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const envelope = await adapter.normalize(payload, context);
|
|
202
|
+
validateEnvelope(channel, envelope, this.maxTextLength);
|
|
203
|
+
|
|
204
|
+
const authentication = await adapter.authenticate(envelope, context);
|
|
205
|
+
if (!authentication?.allowed) {
|
|
206
|
+
await this.onSecurityEvent({
|
|
207
|
+
channel,
|
|
208
|
+
sourceKey: envelope.sourceKey,
|
|
209
|
+
threadKey: envelope.threadKey,
|
|
210
|
+
sender: envelope.sender,
|
|
211
|
+
provider: authentication?.provider,
|
|
212
|
+
method: authentication?.method,
|
|
213
|
+
reason: authentication?.reason
|
|
214
|
+
});
|
|
215
|
+
throw new SecurityError();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const resolution = await this.resolvePrincipal({ envelope, authentication, context });
|
|
219
|
+
validatePrincipal(resolution);
|
|
220
|
+
const scope = {
|
|
221
|
+
tenantKey: resolution.tenantKey,
|
|
222
|
+
actorKey: resolution.actorKey,
|
|
223
|
+
channel,
|
|
224
|
+
threadKey: envelope.threadKey
|
|
225
|
+
};
|
|
226
|
+
const storeContext = { principal: resolution.principal, context };
|
|
227
|
+
|
|
228
|
+
if (this.rateLimit) {
|
|
229
|
+
const access = await this.rateLimit({
|
|
230
|
+
...scope,
|
|
231
|
+
envelope,
|
|
232
|
+
principal: resolution.principal,
|
|
233
|
+
context
|
|
234
|
+
});
|
|
235
|
+
if (!access?.allowed) throw new RateLimitError(access?.reason);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const claimed = await this.turnStore.claim({
|
|
239
|
+
...scope,
|
|
240
|
+
sourceKey: envelope.sourceKey,
|
|
241
|
+
status: 'received',
|
|
242
|
+
input: envelope,
|
|
243
|
+
security: sanitizeAuthentication(authentication)
|
|
244
|
+
}, storeContext);
|
|
245
|
+
if (!claimed.created) {
|
|
246
|
+
return await this._result(adapter, claimed.turn, envelope, context, true);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let turn = claimed.turn;
|
|
250
|
+
try {
|
|
251
|
+
turn = await this.turnStore.transition(turn, 'authenticated', {}, storeContext);
|
|
252
|
+
await emit({ type: 'turn', status: turn.status, turnId: turn.id });
|
|
253
|
+
|
|
254
|
+
// Read without claiming: interpretation can be asynchronous or inconclusive.
|
|
255
|
+
// The later atomic claim must still match this exact scoped action snapshot.
|
|
256
|
+
const pendingTurn = await this.turnStore.getPending(scope, storeContext);
|
|
257
|
+
const interpreted = await this.parseConfirmation(envelope.text, {
|
|
258
|
+
channel, envelope, context, principal: resolution.principal,
|
|
259
|
+
pendingTurn: pendingTurn ? structuredClone(pendingTurn) : null
|
|
260
|
+
});
|
|
261
|
+
const decision = [null, undefined, 'confirm', 'cancel'].includes(interpreted)
|
|
262
|
+
? interpreted : 'clarify';
|
|
263
|
+
if (decision === 'clarify') {
|
|
264
|
+
turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
|
|
265
|
+
const output = await this.render({
|
|
266
|
+
channel, envelope, results: [], pendingActions: [], decision,
|
|
267
|
+
plan: { content: 'Please tell me whether you want to go ahead with the pending actions, or what you would like to change.' },
|
|
268
|
+
context
|
|
269
|
+
});
|
|
270
|
+
turn = await this.turnStore.transition(turn, 'completed', { output, results: [] }, storeContext);
|
|
271
|
+
return await this._result(adapter, turn, envelope, context, false);
|
|
272
|
+
}
|
|
273
|
+
if (decision) {
|
|
274
|
+
return await this._handleDecision({
|
|
275
|
+
adapter,
|
|
276
|
+
context,
|
|
277
|
+
decision,
|
|
278
|
+
emit,
|
|
279
|
+
envelope,
|
|
280
|
+
principal: resolution.principal,
|
|
281
|
+
scope,
|
|
282
|
+
storeContext,
|
|
283
|
+
pendingTurn,
|
|
284
|
+
turn
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
|
|
289
|
+
await emit({ type: 'turn', status: turn.status, turnId: turn.id });
|
|
290
|
+
const plan = await this.plan({
|
|
291
|
+
principal: resolution.principal,
|
|
292
|
+
envelope,
|
|
293
|
+
turn,
|
|
294
|
+
emit,
|
|
295
|
+
context
|
|
296
|
+
});
|
|
297
|
+
const actions = Array.isArray(plan?.actions) ? plan.actions : [];
|
|
298
|
+
if (actions.length > this.maxActions) {
|
|
299
|
+
throw new ChannelRuntimeError('Plan contains too many actions', {
|
|
300
|
+
code: 'CHANNEL_ACTION_LIMIT_EXCEEDED',
|
|
301
|
+
statusCode: 400
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const authorization = await this.authorize({
|
|
305
|
+
principal: resolution.principal,
|
|
306
|
+
envelope,
|
|
307
|
+
turn,
|
|
308
|
+
plan,
|
|
309
|
+
actions,
|
|
310
|
+
confirmed: false,
|
|
311
|
+
context
|
|
312
|
+
});
|
|
313
|
+
const classified = classifyActions(actions, authorization);
|
|
314
|
+
const approved = classified.filter(item => item.decision === 'approve').map(item => item.action);
|
|
315
|
+
const pendingActions = classified.filter(item => item.decision === 'confirm').map(item => item.action);
|
|
316
|
+
const results = classified
|
|
317
|
+
.filter(item => item.decision === 'reject')
|
|
318
|
+
.map(item => ({ action: item.action, status: 'rejected', message: item.reason }));
|
|
319
|
+
results.push(...await this._executeActions(
|
|
320
|
+
resolution.principal,
|
|
321
|
+
turn,
|
|
322
|
+
approved,
|
|
323
|
+
envelope,
|
|
324
|
+
emit,
|
|
325
|
+
context
|
|
326
|
+
));
|
|
327
|
+
|
|
328
|
+
const output = await this.render({
|
|
329
|
+
channel,
|
|
330
|
+
envelope,
|
|
331
|
+
plan,
|
|
332
|
+
results,
|
|
333
|
+
pendingActions,
|
|
334
|
+
decision: null,
|
|
335
|
+
context
|
|
336
|
+
});
|
|
337
|
+
turn = await this.turnStore.transition(
|
|
338
|
+
turn,
|
|
339
|
+
pendingActions.length > 0 ? 'awaiting_confirmation' : 'completed',
|
|
340
|
+
{
|
|
341
|
+
plan,
|
|
342
|
+
output,
|
|
343
|
+
pendingActions,
|
|
344
|
+
results,
|
|
345
|
+
...(pendingActions.length > 0
|
|
346
|
+
? { expiresAt: new Date(this.now().getTime() + this.confirmationTtlMs).toISOString() }
|
|
347
|
+
: {})
|
|
348
|
+
},
|
|
349
|
+
storeContext
|
|
350
|
+
);
|
|
351
|
+
await emit({ type: 'turn', status: turn.status, turnId: turn.id });
|
|
352
|
+
return await this._result(adapter, turn, envelope, context, false);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
await this.onError(error, { turn, envelope, scope });
|
|
355
|
+
if (TURN_TRANSITIONS[turn.status]?.includes('failed')) {
|
|
356
|
+
turn = await this.turnStore.transition(turn, 'failed', {
|
|
357
|
+
lastError: error.code || 'CHANNEL_PROCESSING_FAILED'
|
|
358
|
+
}, storeContext);
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async _handleDecision({
|
|
365
|
+
adapter,
|
|
366
|
+
context,
|
|
367
|
+
decision,
|
|
368
|
+
emit,
|
|
369
|
+
envelope,
|
|
370
|
+
principal,
|
|
371
|
+
scope,
|
|
372
|
+
storeContext,
|
|
373
|
+
pendingTurn,
|
|
374
|
+
turn
|
|
375
|
+
}) {
|
|
376
|
+
turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
|
|
377
|
+
const pending = pendingTurn && await this.turnStore.claimPending({
|
|
378
|
+
...scope, turnId: pendingTurn.id, pendingActions: pendingTurn.pendingActions
|
|
379
|
+
}, storeContext);
|
|
380
|
+
if (!pending) {
|
|
381
|
+
const output = await this.render({
|
|
382
|
+
channel: envelope.channel,
|
|
383
|
+
envelope,
|
|
384
|
+
plan: { content: 'There are no pending actions for this conversation.' },
|
|
385
|
+
results: [],
|
|
386
|
+
pendingActions: [],
|
|
387
|
+
decision,
|
|
388
|
+
context
|
|
389
|
+
});
|
|
390
|
+
turn = await this.turnStore.transition(turn, 'completed', { output, results: [] }, storeContext);
|
|
391
|
+
return await this._result(adapter, turn, envelope, context, false);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (pending.id !== pendingTurn.id ||
|
|
395
|
+
pending.tenantKey !== scope.tenantKey || pending.actorKey !== scope.actorKey ||
|
|
396
|
+
pending.channel !== scope.channel || pending.threadKey !== scope.threadKey ||
|
|
397
|
+
pending.status !== 'executing' || pending.sourceKey === envelope.sourceKey ||
|
|
398
|
+
!(new Date(pending.expiresAt) > this.now()) ||
|
|
399
|
+
actionSignature(pending.pendingActions) !== actionSignature(pendingTurn.pendingActions)) {
|
|
400
|
+
throw new SecurityError('Pending action changed before confirmation');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (decision === 'cancel') {
|
|
404
|
+
await this.turnStore.transition(pending, 'cancelled', {}, storeContext);
|
|
405
|
+
const output = await this.render({
|
|
406
|
+
channel: envelope.channel,
|
|
407
|
+
envelope,
|
|
408
|
+
plan: { content: 'The pending action was cancelled.' },
|
|
409
|
+
results: [],
|
|
410
|
+
pendingActions: [],
|
|
411
|
+
decision,
|
|
412
|
+
context
|
|
413
|
+
});
|
|
414
|
+
turn = await this.turnStore.transition(turn, 'completed', {
|
|
415
|
+
output,
|
|
416
|
+
results: [],
|
|
417
|
+
resolvedTurnId: pending.id
|
|
418
|
+
}, storeContext);
|
|
419
|
+
return await this._result(adapter, turn, envelope, context, false);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const pendingActions = pending.pendingActions || [];
|
|
424
|
+
const authorization = await this.authorize({
|
|
425
|
+
principal,
|
|
426
|
+
envelope,
|
|
427
|
+
turn: pending,
|
|
428
|
+
plan: pending.plan,
|
|
429
|
+
actions: pendingActions,
|
|
430
|
+
confirmed: true,
|
|
431
|
+
context
|
|
432
|
+
});
|
|
433
|
+
const classified = classifyActions(pendingActions, authorization);
|
|
434
|
+
const executable = classified
|
|
435
|
+
.filter(item => item.decision !== 'reject')
|
|
436
|
+
.map(item => item.action);
|
|
437
|
+
const results = classified
|
|
438
|
+
.filter(item => item.decision === 'reject')
|
|
439
|
+
.map(item => ({ action: item.action, status: 'rejected', message: item.reason }));
|
|
440
|
+
results.push(...await this._executeActions(
|
|
441
|
+
principal,
|
|
442
|
+
pending,
|
|
443
|
+
executable,
|
|
444
|
+
envelope,
|
|
445
|
+
emit,
|
|
446
|
+
context,
|
|
447
|
+
{ decision: 'confirm', turnId: pending.id, sourceKey: envelope.sourceKey }
|
|
448
|
+
));
|
|
449
|
+
|
|
450
|
+
const output = await this.render({
|
|
451
|
+
channel: envelope.channel,
|
|
452
|
+
envelope,
|
|
453
|
+
plan: pending.plan,
|
|
454
|
+
results,
|
|
455
|
+
pendingActions: [],
|
|
456
|
+
decision,
|
|
457
|
+
context
|
|
458
|
+
});
|
|
459
|
+
await this.turnStore.transition(
|
|
460
|
+
pending,
|
|
461
|
+
'completed',
|
|
462
|
+
{ output, results, pendingActions: [] },
|
|
463
|
+
storeContext
|
|
464
|
+
);
|
|
465
|
+
turn = await this.turnStore.transition(turn, 'completed', {
|
|
466
|
+
output,
|
|
467
|
+
results,
|
|
468
|
+
resolvedTurnId: pending.id
|
|
469
|
+
}, storeContext);
|
|
470
|
+
await emit({ type: 'turn', status: turn.status, turnId: turn.id });
|
|
471
|
+
return await this._result(adapter, turn, envelope, context, false);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
await this.turnStore.transition(pending, 'failed', {
|
|
474
|
+
lastError: error.code || 'CHANNEL_CONFIRMATION_FAILED'
|
|
475
|
+
}, storeContext);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async _executeActions(principal, turn, actions, envelope, emit, context, confirmation = null) {
|
|
481
|
+
const results = [];
|
|
482
|
+
for (let index = 0; index < actions.length; index++) {
|
|
483
|
+
const action = actions[index];
|
|
484
|
+
await emit({ type: 'action', status: 'executing', action, turnId: turn.id });
|
|
485
|
+
try {
|
|
486
|
+
const execution = await this.execute({
|
|
487
|
+
principal,
|
|
488
|
+
action,
|
|
489
|
+
envelope,
|
|
490
|
+
turn,
|
|
491
|
+
context,
|
|
492
|
+
confirmation,
|
|
493
|
+
idempotencyKey: actionIdempotencyKey(turn, action, index)
|
|
494
|
+
});
|
|
495
|
+
const succeeded = !execution || !['error', 'failed', 'rejected'].includes(execution.status);
|
|
496
|
+
results.push(succeeded
|
|
497
|
+
? { action, status: 'succeeded', result: execution?.result ?? execution }
|
|
498
|
+
: { action, status: 'failed', message: execution.message || 'Action failed' });
|
|
499
|
+
} catch (error) {
|
|
500
|
+
await this.onError(error, { turn, envelope, action });
|
|
501
|
+
results.push({ action, status: 'failed', message: 'Action failed' });
|
|
502
|
+
}
|
|
503
|
+
await emit({ type: 'action', ...results.at(-1), turnId: turn.id });
|
|
504
|
+
}
|
|
505
|
+
return results;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async _result(adapter, turn, envelope, context, duplicate) {
|
|
509
|
+
const response = typeof adapter.format === 'function'
|
|
510
|
+
? await adapter.format(turn.output || {}, { envelope, turn, context, duplicate })
|
|
511
|
+
: turn.output;
|
|
512
|
+
return { turn, output: turn.output || null, response, duplicate };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
module.exports = {
|
|
517
|
+
ChannelRuntime,
|
|
518
|
+
ChannelRuntimeError,
|
|
519
|
+
EnvelopeValidationError,
|
|
520
|
+
SecurityError,
|
|
521
|
+
RateLimitError,
|
|
522
|
+
TURN_STATUSES,
|
|
523
|
+
TURN_TRANSITIONS,
|
|
524
|
+
parseDecision
|
|
525
|
+
};
|