@ixo/flow-work 0.2.0 → 0.4.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/listener.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { findCapability } from './capability.js';
2
- import { FLOW_WORK_INVOKE_EVENT, FLOW_WORK_RECEIPT_EVENT, flowWorkInvokeSchema, } from './protocol.js';
2
+ import { FlowWorkInputError, InputRequestCoordinator, } from './input-requests.js';
3
+ import { FLOW_WORK_ASSIGN_EVENT, FLOW_WORK_BINDING_FIELDS, FLOW_WORK_INVOKE_EVENT, FLOW_WORK_READY_EVENT, FLOW_WORK_RECEIPT_EVENT, computeFlowWorkInputHash, flowWorkAssignSchema, flowWorkInvokeSchema, flowWorkReadySchema, flowWorkReceiptSchema, sameFlowWorkBinding, sameFlowWorkNb, } from './protocol.js';
3
4
  class FlowWorkFailure extends Error {
4
5
  code;
5
6
  constructor(code, message) {
@@ -8,54 +9,53 @@ class FlowWorkFailure extends Error {
8
9
  this.name = 'FlowWorkFailure';
9
10
  }
10
11
  }
11
- /** Hostname of '@user:server.tld' or a bare/prefixed server name. */
12
- export function homeserverOf(value) {
13
- let candidate = value.trim().toLowerCase();
14
- if (candidate.startsWith('@')) {
15
- const separator = candidate.indexOf(':');
16
- if (separator < 0)
17
- return null;
18
- candidate = candidate.slice(separator + 1);
19
- }
20
- if (candidate === '')
21
- return null;
22
- try {
23
- return new URL(candidate.includes('://') ? candidate : `matrix://${candidate}`).hostname
24
- .replace(/\.$/, '')
25
- .toLowerCase();
26
- }
27
- catch {
28
- return null;
29
- }
30
- }
31
12
  function isRecord(value) {
32
13
  return typeof value === 'object' && value !== null && !Array.isArray(value);
33
14
  }
34
15
  /**
35
- * The oracle side of the flow-work contract: accepts invites from trusted
36
- * homeservers, executes UCAN-authorized `ixo.flow.work.invoke` events via
37
- * the declared capability handlers, and posts `ixo.flow.work.receipt`s.
16
+ * Oracle-side flow-work v2 state machine.
17
+ *
18
+ * Assignment and ready are durable state events. Invoke remains a timeline
19
+ * command. Receipt is durable state keyed by invocationId, so restart replay
20
+ * does not depend on a recent-event window.
38
21
  */
39
22
  export class FlowWorkListener {
40
23
  options;
41
- trusted;
24
+ assignments = new Map();
42
25
  active = new Set();
43
26
  completed = new Set();
27
+ pendingReceipts = new Map();
28
+ publishedReceipts = new Map();
29
+ publishing = new Set();
30
+ retryTimers = new Map();
31
+ readyTimers = new Map();
32
+ /** Per-room seeding chain, so concurrent seeds never interleave. */
33
+ seeding = new Map();
34
+ /** In-flight ready publications, so a seed never double-sends a ready. */
35
+ acknowledging = new Map();
44
36
  unsubscribe = [];
37
+ started = false;
38
+ inputCoordinator;
45
39
  constructor(options) {
46
40
  this.options = options;
47
- const trusted = options.trustedHomeservers
48
- .map(homeserverOf)
49
- .filter((value) => value !== null);
50
- this.trusted = new Set(trusted);
51
- if (this.trusted.size === 0) {
52
- throw new Error('flow-work has no valid trusted homeserver domains');
53
- }
54
41
  if (options.capabilities.length === 0) {
55
42
  throw new Error('flow-work has no declared capabilities');
56
43
  }
44
+ const retryMs = this.retryMs();
45
+ const now = () => this.options.now?.() ?? Date.now();
46
+ this.inputCoordinator = new InputRequestCoordinator({
47
+ port: options.port,
48
+ logger: options.logger,
49
+ retryMs,
50
+ now,
51
+ });
57
52
  }
53
+ /** Subscribe synchronously before any Matrix initial sync is started. */
58
54
  start() {
55
+ if (this.started)
56
+ return;
57
+ this.started = true;
58
+ this.inputCoordinator.start();
59
59
  const { port, logger } = this.options;
60
60
  this.unsubscribe = [
61
61
  port.onInvite((invite) => {
@@ -69,93 +69,329 @@ export class FlowWorkListener {
69
69
  });
70
70
  }),
71
71
  ];
72
- logger.log(`[flow-work] listening capabilities: ${this.options.capabilities
72
+ // An assignment written before we joined arrives as room state, never as a
73
+ // live timeline event. joinRoom resolves before sync has that state locally,
74
+ // so seed again the moment our membership actually becomes join.
75
+ const offJoin = port.onJoin?.((roomId) => {
76
+ void this.bootstrapRoom(roomId).catch((error) => {
77
+ logger.error(`[flow-work] join seeding failed for ${roomId}: ${message(error)}`);
78
+ });
79
+ });
80
+ if (offJoin)
81
+ this.unsubscribe.push(offJoin);
82
+ logger.log(`[flow-work] listening - capabilities: ${this.options.capabilities
73
83
  .map((entry) => `${entry.can}#${entry.actionType}`)
74
84
  .join(', ')}`);
85
+ void this.bootstrap().catch((error) => {
86
+ logger.error(`[flow-work] bootstrap failed: ${message(error)}`);
87
+ });
88
+ for (const key of this.pendingReceipts.keys())
89
+ void this.publishReceipt(key);
75
90
  }
76
91
  stop() {
92
+ this.inputCoordinator.stop();
77
93
  for (const off of this.unsubscribe)
78
94
  off();
79
95
  this.unsubscribe = [];
96
+ for (const timer of this.retryTimers.values())
97
+ clearTimeout(timer);
98
+ for (const timer of this.readyTimers.values())
99
+ clearTimeout(timer);
100
+ this.retryTimers.clear();
101
+ this.readyTimers.clear();
102
+ this.started = false;
80
103
  }
81
- async handleInvite(invite) {
82
- const host = homeserverOf(invite.inviterUserId);
83
- if (!host || !this.trusted.has(host)) {
84
- this.options.logger.warn(`[flow-work] rejected invite to ${invite.roomId} from untrusted sender ${invite.inviterUserId}`);
85
- return;
104
+ /** Reconcile invitations and current assignment state after initial sync. */
105
+ async bootstrap() {
106
+ for (const invite of this.options.port.getPendingInvites()) {
107
+ await this.handleInvite(invite);
86
108
  }
109
+ for (const roomId of this.options.port.getJoinedRoomIds()) {
110
+ await this.bootstrapRoom(roomId);
111
+ }
112
+ }
113
+ now() {
114
+ return this.options.now?.() ?? Date.now();
115
+ }
116
+ retryMs() {
117
+ const configured = this.options.receiptRetryMs ?? 1_000;
118
+ return Number.isFinite(configured) && configured >= 0 ? configured : 1_000;
119
+ }
120
+ async handleInvite(invite) {
87
121
  await this.options.port.joinRoom(invite.roomId);
88
122
  this.options.logger.log(`[flow-work] joined ${invite.roomId} (invited by ${invite.inviterUserId})`);
123
+ await this.bootstrapRoom(invite.roomId);
124
+ }
125
+ /**
126
+ * Seed the assignment table from a room's current state.
127
+ *
128
+ * Serialised per room and chained behind any seed already running, so a join
129
+ * transition, a pending invite and post-sync bootstrap can all request the
130
+ * same room without interleaving. Every accepted assignment is routed through
131
+ * {@link handleAssignment} — the same validation and deduplication path the
132
+ * live timeline handler uses — so a seeded assignment is never reprocessed
133
+ * and never re-acknowledged.
134
+ */
135
+ async bootstrapRoom(roomId) {
136
+ const previous = this.seeding.get(roomId) ?? Promise.resolve();
137
+ const run = previous.then(() => this.seedRoomAssignments(roomId));
138
+ this.seeding.set(roomId, run);
139
+ try {
140
+ await run;
141
+ }
142
+ finally {
143
+ if (this.seeding.get(roomId) === run)
144
+ this.seeding.delete(roomId);
145
+ }
146
+ }
147
+ /** Never rejects: one unreadable event must not abandon the rest of a room. */
148
+ async seedRoomAssignments(roomId) {
149
+ let events;
150
+ try {
151
+ events = this.options.port.stateEvents(roomId, FLOW_WORK_ASSIGN_EVENT);
152
+ }
153
+ catch (error) {
154
+ this.options.logger.error(`[flow-work] failed to read assignment state in ${roomId}: ${message(error)}`);
155
+ return;
156
+ }
157
+ for (const event of events) {
158
+ try {
159
+ await this.handleAssignment(event);
160
+ }
161
+ catch (error) {
162
+ this.options.logger.error(`[flow-work] failed to seed an assignment in ${roomId}: ${message(error)}`);
163
+ }
164
+ }
89
165
  }
90
166
  async handleRoomEvent(event) {
91
- if (event.type !== FLOW_WORK_INVOKE_EVENT || !isRecord(event.content)) {
167
+ if (event.type === FLOW_WORK_ASSIGN_EVENT) {
168
+ await this.handleAssignment(event);
92
169
  return;
93
170
  }
171
+ if (event.type === FLOW_WORK_INVOKE_EVENT) {
172
+ await this.handleInvocation(event);
173
+ }
174
+ }
175
+ async handleAssignment(event) {
176
+ if (!isRecord(event.content))
177
+ return;
94
178
  if (event.content.audience !== this.options.oracleDid)
95
179
  return;
96
- const rawId = event.content.invocationId;
97
- if (typeof rawId !== 'string' || rawId.trim() === '')
180
+ const parsed = flowWorkAssignSchema.safeParse(event.content);
181
+ if (!parsed.success) {
182
+ this.options.logger.warn(`[flow-work] ignored malformed assignment in ${event.roomId}: ${issues(parsed.error.issues)}`);
98
183
  return;
99
- const invocationId = rawId.trim();
100
- const key = `${event.roomId}:${invocationId}`;
101
- if (this.active.has(key) || this.completed.has(key))
184
+ }
185
+ const assignmentContent = parsed.data;
186
+ const assignment = bindingOf(assignmentContent);
187
+ const selfUserId = this.options.port.getUserId();
188
+ if (event.stateKey !== assignment.invocationId ||
189
+ !event.sender ||
190
+ !selfUserId ||
191
+ assignmentContent.oracleMatrixId !== selfUserId) {
192
+ this.options.logger.warn(`[flow-work] ignored malformed assignment ${assignment.invocationId} in ${event.roomId}`);
102
193
  return;
103
- this.active.add(key);
104
- let receiptPosted = false;
194
+ }
195
+ if (assignment.deadline <= this.now()) {
196
+ this.options.logger.warn(`[flow-work] ignored expired assignment ${assignment.invocationId}`);
197
+ return;
198
+ }
199
+ const key = assignmentKey(event.roomId, assignment.invocationId);
200
+ const existing = this.assignments.get(key);
201
+ if (existing) {
202
+ if (existing.sender !== event.sender ||
203
+ assignment.attempt < existing.binding.attempt ||
204
+ (assignment.attempt === existing.binding.attempt &&
205
+ !sameFlowWorkBinding(existing.binding, assignment))) {
206
+ this.options.logger.warn(`[flow-work] ignored mutation of accepted assignment ${assignment.invocationId}`);
207
+ return;
208
+ }
209
+ if (assignment.attempt === existing.binding.attempt &&
210
+ sameFlowWorkBinding(existing.binding, assignment)) {
211
+ if (!existing.ready) {
212
+ await this.acknowledgeAssignment(event.roomId, existing);
213
+ }
214
+ return;
215
+ }
216
+ const oldTimer = this.readyTimers.get(key);
217
+ if (oldTimer)
218
+ clearTimeout(oldTimer);
219
+ this.readyTimers.delete(key);
220
+ }
221
+ const accepted = {
222
+ binding: cloneBinding(assignment),
223
+ sender: event.sender,
224
+ ready: this.hasOwnReady(event.roomId, assignment),
225
+ };
226
+ this.assignments.set(key, accepted);
227
+ if (!accepted.ready)
228
+ await this.acknowledgeAssignment(event.roomId, accepted);
229
+ }
230
+ hasOwnReady(roomId, binding) {
231
+ const self = this.options.port.getUserId();
232
+ if (!self)
233
+ return false;
234
+ return this.options.port
235
+ .stateEvents(roomId, FLOW_WORK_READY_EVENT)
236
+ .some((event) => {
237
+ if (event.stateKey !== binding.invocationId || event.sender !== self) {
238
+ return false;
239
+ }
240
+ const ready = flowWorkReadySchema.safeParse(event.content);
241
+ return ready.success && sameFlowWorkBinding(ready.data, binding);
242
+ });
243
+ }
244
+ /**
245
+ * Publish ready at most once per accepted assignment at a time.
246
+ *
247
+ * `handleAssignment` runs to the point of registering the assignment
248
+ * synchronously, but the send that follows is awaited. Without this guard a
249
+ * seed and the live delivery of the same assign could both observe
250
+ * `ready === false` and each publish a ready. Concurrent callers for the same
251
+ * assignment object share the in-flight publication; a genuinely new
252
+ * assignment object (a later attempt) is allowed its own send.
253
+ */
254
+ async acknowledgeAssignment(roomId, assignment) {
255
+ const key = assignmentKey(roomId, assignment.binding.invocationId);
256
+ const inFlight = this.acknowledging.get(key);
257
+ if (inFlight?.assignment === assignment) {
258
+ await inFlight.promise;
259
+ return;
260
+ }
261
+ const promise = this.publishReady(roomId, assignment, key).finally(() => {
262
+ if (this.acknowledging.get(key)?.promise === promise) {
263
+ this.acknowledging.delete(key);
264
+ }
265
+ });
266
+ this.acknowledging.set(key, { assignment, promise });
267
+ await promise;
268
+ }
269
+ async publishReady(roomId, assignment, key) {
105
270
  try {
106
- if (this.hasExistingReceipt(event.roomId, invocationId)) {
107
- this.completed.add(key);
271
+ await this.options.port.sendStateEvent(roomId, FLOW_WORK_READY_EVENT, assignment.binding.invocationId, { ...assignment.binding, status: 'ready' });
272
+ assignment.ready = true;
273
+ const timer = this.readyTimers.get(key);
274
+ if (timer)
275
+ clearTimeout(timer);
276
+ this.readyTimers.delete(key);
277
+ }
278
+ catch (error) {
279
+ assignment.ready = false;
280
+ this.options.logger.error(`[flow-work] failed to publish ready for ${assignment.binding.invocationId}: ${message(error)}`);
281
+ if (this.started && !this.readyTimers.has(key)) {
282
+ const timer = setTimeout(() => {
283
+ this.readyTimers.delete(key);
284
+ const current = this.assignments.get(key);
285
+ if (current === assignment && !current.ready) {
286
+ void this.acknowledgeAssignment(roomId, current);
287
+ }
288
+ }, this.retryMs());
289
+ timer.unref?.();
290
+ this.readyTimers.set(key, timer);
291
+ }
292
+ }
293
+ }
294
+ async handleInvocation(event) {
295
+ if (!isRecord(event.content))
296
+ return;
297
+ if (event.content.audience !== this.options.oracleDid)
298
+ return;
299
+ const parsed = flowWorkInvokeSchema.safeParse(event.content);
300
+ if (!parsed.success) {
301
+ this.options.logger.warn(`[flow-work] ignored malformed invoke in ${event.roomId}: ${issues(parsed.error.issues)}`);
302
+ return;
303
+ }
304
+ const invoke = parsed.data;
305
+ const binding = bindingOf(invoke);
306
+ const assignment = this.assignments.get(assignmentKey(event.roomId, invoke.invocationId));
307
+ if (!assignment) {
308
+ this.options.logger.warn(`[flow-work] ignored invoke ${invoke.invocationId}: no accepted assignment`);
309
+ return;
310
+ }
311
+ if (!event.sender || event.sender !== assignment.sender) {
312
+ this.options.logger.warn(`[flow-work] ignored invoke ${invoke.invocationId}: Matrix sender does not match assignment`);
313
+ return;
314
+ }
315
+ if (!assignment.ready) {
316
+ // sendStateEvent may be accepted by Matrix even when its response is
317
+ // lost. The manager can then observe our durable ready state and send
318
+ // the invoke while the original promise left this flag false. Reconcile
319
+ // against authenticated current state before rejecting the command.
320
+ if (this.hasOwnReady(event.roomId, assignment.binding)) {
321
+ assignment.ready = true;
322
+ const readyTimer = this.readyTimers.get(assignmentKey(event.roomId, invoke.invocationId));
323
+ if (readyTimer)
324
+ clearTimeout(readyTimer);
325
+ this.readyTimers.delete(assignmentKey(event.roomId, invoke.invocationId));
326
+ }
327
+ else {
328
+ this.options.logger.warn(`[flow-work] ignored invoke ${invoke.invocationId}: ready handshake is incomplete`);
108
329
  return;
109
330
  }
110
- const parsed = flowWorkInvokeSchema.safeParse(event.content);
111
- if (!parsed.success) {
112
- await this.postFailure(event.roomId, invocationId, 'input_invalid', parsed.error.issues
113
- .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
114
- .join('; '));
115
- receiptPosted = true;
116
- this.completed.add(key);
331
+ }
332
+ if (!sameFlowWorkBinding(assignment.binding, binding)) {
333
+ this.options.logger.warn(`[flow-work] ignored invoke ${invoke.invocationId}: immutable assignment binding differs`);
334
+ return;
335
+ }
336
+ const key = workKey(event.roomId, binding);
337
+ if (this.completed.has(key)) {
338
+ if (this.hasOwnReceipt(event.roomId, binding))
339
+ return;
340
+ const published = this.publishedReceipts.get(key);
341
+ if (published) {
342
+ this.pendingReceipts.set(key, published);
343
+ await this.publishReceipt(key);
117
344
  return;
118
345
  }
119
- const receipt = await this.execute(parsed.data);
120
- await this.postReceipt(event.roomId, receipt);
121
- receiptPosted = true;
346
+ // Current state no longer contains our receipt. A forged state event
347
+ // must not suppress authenticated redelivery after a process restart.
348
+ this.completed.delete(key);
349
+ }
350
+ if (this.pendingReceipts.has(key)) {
351
+ await this.publishReceipt(key);
352
+ return;
353
+ }
354
+ if (this.active.has(key))
355
+ return;
356
+ if (this.hasOwnReceipt(event.roomId, binding)) {
122
357
  this.completed.add(key);
358
+ return;
123
359
  }
124
- catch (error) {
125
- const failure = error instanceof FlowWorkFailure
126
- ? error
127
- : new FlowWorkFailure('tool_error', message(error));
128
- try {
129
- await this.postFailure(event.roomId, invocationId, failure.code, failure.message);
130
- receiptPosted = true;
131
- this.completed.add(key);
360
+ this.active.add(key);
361
+ try {
362
+ let receipt;
363
+ if (binding.deadline <= this.now()) {
364
+ receipt = failureReceipt(binding, 'deadline_expired', `Invocation deadline ${binding.deadline} has passed.`);
365
+ }
366
+ else if (computeFlowWorkInputHash(invoke.inputs) !== binding.inputHash) {
367
+ receipt = failureReceipt(binding, 'binding_mismatch', 'Invocation inputs do not match the assignment inputHash.');
132
368
  }
133
- catch (postError) {
134
- this.options.logger.error(`[flow-work] failed to post receipt for ${invocationId}: ${message(postError)}`);
369
+ else {
370
+ try {
371
+ receipt = await this.execute(event.roomId, invoke, binding);
372
+ }
373
+ catch (error) {
374
+ if (error instanceof FlowWorkInputError && error.code === 'aborted') {
375
+ return;
376
+ }
377
+ const failure = error instanceof FlowWorkFailure
378
+ ? error
379
+ : new FlowWorkFailure('tool_error', message(error));
380
+ receipt = failureReceipt(binding, failure.code, failure.message);
381
+ }
135
382
  }
383
+ this.pendingReceipts.set(key, {
384
+ roomId: event.roomId,
385
+ workKey: key,
386
+ receipt,
387
+ });
136
388
  }
137
389
  finally {
138
390
  this.active.delete(key);
139
- if (!receiptPosted)
140
- this.completed.delete(key);
141
- }
142
- }
143
- /**
144
- * Bind nb caveats to the invocation before executing. A delegation minted
145
- * for one claim schema must not be reusable with unrelated claim data:
146
- * `nb.claimSchema` requires the invocation's `inputs.claimSchemaId` to be
147
- * present and equal.
148
- */
149
- enforceCaveats(nb, invoke) {
150
- const claimSchema = nb?.claimSchema;
151
- if (typeof claimSchema !== 'string' || claimSchema === '')
152
- return;
153
- const provided = invoke.inputs.claimSchemaId;
154
- if (provided !== claimSchema) {
155
- throw new FlowWorkFailure('delegation_invalid', `Delegation is caveated to claim schema '${claimSchema}' but the invocation carries '${typeof provided === 'string' ? provided : 'none'}'.`);
156
391
  }
392
+ await this.publishReceipt(key);
157
393
  }
158
- async execute(invoke) {
394
+ async execute(roomId, invoke, binding) {
159
395
  const capability = findCapability(this.options.capabilities, invoke.capability, invoke.actionType);
160
396
  if (!capability) {
161
397
  throw new FlowWorkFailure('capability_not_declared', `Capability '${invoke.capability}' with actionType '${invoke.actionType}' is not declared by this oracle.`);
@@ -164,60 +400,161 @@ export class FlowWorkListener {
164
400
  if (!validation.ok) {
165
401
  throw new FlowWorkFailure(validation.code, validation.message);
166
402
  }
167
- if (validation.capability &&
168
- validation.capability.can !== invoke.capability) {
169
- throw new FlowWorkFailure('delegation_invalid', `Delegation grants '${validation.capability.can}', not '${invoke.capability}'.`);
403
+ if (validation.invokerDid !== binding.managerDid) {
404
+ throw new FlowWorkFailure('delegation_invalid', `Delegation invoker '${validation.invokerDid}' does not match managerDid '${binding.managerDid}'.`);
405
+ }
406
+ if (!validation.capability) {
407
+ throw new FlowWorkFailure('delegation_invalid', 'Delegation validation returned no capability grant.');
408
+ }
409
+ if (validation.capability.can !== binding.capability ||
410
+ validation.capability.with !== binding.resource ||
411
+ !sameFlowWorkNb(validation.capability.nb, binding)) {
412
+ throw new FlowWorkFailure('delegation_invalid', 'Delegation can, with, or nb does not exactly match the assigned work binding.');
413
+ }
414
+ this.enforceCaveats(validation.capability.nb, invoke);
415
+ if (binding.deadline <= this.now()) {
416
+ throw new FlowWorkFailure('deadline_expired', `Invocation deadline ${binding.deadline} passed before execution.`);
170
417
  }
171
- this.enforceCaveats(validation.capability?.nb, invoke);
172
418
  const inputs = capability.inputSchema.safeParse(invoke.inputs);
173
419
  if (!inputs.success) {
174
- throw new FlowWorkFailure('input_invalid', inputs.error.issues
175
- .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
176
- .join('; '));
420
+ throw new FlowWorkFailure('input_invalid', issues(inputs.error.issues));
177
421
  }
178
422
  let output;
179
423
  try {
180
424
  output = await capability.handler(inputs.data, {
181
425
  invocationId: invoke.invocationId,
426
+ managerDid: invoke.managerDid,
182
427
  capability: invoke.capability,
183
428
  actionType: invoke.actionType,
184
429
  nodeId: invoke.nodeId,
430
+ resource: invoke.resource,
431
+ inputHash: invoke.inputHash,
432
+ attempt: invoke.attempt,
433
+ deadline: invoke.deadline,
434
+ contractCaveats: structuredClone(binding.contractCaveats),
185
435
  audience: invoke.audience,
186
436
  delegation: invoke.delegation,
437
+ input: {
438
+ request: (params) => this.inputCoordinator.request({
439
+ roomId,
440
+ invocationId: invoke.invocationId,
441
+ nodeId: invoke.nodeId,
442
+ attempt: invoke.attempt,
443
+ inputHash: invoke.inputHash,
444
+ bindingDeadline: binding.deadline,
445
+ }, params),
446
+ },
187
447
  });
188
448
  }
189
449
  catch (error) {
450
+ if (error instanceof FlowWorkInputError)
451
+ throw error;
190
452
  throw new FlowWorkFailure('tool_error', message(error));
191
453
  }
454
+ if (capability.outputSchema) {
455
+ const validatedOutput = capability.outputSchema.safeParse(output);
456
+ if (!validatedOutput.success) {
457
+ throw new FlowWorkFailure('output_invalid', issues(validatedOutput.error.issues));
458
+ }
459
+ output = validatedOutput.data;
460
+ }
192
461
  return {
193
- invocationId: invoke.invocationId,
462
+ ...binding,
194
463
  status: 'success',
195
464
  output: toReceiptOutput(output),
196
465
  error: null,
197
466
  };
198
467
  }
199
- hasExistingReceipt(roomId, invocationId) {
200
- const limit = this.options.receiptScanLimit ?? 100;
468
+ /** Preserve service contract caveats after the full exact nb comparison. */
469
+ enforceCaveats(nb, invoke) {
470
+ const claimSchema = nb?.claimSchema;
471
+ if (typeof claimSchema !== 'string' || claimSchema === '')
472
+ return;
473
+ const provided = invoke.inputs.claimSchemaId;
474
+ if (provided !== claimSchema) {
475
+ throw new FlowWorkFailure('delegation_invalid', `Delegation is caveated to claim schema '${claimSchema}' but the invocation carries '${typeof provided === 'string' ? provided : 'none'}'.`);
476
+ }
477
+ }
478
+ hasOwnReceipt(roomId, binding) {
479
+ const self = this.options.port.getUserId();
480
+ if (!self)
481
+ return false;
201
482
  return this.options.port
202
- .recentEvents(roomId, limit)
203
- .some((event) => event.type === FLOW_WORK_RECEIPT_EVENT &&
204
- isRecord(event.content) &&
205
- event.content.invocationId === invocationId);
206
- }
207
- async postFailure(roomId, invocationId, code, errorMessage) {
208
- await this.postReceipt(roomId, {
209
- invocationId,
210
- status: 'failure',
211
- output: null,
212
- error: { code, message: errorMessage },
483
+ .stateEvents(roomId, FLOW_WORK_RECEIPT_EVENT)
484
+ .some((event) => {
485
+ if (event.stateKey !== binding.invocationId || event.sender !== self) {
486
+ return false;
487
+ }
488
+ const parsed = flowWorkReceiptSchema.safeParse(event.content);
489
+ return parsed.success && sameFlowWorkBinding(parsed.data, binding);
213
490
  });
214
491
  }
215
- async postReceipt(roomId, receipt) {
216
- await this.options.port.sendEvent(roomId, FLOW_WORK_RECEIPT_EVENT, {
217
- ...receipt,
218
- });
492
+ async publishReceipt(key) {
493
+ const pending = this.pendingReceipts.get(key);
494
+ if (!pending || this.publishing.has(key))
495
+ return;
496
+ this.publishing.add(key);
497
+ try {
498
+ await this.options.port.sendStateEvent(pending.roomId, FLOW_WORK_RECEIPT_EVENT, pending.receipt.invocationId, { ...pending.receipt });
499
+ this.pendingReceipts.delete(key);
500
+ this.publishedReceipts.set(key, pending);
501
+ this.completed.add(pending.workKey);
502
+ const timer = this.retryTimers.get(key);
503
+ if (timer)
504
+ clearTimeout(timer);
505
+ this.retryTimers.delete(key);
506
+ }
507
+ catch (error) {
508
+ this.options.logger.error(`[flow-work] failed to publish receipt for ${pending.receipt.invocationId}: ${message(error)}`);
509
+ this.scheduleReceiptRetry(key);
510
+ }
511
+ finally {
512
+ this.publishing.delete(key);
513
+ }
514
+ }
515
+ scheduleReceiptRetry(key) {
516
+ if (!this.started || this.retryTimers.has(key))
517
+ return;
518
+ const timer = setTimeout(() => {
519
+ this.retryTimers.delete(key);
520
+ void this.publishReceipt(key);
521
+ }, this.retryMs());
522
+ timer.unref?.();
523
+ this.retryTimers.set(key, timer);
219
524
  }
220
525
  }
526
+ function assignmentKey(roomId, invocationId) {
527
+ return `${roomId}\u0000${invocationId}`;
528
+ }
529
+ function workKey(roomId, binding) {
530
+ return `${roomId}\u0000${binding.invocationId}\u0000${binding.attempt}`;
531
+ }
532
+ function bindingOf(value) {
533
+ return cloneBinding(Object.fromEntries(FLOW_WORK_BINDING_FIELDS.map((field) => [field, value[field]])));
534
+ }
535
+ function cloneBinding(binding) {
536
+ return deepFreeze({
537
+ ...binding,
538
+ contractCaveats: structuredClone(binding.contractCaveats),
539
+ });
540
+ }
541
+ function deepFreeze(value) {
542
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
543
+ Object.freeze(value);
544
+ for (const nested of Object.values(value)) {
545
+ deepFreeze(nested);
546
+ }
547
+ }
548
+ return value;
549
+ }
550
+ function failureReceipt(binding, code, errorMessage) {
551
+ return {
552
+ ...binding,
553
+ status: 'failure',
554
+ output: null,
555
+ error: { code, message: errorMessage },
556
+ };
557
+ }
221
558
  function toReceiptOutput(value) {
222
559
  if (value === null || value === undefined)
223
560
  return null;
@@ -227,6 +564,11 @@ function toReceiptOutput(value) {
227
564
  const serializable = JSON.parse(json);
228
565
  return isRecord(serializable) ? serializable : { value: serializable };
229
566
  }
567
+ function issues(values) {
568
+ return values
569
+ .map((issue) => `${issue.path.map(String).join('.')}: ${issue.message}`)
570
+ .join('; ');
571
+ }
230
572
  function message(error) {
231
573
  return error instanceof Error ? error.message : String(error);
232
574
  }