@evomap/evolver-proxy 2.0.0-beta.1 → 2.0.0-beta.11

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 (43) hide show
  1. package/dist/bin/evolver-proxy.d.ts +69 -7
  2. package/dist/bin/evolver-proxy.js +437 -91
  3. package/dist/bin/proxySettings.d.ts +2 -0
  4. package/dist/bin/proxySettings.js +8 -1
  5. package/dist/daemon/collaborationFacade.d.ts +56 -0
  6. package/dist/daemon/collaborationFacade.js +877 -0
  7. package/dist/daemon/proxyDaemon.d.ts +4 -0
  8. package/dist/daemon/proxyDaemon.js +130 -2
  9. package/dist/daemon/selectHub.js +17 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.js +2 -1
  12. package/dist/lifecycle/legacyNodeId.d.ts +11 -13
  13. package/dist/lifecycle/legacyNodeId.js +35 -20
  14. package/dist/llm/traceControl.js +1 -1
  15. package/dist/private/accountAssetCompatibility.d.ts +28 -0
  16. package/dist/private/accountAssetCompatibility.js +196 -0
  17. package/dist/private/adapterLoader.d.ts +19 -2
  18. package/dist/private/adapterLoader.js +78 -4
  19. package/dist/router/messagesRoute.d.ts +13 -0
  20. package/dist/router/messagesRoute.js +56 -0
  21. package/dist/selfUpdate/executor.d.ts +10 -5
  22. package/dist/selfUpdate/executor.js +81 -6
  23. package/dist/selfUpdate/failureCodes.d.ts +6 -0
  24. package/dist/selfUpdate/failureCodes.js +6 -0
  25. package/dist/selfUpdate/index.d.ts +4 -1
  26. package/dist/selfUpdate/index.js +4 -1
  27. package/dist/selfUpdate/lastUpdate.d.ts +3 -1
  28. package/dist/selfUpdate/lastUpdate.js +37 -6
  29. package/dist/selfUpdate/releaseBinary.d.ts +10 -0
  30. package/dist/selfUpdate/releaseBinary.js +43 -6
  31. package/dist/selfUpdate/transaction.d.ts +109 -0
  32. package/dist/selfUpdate/transaction.js +1174 -0
  33. package/dist/selfUpdate/unixController.d.ts +15 -0
  34. package/dist/selfUpdate/unixController.js +186 -0
  35. package/dist/selfUpdate/version.d.ts +6 -2
  36. package/dist/selfUpdate/version.js +5 -3
  37. package/dist/selfUpdate/windowsController.d.ts +23 -0
  38. package/dist/selfUpdate/windowsController.js +274 -0
  39. package/dist/selfUpdate/windowsUpdater.d.ts +79 -0
  40. package/dist/selfUpdate/windowsUpdater.js +715 -0
  41. package/dist/sync/engine.d.ts +6 -5
  42. package/dist/sync/engine.js +102 -58
  43. package/package.json +8 -3
