@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/README.md +135 -9
- package/dist/capability.d.ts +39 -11
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js.map +1 -1
- package/dist/flow-work.plugin.d.ts +1 -3
- package/dist/flow-work.plugin.d.ts.map +1 -1
- package/dist/flow-work.plugin.js +20 -11
- package/dist/flow-work.plugin.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/input-requests.d.ts +65 -0
- package/dist/input-requests.d.ts.map +1 -0
- package/dist/input-requests.js +397 -0
- package/dist/input-requests.js.map +1 -0
- package/dist/input.d.ts +199 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +198 -0
- package/dist/input.js.map +1 -0
- package/dist/listener.d.ts +59 -18
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +453 -111
- package/dist/listener.js.map +1 -1
- package/dist/matrix-port.d.ts +40 -10
- package/dist/matrix-port.d.ts.map +1 -1
- package/dist/matrix-port.js +98 -18
- package/dist/matrix-port.js.map +1 -1
- package/dist/protocol.d.ts +145 -17
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +165 -16
- package/dist/protocol.js.map +1 -1
- package/package.json +6 -2
package/dist/listener.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { findCapability } from './capability.js';
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.
|
|
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
|
|
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
|
|
97
|
-
if (
|
|
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
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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.
|
|
168
|
-
validation.
|
|
169
|
-
|
|
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
|
-
|
|
462
|
+
...binding,
|
|
194
463
|
status: 'success',
|
|
195
464
|
output: toReceiptOutput(output),
|
|
196
465
|
error: null,
|
|
197
466
|
};
|
|
198
467
|
}
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
.
|
|
203
|
-
.some((event) =>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
}
|