@opencraw/azure-durable 0.0.24 → 0.0.26
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 +1 -0
- package/dist/index.esm.js +387 -75
- package/dist/src/callout-resume/callout-event.mapper.d.ts +30 -0
- package/dist/src/callout-resume/callout-ticket.algorithm.d.ts +36 -0
- package/dist/src/callout-resume/index.d.ts +5 -0
- package/dist/src/callout-resume/resolve-callout.handler.d.ts +17 -0
- package/dist/src/crawl-run/crawl-job.model.d.ts +13 -1
- package/dist/src/crawl-run/crawl-orchestration.use-case.d.ts +11 -1
- package/dist/src/crawl-run/run-recipe.use-case.d.ts +7 -2
- package/dist/src/host-options/host-options.config.d.ts +16 -1
- package/dist/src/host-options/index.d.ts +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/open-craw-host/register-open-craw.use-case.d.ts +4 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ Everything that is code or a secret stays in the host: hooks, captcha solvers, a
|
|
|
53
53
|
| `hooks`, `captchaSolvers`, `access`, `accessPlugins`, `browser` | As in `createCrawler`. `captchaSolvers` is a factory: each crawler gets its own set. |
|
|
54
54
|
| `pools` | `windows` (the default policy), `maxWindows` (the cap per pool, default 8), `idleTtlMs` (a pool with no item this long closes, default 10 minutes). |
|
|
55
55
|
| `authLevel`, `routePrefix` | Default `function` (a function key) and no prefix. |
|
|
56
|
+
| `callouts` | Lets a hook that answers `pending` post its result back, `POST /callouts/{token}/resolve`, instead of being asked again: `{ signingKeyEnv, publicUrl?, waitMs? }`. See [the guide](https://github.com/Open-Craw/OpenCraw/blob/main/docs/recipes/azure-durable.md#8-hooks-that-take-a-while). Off by default. |
|
|
56
57
|
|
|
57
58
|
## Call it
|
|
58
59
|
|
package/dist/index.esm.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { app } from '@azure/functions';
|
|
2
|
-
import {
|
|
2
|
+
import { any, resultOf, eventTask, defineEvent, timerTask, activityTask, startOrchestration, callActivity, defineOrchestration, defineActivity } from '@mnci/az-durable';
|
|
3
3
|
import * as df from 'durable-functions';
|
|
4
|
-
import {
|
|
4
|
+
import { timingSafeEqual, createHmac, createHash } from 'node:crypto';
|
|
5
|
+
import { calloutResolutionSchema, memorySink, createCrawler, loadRecipes, withCalloutWaiter, createWorkInbox } from '@opencraw/core';
|
|
5
6
|
import { BlobServiceClient, BlobSASPermissions, RestError } from '@azure/storage-blob';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
@@ -9,46 +10,317 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
9
10
|
import { setTimeout } from 'node:timers/promises';
|
|
10
11
|
import { probeUrl } from '@opencraw/cli';
|
|
11
12
|
import { validateTool } from '@opencraw/mcp';
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
|
+
const SEPARATOR = '.';
|
|
17
|
+
function signature(secret, payload) {
|
|
18
|
+
return createHmac('sha256', secret).update(payload).digest('base64url');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The token a handler posts its result back with. It is the ticket and an HMAC of it, so the host holds no
|
|
22
|
+
* state for it: any instance of the Function App can check it.
|
|
23
|
+
*
|
|
24
|
+
* @param secret - The host's signing key.
|
|
25
|
+
* @param ticket - What the token lets its holder do.
|
|
26
|
+
* @returns A URL-safe token.
|
|
27
|
+
*/
|
|
28
|
+
function signCalloutTicket(secret, ticket) {
|
|
29
|
+
const payload = Buffer.from(JSON.stringify(ticket), 'utf8').toString('base64url');
|
|
30
|
+
return `${payload}${SEPARATOR}${signature(secret, payload)}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Checks a token and reads the ticket in it.
|
|
34
|
+
*
|
|
35
|
+
* @param secret - The host's signing key.
|
|
36
|
+
* @param token - What was posted to.
|
|
37
|
+
* @param now - The current time, epoch milliseconds.
|
|
38
|
+
* @returns The ticket, or why the token is not good.
|
|
39
|
+
*/
|
|
40
|
+
function readCalloutTicket(secret, token, now) {
|
|
41
|
+
const [payload, given, ...rest] = token.split(SEPARATOR);
|
|
42
|
+
if (payload === undefined || given === undefined || rest.length > 0) return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: 'malformed'
|
|
45
|
+
};
|
|
46
|
+
const expected = Buffer.from(signature(secret, payload));
|
|
47
|
+
const actual = Buffer.from(given);
|
|
48
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: 'signature'
|
|
51
|
+
};
|
|
52
|
+
let ticket;
|
|
53
|
+
try {
|
|
54
|
+
ticket = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: 'malformed'
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (typeof ticket.instanceId !== 'string' || typeof ticket.key !== 'string' || typeof ticket.index !== 'number' || typeof ticket.expiresAt !== 'number') return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: 'malformed'
|
|
64
|
+
};
|
|
65
|
+
if (ticket.expiresAt < now) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: 'expired'
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
ticket
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The name of the external event a posted-back result is raised as. The call's idempotency key is the
|
|
77
|
+
* same for the same call anywhere in the job, so the recipe's index keeps two recipes that make an
|
|
78
|
+
* identical call apart.
|
|
79
|
+
*
|
|
80
|
+
* @param index - The input recipe of the job.
|
|
81
|
+
* @param key - The call's idempotency key.
|
|
82
|
+
* @returns The event name.
|
|
83
|
+
*/
|
|
84
|
+
function calloutEventName(index, key) {
|
|
85
|
+
return `opencraw-callout:${String(index)}:${key}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Where a handler posts a result, given the URL the job was started at.
|
|
89
|
+
*
|
|
90
|
+
* `publicUrl` wins when the host sets it (behind a proxy or a custom domain the request does not show);
|
|
91
|
+
* otherwise it is the start URL without its last path segment, which is where `/crawl` and
|
|
92
|
+
* `/callouts/{token}/resolve` are siblings.
|
|
93
|
+
*
|
|
94
|
+
* @param startUrl - The URL of the `/crawl` request.
|
|
95
|
+
* @param settings - The host settings.
|
|
96
|
+
* @returns The base all callback URLs start with, without a trailing slash.
|
|
97
|
+
*/
|
|
98
|
+
function callbackBaseOf(startUrl, settings) {
|
|
99
|
+
const configured = settings.callouts?.publicUrl;
|
|
100
|
+
if (configured !== undefined) return configured.replace(/\/+$/, '');
|
|
101
|
+
const url = new URL(startUrl);
|
|
102
|
+
return `${url.origin}${url.pathname.replace(/\/[^/]*\/?$/, '')}`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* @param base - What `callbackBaseOf` returned.
|
|
106
|
+
* @param token - The signed ticket.
|
|
107
|
+
* @returns The URL a handler posts its `CalloutResolution` to.
|
|
108
|
+
*/
|
|
109
|
+
function callbackUrl(base, token) {
|
|
110
|
+
return `${base}/callouts/${token}/resolve`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param status - The HTTP status.
|
|
115
|
+
* @param body - A JSON body.
|
|
116
|
+
* @returns The response.
|
|
117
|
+
*/
|
|
118
|
+
function reply(status, body) {
|
|
119
|
+
return {
|
|
120
|
+
status,
|
|
121
|
+
jsonBody: body
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* An error the caller can act on: what went wrong, and the issues a recipe
|
|
126
|
+
* error lists (each with its JSON path).
|
|
127
|
+
*
|
|
128
|
+
* @param status - The HTTP status.
|
|
129
|
+
* @param error - What went wrong.
|
|
130
|
+
* @returns The response.
|
|
131
|
+
*/
|
|
132
|
+
function problem(status, error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
const issues = typeof error === 'object' && error !== null && 'issues' in error ? error.issues : undefined;
|
|
135
|
+
return reply(status, {
|
|
136
|
+
error: message,
|
|
137
|
+
...(issues !== undefined && {
|
|
138
|
+
issues
|
|
139
|
+
})
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The request body as JSON.
|
|
144
|
+
*
|
|
145
|
+
* @param request - The request.
|
|
146
|
+
* @returns The body, or `undefined` when it is not JSON.
|
|
147
|
+
*/
|
|
148
|
+
async function readJson(request) {
|
|
149
|
+
try {
|
|
150
|
+
return await request.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `POST /callouts/{token}/resolve`: a handler that answered `pending` posts the result of the call here.
|
|
158
|
+
* The token in the path is the credential (it is a signed ticket for that one call), so the route does
|
|
159
|
+
* not ask for a function key. The result is raised to the waiting job as an external event; a second post
|
|
160
|
+
* for a call that was already settled finds nobody waiting and is refused.
|
|
161
|
+
*
|
|
162
|
+
* @param request - The request.
|
|
163
|
+
* @param client - The Durable client.
|
|
164
|
+
* @param settings - The host settings.
|
|
165
|
+
* @param now - The current time, epoch milliseconds.
|
|
166
|
+
* @returns 202 when the result was handed to the job, or the problem.
|
|
167
|
+
*/
|
|
168
|
+
async function resolveCallout(request, client, settings, now = Date.now()) {
|
|
169
|
+
const callouts = settings.callouts;
|
|
170
|
+
if (callouts === undefined) return problem(404, 'this host does not take posted-back results');
|
|
171
|
+
const secret = process.env[callouts.signingKeyEnv];
|
|
172
|
+
if (secret === undefined || secret === '') return problem(500, 'the host has no signing key for posted-back results');
|
|
173
|
+
const reading = readCalloutTicket(secret, request.params.token ?? '', now);
|
|
174
|
+
if (!reading.ok) return problem(reading.reason === 'expired' ? 410 : 401, `the token is ${reading.reason === 'expired' ? 'expired' : 'not valid'}`);
|
|
175
|
+
const parsed = calloutResolutionSchema.safeParse(await readJson(request));
|
|
176
|
+
if (!parsed.success) return problem(400, `the body must be {"status":"ok","output":…} or {"status":"error","error":"…"}: ${parsed.error.message}`);
|
|
177
|
+
const {
|
|
178
|
+
instanceId,
|
|
179
|
+
index,
|
|
180
|
+
key
|
|
181
|
+
} = reading.ticket;
|
|
182
|
+
const status = await client.getStatus(instanceId);
|
|
183
|
+
if (status === undefined || status.runtimeStatus !== 'Running') return problem(410, 'the job this result is for is no longer waiting for it');
|
|
184
|
+
await client.raiseEvent(instanceId, calloutEventName(index, key), parsed.data);
|
|
185
|
+
return reply(202, {
|
|
186
|
+
status: 'accepted'
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A recipe is run again at most this many times for parked calls, so a hook whose key changes every run cannot loop for ever. */
|
|
191
|
+
const MAX_RESUMES = 25;
|
|
192
|
+
const DEFAULT_WAIT_MS = 60 * 60 * 1000;
|
|
16
193
|
/**
|
|
17
194
|
* The `/crawl` orchestration: one activity per input recipe, all at once;
|
|
18
195
|
* the custom status counts recipes and records as each one finishes.
|
|
19
196
|
*
|
|
197
|
+
* A recipe whose hooks answered `pending` and will post their results back stops its activity with
|
|
198
|
+
* `parked` calls. The orchestration then waits for one external event per call (and a timer, so a
|
|
199
|
+
* handler that never answers fails the recipe, not the job) and runs the recipe again with the results
|
|
200
|
+
* so far: calls that have a result return it without being made again.
|
|
201
|
+
*
|
|
20
202
|
* @param runRecipe - The activity that runs one recipe.
|
|
203
|
+
* @param options - How long to wait for posted-back results.
|
|
21
204
|
* @returns The orchestrator body.
|
|
22
205
|
*/
|
|
23
|
-
function crawlOrchestrator(runRecipe) {
|
|
206
|
+
function crawlOrchestrator(runRecipe, options = {}) {
|
|
207
|
+
const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
|
|
24
208
|
return function* crawl(context, job) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
dedupe: job.dedupe
|
|
32
|
-
})
|
|
209
|
+
const slots = job.inputs.map(() => ({
|
|
210
|
+
resolutions: {},
|
|
211
|
+
waiting: new Set(),
|
|
212
|
+
events: [],
|
|
213
|
+
resumes: 0,
|
|
214
|
+
handlers: []
|
|
33
215
|
}));
|
|
34
|
-
const
|
|
216
|
+
const owners = new Map();
|
|
217
|
+
const outcomes = new Map();
|
|
35
218
|
const progress = {
|
|
36
|
-
recipes:
|
|
219
|
+
recipes: job.inputs.length,
|
|
37
220
|
recipesDone: 0,
|
|
38
221
|
records: 0
|
|
39
222
|
};
|
|
223
|
+
let running = [];
|
|
224
|
+
let waitingRecipes = 0;
|
|
225
|
+
const start = index => {
|
|
226
|
+
const slot = slots[index];
|
|
227
|
+
const task = activityTask(context, runRecipe, {
|
|
228
|
+
allowedHosts: job.allowedHosts,
|
|
229
|
+
output: job.output,
|
|
230
|
+
input: job.inputs[index],
|
|
231
|
+
resultName: `${context.df.instanceId}/${String(index)}.jsonl`,
|
|
232
|
+
instanceId: context.df.instanceId,
|
|
233
|
+
index,
|
|
234
|
+
...(job.dedupe !== undefined && {
|
|
235
|
+
dedupe: job.dedupe
|
|
236
|
+
}),
|
|
237
|
+
...(job.callbackBase !== undefined && {
|
|
238
|
+
callbackBase: job.callbackBase
|
|
239
|
+
}),
|
|
240
|
+
...(Object.keys(slot.resolutions).length > 0 && {
|
|
241
|
+
resolutions: {
|
|
242
|
+
...slot.resolutions
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
owners.set(task, {
|
|
247
|
+
kind: 'recipe',
|
|
248
|
+
index
|
|
249
|
+
});
|
|
250
|
+
running.push(task);
|
|
251
|
+
};
|
|
252
|
+
const finish = (index, result) => {
|
|
253
|
+
outcomes.set(index, result);
|
|
254
|
+
progress.recipesDone += 1;
|
|
255
|
+
progress.records += result.reports.reduce((sum, report) => sum + report.emitted, 0);
|
|
256
|
+
context.df.setCustomStatus(progress);
|
|
257
|
+
};
|
|
258
|
+
const stopWaiting = slot => {
|
|
259
|
+
if (slot.timer !== undefined && !slot.timer.isCompleted()) slot.timer.cancel();
|
|
260
|
+
const stopped = new Set([...slot.events, ...(slot.timer === undefined ? [] : [slot.timer])]);
|
|
261
|
+
running = running.filter(task => !stopped.has(task));
|
|
262
|
+
slot.events = [];
|
|
263
|
+
slot.timer = undefined;
|
|
264
|
+
waitingRecipes -= 1;
|
|
265
|
+
progress.recipesWaiting = waitingRecipes;
|
|
266
|
+
};
|
|
40
267
|
context.df.setCustomStatus(progress);
|
|
41
|
-
let
|
|
268
|
+
for (let index = 0; index < job.inputs.length; index += 1) start(index);
|
|
42
269
|
while (running.length > 0) {
|
|
43
270
|
const winner = yield* any(context, running);
|
|
44
|
-
const result = resultOf(winner);
|
|
45
|
-
finished.set(winner, result);
|
|
46
271
|
running = running.filter(task => task !== winner);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
272
|
+
const owner = owners.get(winner);
|
|
273
|
+
if (owner === undefined) continue;
|
|
274
|
+
const slot = slots[owner.index];
|
|
275
|
+
if (owner.kind === 'recipe') {
|
|
276
|
+
const result = resultOf(winner);
|
|
277
|
+
const parked = result.parked ?? [];
|
|
278
|
+
if (parked.length === 0) {
|
|
279
|
+
finish(owner.index, result);
|
|
280
|
+
} else if (slot.resumes >= MAX_RESUMES) {
|
|
281
|
+
finish(owner.index, {
|
|
282
|
+
reports: [failedReport(job.inputs[owner.index], owner.index, `still waiting for posted-back results after ${String(MAX_RESUMES)} runs: the keys of its calls keep changing`)]
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
slot.resumes += 1;
|
|
286
|
+
slot.waiting = new Set(parked.map(call => call.idempotencyKey));
|
|
287
|
+
slot.handlers = parked.map(call => call.handler);
|
|
288
|
+
for (const call of parked) {
|
|
289
|
+
const event = eventTask(context, defineEvent(calloutEventName(owner.index, call.idempotencyKey)));
|
|
290
|
+
owners.set(event, {
|
|
291
|
+
kind: 'event',
|
|
292
|
+
index: owner.index,
|
|
293
|
+
key: call.idempotencyKey
|
|
294
|
+
});
|
|
295
|
+
slot.events.push(event);
|
|
296
|
+
running.push(event);
|
|
297
|
+
}
|
|
298
|
+
slot.timer = timerTask(context, waitMs);
|
|
299
|
+
owners.set(slot.timer, {
|
|
300
|
+
kind: 'timer',
|
|
301
|
+
index: owner.index
|
|
302
|
+
});
|
|
303
|
+
running.push(slot.timer);
|
|
304
|
+
waitingRecipes += 1;
|
|
305
|
+
progress.recipesWaiting = waitingRecipes;
|
|
306
|
+
context.df.setCustomStatus(progress);
|
|
307
|
+
}
|
|
308
|
+
} else if (owner.kind === 'event') {
|
|
309
|
+
slot.resolutions[owner.key] = resultOf(winner);
|
|
310
|
+
slot.waiting.delete(owner.key);
|
|
311
|
+
if (slot.waiting.size === 0) {
|
|
312
|
+
stopWaiting(slot);
|
|
313
|
+
context.df.setCustomStatus(progress);
|
|
314
|
+
start(owner.index);
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
stopWaiting(slot);
|
|
318
|
+
finish(owner.index, {
|
|
319
|
+
reports: [failedReport(job.inputs[owner.index], owner.index, `no result was posted back within ${String(waitMs)} ms by ${slot.handlers.join(', ')}`)]
|
|
320
|
+
});
|
|
321
|
+
}
|
|
50
322
|
}
|
|
51
|
-
const results =
|
|
323
|
+
const results = job.inputs.flatMap((_, index) => outcomes.get(index) ?? []);
|
|
52
324
|
return {
|
|
53
325
|
recipes: results.flatMap(result => result.reports),
|
|
54
326
|
records: results.flatMap(result => result.records ?? []),
|
|
@@ -56,9 +328,26 @@ function crawlOrchestrator(runRecipe) {
|
|
|
56
328
|
};
|
|
57
329
|
};
|
|
58
330
|
}
|
|
331
|
+
/** The report of a recipe the orchestration gave up on: it never finished a run, so there is nothing from the engine. */
|
|
332
|
+
function failedReport(input, index, error) {
|
|
333
|
+
const id = typeof input === 'object' && input !== null && 'id' in input && typeof input.id === 'string' ? input.id : `input-${String(index)}`;
|
|
334
|
+
return {
|
|
335
|
+
recipeId: id,
|
|
336
|
+
mode: 'api',
|
|
337
|
+
emitted: 0,
|
|
338
|
+
rejected: 0,
|
|
339
|
+
duplicates: 0,
|
|
340
|
+
skipped: 0,
|
|
341
|
+
stepsSkipped: 0,
|
|
342
|
+
pages: 0,
|
|
343
|
+
durationMs: 0,
|
|
344
|
+
error
|
|
345
|
+
};
|
|
346
|
+
}
|
|
59
347
|
|
|
60
348
|
const KIB = 1024;
|
|
61
|
-
const MINUTE_MS = 60_000;
|
|
349
|
+
const MINUTE_MS$1 = 60_000;
|
|
350
|
+
const HOUR_MS = 60 * MINUTE_MS$1;
|
|
62
351
|
/**
|
|
63
352
|
* @param options - As given.
|
|
64
353
|
* @returns The options with their defaults.
|
|
@@ -69,13 +358,15 @@ function resolveHostOptions(options) {
|
|
|
69
358
|
const authLevel = options.authLevel ?? 'function';
|
|
70
359
|
const mcp = options.mcp === undefined || options.mcp === false ? undefined : {
|
|
71
360
|
sampleRecords: 20,
|
|
72
|
-
sampleMs: MINUTE_MS,
|
|
361
|
+
sampleMs: MINUTE_MS$1,
|
|
73
362
|
...(options.mcp !== true && options.mcp)
|
|
74
363
|
};
|
|
75
364
|
if (mcp !== undefined && authLevel === 'anonymous' && options.identify === undefined) throw new Error('the MCP endpoint needs authentication: keep authLevel "function", or put the app behind App Service authentication and set identify');
|
|
365
|
+
const callouts = options.callouts === undefined ? undefined : calloutSettings(options.callouts);
|
|
76
366
|
const {
|
|
77
367
|
mcp: _mcp,
|
|
78
368
|
canPromote,
|
|
369
|
+
callouts: _callouts,
|
|
79
370
|
...rest
|
|
80
371
|
} = options;
|
|
81
372
|
return {
|
|
@@ -83,12 +374,15 @@ function resolveHostOptions(options) {
|
|
|
83
374
|
...(mcp !== undefined && {
|
|
84
375
|
mcp
|
|
85
376
|
}),
|
|
377
|
+
...(callouts !== undefined && {
|
|
378
|
+
callouts
|
|
379
|
+
}),
|
|
86
380
|
canPromote: canPromote ?? (() => false),
|
|
87
381
|
inlineLimitBytes: options.inlineLimitBytes ?? 256 * KIB,
|
|
88
382
|
routePrefix: (options.routePrefix ?? '').replaceAll(/^\/+|\/+$/g, ''),
|
|
89
383
|
authLevel,
|
|
90
384
|
pools: {
|
|
91
|
-
idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS,
|
|
385
|
+
idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS$1,
|
|
92
386
|
windows: options.pools?.windows ?? {
|
|
93
387
|
min: 1,
|
|
94
388
|
max: 4,
|
|
@@ -96,13 +390,24 @@ function resolveHostOptions(options) {
|
|
|
96
390
|
after: 5
|
|
97
391
|
},
|
|
98
392
|
idle: {
|
|
99
|
-
afterMs: MINUTE_MS
|
|
393
|
+
afterMs: MINUTE_MS$1
|
|
100
394
|
}
|
|
101
395
|
},
|
|
102
396
|
maxWindows: options.pools?.maxWindows ?? 8
|
|
103
397
|
}
|
|
104
398
|
};
|
|
105
399
|
}
|
|
400
|
+
function calloutSettings(options) {
|
|
401
|
+
const key = process.env[options.signingKeyEnv];
|
|
402
|
+
if (key === undefined || key === '') throw new Error(`callouts.signingKeyEnv names ${options.signingKeyEnv}, which is not set: it must hold the key that signs resume tokens`);
|
|
403
|
+
return {
|
|
404
|
+
signingKeyEnv: options.signingKeyEnv,
|
|
405
|
+
waitMs: options.waitMs ?? HOUR_MS,
|
|
406
|
+
...(options.publicUrl !== undefined && {
|
|
407
|
+
publicUrl: options.publicUrl
|
|
408
|
+
})
|
|
409
|
+
};
|
|
410
|
+
}
|
|
106
411
|
/**
|
|
107
412
|
* What a crawler for one caller is created with: the host's code and
|
|
108
413
|
* secrets, and the caller's hosts.
|
|
@@ -170,30 +475,45 @@ async function packRecords(records, options) {
|
|
|
170
475
|
};
|
|
171
476
|
}
|
|
172
477
|
|
|
478
|
+
const MINUTE_MS = 60_000;
|
|
173
479
|
/**
|
|
174
480
|
* The `/crawl` activity: one input recipe, on a crawler of its own, limited
|
|
175
481
|
* to the caller's hosts. A recipe that fails is reported, not thrown: the
|
|
176
482
|
* orchestration still gathers the others.
|
|
177
483
|
*
|
|
484
|
+
* When the host takes posted-back results, a hook that answers `pending` parks its call instead of
|
|
485
|
+
* being polled: the run stops and the result says which calls it is waiting for (`parked`), with no
|
|
486
|
+
* reports, because the orchestration runs the recipe again once they have arrived.
|
|
487
|
+
*
|
|
178
488
|
* @param settings - The host settings.
|
|
179
489
|
* @param task - The recipe and where its records go.
|
|
180
|
-
* @
|
|
490
|
+
* @param now - The current time, epoch milliseconds (for the token expiry).
|
|
491
|
+
* @returns Its reports (one per matrix variant) and records (or their link), or what it waits for.
|
|
181
492
|
*/
|
|
182
|
-
async function runRecipeTask(settings, task) {
|
|
493
|
+
async function runRecipeTask(settings, task, now = Date.now()) {
|
|
183
494
|
const sink = memorySink();
|
|
184
495
|
const crawler = createCrawler({
|
|
185
496
|
...crawlOptionsFor(settings, task.allowedHosts),
|
|
186
497
|
sink,
|
|
187
498
|
dedupe: task.dedupe ?? 'recipe'
|
|
188
499
|
});
|
|
500
|
+
const parked = [];
|
|
501
|
+
const waiter = calloutWaiter(settings, task, parked, now);
|
|
189
502
|
let reports;
|
|
190
503
|
try {
|
|
191
504
|
const set = await loadRecipes([task.output, task.input]);
|
|
192
|
-
const
|
|
193
|
-
|
|
505
|
+
const run = async () => {
|
|
506
|
+
const result = await crawler.run(set);
|
|
507
|
+
return result.recipes;
|
|
508
|
+
};
|
|
509
|
+
reports = waiter === undefined ? await run() : await withCalloutWaiter(waiter, run);
|
|
194
510
|
} finally {
|
|
195
511
|
await crawler.close();
|
|
196
512
|
}
|
|
513
|
+
if (parked.length > 0) return {
|
|
514
|
+
reports: [],
|
|
515
|
+
parked
|
|
516
|
+
};
|
|
197
517
|
const packed = await packRecords(sink.records, {
|
|
198
518
|
store: settings.results,
|
|
199
519
|
inlineLimitBytes: settings.inlineLimitBytes,
|
|
@@ -204,6 +524,26 @@ async function runRecipeTask(settings, task) {
|
|
|
204
524
|
...packed
|
|
205
525
|
};
|
|
206
526
|
}
|
|
527
|
+
/** The waiter of a run, when the host takes posted-back results and the job knows where they go. */
|
|
528
|
+
function calloutWaiter(settings, task, parked, now) {
|
|
529
|
+
const callouts = settings.callouts;
|
|
530
|
+
if (callouts === undefined || task.callbackBase === undefined) return undefined;
|
|
531
|
+
const secret = process.env[callouts.signingKeyEnv] ?? '';
|
|
532
|
+
const base = task.callbackBase;
|
|
533
|
+
const expiresAt = now + callouts.waitMs + MINUTE_MS;
|
|
534
|
+
return {
|
|
535
|
+
resolutions: task.resolutions ?? {},
|
|
536
|
+
callbackFor: key => ({
|
|
537
|
+
url: callbackUrl(base, signCalloutTicket(secret, {
|
|
538
|
+
instanceId: task.instanceId,
|
|
539
|
+
index: task.index,
|
|
540
|
+
key,
|
|
541
|
+
expiresAt
|
|
542
|
+
}))
|
|
543
|
+
}),
|
|
544
|
+
parked
|
|
545
|
+
};
|
|
546
|
+
}
|
|
207
547
|
|
|
208
548
|
/**
|
|
209
549
|
* Resolves the caller of a request and the hosts its recipes may reach.
|
|
@@ -234,49 +574,6 @@ function poolKey(access, crawlId) {
|
|
|
234
574
|
return `${access.caller ?? ''}\u{0}${crawlId}`;
|
|
235
575
|
}
|
|
236
576
|
|
|
237
|
-
/**
|
|
238
|
-
* @param status - The HTTP status.
|
|
239
|
-
* @param body - A JSON body.
|
|
240
|
-
* @returns The response.
|
|
241
|
-
*/
|
|
242
|
-
function reply(status, body) {
|
|
243
|
-
return {
|
|
244
|
-
status,
|
|
245
|
-
jsonBody: body
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* An error the caller can act on: what went wrong, and the issues a recipe
|
|
250
|
-
* error lists (each with its JSON path).
|
|
251
|
-
*
|
|
252
|
-
* @param status - The HTTP status.
|
|
253
|
-
* @param error - What went wrong.
|
|
254
|
-
* @returns The response.
|
|
255
|
-
*/
|
|
256
|
-
function problem(status, error) {
|
|
257
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
-
const issues = typeof error === 'object' && error !== null && 'issues' in error ? error.issues : undefined;
|
|
259
|
-
return reply(status, {
|
|
260
|
-
error: message,
|
|
261
|
-
...(issues !== undefined && {
|
|
262
|
-
issues
|
|
263
|
-
})
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* The request body as JSON.
|
|
268
|
-
*
|
|
269
|
-
* @param request - The request.
|
|
270
|
-
* @returns The body, or `undefined` when it is not JSON.
|
|
271
|
-
*/
|
|
272
|
-
async function readJson(request) {
|
|
273
|
-
try {
|
|
274
|
-
return await request.json();
|
|
275
|
-
} catch {
|
|
276
|
-
return undefined;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
577
|
/** A version that does not exist, or a write to one that does. */
|
|
281
578
|
class RecipeStoreError extends Error {
|
|
282
579
|
status;
|
|
@@ -502,6 +799,9 @@ async function startCrawl(request, client, settings, orchestration) {
|
|
|
502
799
|
inputs: recipes.filter(recipe => recipe !== output),
|
|
503
800
|
...(parsed.data.options?.dedupe !== undefined && {
|
|
504
801
|
dedupe: parsed.data.options.dedupe
|
|
802
|
+
}),
|
|
803
|
+
...(settings.callouts !== undefined && {
|
|
804
|
+
callbackBase: callbackBaseOf(request.url, settings)
|
|
505
805
|
})
|
|
506
806
|
};
|
|
507
807
|
const instanceId = await startOrchestration(client, orchestration, job);
|
|
@@ -516,7 +816,8 @@ const crawlJobSchema = z.object({
|
|
|
516
816
|
allowedHosts: z.array(z.string()).min(1),
|
|
517
817
|
output: z.unknown(),
|
|
518
818
|
inputs: z.array(z.unknown()).min(1),
|
|
519
|
-
dedupe: z.enum(['recipe', 'off']).optional()
|
|
819
|
+
dedupe: z.enum(['recipe', 'off']).optional(),
|
|
820
|
+
callbackBase: z.string().optional()
|
|
520
821
|
});
|
|
521
822
|
/**
|
|
522
823
|
* Checks a `/crawl` orchestration's input as it comes back out of the task
|
|
@@ -1369,7 +1670,8 @@ function hostJsonWarnings(maxWindows, directory = process.cwd()) {
|
|
|
1369
1670
|
* model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
|
|
1370
1671
|
* sends one item to the caller's warm pool, `GET` and `DELETE
|
|
1371
1672
|
* /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
|
|
1372
|
-
* makes a draft runnable,
|
|
1673
|
+
* makes a draft runnable, with `callouts` set `POST /callouts/{token}/resolve`
|
|
1674
|
+
* takes the results of hooks that answered `pending`, and with `mcp` on, `/mcp` serves the authoring
|
|
1373
1675
|
* tools. Call it once, from the app's entry module; nothing is registered by
|
|
1374
1676
|
* importing the package.
|
|
1375
1677
|
*
|
|
@@ -1383,7 +1685,9 @@ function registerOpenCraw(options) {
|
|
|
1383
1685
|
for (const warning of hostJsonWarnings(settings.pools.maxWindows)) console.warn(`[opencraw] ${warning}`);
|
|
1384
1686
|
const runRecipe = async task => await runRecipeTask(settings, task);
|
|
1385
1687
|
const runItem = async job => await pools.run(job);
|
|
1386
|
-
const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe)
|
|
1688
|
+
const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe), {
|
|
1689
|
+
waitMs: settings.callouts?.waitMs
|
|
1690
|
+
}), {
|
|
1387
1691
|
parse: parseCrawlJob
|
|
1388
1692
|
});
|
|
1389
1693
|
const item = defineOrchestration('OpenCrawItem', itemOrchestrator(defineActivity('OpenCrawRunItem', runItem)), {
|
|
@@ -1391,6 +1695,7 @@ function registerOpenCraw(options) {
|
|
|
1391
1695
|
});
|
|
1392
1696
|
const http = {
|
|
1393
1697
|
startCrawl: async (request, client) => await startCrawl(request, client, settings, crawl),
|
|
1698
|
+
resolveCallout: async (request, client) => await resolveCallout(request, client, settings),
|
|
1394
1699
|
submitItem: async (request, client) => await submitItem(request, client, settings, pools, item),
|
|
1395
1700
|
job: async request => await jobRequest(request, settings, pools),
|
|
1396
1701
|
mcp: async (request, client) => await mcpEndpoint(request, client, settings, crawl),
|
|
@@ -1421,6 +1726,13 @@ function registerOpenCraw(options) {
|
|
|
1421
1726
|
authLevel: settings.authLevel,
|
|
1422
1727
|
handler: http.promote
|
|
1423
1728
|
});
|
|
1729
|
+
// The token in the path is the credential: the services that post results back hold no function key.
|
|
1730
|
+
if (settings.callouts !== undefined) df.app.client.http('OpenCrawResolveCallout', {
|
|
1731
|
+
route: route('callouts/{token}/resolve'),
|
|
1732
|
+
methods: ['POST'],
|
|
1733
|
+
authLevel: 'anonymous',
|
|
1734
|
+
handler: http.resolveCallout
|
|
1735
|
+
});
|
|
1424
1736
|
if (settings.mcp !== undefined) df.app.client.http('OpenCrawMcp', {
|
|
1425
1737
|
route: route('mcp'),
|
|
1426
1738
|
methods: ['GET', 'POST', 'DELETE'],
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HostSettings } from '../host-options/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The name of the external event a posted-back result is raised as. The call's idempotency key is the
|
|
4
|
+
* same for the same call anywhere in the job, so the recipe's index keeps two recipes that make an
|
|
5
|
+
* identical call apart.
|
|
6
|
+
*
|
|
7
|
+
* @param index - The input recipe of the job.
|
|
8
|
+
* @param key - The call's idempotency key.
|
|
9
|
+
* @returns The event name.
|
|
10
|
+
*/
|
|
11
|
+
export declare function calloutEventName(index: number, key: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Where a handler posts a result, given the URL the job was started at.
|
|
14
|
+
*
|
|
15
|
+
* `publicUrl` wins when the host sets it (behind a proxy or a custom domain the request does not show);
|
|
16
|
+
* otherwise it is the start URL without its last path segment, which is where `/crawl` and
|
|
17
|
+
* `/callouts/{token}/resolve` are siblings.
|
|
18
|
+
*
|
|
19
|
+
* @param startUrl - The URL of the `/crawl` request.
|
|
20
|
+
* @param settings - The host settings.
|
|
21
|
+
* @returns The base all callback URLs start with, without a trailing slash.
|
|
22
|
+
*/
|
|
23
|
+
export declare function callbackBaseOf(startUrl: string, settings: Pick<HostSettings, 'callouts'>): string;
|
|
24
|
+
/**
|
|
25
|
+
* @param base - What `callbackBaseOf` returned.
|
|
26
|
+
* @param token - The signed ticket.
|
|
27
|
+
* @returns The URL a handler posts its `CalloutResolution` to.
|
|
28
|
+
*/
|
|
29
|
+
export declare function callbackUrl(base: string, token: string): string;
|
|
30
|
+
//# sourceMappingURL=callout-event.mapper.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** What a resume token says: which call of which recipe of which job it may settle, and until when. */
|
|
2
|
+
export interface CalloutTicket {
|
|
3
|
+
instanceId: string;
|
|
4
|
+
/** The input recipe of the job the call belongs to. */
|
|
5
|
+
index: number;
|
|
6
|
+
/** The call's idempotency key. */
|
|
7
|
+
key: string;
|
|
8
|
+
/** Epoch milliseconds. */
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
export type TicketReading = {
|
|
12
|
+
ok: true;
|
|
13
|
+
ticket: CalloutTicket;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: 'malformed' | 'signature' | 'expired';
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The token a handler posts its result back with. It is the ticket and an HMAC of it, so the host holds no
|
|
20
|
+
* state for it: any instance of the Function App can check it.
|
|
21
|
+
*
|
|
22
|
+
* @param secret - The host's signing key.
|
|
23
|
+
* @param ticket - What the token lets its holder do.
|
|
24
|
+
* @returns A URL-safe token.
|
|
25
|
+
*/
|
|
26
|
+
export declare function signCalloutTicket(secret: string, ticket: CalloutTicket): string;
|
|
27
|
+
/**
|
|
28
|
+
* Checks a token and reads the ticket in it.
|
|
29
|
+
*
|
|
30
|
+
* @param secret - The host's signing key.
|
|
31
|
+
* @param token - What was posted to.
|
|
32
|
+
* @param now - The current time, epoch milliseconds.
|
|
33
|
+
* @returns The ticket, or why the token is not good.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readCalloutTicket(secret: string, token: string, now: number): TicketReading;
|
|
36
|
+
//# sourceMappingURL=callout-ticket.algorithm.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { signCalloutTicket, readCalloutTicket } from './callout-ticket.algorithm.js';
|
|
2
|
+
export type { CalloutTicket, TicketReading } from './callout-ticket.algorithm.js';
|
|
3
|
+
export { calloutEventName, callbackBaseOf, callbackUrl } from './callout-event.mapper.js';
|
|
4
|
+
export { resolveCallout } from './resolve-callout.handler.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HttpRequest, HttpResponseInit } from '@azure/functions';
|
|
2
|
+
import type { DurableClient } from 'durable-functions';
|
|
3
|
+
import type { HostSettings } from '../host-options/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* `POST /callouts/{token}/resolve`: a handler that answered `pending` posts the result of the call here.
|
|
6
|
+
* The token in the path is the credential (it is a signed ticket for that one call), so the route does
|
|
7
|
+
* not ask for a function key. The result is raised to the waiting job as an external event; a second post
|
|
8
|
+
* for a call that was already settled finds nobody waiting and is refused.
|
|
9
|
+
*
|
|
10
|
+
* @param request - The request.
|
|
11
|
+
* @param client - The Durable client.
|
|
12
|
+
* @param settings - The host settings.
|
|
13
|
+
* @param now - The current time, epoch milliseconds.
|
|
14
|
+
* @returns 202 when the result was handed to the job, or the problem.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveCallout(request: HttpRequest, client: DurableClient, settings: HostSettings, now?: number): Promise<HttpResponseInit>;
|
|
17
|
+
//# sourceMappingURL=resolve-callout.handler.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OutputRecord, RecipeReport } from '@opencraw/core';
|
|
1
|
+
import type { CalloutResolution, OutputRecord, ParkedCallout, RecipeReport } from '@opencraw/core';
|
|
2
2
|
/** What a `/crawl` orchestration runs: written by the starter, never by the caller. */
|
|
3
3
|
export interface CrawlJob {
|
|
4
4
|
caller?: string;
|
|
@@ -6,6 +6,8 @@ export interface CrawlJob {
|
|
|
6
6
|
output: unknown;
|
|
7
7
|
inputs: unknown[];
|
|
8
8
|
dedupe?: 'recipe' | 'off';
|
|
9
|
+
/** Where handlers post results back (`https://app/api`): set by the starter when the host takes posted-back results. */
|
|
10
|
+
callbackBase?: string;
|
|
9
11
|
}
|
|
10
12
|
/** One input recipe of a job, run by one activity. */
|
|
11
13
|
export interface RecipeTask {
|
|
@@ -15,12 +17,20 @@ export interface RecipeTask {
|
|
|
15
17
|
dedupe?: 'recipe' | 'off';
|
|
16
18
|
/** Where its records go when too large to return: `<instance>/<index>.jsonl`. */
|
|
17
19
|
resultName: string;
|
|
20
|
+
/** The job's instance and this recipe's index in it: what a resume token names. */
|
|
21
|
+
instanceId: string;
|
|
22
|
+
index: number;
|
|
23
|
+
callbackBase?: string;
|
|
24
|
+
/** Results of calls that parked on an earlier run of this recipe, by idempotency key. */
|
|
25
|
+
resolutions?: Record<string, CalloutResolution>;
|
|
18
26
|
}
|
|
19
27
|
export interface RecipeTaskResult {
|
|
20
28
|
/** One per run: a recipe with a `matrix` runs once per variant. */
|
|
21
29
|
reports: RecipeReport[];
|
|
22
30
|
records?: OutputRecord[];
|
|
23
31
|
resultUrl?: string;
|
|
32
|
+
/** Set when calls are waiting for their results to be posted back: the run stopped, and this is what it waits for. */
|
|
33
|
+
parked?: ParkedCallout[];
|
|
24
34
|
}
|
|
25
35
|
/** What a `/crawl` orchestration returns. */
|
|
26
36
|
export interface CrawlResult {
|
|
@@ -36,5 +46,7 @@ export interface CrawlProgress {
|
|
|
36
46
|
recipes: number;
|
|
37
47
|
recipesDone: number;
|
|
38
48
|
records: number;
|
|
49
|
+
/** Recipes waiting for a posted-back result. Present only when the host takes them. */
|
|
50
|
+
recipesWaiting?: number;
|
|
39
51
|
}
|
|
40
52
|
//# sourceMappingURL=crawl-job.model.d.ts.map
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import type { TypedActivity } from '@mnci/az-durable';
|
|
2
2
|
import type { OrchestrationContext, Task } from 'durable-functions';
|
|
3
3
|
import type { CrawlJob, CrawlResult, RecipeTask, RecipeTaskResult } from './crawl-job.model.js';
|
|
4
|
+
export interface CrawlOrchestratorOptions {
|
|
5
|
+
/** How long a recipe waits for the results of parked calls before it fails. Only used when a call parks. */
|
|
6
|
+
waitMs?: number;
|
|
7
|
+
}
|
|
4
8
|
/**
|
|
5
9
|
* The `/crawl` orchestration: one activity per input recipe, all at once;
|
|
6
10
|
* the custom status counts recipes and records as each one finishes.
|
|
7
11
|
*
|
|
12
|
+
* A recipe whose hooks answered `pending` and will post their results back stops its activity with
|
|
13
|
+
* `parked` calls. The orchestration then waits for one external event per call (and a timer, so a
|
|
14
|
+
* handler that never answers fails the recipe, not the job) and runs the recipe again with the results
|
|
15
|
+
* so far: calls that have a result return it without being made again.
|
|
16
|
+
*
|
|
8
17
|
* @param runRecipe - The activity that runs one recipe.
|
|
18
|
+
* @param options - How long to wait for posted-back results.
|
|
9
19
|
* @returns The orchestrator body.
|
|
10
20
|
*/
|
|
11
|
-
export declare function crawlOrchestrator(runRecipe: TypedActivity<RecipeTask, RecipeTaskResult
|
|
21
|
+
export declare function crawlOrchestrator(runRecipe: TypedActivity<RecipeTask, RecipeTaskResult>, options?: CrawlOrchestratorOptions): (context: OrchestrationContext, job: CrawlJob) => Generator<Task, CrawlResult, unknown>;
|
|
12
22
|
//# sourceMappingURL=crawl-orchestration.use-case.d.ts.map
|
|
@@ -5,9 +5,14 @@ import type { RecipeTask, RecipeTaskResult } from './crawl-job.model.js';
|
|
|
5
5
|
* to the caller's hosts. A recipe that fails is reported, not thrown: the
|
|
6
6
|
* orchestration still gathers the others.
|
|
7
7
|
*
|
|
8
|
+
* When the host takes posted-back results, a hook that answers `pending` parks its call instead of
|
|
9
|
+
* being polled: the run stops and the result says which calls it is waiting for (`parked`), with no
|
|
10
|
+
* reports, because the orchestration runs the recipe again once they have arrived.
|
|
11
|
+
*
|
|
8
12
|
* @param settings - The host settings.
|
|
9
13
|
* @param task - The recipe and where its records go.
|
|
10
|
-
* @
|
|
14
|
+
* @param now - The current time, epoch milliseconds (for the token expiry).
|
|
15
|
+
* @returns Its reports (one per matrix variant) and records (or their link), or what it waits for.
|
|
11
16
|
*/
|
|
12
|
-
export declare function runRecipeTask(settings: HostSettings, task: RecipeTask): Promise<RecipeTaskResult>;
|
|
17
|
+
export declare function runRecipeTask(settings: HostSettings, task: RecipeTask, now?: number): Promise<RecipeTaskResult>;
|
|
13
18
|
//# sourceMappingURL=run-recipe.use-case.d.ts.map
|
|
@@ -42,6 +42,19 @@ export interface OpenCrawHostOptions {
|
|
|
42
42
|
authLevel?: 'anonymous' | 'function' | 'admin';
|
|
43
43
|
/** Prepended to every route: `'opencraw'` gives `/api/opencraw/crawl`. Default none. */
|
|
44
44
|
routePrefix?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Lets a hook that answers `pending` post its result back (`POST /callouts/{token}/resolve`) instead of
|
|
47
|
+
* being asked again. Off by default: a pending hook is then polled inside the activity.
|
|
48
|
+
*/
|
|
49
|
+
callouts?: CalloutSettings;
|
|
50
|
+
}
|
|
51
|
+
export interface CalloutSettings {
|
|
52
|
+
/** The name of the environment variable holding the key that signs resume tokens. A secret: never in code or a recipe. */
|
|
53
|
+
signingKeyEnv: string;
|
|
54
|
+
/** The base URL handlers post to (`https://app.example.com/api`), when the request URL is not it (a proxy, a custom domain). Default: derived from the request that started the job. */
|
|
55
|
+
publicUrl?: string;
|
|
56
|
+
/** How long a job waits for a posted-back result before the recipe fails. Default 1 hour. */
|
|
57
|
+
waitMs?: number;
|
|
45
58
|
}
|
|
46
59
|
export interface McpSettings {
|
|
47
60
|
/** A sample run (`run` without `full`) stops after this many records per input recipe. Default 20. */
|
|
@@ -58,7 +71,7 @@ export interface PoolSettings {
|
|
|
58
71
|
maxWindows?: number;
|
|
59
72
|
}
|
|
60
73
|
/** The options with their defaults. */
|
|
61
|
-
export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlineLimitBytes' | 'routePrefix' | 'authLevel' | 'mcp' | 'canPromote'> {
|
|
74
|
+
export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlineLimitBytes' | 'routePrefix' | 'authLevel' | 'mcp' | 'canPromote' | 'callouts'> {
|
|
62
75
|
inlineLimitBytes: number;
|
|
63
76
|
routePrefix: string;
|
|
64
77
|
authLevel: 'anonymous' | 'function' | 'admin';
|
|
@@ -66,6 +79,8 @@ export interface HostSettings extends Omit<OpenCrawHostOptions, 'pools' | 'inlin
|
|
|
66
79
|
/** `undefined` when the endpoint is off. */
|
|
67
80
|
mcp?: Required<McpSettings>;
|
|
68
81
|
canPromote: (caller: string | undefined) => boolean;
|
|
82
|
+
/** `undefined` when posted-back results are off. */
|
|
83
|
+
callouts?: Required<Pick<CalloutSettings, 'signingKeyEnv' | 'waitMs'>> & Pick<CalloutSettings, 'publicUrl'>;
|
|
69
84
|
}
|
|
70
85
|
/**
|
|
71
86
|
* @param options - As given.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { resolveHostOptions, crawlOptionsFor } from './host-options.config.js';
|
|
2
|
-
export type { OpenCrawHostOptions, PoolSettings, McpSettings, HostSettings } from './host-options.config.js';
|
|
2
|
+
export type { OpenCrawHostOptions, PoolSettings, McpSettings, CalloutSettings, HostSettings } from './host-options.config.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { registerOpenCraw, hostJsonWarnings } from './open-craw-host/index.js';
|
|
2
2
|
export type { OpenCrawHost } from './open-craw-host/index.js';
|
|
3
|
-
export type { OpenCrawHostOptions, PoolSettings, McpSettings, HostSettings } from './host-options/index.js';
|
|
3
|
+
export type { OpenCrawHostOptions, PoolSettings, McpSettings, CalloutSettings, HostSettings } from './host-options/index.js';
|
|
4
4
|
export { memoryRecipes, blobRecipes, RecipeStoreError } from './recipe-store/index.js';
|
|
5
5
|
export type { RecipeStore, StoredRecipes, BlobRecipesOptions } from './recipe-store/index.js';
|
|
6
6
|
export { blobResults } from './result-store/index.js';
|
|
@@ -19,6 +19,8 @@ export interface OpenCrawHost {
|
|
|
19
19
|
};
|
|
20
20
|
http: {
|
|
21
21
|
startCrawl: (request: HttpRequest, client: DurableClient) => Promise<HttpResponse | HttpResponseInit>;
|
|
22
|
+
/** `POST /callouts/{token}/resolve`; registered only when `callouts` is set. */
|
|
23
|
+
resolveCallout: (request: HttpRequest, client: DurableClient) => Promise<HttpResponseInit>;
|
|
22
24
|
submitItem: (request: HttpRequest, client: DurableClient) => Promise<HttpResponse | HttpResponseInit>;
|
|
23
25
|
job: (request: HttpRequest) => Promise<HttpResponseInit>;
|
|
24
26
|
/** The authoring MCP endpoint; registered only when `mcp` is on. */
|
|
@@ -33,7 +35,8 @@ export interface OpenCrawHost {
|
|
|
33
35
|
* model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
|
|
34
36
|
* sends one item to the caller's warm pool, `GET` and `DELETE
|
|
35
37
|
* /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
|
|
36
|
-
* makes a draft runnable,
|
|
38
|
+
* makes a draft runnable, with `callouts` set `POST /callouts/{token}/resolve`
|
|
39
|
+
* takes the results of hooks that answered `pending`, and with `mcp` on, `/mcp` serves the authoring
|
|
37
40
|
* tools. Call it once, from the app's entry module; nothing is registered by
|
|
38
41
|
* importing the package.
|
|
39
42
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencraw/azure-durable",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.esm.js",
|
|
6
6
|
"module": "./dist/index.esm.js",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"@azure/storage-blob": "^12.34.0",
|
|
25
25
|
"@mnci/az-durable": "^0.1.12",
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
27
|
-
"@opencraw/cli": "^0.1.
|
|
28
|
-
"@opencraw/core": "^0.1.
|
|
29
|
-
"@opencraw/mcp": "^0.1.
|
|
27
|
+
"@opencraw/cli": "^0.1.32",
|
|
28
|
+
"@opencraw/core": "^0.1.31",
|
|
29
|
+
"@opencraw/mcp": "^0.1.32",
|
|
30
30
|
"durable-functions": ">=3.0.0 <4",
|
|
31
31
|
"zod": "^4.6.5"
|
|
32
32
|
},
|