@@ -0,0 +1,877 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { hub, mailbox } from '@evomap/evolver-core';
3
+ const DEFAULT_LIMIT = 20;
4
+ const MAX_LIMIT = 100;
5
+ const DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
6
+ const SUBSCRIPTION_STATE_KEY = 'compat:v1:task_subscription';
7
+ const TASK_METRICS_STATE_KEY = 'compat:v1:task_metrics';
8
+ const TASK_CLAIM_PREFIX = 'compat:v1:task_claim:';
9
+ const TASK_COMPLETE_PREFIX = 'compat:v1:task_complete:';
10
+ const DM_SEND_PREFIX = 'compat:v1:dm_send:';
11
+ const LOCAL_ACK_TYPES = new Set(['dm', 'task_available', 'task_claim_result', 'task_complete_result']);
12
+ const TASK_RESULT_TYPES = new Set(['task_claim_result', 'task_complete_result']);
13
+ const DIRECT_ATTEMPT_RETRY_GRACE_MS = 1_000;
14
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 512;
15
+ const UNSUPPORTED_ENDPOINTS = new Set([
16
+ 'POST /session/create',
17
+ 'POST /session/join',
18
+ 'POST /session/leave',
19
+ 'POST /session/message',
20
+ 'POST /session/delegate',
21
+ 'POST /session/submit',
22
+ 'POST /session/invites/poll',
23
+ 'GET /session/list',
24
+ ]);
25
+ export class CollaborationFacade {
26
+ deps;
27
+ pendingClaims = new Map();
28
+ pendingCompletes = new Map();
29
+ pendingDmSends = new Map();
30
+ pendingSubscriptions = new Map();
31
+ constructor(deps) {
32
+ this.deps = deps;
33
+ }
34
+ async handle(ctx) {
35
+ if (UNSUPPORTED_ENDPOINTS.has(ctx.route)) {
36
+ ctx.json(501, {
37
+ error: 'unsupported endpoint',
38
+ code: 'unsupported_endpoint',
39
+ endpoint: ctx.url.pathname,
40
+ category: 'session',
41
+ });
42
+ return true;
43
+ }
44
+ switch (ctx.route) {
45
+ case 'POST /task/subscribe': return this.taskSubscribe(ctx);
46
+ case 'POST /task/unsubscribe': return this.taskUnsubscribe(ctx);
47
+ case 'GET /task/list': return this.taskList(ctx);
48
+ case 'POST /task/claim': return this.taskClaim(ctx);
49
+ case 'POST /task/complete': return this.taskComplete(ctx);
50
+ case 'GET /task/metrics': return this.taskMetrics(ctx);
51
+ case 'POST /dm/send': return this.dmSend(ctx);
52
+ case 'POST /dm/poll': return this.dmPoll(ctx);
53
+ case 'GET /dm/list': return this.dmList(ctx);
54
+ case 'POST /mailbox/poll': return this.mailboxPoll(ctx);
55
+ case 'POST /mailbox/ack': return this.mailboxAck(ctx);
56
+ default: return false;
57
+ }
58
+ }
59
+ /** Completes V1 durable task intents replayed by SyncEngine after a restart or transient Hub failure. */
60
+ handleOutboundSucceeded(envelope, handlerResult) {
61
+ if (envelope.runtimeNamespace !== (this.deps.runtimeNamespace ?? 'default'))
62
+ return;
63
+ if (envelope.type === 'task_claim' && envelope.idempotencyKey.startsWith(TASK_CLAIM_PREFIX)) {
64
+ const payload = asRecord(envelope.payload);
65
+ const taskId = requiredValue(payload, 'taskId', 'task_id');
66
+ const claimId = requiredValue(asRecord(handlerResult), 'claimId', 'claim_id');
67
+ if (!taskId || !claimId)
68
+ throw new FacadeProtocolError('Hub returned an invalid task claim result');
69
+ this.finalizeClaim(envelope, envelope.idempotencyKey, taskId, claimId, this.deps.now());
70
+ return;
71
+ }
72
+ if (envelope.type === 'task_complete' && envelope.idempotencyKey.startsWith(TASK_COMPLETE_PREFIX)) {
73
+ const payload = asRecord(envelope.payload);
74
+ const taskId = requiredValue(payload, 'taskId', 'task_id');
75
+ const claimId = requiredValue(payload, 'claimId', 'claim_id');
76
+ if (!taskId || !claimId)
77
+ throw new FacadeProtocolError('Durable task completion intent is invalid');
78
+ this.finalizeComplete(envelope, completeSuccessKey(claimId), taskId, claimId, numberValue(payload['startedAt']), this.deps.now());
79
+ }
80
+ }
81
+ /** Maps a Public Hub task result echo onto the same durable row as the facade-generated result. */
82
+ normalizeInboundEnvelope(envelope) {
83
+ if (!TASK_RESULT_TYPES.has(envelope.type)
84
+ || envelope.runtimeNamespace !== (this.deps.runtimeNamespace ?? 'default'))
85
+ return envelope;
86
+ const intent = this.facadeIntentForResult(envelope);
87
+ if (!intent)
88
+ return envelope;
89
+ const intentPayload = asRecord(intent.payload);
90
+ const taskId = requiredValue(intentPayload, 'taskId', 'task_id');
91
+ if (!taskId)
92
+ return envelope;
93
+ if (envelope.type === 'task_claim_result') {
94
+ const resultRef = taskResultRef(envelope);
95
+ const claimId = requiredValue(asRecord(envelope.payload), 'claimId', 'claim_id')
96
+ ?? processed(this.deps.store, TASK_CLAIM_PREFIX + taskId)?.claim_id
97
+ ?? (resultRef === `claim-${taskId}` ? resultRef : undefined);
98
+ if (!claimId)
99
+ return envelope;
100
+ return canonicalTaskResultEnvelope(envelope.type, {
101
+ task_id: taskId,
102
+ claim_id: claimId,
103
+ message_id: intent.id,
104
+ status: 'pending',
105
+ claim_status: 'claimed',
106
+ }, intent, envelope.createdAt);
107
+ }
108
+ const claimId = requiredValue(intentPayload, 'claimId', 'claim_id');
109
+ if (!claimId)
110
+ return envelope;
111
+ return canonicalTaskResultEnvelope('task_complete_result', {
112
+ task_id: taskId,
113
+ claim_id: claimId,
114
+ message_id: intent.id,
115
+ status: 'pending',
116
+ completion_status: 'completed',
117
+ }, intent, envelope.createdAt);
118
+ }
119
+ /** Caches a sanitized terminal result for durable task intents failed by SyncEngine. */
120
+ handleOutboundTerminal(envelope, error) {
121
+ if (envelope.runtimeNamespace !== (this.deps.runtimeNamespace ?? 'default'))
122
+ return;
123
+ if (envelope.type === 'task_claim' && envelope.idempotencyKey.startsWith(TASK_CLAIM_PREFIX)) {
124
+ this.cacheTerminalIntentFailure(envelope, envelope.idempotencyKey, error);
125
+ return;
126
+ }
127
+ if (envelope.type === 'task_complete' && envelope.idempotencyKey.startsWith(TASK_COMPLETE_PREFIX)) {
128
+ this.cacheTerminalIntentFailure(envelope, envelope.idempotencyKey, error);
129
+ }
130
+ }
131
+ async taskSubscribe(ctx) {
132
+ const body = asRecord(await ctx.readJson());
133
+ const filter = Array.isArray(body['capability_filter'])
134
+ ? body['capability_filter']
135
+ : Array.isArray(body['filters']) ? body['filters'] : [];
136
+ const filterHash = stableHash(filter);
137
+ const operation = this.getOrStart(this.pendingSubscriptions, filterHash, async () => {
138
+ const previous = asRecord(parseJson(this.deps.store.getState(SUBSCRIPTION_STATE_KEY)));
139
+ const previousId = typeof previous['message_id'] === 'string' ? previous['message_id'] : '';
140
+ const previousMessage = previousId ? this.deps.store.getById(previousId) : undefined;
141
+ if (previous['enabled'] === true
142
+ && previous['filter_hash'] === filterHash
143
+ && previousMessage
144
+ && (previousMessage.status === 'pending' || previousMessage.status === 'in_flight' || previousMessage.status === 'done')) {
145
+ return { message_id: previousId, status: 'pending' };
146
+ }
147
+ const result = this.enqueue('task_subscribe', { capability_filter: filter }, ctx.now, `task-subscribe:${filterHash}`);
148
+ this.deps.store.setState(SUBSCRIPTION_STATE_KEY, JSON.stringify({
149
+ enabled: true,
150
+ filters: filter,
151
+ filter_hash: filterHash,
152
+ message_id: result.message_id,
153
+ subscribed_at: new Date(ctx.now).toISOString(),
154
+ }));
155
+ return result;
156
+ });
157
+ ctx.json(200, await operation);
158
+ return true;
159
+ }
160
+ async taskUnsubscribe(ctx) {
161
+ await ctx.readJson();
162
+ this.deps.store.setState(SUBSCRIPTION_STATE_KEY, JSON.stringify({
163
+ enabled: false,
164
+ unsubscribed_at: new Date(ctx.now).toISOString(),
165
+ }));
166
+ ctx.json(200, this.enqueue('task_unsubscribe', {}, ctx.now, 'task-unsubscribe'));
167
+ return true;
168
+ }
169
+ taskList(ctx) {
170
+ this.observeReceivedTasks();
171
+ const tasks = this.messages('task_available', 'inbound', { status: 'pending' })
172
+ .slice(0, boundedLimit(ctx.url.searchParams.get('limit')))
173
+ .map(v1Message);
174
+ ctx.json(200, { tasks, count: tasks.length });
175
+ return true;
176
+ }
177
+ async taskClaim(ctx) {
178
+ const body = asRecord(await ctx.readJson());
179
+ const taskId = requiredIdentifier(body, 'task_id');
180
+ if (!taskId.ok)
181
+ return badRequest(ctx, taskId.error);
182
+ const key = TASK_CLAIM_PREFIX + taskId.value;
183
+ const existing = processed(this.deps.store, key);
184
+ if (existing) {
185
+ ctx.json(200, existing);
186
+ return true;
187
+ }
188
+ const terminal = processed(this.deps.store, terminalKey(key));
189
+ if (terminal) {
190
+ writeOperationClassification(ctx, terminal);
191
+ return true;
192
+ }
193
+ const intent = this.ensureTaskIntent('task_claim', { taskId: taskId.value }, key, ctx.now);
194
+ const operation = this.pendingClaims.get(key)
195
+ ?? (intent.created ? this.getOrStart(this.pendingClaims, key, () => this.executeClaim(intent.envelope, key, taskId.value)) : undefined);
196
+ if (!operation) {
197
+ ctx.json(200, pendingClaim(intent.envelope.id, taskId.value));
198
+ return true;
199
+ }
200
+ try {
201
+ ctx.json(200, await withTimeout(operation, this.timeoutMs()));
202
+ }
203
+ catch (error) {
204
+ const classification = classifyOperationError(error);
205
+ if (classification.retryable)
206
+ ctx.json(200, pendingClaim(intent.envelope.id, taskId.value));
207
+ else
208
+ writeOperationError(ctx, error);
209
+ }
210
+ return true;
211
+ }
212
+ async taskComplete(ctx) {
213
+ const body = asRecord(await ctx.readJson());
214
+ const taskId = requiredIdentifier(body, 'task_id');
215
+ if (!taskId.ok)
216
+ return badRequest(ctx, taskId.error);
217
+ const assetId = requiredIdentifier(body, 'asset_id');
218
+ if (!assetId.ok)
219
+ return badRequest(ctx, assetId.error);
220
+ const claim = processed(this.deps.store, TASK_CLAIM_PREFIX + taskId.value);
221
+ if (!claim)
222
+ return forbidden(ctx, 'task must be claimed through the facade before completion');
223
+ const claimId = optionalString(body['claim_id']) ?? claim.claim_id;
224
+ if (claim.claim_id !== claimId)
225
+ return forbidden(ctx, 'claim does not belong to task_id');
226
+ const successKey = completeSuccessKey(claimId);
227
+ const existing = processed(this.deps.store, successKey);
228
+ if (existing) {
229
+ ctx.json(200, existing);
230
+ return true;
231
+ }
232
+ const resultPayload = Object.hasOwn(body, 'result')
233
+ ? body['result']
234
+ : Object.hasOwn(body, 'payload') ? body['payload'] : {};
235
+ const startedAt = numberValue(body['started_at']);
236
+ const attemptKey = completeAttemptKey(taskId.value, claimId, assetId.value, resultPayload);
237
+ const terminal = processed(this.deps.store, terminalKey(attemptKey));
238
+ if (terminal) {
239
+ writeOperationClassification(ctx, terminal);
240
+ return true;
241
+ }
242
+ const active = this.activeCompleteIntent(claimId);
243
+ const activeOperation = this.pendingCompletes.get(successKey);
244
+ if (active) {
245
+ if (!activeOperation) {
246
+ ctx.json(200, pendingComplete(active.id, taskId.value, claimId));
247
+ return true;
248
+ }
249
+ try {
250
+ ctx.json(200, await withTimeout(activeOperation, this.timeoutMs()));
251
+ }
252
+ catch (error) {
253
+ const classification = classifyOperationError(error);
254
+ if (classification.retryable)
255
+ ctx.json(200, pendingComplete(active.id, taskId.value, claimId));
256
+ else
257
+ writeOperationError(ctx, error);
258
+ }
259
+ return true;
260
+ }
261
+ const intent = this.ensureTaskIntent('task_complete', {
262
+ claimId,
263
+ taskId: taskId.value,
264
+ assetId: assetId.value,
265
+ result: resultPayload,
266
+ ...(startedAt !== undefined ? { startedAt } : {}),
267
+ }, attemptKey, ctx.now);
268
+ const operation = this.pendingCompletes.get(successKey)
269
+ ?? (intent.created
270
+ ? this.getOrStart(this.pendingCompletes, successKey, () => this.executeComplete(intent.envelope, successKey, taskId.value, claimId, assetId.value, resultPayload, startedAt))
271
+ : undefined);
272
+ if (!operation) {
273
+ ctx.json(200, pendingComplete(intent.envelope.id, taskId.value, claimId));
274
+ return true;
275
+ }
276
+ try {
277
+ ctx.json(200, await withTimeout(operation, this.timeoutMs()));
278
+ }
279
+ catch (error) {
280
+ const classification = classifyOperationError(error);
281
+ if (classification.retryable)
282
+ ctx.json(200, pendingComplete(intent.envelope.id, taskId.value, claimId));
283
+ else
284
+ writeOperationError(ctx, error);
285
+ }
286
+ return true;
287
+ }
288
+ taskMetrics(ctx) {
289
+ const metrics = this.observeReceivedTasks();
290
+ const tasksPending = this.deps.store.countMessages({
291
+ type: 'task_available', direction: 'inbound', status: 'pending',
292
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
293
+ });
294
+ ctx.json(200, {
295
+ subscribed: Boolean(asRecord(parseJson(this.deps.store.getState(SUBSCRIPTION_STATE_KEY)))['enabled']),
296
+ tasks_received: metrics.tasks_received,
297
+ tasks_claimed: metrics.tasks_claimed,
298
+ tasks_completed: metrics.tasks_completed,
299
+ tasks_failed: metrics.tasks_failed,
300
+ tasks_pending: tasksPending,
301
+ last_claim_at: metrics.last_claim_at,
302
+ last_complete_at: metrics.last_complete_at,
303
+ avg_completion_ms: metrics.avg_completion_ms,
304
+ });
305
+ return true;
306
+ }
307
+ async dmSend(ctx) {
308
+ const body = asRecord(await ctx.readJson());
309
+ const recipient = requiredIdentifier(body, 'recipient_node_id');
310
+ if (!recipient.ok)
311
+ return badRequest(ctx, recipient.error);
312
+ const content = requiredDmContent(body['content']);
313
+ if (!content.ok)
314
+ return badRequest(ctx, content.error);
315
+ const metadata = asRecord(body['metadata']);
316
+ const requestKey = boundedOptionalString(ctx.req.headers['idempotency-key'], MAX_IDEMPOTENCY_KEY_LENGTH)
317
+ ?? boundedOptionalString(body['idempotency_key'], MAX_IDEMPOTENCY_KEY_LENGTH);
318
+ const payload = {
319
+ recipient_node_id: recipient.value,
320
+ content: content.value,
321
+ metadata,
322
+ sent_at: new Date(ctx.now).toISOString(),
323
+ };
324
+ if (requestKey === undefined) {
325
+ ctx.json(200, this.enqueue('dm_outbound', payload, ctx.now));
326
+ return true;
327
+ }
328
+ const key = DM_SEND_PREFIX + requestKey;
329
+ const existing = processed(this.deps.store, key);
330
+ if (existing) {
331
+ ctx.json(200, existing);
332
+ return true;
333
+ }
334
+ const operation = this.getOrStart(this.pendingDmSends, key, async () => {
335
+ const replay = processed(this.deps.store, key);
336
+ if (replay)
337
+ return replay;
338
+ const result = this.enqueue('dm_outbound', payload, ctx.now, key, true);
339
+ this.deps.store.markProcessed(key, result, ctx.now);
340
+ return result;
341
+ });
342
+ ctx.json(200, await operation);
343
+ return true;
344
+ }
345
+ async dmPoll(ctx) {
346
+ const body = asRecord(await ctx.readJson());
347
+ const messages = this.dmMessages({ status: 'pending' })
348
+ .slice(0, boundedLimit(body['limit']))
349
+ .map(v1Message);
350
+ ctx.json(200, { messages, count: messages.length });
351
+ return true;
352
+ }
353
+ async mailboxPoll(ctx) {
354
+ const body = asRecord(await ctx.readJson());
355
+ const type = optionalString(body['type']);
356
+ const direction = optionalDirection(body['direction']);
357
+ const limit = boundedMailboxPollLimit(body['limit']);
358
+ const messages = this.deps.store.list({
359
+ status: 'pending',
360
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
361
+ ...(type ? { type } : {}),
362
+ ...(direction ? { direction } : {}),
363
+ limit,
364
+ })
365
+ .map((message) => TASK_RESULT_TYPES.has(message.type) ? v1Message(message) : message);
366
+ ctx.json(200, { messages, count: messages.length });
367
+ return true;
368
+ }
369
+ async mailboxAck(ctx) {
370
+ const body = asRecord(await ctx.readJson());
371
+ const rawMessageIds = body['message_ids'];
372
+ if (!Array.isArray(rawMessageIds))
373
+ return badRequest(ctx, 'message_ids must be an array');
374
+ const messageIds = [];
375
+ for (const [index, value] of rawMessageIds.entries()) {
376
+ const messageId = optionalString(value);
377
+ if (!messageId)
378
+ return badRequest(ctx, `message_ids[${index}] must be a non-empty string`);
379
+ if (messageId.length > 512)
380
+ return badRequest(ctx, `message_ids[${index}] is too long`);
381
+ messageIds.push(messageId);
382
+ }
383
+ const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
384
+ let acknowledged = 0;
385
+ for (const messageId of messageIds) {
386
+ const message = this.deps.store.getById(messageId);
387
+ if (message?.direction !== 'inbound'
388
+ || (!LOCAL_ACK_TYPES.has(message.type) && !isDirectDialogMessage(message))
389
+ || message.status !== 'pending'
390
+ || message.runtimeNamespace !== runtimeNamespace)
391
+ continue;
392
+ this.deps.store.complete(messageId, ctx.now);
393
+ acknowledged += 1;
394
+ }
395
+ ctx.json(200, { acknowledged });
396
+ return true;
397
+ }
398
+ dmList(ctx) {
399
+ const limit = boundedLimit(ctx.url.searchParams.get('limit'));
400
+ const offset = boundedOffset(ctx.url.searchParams.get('offset'));
401
+ const messages = this.dmMessages({ newestFirst: true, includeOutbound: true })
402
+ .slice(offset, offset + limit)
403
+ .map(v1Message);
404
+ ctx.json(200, { messages, count: messages.length });
405
+ return true;
406
+ }
407
+ ensureTaskIntent(type, payload, key, now) {
408
+ const candidate = mailbox.createEnvelope({
409
+ id: facadeMessageId(type, key),
410
+ type,
411
+ payload,
412
+ idempotencyKey: key,
413
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
414
+ now,
415
+ });
416
+ const { stored } = this.deps.store.send(candidate);
417
+ if (stored) {
418
+ this.deps.store.defer(candidate.id, 'facade synchronous attempt in progress', now, this.timeoutMs() + DIRECT_ATTEMPT_RETRY_GRACE_MS);
419
+ this.deps.notifyOutbound();
420
+ }
421
+ return { envelope: this.deps.store.getById(candidate.id) ?? candidate, created: stored };
422
+ }
423
+ async executeClaim(intent, key, taskId) {
424
+ try {
425
+ const claim = await this.deps.hub.task.claim(taskId);
426
+ const claimId = optionalString(claim.claimId);
427
+ if (!claimId)
428
+ throw new FacadeProtocolError('Hub returned an invalid task claim result');
429
+ return this.finalizeClaim(intent, key, taskId, claimId, this.deps.now());
430
+ }
431
+ catch (error) {
432
+ this.recordTerminalIntentFailure(intent, key, error);
433
+ throw error;
434
+ }
435
+ }
436
+ async executeComplete(intent, successKey, taskId, claimId, assetId, resultPayload, startedAt) {
437
+ try {
438
+ await this.deps.hub.task.complete(claimId, resultPayload, { taskId, assetId });
439
+ return this.finalizeComplete(intent, successKey, taskId, claimId, startedAt, this.deps.now());
440
+ }
441
+ catch (error) {
442
+ this.recordTerminalIntentFailure(intent, intent.idempotencyKey, error);
443
+ throw error;
444
+ }
445
+ }
446
+ activeCompleteIntent(claimId) {
447
+ return this.deps.store.list({
448
+ type: 'task_complete',
449
+ direction: 'outbound',
450
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
451
+ newestFirst: true,
452
+ limit: 10_000,
453
+ }).find((message) => {
454
+ const payload = asRecord(message.payload);
455
+ return requiredValue(payload, 'claimId', 'claim_id') === claimId
456
+ && (message.status === 'pending' || message.status === 'in_flight');
457
+ });
458
+ }
459
+ facadeIntentForResult(result) {
460
+ const intentType = result.type === 'task_claim_result' ? 'task_claim' : 'task_complete';
461
+ const resultRef = taskResultRef(result);
462
+ if (!resultRef)
463
+ return undefined;
464
+ return this.deps.store.list({
465
+ type: intentType,
466
+ direction: 'outbound',
467
+ runtimeNamespace: result.runtimeNamespace,
468
+ newestFirst: true,
469
+ limit: 10_000,
470
+ }).find((intent) => {
471
+ if (!intent.idempotencyKey.startsWith(intentType === 'task_claim' ? TASK_CLAIM_PREFIX : TASK_COMPLETE_PREFIX))
472
+ return false;
473
+ const payload = asRecord(intent.payload);
474
+ const taskId = requiredValue(payload, 'taskId', 'task_id');
475
+ if (!taskId)
476
+ return false;
477
+ if (intentType === 'task_claim')
478
+ return resultRef === `claim-${taskId}`;
479
+ const claimId = requiredValue(payload, 'claimId', 'claim_id');
480
+ return Boolean(claimId && resultRef === `complete-${claimId}`);
481
+ });
482
+ }
483
+ finalizeClaim(intent, key, taskId, claimId, now) {
484
+ const existing = processed(this.deps.store, key);
485
+ if (existing) {
486
+ this.completeIntent(intent.id, now);
487
+ return existing;
488
+ }
489
+ const result = {
490
+ task_id: taskId,
491
+ claim_id: claimId,
492
+ message_id: intent.id,
493
+ status: 'pending',
494
+ claim_status: 'claimed',
495
+ };
496
+ this.enqueueTaskResult('task_claim_result', result, intent, now);
497
+ this.recordMetricOnce(`${key}:metrics`, now, () => {
498
+ this.updateMetrics((metrics) => ({ ...metrics, tasks_claimed: metrics.tasks_claimed + 1, last_claim_at: now }));
499
+ });
500
+ this.deps.store.markProcessed(key, result, now);
501
+ this.completeIntent(intent.id, now);
502
+ return result;
503
+ }
504
+ finalizeComplete(intent, key, taskId, claimId, startedAt, now) {
505
+ const existing = processed(this.deps.store, key);
506
+ if (existing) {
507
+ this.completeIntent(intent.id, now);
508
+ return existing;
509
+ }
510
+ const result = {
511
+ task_id: taskId,
512
+ claim_id: claimId,
513
+ message_id: intent.id,
514
+ status: 'pending',
515
+ completion_status: 'completed',
516
+ };
517
+ this.enqueueTaskResult('task_complete_result', result, intent, now);
518
+ this.recordMetricOnce(`${key}:metrics`, now, () => this.recordCompletion(now, startedAt));
519
+ this.deps.store.markProcessed(key, result, now);
520
+ this.completeIntent(intent.id, now);
521
+ return result;
522
+ }
523
+ enqueueTaskResult(type, payload, intent, now) {
524
+ this.deps.store.send(mailbox.createEnvelope({
525
+ id: facadeMessageId(type, intent.id),
526
+ type,
527
+ payload,
528
+ correlationId: intent.id,
529
+ replyTo: intent.id,
530
+ idempotencyKey: `${intent.id}:result`,
531
+ runtimeNamespace: intent.runtimeNamespace,
532
+ now,
533
+ }));
534
+ }
535
+ recordMetricOnce(key, now, record) {
536
+ if (this.deps.store.isProcessed(key))
537
+ return;
538
+ record();
539
+ this.deps.store.markProcessed(key, { recorded: true }, now);
540
+ }
541
+ recordTerminalIntentFailure(intent, key, error) {
542
+ const classification = this.cacheTerminalIntentFailure(intent, key, error);
543
+ if (!classification)
544
+ return;
545
+ const now = this.deps.now();
546
+ this.deps.store.fail(intent.id, classification.message, now, 1);
547
+ }
548
+ cacheTerminalIntentFailure(intent, key, error) {
549
+ const classification = classifyOperationError(error);
550
+ if (classification.retryable || isDurablyRecoverableAuthError(error))
551
+ return undefined;
552
+ const current = this.deps.store.getById(intent.id);
553
+ if (this.deps.store.isProcessed(key) || current?.status === 'done')
554
+ return undefined;
555
+ const now = this.deps.now();
556
+ this.deps.store.markProcessed(terminalKey(key), classification, now);
557
+ if (intent.type === 'task_complete') {
558
+ this.recordMetricOnce(`${key}:failure_metrics`, now, () => {
559
+ this.updateMetrics((metrics) => ({ ...metrics, tasks_failed: metrics.tasks_failed + 1 }));
560
+ });
561
+ }
562
+ return classification;
563
+ }
564
+ completeIntent(id, now) {
565
+ if (this.deps.store.getById(id)?.status === 'failed')
566
+ this.deps.store.replayDlq(id, now);
567
+ this.deps.store.complete(id, now);
568
+ }
569
+ enqueue(type, payload, now, idempotencyKey, stableMessageId = false) {
570
+ const env = mailbox.createEnvelope({
571
+ ...(stableMessageId && idempotencyKey !== undefined ? { id: facadeMessageId(type, idempotencyKey) } : {}),
572
+ type,
573
+ payload,
574
+ ...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
575
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
576
+ now,
577
+ });
578
+ const stored = this.deps.store.send(env).stored;
579
+ if (stored && env.handler === 'proxy')
580
+ this.deps.notifyOutbound();
581
+ return { message_id: env.id, status: 'pending' };
582
+ }
583
+ getOrStart(pending, key, start) {
584
+ const existing = pending.get(key);
585
+ if (existing)
586
+ return existing;
587
+ const operation = start().finally(() => pending.delete(key));
588
+ pending.set(key, operation);
589
+ return operation;
590
+ }
591
+ messages(type, direction, opts = {}) {
592
+ return this.deps.store.list({
593
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
594
+ type,
595
+ direction,
596
+ ...(opts.status ? { status: opts.status } : {}),
597
+ ...(opts.newestFirst ? { newestFirst: true } : {}),
598
+ limit: 10_000,
599
+ });
600
+ }
601
+ dmMessages(opts = {}) {
602
+ return this.deps.store.list({
603
+ runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
604
+ typeDirections: [
605
+ { type: 'dm', direction: 'inbound' },
606
+ { type: 'dialog_message', direction: 'inbound' },
607
+ ...(opts.includeOutbound ? [{ type: 'dm_outbound', direction: 'outbound' }] : []),
608
+ ],
609
+ ...(opts.status ? { status: opts.status } : {}),
610
+ ...(opts.newestFirst ? { newestFirst: true } : {}),
611
+ limit: 10_000,
612
+ }).filter((message) => message.type !== 'dialog_message' || isDirectDialogMessage(message));
613
+ }
614
+ timeoutMs() {
615
+ return this.deps.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
616
+ }
617
+ loadMetrics() {
618
+ const raw = asRecord(parseJson(this.deps.store.getState(TASK_METRICS_STATE_KEY)));
619
+ return {
620
+ tasks_received: finiteNonNegative(raw['tasks_received']),
621
+ tasks_claimed: finiteNonNegative(raw['tasks_claimed']),
622
+ tasks_completed: finiteNonNegative(raw['tasks_completed']),
623
+ tasks_failed: finiteNonNegative(raw['tasks_failed']),
624
+ last_claim_at: nullableNumber(raw['last_claim_at']),
625
+ last_complete_at: nullableNumber(raw['last_complete_at']),
626
+ avg_completion_ms: finiteNonNegative(raw['avg_completion_ms']),
627
+ completion_times: Array.isArray(raw['completion_times'])
628
+ ? raw['completion_times'].map(numberValue).filter((value) => value !== undefined).slice(-100)
629
+ : [],
630
+ };
631
+ }
632
+ updateMetrics(update) {
633
+ this.deps.store.setState(TASK_METRICS_STATE_KEY, JSON.stringify(update(this.loadMetrics())));
634
+ }
635
+ observeReceivedTasks() {
636
+ const metrics = this.loadMetrics();
637
+ const observed = this.deps.store.countMessages({
638
+ type: 'task_available', direction: 'inbound', runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
639
+ });
640
+ if (observed <= metrics.tasks_received)
641
+ return metrics;
642
+ const updated = { ...metrics, tasks_received: observed };
643
+ this.deps.store.setState(TASK_METRICS_STATE_KEY, JSON.stringify(updated));
644
+ return updated;
645
+ }
646
+ recordCompletion(now, startedAt) {
647
+ this.updateMetrics((metrics) => {
648
+ const completionTimes = startedAt === undefined
649
+ ? metrics.completion_times
650
+ : [...metrics.completion_times, Math.max(0, now - startedAt)].slice(-100);
651
+ const average = completionTimes.length === 0
652
+ ? metrics.avg_completion_ms
653
+ : Math.round(completionTimes.reduce((sum, value) => sum + value, 0) / completionTimes.length);
654
+ return {
655
+ ...metrics,
656
+ tasks_completed: metrics.tasks_completed + 1,
657
+ last_complete_at: now,
658
+ avg_completion_ms: average,
659
+ completion_times: completionTimes,
660
+ };
661
+ });
662
+ }
663
+ }
664
+ function processed(store, key) {
665
+ if (!store.isProcessed(key))
666
+ return undefined;
667
+ const value = store.getProcessed(key);
668
+ return value && typeof value === 'object' ? value : undefined;
669
+ }
670
+ function v1Message(message) {
671
+ return {
672
+ id: message.id,
673
+ message_id: message.id,
674
+ channel: 'evomap-hub',
675
+ direction: message.direction,
676
+ type: message.type === 'dm_outbound' || isDirectDialogMessage(message) ? 'dm' : message.type,
677
+ status: message.status === 'done' ? (message.direction === 'inbound' ? 'delivered' : 'synced') : message.status,
678
+ payload: message.payload,
679
+ priority: 'normal',
680
+ ref_id: message.replyTo,
681
+ created_at: message.createdAt,
682
+ synced_at: message.status === 'done' ? message.updatedAt : null,
683
+ expires_at: message.ttlAt,
684
+ retry_count: message.attempts,
685
+ error: null,
686
+ };
687
+ }
688
+ function writeOperationError(ctx, error) {
689
+ writeOperationClassification(ctx, classifyOperationError(error));
690
+ }
691
+ function writeOperationClassification(ctx, classification) {
692
+ ctx.json(classification.status, {
693
+ error: classification.message,
694
+ code: classification.code,
695
+ retryable: classification.retryable,
696
+ });
697
+ }
698
+ function classifyOperationError(error) {
699
+ if (error instanceof FacadeTimeoutError)
700
+ return { status: 504, code: 'timeout', message: 'Hub operation timed out', retryable: true };
701
+ if (error instanceof FacadeProtocolError)
702
+ return { status: 502, code: 'hub_error', message: error.message, retryable: false };
703
+ if (error instanceof hub.PublishRejectedError) {
704
+ const signature = `${error.status} ${error.message}`;
705
+ if (/auth/i.test(signature))
706
+ return { status: 401, code: 'unauthorized', message: 'Hub authentication failed', retryable: false };
707
+ if (/forbidden|permission/i.test(signature))
708
+ return { status: 403, code: 'forbidden', message: 'Hub permission denied', retryable: false };
709
+ if (/not[_ -]?found|expired/i.test(signature))
710
+ return { status: 404, code: 'not_found', message: 'Task or claim not found', retryable: false };
711
+ if (/conflict|already[_ -]?claimed/i.test(signature))
712
+ return { status: 409, code: 'conflict', message: 'Task state conflicts with this request', retryable: false };
713
+ if (/rate[_ -]?limit|overload|too[_ -]?many/i.test(signature)) {
714
+ return { status: 429, code: 'rate_limited', message: 'Hub rate limited the request', retryable: true };
715
+ }
716
+ const retryable = error.retryable === true
717
+ || error.terminal === false
718
+ || /\b5\d\d\b|temporar|unavailable|timeout|overload/i.test(signature);
719
+ return { status: 502, code: 'hub_error', message: 'Hub collaboration operation failed', retryable };
720
+ }
721
+ const candidate = error;
722
+ const status = numberValue(candidate?.statusCode) ?? numberValue(candidate?.status);
723
+ const signature = `${String(candidate?.name ?? '')} ${String(candidate?.code ?? '')}`;
724
+ if (status === 403 || /forbidden|permission/i.test(signature))
725
+ return { status: 403, code: 'forbidden', message: 'Hub permission denied', retryable: false };
726
+ if (status === 401 || /auth/i.test(signature))
727
+ return { status: 401, code: 'unauthorized', message: 'Hub authentication failed', retryable: false };
728
+ if (status === 404)
729
+ return { status: 404, code: 'not_found', message: 'Task or claim not found', retryable: false };
730
+ if (status === 409 || /conflict|already_claimed/i.test(signature))
731
+ return { status: 409, code: 'conflict', message: 'Task state conflicts with this request', retryable: false };
732
+ if (status === 429)
733
+ return { status: 429, code: 'rate_limited', message: 'Hub rate limited the request', retryable: true };
734
+ if ((status !== undefined && status >= 500) || candidate?.retryable === true
735
+ || /HubUnreachable|HUB_UNREACHABLE|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch/i.test(signature)) {
736
+ return { status: 502, code: 'hub_error', message: 'Hub collaboration operation failed', retryable: true };
737
+ }
738
+ // Unknown transport failures are retried from the durable intent; known authz/conflict failures returned above are terminal.
739
+ return { status: 502, code: 'hub_error', message: 'Hub collaboration operation failed', retryable: true };
740
+ }
741
+ function isDurablyRecoverableAuthError(error) {
742
+ const candidate = error;
743
+ const status = numberValue(candidate?.statusCode) ?? numberValue(candidate?.status);
744
+ return status === 401;
745
+ }
746
+ class FacadeTimeoutError extends Error {
747
+ }
748
+ class FacadeProtocolError extends Error {
749
+ }
750
+ async function withTimeout(operation, timeoutMs) {
751
+ let timer;
752
+ try {
753
+ return await Promise.race([
754
+ operation,
755
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new FacadeTimeoutError()), timeoutMs); }),
756
+ ]);
757
+ }
758
+ finally {
759
+ if (timer)
760
+ clearTimeout(timer);
761
+ }
762
+ }
763
+ function badRequest(ctx, error) {
764
+ ctx.json(400, { error, code: 'invalid_request' });
765
+ return true;
766
+ }
767
+ function forbidden(ctx, error) {
768
+ ctx.json(403, { error, code: 'forbidden' });
769
+ return true;
770
+ }
771
+ function asRecord(value) {
772
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
773
+ }
774
+ function requiredString(record, key) {
775
+ const value = optionalString(record[key]);
776
+ return value ? { ok: true, value } : { ok: false, error: `${key} is required` };
777
+ }
778
+ function requiredDmContent(value) {
779
+ if (typeof value === 'string' && value.trim())
780
+ return { ok: true, value };
781
+ if (value !== null && typeof value === 'object' && !Array.isArray(value))
782
+ return { ok: true, value: value };
783
+ return { ok: false, error: 'content is required' };
784
+ }
785
+ function requiredIdentifier(record, key) {
786
+ const result = requiredString(record, key);
787
+ if (!result.ok)
788
+ return result;
789
+ return result.value.length <= 512 ? result : { ok: false, error: `${key} is too long` };
790
+ }
791
+ function optionalString(value) {
792
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
793
+ }
794
+ function optionalDirection(value) {
795
+ return value === 'inbound' || value === 'outbound' || value === 'local' ? value : undefined;
796
+ }
797
+ function requiredValue(record, ...keys) {
798
+ for (const key of keys) {
799
+ const value = optionalString(record[key]);
800
+ if (value)
801
+ return value;
802
+ }
803
+ return undefined;
804
+ }
805
+ function isDirectDialogMessage(message) {
806
+ return message.type === 'dialog_message' && asRecord(message.payload)['dialog_type'] === 'direct_message';
807
+ }
808
+ function pendingClaim(messageId, taskId) {
809
+ return { task_id: taskId, message_id: messageId, status: 'pending', claim_status: 'pending' };
810
+ }
811
+ function pendingComplete(messageId, taskId, claimId) {
812
+ return { task_id: taskId, claim_id: claimId, message_id: messageId, status: 'pending', completion_status: 'pending' };
813
+ }
814
+ function taskResultRef(envelope) {
815
+ return optionalString(envelope.replyTo)
816
+ ?? requiredValue(asRecord(envelope.payload), 'ref_id', 'refId');
817
+ }
818
+ function completeSuccessKey(claimId) {
819
+ return TASK_COMPLETE_PREFIX + claimId;
820
+ }
821
+ function completeAttemptKey(taskId, claimId, assetId, result) {
822
+ return `${completeSuccessKey(claimId)}:attempt:${stableHash({ taskId, claimId, assetId, result })}`;
823
+ }
824
+ function canonicalTaskResultEnvelope(type, payload, intent, now) {
825
+ return mailbox.createEnvelope({
826
+ id: facadeMessageId(type, intent.id),
827
+ type,
828
+ payload,
829
+ correlationId: intent.id,
830
+ replyTo: intent.id,
831
+ idempotencyKey: `${intent.id}:result`,
832
+ runtimeNamespace: intent.runtimeNamespace,
833
+ now,
834
+ });
835
+ }
836
+ function boundedOptionalString(value, maxLength) {
837
+ const parsed = optionalString(value);
838
+ return parsed?.slice(0, maxLength);
839
+ }
840
+ function boundedLimit(value) {
841
+ return Math.max(1, Math.min(MAX_LIMIT, Math.floor(numberValue(value) ?? DEFAULT_LIMIT)));
842
+ }
843
+ function boundedMailboxPollLimit(value) {
844
+ return Math.max(1, Math.min(50, Math.floor(numberValue(value) ?? 10)));
845
+ }
846
+ function boundedOffset(value) {
847
+ return Math.max(0, Math.min(10_000, Math.floor(numberValue(value) ?? 0)));
848
+ }
849
+ function numberValue(value) {
850
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
851
+ return Number.isFinite(parsed) ? parsed : undefined;
852
+ }
853
+ function finiteNonNegative(value) {
854
+ return Math.max(0, Math.floor(numberValue(value) ?? 0));
855
+ }
856
+ function nullableNumber(value) {
857
+ return numberValue(value) ?? null;
858
+ }
859
+ function parseJson(value) {
860
+ if (!value)
861
+ return undefined;
862
+ try {
863
+ return JSON.parse(value);
864
+ }
865
+ catch {
866
+ return undefined;
867
+ }
868
+ }
869
+ function stableHash(value) {
870
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
871
+ }
872
+ function facadeMessageId(type, idempotencyKey) {
873
+ return `compat:${type}:${stableHash(idempotencyKey)}`;
874
+ }
875
+ function terminalKey(key) {
876
+ return `${key}:terminal`;
877
+ }