@opencraw/azure-durable 0.0.47 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/caller-access/caller-access.policy.d.ts +28 -0
  2. package/dist/caller-access/index.d.ts +3 -0
  3. package/dist/callout-resume/callout-event.mapper.d.ts +30 -0
  4. package/dist/callout-resume/callout-ticket.algorithm.d.ts +36 -0
  5. package/dist/callout-resume/index.d.ts +5 -0
  6. package/dist/callout-resume/resolve-callout.handler.d.ts +17 -0
  7. package/dist/crawl-run/crawl-job.model.d.ts +52 -0
  8. package/dist/crawl-run/crawl-job.validator.d.ts +11 -0
  9. package/dist/crawl-run/crawl-orchestration.use-case.d.ts +22 -0
  10. package/dist/crawl-run/crawl-request.validator.d.ts +25 -0
  11. package/dist/crawl-run/index.d.ts +8 -0
  12. package/dist/crawl-run/run-recipe.use-case.d.ts +18 -0
  13. package/dist/crawl-run/start-crawl.handler.d.ts +18 -0
  14. package/dist/host-options/host-options.config.d.ts +100 -0
  15. package/dist/host-options/index.d.ts +3 -0
  16. package/dist/http-reply/http-reply.mapper.d.ts +24 -0
  17. package/dist/http-reply/index.d.ts +2 -0
  18. package/dist/index.d.ts +10 -1
  19. package/dist/index.esm.js +428 -1157
  20. package/dist/open-craw-host/host-json.validator.d.ts +11 -0
  21. package/dist/open-craw-host/index.d.ts +4 -0
  22. package/dist/open-craw-host/register-open-craw.use-case.d.ts +48 -0
  23. package/dist/recipe-authoring/authoring-server.use-case.d.ts +13 -0
  24. package/dist/recipe-authoring/authoring-tools.use-case.d.ts +96 -0
  25. package/dist/recipe-authoring/index.d.ts +5 -0
  26. package/dist/recipe-authoring/mcp-endpoint.handler.d.ts +18 -0
  27. package/dist/recipe-authoring/promote.handler.d.ts +13 -0
  28. package/dist/recipe-store/blob-recipe.store.d.ts +20 -0
  29. package/dist/recipe-store/find-recipes.use-case.d.ts +13 -0
  30. package/dist/recipe-store/index.d.ts +7 -0
  31. package/dist/recipe-store/memory-recipe.store.d.ts +12 -0
  32. package/dist/recipe-store/recipe-store.contract.d.ts +28 -0
  33. package/dist/recipe-store/recipe-store.error.d.ts +6 -0
  34. package/dist/result-store/blob-result.store.d.ts +18 -0
  35. package/dist/result-store/index.d.ts +6 -0
  36. package/dist/result-store/result-packing.policy.d.ts +22 -0
  37. package/dist/result-store/result-store.contract.d.ts +13 -0
  38. package/dist/src/crawl-run/crawl-request.validator.d.ts +2 -2
  39. package/dist/src/worker-pools/item-request.validator.d.ts +1 -1
  40. package/dist/worker-pools/index.d.ts +10 -0
  41. package/dist/worker-pools/item-job.validator.d.ts +10 -0
  42. package/dist/worker-pools/item-orchestration.use-case.d.ts +14 -0
  43. package/dist/worker-pools/item-request.validator.d.ts +35 -0
  44. package/dist/worker-pools/job.handler.d.ts +14 -0
  45. package/dist/worker-pools/pool-job.model.d.ts +43 -0
  46. package/dist/worker-pools/submit-item.handler.d.ts +20 -0
  47. package/dist/worker-pools/windows-cap.policy.d.ts +12 -0
  48. package/dist/worker-pools/worker-pools.store.d.ts +58 -0
  49. package/package.json +10 -38
package/dist/index.esm.js CHANGED
@@ -1,228 +1,100 @@
1
- import { app } from '@azure/functions';
2
- import { any, resultOf, eventTask, defineEvent, timerTask, activityTask, startOrchestration, callActivity, defineOrchestration, defineActivity } from '@mnci/az-durable';
3
- import * as df from 'durable-functions';
4
- import { timingSafeEqual, createHmac, createHash } from 'node:crypto';
5
- import { calloutResolutionSchema, memorySink, createCrawler, loadRecipes, withCalloutWaiter, createWorkInbox } from '@opencraw/core';
6
- import { BlobServiceClient, BlobSASPermissions, RestError } from '@azure/storage-blob';
7
- import { z } from 'zod';
8
- import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
9
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
- import { setTimeout } from 'node:timers/promises';
11
- import { probeUrl } from '@opencraw/cli';
12
- import { validateTool } from '@opencraw/mcp';
13
- import { readFileSync } from 'node:fs';
14
- import { join } from 'node:path';
1
+ // src/open-craw-host/register-open-craw.use-case.ts
2
+ import { app } from "@azure/functions";
3
+ import { defineActivity, defineOrchestration } from "@mnci/az-durable";
4
+ import * as df from "durable-functions";
15
5
 
16
- const SEPARATOR = '.';
6
+ // src/callout-resume/callout-ticket.algorithm.ts
7
+ import { createHmac, timingSafeEqual } from "node:crypto";
8
+ var SEPARATOR = ".";
17
9
  function signature(secret, payload) {
18
- return createHmac('sha256', secret).update(payload).digest('base64url');
10
+ return createHmac("sha256", secret).update(payload).digest("base64url");
19
11
  }
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
12
  function signCalloutTicket(secret, ticket) {
29
- const payload = Buffer.from(JSON.stringify(ticket), 'utf8').toString('base64url');
13
+ const payload = Buffer.from(JSON.stringify(ticket), "utf8").toString("base64url");
30
14
  return `${payload}${SEPARATOR}${signature(secret, payload)}`;
31
15
  }
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
16
  function readCalloutTicket(secret, token, now) {
41
17
  const [payload, given, ...rest] = token.split(SEPARATOR);
42
- if (payload === undefined || given === undefined || rest.length > 0) return {
43
- ok: false,
44
- reason: 'malformed'
45
- };
18
+ if (payload === void 0 || given === void 0 || rest.length > 0) return { ok: false, reason: "malformed" };
46
19
  const expected = Buffer.from(signature(secret, payload));
47
20
  const actual = Buffer.from(given);
48
- if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return {
49
- ok: false,
50
- reason: 'signature'
51
- };
21
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return { ok: false, reason: "signature" };
52
22
  let ticket;
53
23
  try {
54
- ticket = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
24
+ ticket = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
55
25
  } catch {
56
- return {
57
- ok: false,
58
- reason: 'malformed'
59
- };
26
+ return { ok: false, reason: "malformed" };
60
27
  }
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
- };
28
+ if (typeof ticket.instanceId !== "string" || typeof ticket.key !== "string" || typeof ticket.index !== "number" || typeof ticket.expiresAt !== "number") return { ok: false, reason: "malformed" };
29
+ if (ticket.expiresAt < now) return { ok: false, reason: "expired" };
30
+ return { ok: true, ticket };
73
31
  }
74
32
 
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
- */
33
+ // src/callout-resume/callout-event.mapper.ts
84
34
  function calloutEventName(index, key) {
85
35
  return `opencraw-callout:${String(index)}:${key}`;
86
36
  }
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
37
  function callbackBaseOf(startUrl, settings) {
99
38
  const configured = settings.callouts?.publicUrl;
100
- if (configured !== undefined) return configured.replace(/\/+$/, '');
39
+ if (configured !== void 0) return configured.replace(/\/+$/, "");
101
40
  const url = new URL(startUrl);
102
- return `${url.origin}${url.pathname.replace(/\/[^/]*\/?$/, '')}`;
41
+ return `${url.origin}${url.pathname.replace(/\/[^/]*\/?$/, "")}`;
103
42
  }
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
43
  function callbackUrl(base, token) {
110
44
  return `${base}/callouts/${token}/resolve`;
111
45
  }
112
46
 
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
- };
47
+ // src/callout-resume/resolve-callout.handler.ts
48
+ import { calloutResolutionSchema } from "@opencraw/core";
49
+
50
+ // src/http-reply/http-reply.mapper.ts
51
+ function reply(status2, body) {
52
+ return { status: status2, jsonBody: body };
123
53
  }
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) {
54
+ function problem(status2, error) {
133
55
  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
- });
56
+ const issues = typeof error === "object" && error !== null && "issues" in error ? error.issues : void 0;
57
+ return reply(status2, { error: message, ...issues !== void 0 && { issues } });
141
58
  }
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
59
  async function readJson(request) {
149
60
  try {
150
61
  return await request.json();
151
62
  } catch {
152
- return undefined;
63
+ return void 0;
153
64
  }
154
65
  }
155
66
 
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
- */
67
+ // src/callout-resume/resolve-callout.handler.ts
168
68
  async function resolveCallout(request, client, settings, now = Date.now()) {
169
69
  const callouts = settings.callouts;
170
- if (callouts === undefined) return problem(404, 'this host does not take posted-back results');
70
+ if (callouts === void 0) return problem(404, "this host does not take posted-back results");
171
71
  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'}`);
72
+ if (secret === void 0 || secret === "") return problem(500, "the host has no signing key for posted-back results");
73
+ const reading = readCalloutTicket(secret, request.params.token ?? "", now);
74
+ if (!reading.ok) return problem(reading.reason === "expired" ? 410 : 401, `the token is ${reading.reason === "expired" ? "expired" : "not valid"}`);
175
75
  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');
76
+ if (!parsed.success) return problem(400, `the body must be {"status":"ok","output":\u2026} or {"status":"error","error":"\u2026"}: ${parsed.error.message}`);
77
+ const { instanceId, index, key } = reading.ticket;
78
+ const status2 = await client.getStatus(instanceId);
79
+ if (status2 === void 0 || status2.runtimeStatus !== "Running") return problem(410, "the job this result is for is no longer waiting for it");
184
80
  await client.raiseEvent(instanceId, calloutEventName(index, key), parsed.data);
185
- return reply(202, {
186
- status: 'accepted'
187
- });
81
+ return reply(202, { status: "accepted" });
188
82
  }
189
83
 
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;
193
- /**
194
- * The `/crawl` orchestration: one activity per input recipe, all at once;
195
- * the custom status counts recipes and records as each one finishes.
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
- *
202
- * @param runRecipe - The activity that runs one recipe.
203
- * @param options - How long to wait for posted-back results.
204
- * @returns The orchestrator body.
205
- */
206
- function crawlOrchestrator(runRecipe, options = {}) {
207
- const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
84
+ // src/crawl-run/crawl-orchestration.use-case.ts
85
+ import { activityTask, any, defineEvent, eventTask, resultOf, timerTask } from "@mnci/az-durable";
86
+ var MAX_RESUMES = 25;
87
+ var DEFAULT_WAIT_MS = 60 * 60 * 1e3;
88
+ function crawlOrchestrator(runRecipe, options2 = {}) {
89
+ const waitMs = options2.waitMs ?? DEFAULT_WAIT_MS;
208
90
  return function* crawl(context, job) {
209
- const slots = job.inputs.map(() => ({
210
- resolutions: {},
211
- waiting: new Set(),
212
- events: [],
213
- resumes: 0,
214
- handlers: []
215
- }));
216
- const owners = new Map();
217
- const outcomes = new Map();
218
- const progress = {
219
- recipes: job.inputs.length,
220
- recipesDone: 0,
221
- records: 0
222
- };
91
+ const slots = job.inputs.map(() => ({ resolutions: {}, waiting: /* @__PURE__ */ new Set(), events: [], resumes: 0, handlers: [] }));
92
+ const owners = /* @__PURE__ */ new Map();
93
+ const outcomes = /* @__PURE__ */ new Map();
94
+ const progress = { recipes: job.inputs.length, recipesDone: 0, records: 0 };
223
95
  let running = [];
224
96
  let waitingRecipes = 0;
225
- const start = index => {
97
+ const start = (index) => {
226
98
  const slot = slots[index];
227
99
  const task = activityTask(context, runRecipe, {
228
100
  allowedHosts: job.allowedHosts,
@@ -231,22 +103,11 @@ function crawlOrchestrator(runRecipe, options = {}) {
231
103
  resultName: `${context.df.instanceId}/${String(index)}.jsonl`,
232
104
  instanceId: context.df.instanceId,
233
105
  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
106
+ ...job.dedupe !== void 0 && { dedupe: job.dedupe },
107
+ ...job.callbackBase !== void 0 && { callbackBase: job.callbackBase },
108
+ ...Object.keys(slot.resolutions).length > 0 && { resolutions: { ...slot.resolutions } }
249
109
  });
110
+ owners.set(task, { kind: "recipe", index });
250
111
  running.push(task);
251
112
  };
252
113
  const finish = (index, result) => {
@@ -255,12 +116,12 @@ function crawlOrchestrator(runRecipe, options = {}) {
255
116
  progress.records += result.reports.reduce((sum, report) => sum + report.emitted, 0);
256
117
  context.df.setCustomStatus(progress);
257
118
  };
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));
119
+ const stopWaiting = (slot) => {
120
+ if (slot.timer !== void 0 && !slot.timer.isCompleted()) slot.timer.cancel();
121
+ const stopped = /* @__PURE__ */ new Set([...slot.events, ...slot.timer === void 0 ? [] : [slot.timer]]);
122
+ running = running.filter((task) => !stopped.has(task));
262
123
  slot.events = [];
263
- slot.timer = undefined;
124
+ slot.timer = void 0;
264
125
  waitingRecipes -= 1;
265
126
  progress.recipesWaiting = waitingRecipes;
266
127
  };
@@ -268,44 +129,35 @@ function crawlOrchestrator(runRecipe, options = {}) {
268
129
  for (let index = 0; index < job.inputs.length; index += 1) start(index);
269
130
  while (running.length > 0) {
270
131
  const winner = yield* any(context, running);
271
- running = running.filter(task => task !== winner);
132
+ running = running.filter((task) => task !== winner);
272
133
  const owner = owners.get(winner);
273
- if (owner === undefined) continue;
134
+ if (owner === void 0) continue;
274
135
  const slot = slots[owner.index];
275
- if (owner.kind === 'recipe') {
136
+ if (owner.kind === "recipe") {
276
137
  const result = resultOf(winner);
277
138
  const parked = result.parked ?? [];
278
139
  if (parked.length === 0) {
279
140
  finish(owner.index, result);
280
141
  } 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
- });
142
+ finish(owner.index, { 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`)] });
284
143
  } else {
285
144
  slot.resumes += 1;
286
- slot.waiting = new Set(parked.map(call => call.idempotencyKey));
287
- slot.handlers = parked.map(call => call.handler);
145
+ slot.waiting = new Set(parked.map((call) => call.idempotencyKey));
146
+ slot.handlers = parked.map((call) => call.handler);
288
147
  for (const call of parked) {
289
148
  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
- });
149
+ owners.set(event, { kind: "event", index: owner.index, key: call.idempotencyKey });
295
150
  slot.events.push(event);
296
151
  running.push(event);
297
152
  }
298
153
  slot.timer = timerTask(context, waitMs);
299
- owners.set(slot.timer, {
300
- kind: 'timer',
301
- index: owner.index
302
- });
154
+ owners.set(slot.timer, { kind: "timer", index: owner.index });
303
155
  running.push(slot.timer);
304
156
  waitingRecipes += 1;
305
157
  progress.recipesWaiting = waitingRecipes;
306
158
  context.df.setCustomStatus(progress);
307
159
  }
308
- } else if (owner.kind === 'event') {
160
+ } else if (owner.kind === "event") {
309
161
  slot.resolutions[owner.key] = resultOf(winner);
310
162
  slot.waiting.delete(owner.key);
311
163
  if (slot.waiting.size === 0) {
@@ -315,107 +167,56 @@ function crawlOrchestrator(runRecipe, options = {}) {
315
167
  }
316
168
  } else {
317
169
  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
- });
170
+ finish(owner.index, { reports: [failedReport(job.inputs[owner.index], owner.index, `no result was posted back within ${String(waitMs)} ms by ${slot.handlers.join(", ")}`)] });
321
171
  }
322
172
  }
323
173
  const results = job.inputs.flatMap((_, index) => outcomes.get(index) ?? []);
324
174
  return {
325
- recipes: results.flatMap(result => result.reports),
326
- records: results.flatMap(result => result.records ?? []),
327
- results: results.flatMap(result => result.resultUrl === undefined ? [] : [result.resultUrl])
175
+ recipes: results.flatMap((result) => result.reports),
176
+ records: results.flatMap((result) => result.records ?? []),
177
+ results: results.flatMap((result) => result.resultUrl === void 0 ? [] : [result.resultUrl])
328
178
  };
329
179
  };
330
180
  }
331
- /** The report of a recipe the orchestration gave up on: it never finished a run, so there is nothing from the engine. */
332
181
  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
- };
182
+ const id2 = typeof input === "object" && input !== null && "id" in input && typeof input.id === "string" ? input.id : `input-${String(index)}`;
183
+ return { recipeId: id2, mode: "api", emitted: 0, rejected: 0, duplicates: 0, skipped: 0, stepsSkipped: 0, pages: 0, durationMs: 0, error };
346
184
  }
347
185
 
348
- const KIB = 1024;
349
- const MINUTE_MS$1 = 60_000;
350
- const HOUR_MS = 60 * MINUTE_MS$1;
351
- /**
352
- * @param options - As given.
353
- * @returns The options with their defaults.
354
- * @throws Error when `allowedHosts` is an empty list (a host that may reach nothing runs nothing), or the MCP endpoint would be open to anyone.
355
- */
356
- function resolveHostOptions(options) {
357
- if (Array.isArray(options.allowedHosts) && options.allowedHosts.length === 0) throw new Error('allowedHosts is empty: list the hosts recipes may reach, or "*" for any');
358
- const authLevel = options.authLevel ?? 'function';
359
- const mcp = options.mcp === undefined || options.mcp === false ? undefined : {
360
- sampleRecords: 20,
361
- sampleMs: MINUTE_MS$1,
362
- ...(options.mcp !== true && options.mcp)
363
- };
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);
366
- const {
367
- mcp: _mcp,
368
- canPromote,
369
- callouts: _callouts,
370
- ...rest
371
- } = options;
186
+ // src/crawl-run/run-recipe.use-case.ts
187
+ import { createCrawler, loadRecipes, memorySink, withCalloutWaiter } from "@opencraw/core";
188
+
189
+ // src/host-options/host-options.config.ts
190
+ var KIB = 1024;
191
+ var MINUTE_MS = 6e4;
192
+ var HOUR_MS = 60 * MINUTE_MS;
193
+ function resolveHostOptions(options2) {
194
+ if (Array.isArray(options2.allowedHosts) && options2.allowedHosts.length === 0) throw new Error('allowedHosts is empty: list the hosts recipes may reach, or "*" for any');
195
+ const authLevel = options2.authLevel ?? "function";
196
+ const mcp = options2.mcp === void 0 || options2.mcp === false ? void 0 : { sampleRecords: 20, sampleMs: MINUTE_MS, ...options2.mcp !== true && options2.mcp };
197
+ if (mcp !== void 0 && authLevel === "anonymous" && options2.identify === void 0) throw new Error('the MCP endpoint needs authentication: keep authLevel "function", or put the app behind App Service authentication and set identify');
198
+ const callouts = options2.callouts === void 0 ? void 0 : calloutSettings(options2.callouts);
199
+ const { mcp: _mcp, canPromote, callouts: _callouts, ...rest } = options2;
372
200
  return {
373
201
  ...rest,
374
- ...(mcp !== undefined && {
375
- mcp
376
- }),
377
- ...(callouts !== undefined && {
378
- callouts
379
- }),
202
+ ...mcp !== void 0 && { mcp },
203
+ ...callouts !== void 0 && { callouts },
380
204
  canPromote: canPromote ?? (() => false),
381
- inlineLimitBytes: options.inlineLimitBytes ?? 256 * KIB,
382
- routePrefix: (options.routePrefix ?? '').replaceAll(/^\/+|\/+$/g, ''),
205
+ inlineLimitBytes: options2.inlineLimitBytes ?? 256 * KIB,
206
+ routePrefix: (options2.routePrefix ?? "").replaceAll(/^\/+|\/+$/g, ""),
383
207
  authLevel,
384
208
  pools: {
385
- idleTtlMs: options.pools?.idleTtlMs ?? 10 * MINUTE_MS$1,
386
- windows: options.pools?.windows ?? {
387
- min: 1,
388
- max: 4,
389
- grow: {
390
- after: 5
391
- },
392
- idle: {
393
- afterMs: MINUTE_MS$1
394
- }
395
- },
396
- maxWindows: options.pools?.maxWindows ?? 8
209
+ idleTtlMs: options2.pools?.idleTtlMs ?? 10 * MINUTE_MS,
210
+ windows: options2.pools?.windows ?? { min: 1, max: 4, grow: { after: 5 }, idle: { afterMs: MINUTE_MS } },
211
+ maxWindows: options2.pools?.maxWindows ?? 8
397
212
  }
398
213
  };
399
214
  }
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
- };
215
+ function calloutSettings(options2) {
216
+ const key = process.env[options2.signingKeyEnv];
217
+ if (key === void 0 || key === "") throw new Error(`callouts.signingKeyEnv names ${options2.signingKeyEnv}, which is not set: it must hold the key that signs resume tokens`);
218
+ return { signingKeyEnv: options2.signingKeyEnv, waitMs: options2.waitMs ?? HOUR_MS, ...options2.publicUrl !== void 0 && { publicUrl: options2.publicUrl } };
410
219
  }
411
- /**
412
- * What a crawler for one caller is created with: the host's code and
413
- * secrets, and the caller's hosts.
414
- *
415
- * @param settings - The host settings.
416
- * @param allowedHosts - The caller's hosts.
417
- * @returns Crawl options, without a sink.
418
- */
419
220
  function crawlOptionsFor(settings, allowedHosts) {
420
221
  return {
421
222
  allowedHosts: [...allowedHosts],
@@ -427,234 +228,127 @@ function crawlOptionsFor(settings, allowedHosts) {
427
228
  };
428
229
  }
429
230
 
430
- const DAY_MS = 86_400_000;
431
- /**
432
- * Results in Azure Blob Storage, as JSON Lines, returned as read-only SAS
433
- * links that expire.
434
- *
435
- * @param options - The account, container and link lifetime.
436
- * @returns The store.
437
- */
438
- function blobResults(options) {
439
- const container = BlobServiceClient.fromConnectionString(options.connectionString).getContainerClient(options.container ?? 'opencraw-results');
231
+ // src/result-store/blob-result.store.ts
232
+ import { BlobServiceClient, BlobSASPermissions } from "@azure/storage-blob";
233
+ var DAY_MS = 864e5;
234
+ function blobResults(options2) {
235
+ const container = BlobServiceClient.fromConnectionString(options2.connectionString).getContainerClient(options2.container ?? "opencraw-results");
440
236
  let ready;
441
237
  return {
442
238
  async save(name, records) {
443
239
  ready ??= container.createIfNotExists();
444
240
  await ready;
445
241
  const blob = container.getBlockBlobClient(name);
446
- const body = records.map(record => JSON.stringify(record)).join('\n');
447
- await blob.upload(body, Buffer.byteLength(body), {
448
- blobHTTPHeaders: {
449
- blobContentType: 'application/x-ndjson'
450
- }
451
- });
452
- return await blob.generateSasUrl({
453
- permissions: BlobSASPermissions.parse('r'),
454
- expiresOn: new Date(Date.now() + (options.linkTtlMs ?? DAY_MS))
455
- });
242
+ const body = records.map((record) => JSON.stringify(record)).join("\n");
243
+ await blob.upload(body, Buffer.byteLength(body), { blobHTTPHeaders: { blobContentType: "application/x-ndjson" } });
244
+ return await blob.generateSasUrl({ permissions: BlobSASPermissions.parse("r"), expiresOn: new Date(Date.now() + (options2.linkTtlMs ?? DAY_MS)) });
456
245
  }
457
246
  };
458
247
  }
459
248
 
460
- /**
461
- * Returns records inline when small, else saves them and returns a link: an
462
- * orchestration's history holds every activity output, so a large result
463
- * belongs in storage, not in the task hub.
464
- *
465
- * @param records - The records.
466
- * @param options - The store (without one, records are always inline), the limit, the name to save under.
467
- * @returns The records or their link.
468
- */
469
- async function packRecords(records, options) {
470
- if (options.store === undefined || Buffer.byteLength(JSON.stringify(records)) <= options.inlineLimitBytes) return {
471
- records: [...records]
472
- };
473
- return {
474
- resultUrl: await options.store.save(options.name, records)
475
- };
249
+ // src/result-store/result-packing.policy.ts
250
+ async function packRecords(records, options2) {
251
+ if (options2.store === void 0 || Buffer.byteLength(JSON.stringify(records)) <= options2.inlineLimitBytes) return { records: [...records] };
252
+ return { resultUrl: await options2.store.save(options2.name, records) };
476
253
  }
477
254
 
478
- const MINUTE_MS = 60_000;
479
- /**
480
- * The `/crawl` activity: one input recipe, on a crawler of its own, limited
481
- * to the caller's hosts. A recipe that fails is reported, not thrown: the
482
- * orchestration still gathers the others.
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
- *
488
- * @param settings - The host settings.
489
- * @param task - The recipe and where its records go.
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.
492
- */
255
+ // src/crawl-run/run-recipe.use-case.ts
256
+ var MINUTE_MS2 = 6e4;
493
257
  async function runRecipeTask(settings, task, now = Date.now()) {
494
258
  const sink = memorySink();
495
- const crawler = createCrawler({
496
- ...crawlOptionsFor(settings, task.allowedHosts),
497
- sink,
498
- dedupe: task.dedupe ?? 'recipe'
499
- });
259
+ const crawler = createCrawler({ ...crawlOptionsFor(settings, task.allowedHosts), sink, dedupe: task.dedupe ?? "recipe" });
500
260
  const parked = [];
501
261
  const waiter = calloutWaiter(settings, task, parked, now);
502
262
  let reports;
503
263
  try {
504
264
  const set = await loadRecipes([task.output, task.input]);
505
- const run = async () => {
265
+ const run2 = async () => {
506
266
  const result = await crawler.run(set);
507
267
  return result.recipes;
508
268
  };
509
- reports = waiter === undefined ? await run() : await withCalloutWaiter(waiter, run);
269
+ reports = waiter === void 0 ? await run2() : await withCalloutWaiter(waiter, run2);
510
270
  } finally {
511
271
  await crawler.close();
512
272
  }
513
- if (parked.length > 0) return {
514
- reports: [],
515
- parked
516
- };
517
- const packed = await packRecords(sink.records, {
518
- store: settings.results,
519
- inlineLimitBytes: settings.inlineLimitBytes,
520
- name: task.resultName
521
- });
522
- return {
523
- reports,
524
- ...packed
525
- };
273
+ if (parked.length > 0) return { reports: [], parked };
274
+ const packed = await packRecords(sink.records, { store: settings.results, inlineLimitBytes: settings.inlineLimitBytes, name: task.resultName });
275
+ return { reports, ...packed };
526
276
  }
527
- /** The waiter of a run, when the host takes posted-back results and the job knows where they go. */
528
277
  function calloutWaiter(settings, task, parked, now) {
529
278
  const callouts = settings.callouts;
530
- if (callouts === undefined || task.callbackBase === undefined) return undefined;
531
- const secret = process.env[callouts.signingKeyEnv] ?? '';
279
+ if (callouts === void 0 || task.callbackBase === void 0) return void 0;
280
+ const secret = process.env[callouts.signingKeyEnv] ?? "";
532
281
  const base = task.callbackBase;
533
- const expiresAt = now + callouts.waitMs + MINUTE_MS;
282
+ const expiresAt = now + callouts.waitMs + MINUTE_MS2;
534
283
  return {
535
284
  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
- }),
285
+ callbackFor: (key) => ({ url: callbackUrl(base, signCalloutTicket(secret, { instanceId: task.instanceId, index: task.index, key, expiresAt })) }),
544
286
  parked
545
287
  };
546
288
  }
547
289
 
548
- /**
549
- * Resolves the caller of a request and the hosts its recipes may reach.
550
- *
551
- * @param request - The request.
552
- * @param rules - The host's `identify` and `allowedHosts`.
553
- * @returns The access, or `undefined` when this caller may run nothing.
554
- */
290
+ // src/crawl-run/start-crawl.handler.ts
291
+ import { startOrchestration } from "@mnci/az-durable";
292
+ import { loadRecipes as loadRecipes2 } from "@opencraw/core";
293
+
294
+ // src/caller-access/caller-access.policy.ts
555
295
  function callerAccess(request, rules) {
556
296
  const caller = rules.identify?.(request);
557
- const allowedHosts = typeof rules.allowedHosts === 'function' ? rules.allowedHosts(caller) : rules.allowedHosts;
558
- if (allowedHosts === undefined || allowedHosts.length === 0) return undefined;
559
- return {
560
- ...(caller !== undefined && {
561
- caller
562
- }),
563
- allowedHosts
564
- };
297
+ const allowedHosts = typeof rules.allowedHosts === "function" ? rules.allowedHosts(caller) : rules.allowedHosts;
298
+ if (allowedHosts === void 0 || allowedHosts.length === 0) return void 0;
299
+ return { ...caller !== void 0 && { caller }, allowedHosts };
565
300
  }
566
- /**
567
- * The key a caller's pools and jobs live under, so two callers never share a pool.
568
- *
569
- * @param access - The caller.
570
- * @param crawlId - The job.
571
- * @returns The key.
572
- */
573
301
  function poolKey(access, crawlId) {
574
- return `${access.caller ?? ''}\u{0}${crawlId}`;
302
+ return `${access.caller ?? ""}\0${crawlId}`;
575
303
  }
576
304
 
577
- /** A version that does not exist, or a write to one that does. */
578
- class RecipeStoreError extends Error {
579
- status;
580
- constructor(message, status) {
305
+ // src/recipe-store/recipe-store.error.ts
306
+ var RecipeStoreError = class extends Error {
307
+ constructor(message, status2) {
581
308
  super(message);
582
- this.status = status;
583
- this.name = 'RecipeStoreError';
309
+ this.status = status2;
310
+ this.name = "RecipeStoreError";
584
311
  }
585
- }
312
+ status;
313
+ };
586
314
 
587
- const keyOf = (name, version) => `${name}@${version}`;
588
- /**
589
- * Recipes held in memory: the ones a deployment ships with (promoted), plus
590
- * drafts published while it runs, which a restart forgets.
591
- *
592
- * @param entries - The recipe sets; `state` defaults to `promoted`.
593
- * @returns The store.
594
- */
315
+ // src/recipe-store/memory-recipe.store.ts
316
+ var keyOf = (name, version) => `${name}@${version}`;
595
317
  function memoryRecipes(entries = []) {
596
- const byKey = new Map();
597
- for (const entry of entries) byKey.set(keyOf(entry.name, entry.version), {
598
- ...entry,
599
- state: entry.state ?? 'promoted'
600
- });
318
+ const byKey = /* @__PURE__ */ new Map();
319
+ for (const entry of entries) byKey.set(keyOf(entry.name, entry.version), { ...entry, state: entry.state ?? "promoted" });
601
320
  return {
602
321
  get: async (name, version) => byKey.get(keyOf(name, version)),
603
- list: async () => Array.from(byKey.values(), ({
604
- recipes: _recipes,
605
- ...entry
606
- }) => entry),
607
- put: async entry => {
322
+ list: async () => Array.from(byKey.values(), ({ recipes: _recipes, ...entry }) => entry),
323
+ put: async (entry) => {
608
324
  const key = keyOf(entry.name, entry.version);
609
325
  if (byKey.has(key)) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" exist already; publish a new version`, 409);
610
- byKey.set(key, {
611
- ...entry,
612
- state: 'draft'
613
- });
326
+ byKey.set(key, { ...entry, state: "draft" });
614
327
  },
615
328
  promote: async (name, version) => {
616
329
  const entry = byKey.get(keyOf(name, version));
617
- if (entry === undefined) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
618
- byKey.set(keyOf(name, version), {
619
- ...entry,
620
- state: 'promoted'
621
- });
330
+ if (entry === void 0) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
331
+ byKey.set(keyOf(name, version), { ...entry, state: "promoted" });
622
332
  }
623
333
  };
624
334
  }
625
335
 
626
- /**
627
- * A stored recipe set production may run.
628
- *
629
- * @param store - The store, if the host has one.
630
- * @param name - The set's name.
631
- * @param version - Its version.
632
- * @param allowDraft - Whether a draft will do (authoring tools), or only a promoted version (production).
633
- * @returns The set.
634
- * @throws RecipeStoreError 404 when there is no such version, 409 when it is a draft and a draft won't do.
635
- */
336
+ // src/recipe-store/find-recipes.use-case.ts
636
337
  async function findRecipes(store, name, version, allowDraft = false) {
637
338
  const found = await store?.get(name, version);
638
- if (found === undefined) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
639
- if (!allowDraft && found.state === 'draft') throw new RecipeStoreError(`recipes "${name}" version "${version}" are a draft: promote them before production runs them`, 409);
339
+ if (found === void 0) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
340
+ if (!allowDraft && found.state === "draft") throw new RecipeStoreError(`recipes "${name}" version "${version}" are a draft: promote them before production runs them`, 409);
640
341
  return found;
641
342
  }
642
343
 
643
- const blobName = (name, version) => `${encodeURIComponent(name)}/${encodeURIComponent(version)}.json`;
644
- const missing = error => error instanceof RestError && error.statusCode === 404;
645
- const taken = error => error instanceof RestError && (error.statusCode === 409 || error.statusCode === 412);
646
- /**
647
- * Named recipes in Azure Blob Storage: one JSON blob per version, its state
648
- * (`draft` or `promoted`) in the blob's metadata. A version is written once:
649
- * a second publish of it fails even when two race, since the write only
650
- * succeeds when no blob is there.
651
- *
652
- * @param options - The account, the container, and the sets the deployment ships with.
653
- * @returns The store.
654
- */
655
- function blobRecipes(options) {
656
- const container = BlobServiceClient.fromConnectionString(options.connectionString).getContainerClient(options.container ?? 'opencraw-recipes');
657
- const shipped = memoryRecipes(options.shipped ?? []);
344
+ // src/recipe-store/blob-recipe.store.ts
345
+ import { BlobServiceClient as BlobServiceClient2, RestError } from "@azure/storage-blob";
346
+ var blobName = (name, version) => `${encodeURIComponent(name)}/${encodeURIComponent(version)}.json`;
347
+ var missing = (error) => error instanceof RestError && error.statusCode === 404;
348
+ var taken = (error) => error instanceof RestError && (error.statusCode === 409 || error.statusCode === 412);
349
+ function blobRecipes(options2) {
350
+ const container = BlobServiceClient2.fromConnectionString(options2.connectionString).getContainerClient(options2.container ?? "opencraw-recipes");
351
+ const shipped = memoryRecipes(options2.shipped ?? []);
658
352
  let ready;
659
353
  const blob = async (name, version) => {
660
354
  ready ??= container.createIfNotExists();
@@ -664,16 +358,11 @@ function blobRecipes(options) {
664
358
  return {
665
359
  async get(name, version) {
666
360
  const found = await shipped.get(name, version);
667
- if (found !== undefined) return found;
361
+ if (found !== void 0) return found;
668
362
  const client = await blob(name, version);
669
363
  try {
670
364
  const [bytes, properties] = await Promise.all([client.downloadToBuffer(), client.getProperties()]);
671
- return {
672
- name,
673
- version,
674
- recipes: JSON.parse(bytes.toString('utf8')),
675
- state: properties.metadata?.state === 'promoted' ? 'promoted' : 'draft'
676
- };
365
+ return { name, version, recipes: JSON.parse(bytes.toString("utf8")), state: properties.metadata?.state === "promoted" ? "promoted" : "draft" };
677
366
  } catch (error) {
678
367
  if (missing(error)) return;
679
368
  throw error;
@@ -683,56 +372,31 @@ function blobRecipes(options) {
683
372
  ready ??= container.createIfNotExists();
684
373
  await ready;
685
374
  const published = [];
686
- const blobs = container.listBlobsFlat({
687
- includeMetadata: true
688
- });
375
+ const blobs = container.listBlobsFlat({ includeMetadata: true });
689
376
  for await (const item of blobs) {
690
377
  const metadata = item.metadata;
691
- if (metadata?.name !== undefined && metadata.version !== undefined) published.push({
692
- name: metadata.name,
693
- version: metadata.version,
694
- state: metadata.state === 'promoted' ? 'promoted' : 'draft'
695
- });
378
+ if (metadata?.name !== void 0 && metadata.version !== void 0) published.push({ name: metadata.name, version: metadata.version, state: metadata.state === "promoted" ? "promoted" : "draft" });
696
379
  }
697
- return [...(await shipped.list()), ...published];
380
+ return [...await shipped.list(), ...published];
698
381
  },
699
382
  async put(entry) {
700
- if ((await shipped.get(entry.name, entry.version)) !== undefined) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" ship with the deployment; publish a new version`, 409);
383
+ if (await shipped.get(entry.name, entry.version) !== void 0) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" ship with the deployment; publish a new version`, 409);
701
384
  const body = JSON.stringify(entry.recipes);
702
- const metadata = {
703
- name: entry.name,
704
- version: entry.version,
705
- state: 'draft'
706
- };
385
+ const metadata = { name: entry.name, version: entry.version, state: "draft" };
707
386
  const client = await blob(entry.name, entry.version);
708
387
  try {
709
- await client.getBlockBlobClient().upload(body, Buffer.byteLength(body), {
710
- metadata,
711
- conditions: {
712
- ifNoneMatch: '*'
713
- },
714
- blobHTTPHeaders: {
715
- blobContentType: 'application/json'
716
- }
717
- });
388
+ await client.getBlockBlobClient().upload(body, Buffer.byteLength(body), { metadata, conditions: { ifNoneMatch: "*" }, blobHTTPHeaders: { blobContentType: "application/json" } });
718
389
  } catch (error) {
719
390
  if (taken(error)) throw new RecipeStoreError(`recipes "${entry.name}" version "${entry.version}" exist already; publish a new version`, 409);
720
391
  throw error;
721
392
  }
722
393
  },
723
394
  async promote(name, version) {
724
- if ((await shipped.get(name, version)) !== undefined) return;
395
+ if (await shipped.get(name, version) !== void 0) return;
725
396
  const client = await blob(name, version);
726
397
  try {
727
398
  const properties = await client.getProperties();
728
- await client.setMetadata({
729
- ...properties.metadata,
730
- state: 'promoted'
731
- }, {
732
- conditions: {
733
- ifMatch: properties.etag
734
- }
735
- });
399
+ await client.setMetadata({ ...properties.metadata, state: "promoted" }, { conditions: { ifMatch: properties.etag } });
736
400
  } catch (error) {
737
401
  if (missing(error)) throw new RecipeStoreError(`no recipes "${name}" version "${version}"`, 404);
738
402
  throw error;
@@ -741,210 +405,128 @@ function blobRecipes(options) {
741
405
  };
742
406
  }
743
407
 
744
- const recipeObject = z.record(z.string(), z.unknown());
745
- const options = z.strictObject({
408
+ // src/crawl-run/crawl-request.validator.ts
409
+ import { z } from "zod";
410
+ var recipeObject = z.record(z.string(), z.unknown());
411
+ var options = z.strictObject({
746
412
  /** `recipe` (default) or `off`: each input recipe runs in its own activity, so nothing spans them. */
747
- dedupe: z.enum(['recipe', 'off']).optional()
413
+ dedupe: z.enum(["recipe", "off"]).optional()
748
414
  }).optional();
749
- const inlineRecipes = z.strictObject({
750
- output: recipeObject,
751
- inputs: z.array(recipeObject).min(1),
752
- options
753
- });
754
- const namedRecipes = z.strictObject({
755
- recipe: z.strictObject({
756
- name: z.string().min(1),
757
- version: z.string().min(1)
758
- }),
759
- options
760
- });
761
- /** A `/crawl` body: the recipes themselves, or the name and version of a stored set. */
762
- const crawlRequestSchema = z.union([inlineRecipes, namedRecipes]);
415
+ var inlineRecipes = z.strictObject({ output: recipeObject, inputs: z.array(recipeObject).min(1), options });
416
+ var namedRecipes = z.strictObject({ recipe: z.strictObject({ name: z.string().min(1), version: z.string().min(1) }), options });
417
+ var crawlRequestSchema = z.union([inlineRecipes, namedRecipes]);
763
418
 
764
- /**
765
- * `POST /crawl`: checks the caller and the recipes, then starts the
766
- * orchestration. Recipes that do not load are a 400 before anything is
767
- * queued.
768
- *
769
- * @param request - The request.
770
- * @param client - The Durable client.
771
- * @param settings - The host settings.
772
- * @param orchestration - The `/crawl` orchestration.
773
- * @returns 202 with the Durable status URLs, or the problem.
774
- */
419
+ // src/crawl-run/start-crawl.handler.ts
775
420
  async function startCrawl(request, client, settings, orchestration) {
776
421
  const access = callerAccess(request, settings);
777
- if (access === undefined) return problem(403, 'this caller may not run recipes here');
422
+ if (access === void 0) return problem(403, "this caller may not run recipes here");
778
423
  const parsed = crawlRequestSchema.safeParse(await readJson(request));
779
424
  if (!parsed.success) return problem(400, `the body must be { output, inputs, options? } or { recipe: { name, version }, options? }: ${parsed.error.message}`);
780
425
  let recipes;
781
426
  try {
782
- if ('recipe' in parsed.data) {
783
- const stored = await findRecipes(settings.recipes, parsed.data.recipe.name, parsed.data.recipe.version);
784
- recipes = stored.recipes;
427
+ if ("recipe" in parsed.data) {
428
+ const stored2 = await findRecipes(settings.recipes, parsed.data.recipe.name, parsed.data.recipe.version);
429
+ recipes = stored2.recipes;
785
430
  } else {
786
431
  recipes = [parsed.data.output, ...parsed.data.inputs];
787
432
  }
788
- await loadRecipes(recipes);
433
+ await loadRecipes2(recipes);
789
434
  } catch (error) {
790
435
  return problem(error instanceof RecipeStoreError ? error.status : 400, error);
791
436
  }
792
- const output = recipes.find(recipe => kindOf$1(recipe) === 'output');
437
+ const output = recipes.find((recipe) => kindOf(recipe) === "output");
793
438
  const job = {
794
- ...(access.caller !== undefined && {
795
- caller: access.caller
796
- }),
439
+ ...access.caller !== void 0 && { caller: access.caller },
797
440
  allowedHosts: [...access.allowedHosts],
798
441
  output,
799
- inputs: recipes.filter(recipe => recipe !== output),
800
- ...(parsed.data.options?.dedupe !== undefined && {
801
- dedupe: parsed.data.options.dedupe
802
- }),
803
- ...(settings.callouts !== undefined && {
804
- callbackBase: callbackBaseOf(request.url, settings)
805
- })
442
+ inputs: recipes.filter((recipe) => recipe !== output),
443
+ ...parsed.data.options?.dedupe !== void 0 && { dedupe: parsed.data.options.dedupe },
444
+ ...settings.callouts !== void 0 && { callbackBase: callbackBaseOf(request.url, settings) }
806
445
  };
807
446
  const instanceId = await startOrchestration(client, orchestration, job);
808
447
  return client.createCheckStatusResponse(request, instanceId);
809
448
  }
810
- function kindOf$1(recipe) {
811
- return typeof recipe === 'object' && recipe !== null && 'kind' in recipe ? recipe.kind : undefined;
449
+ function kindOf(recipe) {
450
+ return typeof recipe === "object" && recipe !== null && "kind" in recipe ? recipe.kind : void 0;
812
451
  }
813
452
 
814
- const crawlJobSchema = z.object({
815
- caller: z.string().optional(),
816
- allowedHosts: z.array(z.string()).min(1),
817
- output: z.unknown(),
818
- inputs: z.array(z.unknown()).min(1),
819
- dedupe: z.enum(['recipe', 'off']).optional(),
820
- callbackBase: z.string().optional()
453
+ // src/crawl-run/crawl-job.validator.ts
454
+ import { z as z2 } from "zod";
455
+ var crawlJobSchema = z2.object({
456
+ caller: z2.string().optional(),
457
+ allowedHosts: z2.array(z2.string()).min(1),
458
+ output: z2.unknown(),
459
+ inputs: z2.array(z2.unknown()).min(1),
460
+ dedupe: z2.enum(["recipe", "off"]).optional(),
461
+ callbackBase: z2.string().optional()
821
462
  });
822
- /**
823
- * Checks a `/crawl` orchestration's input as it comes back out of the task
824
- * hub: an instance started before a deploy resumes against the new code.
825
- *
826
- * @param raw - The stored input.
827
- * @returns The job.
828
- * @throws ZodError when it is not one.
829
- */
830
463
  function parseCrawlJob(raw) {
831
464
  return crawlJobSchema.parse(raw);
832
465
  }
833
466
 
834
- /** Names and versions end up in blob names: letters, digits, `_`, `.`, `-`. */
835
- const NAME = /^[\w.-]{1,64}$/;
836
- const text = value => ({
837
- content: [{
838
- type: 'text',
839
- text: JSON.stringify(value)
840
- }],
841
- structuredContent: value
842
- });
843
- const failed = error => ({
844
- content: [{
845
- type: 'text',
846
- text: error instanceof Error ? error.message : String(error)
847
- }],
848
- isError: true
849
- });
850
- /**
851
- * `probe`: where a page's data lives, fetched from the host itself (its IP,
852
- * its proxies) and only from the caller's hosts.
853
- *
854
- * @param context - The call's context.
855
- * @param args - The page, whether to render it, an access profile.
856
- * @returns The findings.
857
- */
467
+ // src/recipe-authoring/mcp-endpoint.handler.ts
468
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
469
+
470
+ // src/recipe-authoring/authoring-server.use-case.ts
471
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
472
+ import { z as z3 } from "zod";
473
+
474
+ // src/recipe-authoring/authoring-tools.use-case.ts
475
+ import { setTimeout as delay } from "node:timers/promises";
476
+ import { startOrchestration as startOrchestration2 } from "@mnci/az-durable";
477
+ import { probeUrl } from "@opencraw/cli";
478
+ import { createCrawler as createCrawler2, loadRecipes as loadRecipes3, memorySink as memorySink2 } from "@opencraw/core";
479
+ import { validateTool } from "@opencraw/mcp";
480
+ var NAME = /^[\w.-]{1,64}$/;
481
+ var text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
482
+ var failed = (error) => ({ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true });
858
483
  async function probe(context, args) {
859
- const {
860
- settings
861
- } = context;
484
+ const { settings } = context;
862
485
  try {
863
486
  return text(await probeUrl(args.url, {
864
487
  browser: args.browser ?? false,
865
488
  insecureTls: settings.browser?.ignoreHTTPSErrors === true,
866
489
  browserPath: settings.browser?.executablePath,
867
490
  allowedHosts: [...context.access.allowedHosts],
868
- accessConfig: settings.access ?? {
869
- profiles: {}
870
- },
491
+ accessConfig: settings.access ?? { profiles: {} },
871
492
  accessPlugins: settings.accessPlugins,
872
- ...(args.access !== undefined && {
873
- accessProfile: args.access
874
- })
493
+ ...args.access !== void 0 && { accessProfile: args.access }
875
494
  }));
876
495
  } catch (error) {
877
496
  return failed(error);
878
497
  }
879
498
  }
880
- /**
881
- * `validate`: `@opencraw/mcp`'s own, on inline recipes only (never paths on the host).
882
- *
883
- * @param args - The recipes.
884
- * @returns Every issue with its JSON path.
885
- */
886
499
  async function validate(args) {
887
- return await validateTool({
888
- recipes: args.recipes
889
- });
500
+ return await validateTool({ recipes: args.recipes });
890
501
  }
891
- /**
892
- * `run`: a sample by default (a few records per input recipe, within the
893
- * time a tool call may take), or with `full` a Durable crawl whose instance
894
- * id `status` reads. Drafts run here; production routes refuse them.
895
- *
896
- * @param context - The call's context.
897
- * @param args - Inline recipes or a stored set, and whether to run it all.
898
- * @returns The sample's reports and records, or the instance id.
899
- */
900
502
  async function run(context, args) {
901
- const {
902
- settings,
903
- access
904
- } = context;
503
+ const { settings, access } = context;
905
504
  let recipes;
906
505
  try {
907
506
  recipes = await recipesOf(context, args);
908
- await loadRecipes(recipes);
507
+ await loadRecipes3(recipes);
909
508
  } catch (error) {
910
509
  return failed(error);
911
510
  }
912
- const output = recipes.find(recipe => kindOf(recipe) === 'output');
913
- const inputs = recipes.filter(recipe => recipe !== output);
511
+ const output = recipes.find((recipe) => kindOf2(recipe) === "output");
512
+ const inputs = recipes.filter((recipe) => recipe !== output);
914
513
  if (args.full === true) {
915
- const job = {
916
- ...(access.caller !== undefined && {
917
- caller: access.caller
918
- }),
919
- allowedHosts: [...access.allowedHosts],
920
- output,
921
- inputs
922
- };
923
- const instanceId = await startOrchestration(context.client, context.crawl, job);
924
- return text({
925
- instanceId,
926
- next: 'call status with this instance id until runtimeStatus is Completed'
927
- });
514
+ const job = { ...access.caller !== void 0 && { caller: access.caller }, allowedHosts: [...access.allowedHosts], output, inputs };
515
+ const instanceId = await startOrchestration2(context.client, context.crawl, job);
516
+ return text({ instanceId, next: "call status with this instance id until runtimeStatus is Completed" });
928
517
  }
929
- const sample = inputs.map(input => withMaxRecords(input, settings.mcp.sampleRecords));
930
- const sink = memorySink();
931
- const crawler = createCrawler({
932
- ...crawlOptionsFor(settings, access.allowedHosts),
933
- sink
934
- });
518
+ const sample = inputs.map((input) => withMaxRecords(input, settings.mcp.sampleRecords));
519
+ const sink = memorySink2();
520
+ const crawler = createCrawler2({ ...crawlOptionsFor(settings, access.allowedHosts), sink });
935
521
  const stop = new AbortController();
936
522
  try {
937
- const set = await loadRecipes([output, ...sample]);
938
- const finished = await Promise.race([crawler.run(set), setTimeout(settings.mcp.sampleMs, undefined, {
939
- signal: stop.signal
940
- })]);
941
- const timedOut = finished === undefined;
523
+ const set = await loadRecipes3([output, ...sample]);
524
+ const finished = await Promise.race([crawler.run(set), delay(settings.mcp.sampleMs, void 0, { signal: stop.signal })]);
525
+ const timedOut = finished === void 0;
942
526
  return text({
943
527
  recipes: finished?.recipes ?? [],
944
528
  records: sink.records,
945
- ...(timedOut && {
946
- stopped: `after ${settings.mcp.sampleMs} ms; these are the records found by then`
947
- })
529
+ ...timedOut && { stopped: `after ${settings.mcp.sampleMs} ms; these are the records found by then` }
948
530
  });
949
531
  } catch (error) {
950
532
  return failed(error);
@@ -953,47 +535,19 @@ async function run(context, args) {
953
535
  await crawler.close();
954
536
  }
955
537
  }
956
- /**
957
- * `status`: a full run's state and result, for the caller who started it.
958
- *
959
- * @param context - The call's context.
960
- * @param args - The instance id `run` returned.
961
- * @returns Its runtime status, progress and output.
962
- */
963
538
  async function status(context, args) {
964
539
  try {
965
- const found = await context.client.getStatus(args.instanceId, {
966
- showInput: true
967
- });
540
+ const found = await context.client.getStatus(args.instanceId, { showInput: true });
968
541
  const job = found.input;
969
542
  if (job?.caller !== context.access.caller) return failed(`no crawl "${args.instanceId}" of yours`);
970
- return text({
971
- runtimeStatus: found.runtimeStatus,
972
- progress: found.customStatus,
973
- output: found.output
974
- });
543
+ return text({ runtimeStatus: found.runtimeStatus, progress: found.customStatus, output: found.output });
975
544
  } catch {
976
545
  return failed(`no crawl "${args.instanceId}" of yours`);
977
546
  }
978
547
  }
979
- /**
980
- * `list_recipes`: the stored recipe sets, with their versions and states.
981
- *
982
- * @param context - The call's context.
983
- * @returns The list.
984
- */
985
548
  async function listRecipes(context) {
986
- return text({
987
- recipes: (await context.settings.recipes?.list()) ?? []
988
- });
549
+ return text({ recipes: await context.settings.recipes?.list() ?? [] });
989
550
  }
990
- /**
991
- * `get_recipes`: a stored set's recipes, drafts included: the start of a new version.
992
- *
993
- * @param context - The call's context.
994
- * @param args - The name and version.
995
- * @returns The set.
996
- */
997
551
  async function getRecipes(context, args) {
998
552
  try {
999
553
  return text(await findRecipes(context.settings.recipes, args.name, args.version, true));
@@ -1001,261 +555,140 @@ async function getRecipes(context, args) {
1001
555
  return failed(error);
1002
556
  }
1003
557
  }
1004
- /**
1005
- * `publish`: saves recipes that load as a new version, as a draft. A person
1006
- * (or a caller allowed to) promotes it before production runs it.
1007
- *
1008
- * @param context - The call's context.
1009
- * @param args - The name, a new version, the recipes.
1010
- * @returns The stored version.
1011
- */
1012
558
  async function publish(context, args) {
1013
559
  const store = context.settings.recipes;
1014
- if (store === undefined) return failed('this host has no recipe store to publish to');
560
+ if (store === void 0) return failed("this host has no recipe store to publish to");
1015
561
  if (!NAME.test(args.name) || !NAME.test(args.version)) return failed('name and version: 1 to 64 letters, digits, "_", "." or "-"');
1016
562
  try {
1017
- await loadRecipes(args.recipes);
1018
- await store.put({
1019
- name: args.name,
1020
- version: args.version,
1021
- recipes: args.recipes
1022
- });
563
+ await loadRecipes3(args.recipes);
564
+ await store.put({ name: args.name, version: args.version, recipes: args.recipes });
1023
565
  } catch (error) {
1024
566
  return failed(error);
1025
567
  }
1026
- return text({
1027
- name: args.name,
1028
- version: args.version,
1029
- state: 'draft',
1030
- next: 'a person promotes it: POST /recipes/{name}/{version}/promote'
1031
- });
568
+ return text({ name: args.name, version: args.version, state: "draft", next: "a person promotes it: POST /recipes/{name}/{version}/promote" });
1032
569
  }
1033
570
  async function recipesOf(context, args) {
1034
- if (args.recipes === undefined === (args.recipe === undefined)) throw new RecipeStoreError('give exactly one of "recipes" or "recipe"', 404);
1035
- if (args.recipes !== undefined) return args.recipes;
1036
- const stored = await findRecipes(context.settings.recipes, args.recipe?.name ?? '', args.recipe?.version ?? '', true);
1037
- return stored.recipes;
571
+ if (args.recipes === void 0 === (args.recipe === void 0)) throw new RecipeStoreError('give exactly one of "recipes" or "recipe"', 404);
572
+ if (args.recipes !== void 0) return args.recipes;
573
+ const stored2 = await findRecipes(context.settings.recipes, args.recipe?.name ?? "", args.recipe?.version ?? "", true);
574
+ return stored2.recipes;
1038
575
  }
1039
- /** An input recipe that stops after `limit` records, or sooner if it already did. */
1040
576
  function withMaxRecords(input, limit) {
1041
- if (typeof input !== 'object' || input === null) return input;
577
+ if (typeof input !== "object" || input === null) return input;
1042
578
  const limits = input.limits;
1043
- return {
1044
- ...input,
1045
- limits: {
1046
- ...limits,
1047
- maxRecords: Math.min(limits?.maxRecords ?? limit, limit)
1048
- }
1049
- };
579
+ return { ...input, limits: { ...limits, maxRecords: Math.min(limits?.maxRecords ?? limit, limit) } };
1050
580
  }
1051
- function kindOf(recipe) {
1052
- return typeof recipe === 'object' && recipe !== null && 'kind' in recipe ? recipe.kind : undefined;
581
+ function kindOf2(recipe) {
582
+ return typeof recipe === "object" && recipe !== null && "kind" in recipe ? recipe.kind : void 0;
1053
583
  }
1054
584
 
1055
- const SERVER_INFO = {
1056
- name: 'opencraw-host',
1057
- version: '0.0.1'
1058
- };
1059
- const recipeObjects = z.array(z.record(z.string(), z.unknown())).min(1);
1060
- const stored = z.object({
1061
- name: z.string(),
1062
- version: z.string()
1063
- });
1064
- /**
1065
- * The authoring MCP server for one call: the tools an agent writes recipes
1066
- * with, run where the recipes will run, for this caller only. Unlike the
1067
- * local `@opencraw/mcp`, nothing takes a path: recipes travel inline, and a
1068
- * finished set is published to the host's store.
1069
- *
1070
- * @param context - The host, the caller, Durable.
1071
- * @returns The server, ready to `connect`.
1072
- */
585
+ // src/recipe-authoring/authoring-server.use-case.ts
586
+ var SERVER_INFO = { name: "opencraw-host", version: "0.0.1" };
587
+ var recipeObjects = z3.array(z3.record(z3.string(), z3.unknown())).min(1);
588
+ var stored = z3.object({ name: z3.string(), version: z3.string() });
1073
589
  function createAuthoringServer(context) {
1074
590
  const server = new McpServer(SERVER_INFO);
1075
- server.registerTool('probe', {
1076
- description: 'Fetch a page from the crawl host (its IP, its proxies) and report where its data lives: JSON-LD, inline JSON, .json URLs, API-looking links, tables, and for files their rows and headers. Only the hosts this caller may reach.',
591
+ server.registerTool("probe", {
592
+ description: "Fetch a page from the crawl host (its IP, its proxies) and report where its data lives: JSON-LD, inline JSON, .json URLs, API-looking links, tables, and for files their rows and headers. Only the hosts this caller may reach.",
1077
593
  inputSchema: {
1078
- url: z.string().describe('The page to fetch and inspect.'),
1079
- browser: z.boolean().optional().describe('Also render the page and list the JSON responses it fetches while settling. Slower.'),
1080
- access: z.string().optional().describe('An access profile (a proxy) the host has.')
594
+ url: z3.string().describe("The page to fetch and inspect."),
595
+ browser: z3.boolean().optional().describe("Also render the page and list the JSON responses it fetches while settling. Slower."),
596
+ access: z3.string().optional().describe("An access profile (a proxy) the host has.")
1081
597
  }
1082
- }, async args => await probe(context, args));
1083
- server.registerTool('validate', {
1084
- description: 'Load and bind recipes passed inline (an output recipe plus its input recipes): every problem with its JSON path.',
1085
- inputSchema: {
1086
- recipes: z.union([z.string(), recipeObjects]).describe('The recipes: an array of recipe objects, or JSON / JSON Lines text.')
1087
- }
1088
- }, async args => await validate(args));
1089
- server.registerTool('run', {
1090
- description: 'Run recipes on the crawl host. By default a sample: a few records per input recipe, returned inline, within a tool call\'s time. With full: true, a complete crawl in the background; poll status with the instance id it returns.',
598
+ }, async (args) => await probe(context, args));
599
+ server.registerTool("validate", {
600
+ description: "Load and bind recipes passed inline (an output recipe plus its input recipes): every problem with its JSON path.",
601
+ inputSchema: { recipes: z3.union([z3.string(), recipeObjects]).describe("The recipes: an array of recipe objects, or JSON / JSON Lines text.") }
602
+ }, async (args) => await validate(args));
603
+ server.registerTool("run", {
604
+ description: "Run recipes on the crawl host. By default a sample: a few records per input recipe, returned inline, within a tool call's time. With full: true, a complete crawl in the background; poll status with the instance id it returns.",
1091
605
  inputSchema: {
1092
606
  recipes: recipeObjects.optional().describe('The recipes inline. Give this or "recipe".'),
1093
607
  recipe: stored.optional().describe('A stored set (drafts included). Give this or "recipes".'),
1094
- full: z.boolean().optional().describe('Run everything, in the background.')
1095
- }
1096
- }, async args => await run(context, args));
1097
- server.registerTool('status', {
1098
- description: 'The state of a full run: runtimeStatus, progress, and when completed the reports, records and result links.',
1099
- inputSchema: {
1100
- instanceId: z.string()
608
+ full: z3.boolean().optional().describe("Run everything, in the background.")
1101
609
  }
1102
- }, async args => await status(context, args));
1103
- server.registerTool('list_recipes', {
1104
- description: 'The recipe sets the host stores: names, versions, draft or promoted.'
1105
- }, async () => await listRecipes(context));
1106
- server.registerTool('get_recipes', {
1107
- description: 'A stored set\'s recipes (drafts included): where to start a new version from.',
1108
- inputSchema: {
1109
- name: z.string(),
1110
- version: z.string()
1111
- }
1112
- }, async args => await getRecipes(context, args));
1113
- server.registerTool('publish', {
1114
- description: 'Save recipes that load as a new version of a named set, as a draft. Production refuses drafts until a person promotes them. A version, once published, never changes: publish the next one instead.',
1115
- inputSchema: {
1116
- name: z.string(),
1117
- version: z.string(),
1118
- recipes: recipeObjects
1119
- }
1120
- }, async args => await publish(context, args));
610
+ }, async (args) => await run(context, args));
611
+ server.registerTool("status", {
612
+ description: "The state of a full run: runtimeStatus, progress, and when completed the reports, records and result links.",
613
+ inputSchema: { instanceId: z3.string() }
614
+ }, async (args) => await status(context, args));
615
+ server.registerTool("list_recipes", { description: "The recipe sets the host stores: names, versions, draft or promoted." }, async () => await listRecipes(context));
616
+ server.registerTool("get_recipes", {
617
+ description: "A stored set's recipes (drafts included): where to start a new version from.",
618
+ inputSchema: { name: z3.string(), version: z3.string() }
619
+ }, async (args) => await getRecipes(context, args));
620
+ server.registerTool("publish", {
621
+ description: "Save recipes that load as a new version of a named set, as a draft. Production refuses drafts until a person promotes them. A version, once published, never changes: publish the next one instead.",
622
+ inputSchema: { name: z3.string(), version: z3.string(), recipes: recipeObjects }
623
+ }, async (args) => await publish(context, args));
1121
624
  return server;
1122
625
  }
1123
626
 
1124
- /**
1125
- * `/mcp`: the authoring MCP server over Streamable HTTP, stateless: every
1126
- * request gets its own server and transport, and a JSON answer, so any
1127
- * instance can take any call.
1128
- *
1129
- * @param request - The request.
1130
- * @param client - The Durable client, for full runs.
1131
- * @param settings - The host settings.
1132
- * @param crawl - The `/crawl` orchestration.
1133
- * @returns The MCP answer, 403 for a caller with no hosts, 404 when the endpoint is off.
1134
- */
627
+ // src/recipe-authoring/mcp-endpoint.handler.ts
1135
628
  async function mcpEndpoint(request, client, settings, crawl) {
1136
- const {
1137
- mcp
1138
- } = settings;
1139
- if (mcp === undefined) return problem(404, 'the MCP endpoint is off on this host');
629
+ const { mcp } = settings;
630
+ if (mcp === void 0) return problem(404, "the MCP endpoint is off on this host");
1140
631
  const access = callerAccess(request, settings);
1141
- if (access === undefined) return problem(403, 'this caller may not run recipes here');
1142
- const server = createAuthoringServer({
1143
- settings: {
1144
- ...settings,
1145
- mcp
1146
- },
1147
- access,
1148
- client,
1149
- crawl
1150
- });
1151
- const transport = new WebStandardStreamableHTTPServerTransport({
1152
- sessionIdGenerator: undefined,
1153
- enableJsonResponse: true
1154
- });
632
+ if (access === void 0) return problem(403, "this caller may not run recipes here");
633
+ const server = createAuthoringServer({ settings: { ...settings, mcp }, access, client, crawl });
634
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
1155
635
  try {
1156
636
  await server.connect(transport);
1157
- const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text();
1158
- const response = await transport.handleRequest(new Request(request.url, {
1159
- method: request.method,
1160
- headers: request.headers,
1161
- body
1162
- }));
1163
- return {
1164
- status: response.status,
1165
- headers: Object.fromEntries(response.headers),
1166
- body: await response.text()
1167
- };
637
+ const body = request.method === "GET" || request.method === "HEAD" ? void 0 : await request.text();
638
+ const response = await transport.handleRequest(new Request(request.url, { method: request.method, headers: request.headers, body }));
639
+ return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
1168
640
  } finally {
1169
641
  await server.close();
1170
642
  }
1171
643
  }
1172
644
 
1173
- /**
1174
- * `POST /recipes/{name}/{version}/promote`: a draft becomes runnable in
1175
- * production. Only for callers `canPromote` allows: an agent publishes, a
1176
- * person promotes.
1177
- *
1178
- * @param request - The request.
1179
- * @param settings - The host settings.
1180
- * @returns The promoted version, 403, or the store's 404.
1181
- */
645
+ // src/recipe-authoring/promote.handler.ts
1182
646
  async function promoteRecipes(request, settings) {
1183
647
  const access = callerAccess(request, settings);
1184
- if (access === undefined || !settings.canPromote(access.caller)) return problem(403, 'this caller may not promote recipes');
1185
- const {
1186
- name = '',
1187
- version = ''
1188
- } = request.params;
1189
- if (settings.recipes === undefined) return problem(404, 'this host has no recipe store');
648
+ if (access === void 0 || !settings.canPromote(access.caller)) return problem(403, "this caller may not promote recipes");
649
+ const { name = "", version = "" } = request.params;
650
+ if (settings.recipes === void 0) return problem(404, "this host has no recipe store");
1190
651
  try {
1191
652
  await settings.recipes.promote(name, version);
1192
653
  } catch (error) {
1193
654
  return problem(error instanceof RecipeStoreError ? error.status : 500, error);
1194
655
  }
1195
- return reply(200, {
1196
- name,
1197
- version,
1198
- state: 'promoted'
1199
- });
656
+ return reply(200, { name, version, state: "promoted" });
1200
657
  }
1201
658
 
1202
- /**
1203
- * A job's windows, within the host's limit and never more than the job has
1204
- * items: twelve windows for three items would only sit idle.
1205
- *
1206
- * @param policy - What the job asked for.
1207
- * @param maxWindows - The host's limit.
1208
- * @param jobSize - About how many items the job has.
1209
- * @returns The policy, capped.
1210
- */
659
+ // src/worker-pools/worker-pools.store.ts
660
+ import { createCrawler as createCrawler3, createWorkInbox, loadRecipes as loadRecipes4 } from "@opencraw/core";
661
+
662
+ // src/worker-pools/windows-cap.policy.ts
1211
663
  function capWindows(policy, maxWindows, jobSize) {
1212
- const asked = typeof policy === 'number' ? {
1213
- min: policy
1214
- } : policy;
664
+ const asked = typeof policy === "number" ? { min: policy } : policy;
1215
665
  const cap = Math.max(1, Math.min(maxWindows, jobSize ?? maxWindows));
1216
666
  const max = Math.min(asked.max ?? asked.min ?? 1, cap);
1217
667
  const min = Math.min(asked.min ?? 1, max);
1218
668
  const start = Math.min(Math.max(asked.start ?? min, min), max);
1219
- return {
1220
- ...asked,
1221
- min,
1222
- max,
1223
- start
1224
- };
669
+ return { ...asked, min, max, start };
1225
670
  }
1226
671
 
1227
- /** Records go back to each item's caller, so the pool keeps none of its own. */
1228
- const discardSink = () => ({
1229
- open: async () => {},
1230
- write: async () => {},
1231
- close: async () => ({
1232
- written: 0
1233
- })
1234
- });
1235
- /** How often idle pools are looked for, at most. */
1236
- const SWEEP_MS = 30_000;
1237
- /**
1238
- * The warm worker pools of this process, one per caller and crawl id. A pool
1239
- * is created by its first item, runs `crawler.work` on an inbox, and closes
1240
- * when its job size is reached, when it has had no item for `idleTtlMs`, or
1241
- * when asked. Pools live in this process's memory: a Function App scaled to
1242
- * several instances has one pool per instance for the same crawl id.
1243
- */
1244
- class WorkerPools {
1245
- settings;
1246
- now;
1247
- pools = new Map();
1248
- /** Each pool's recipe version, known from the moment it starts opening. */
1249
- versions = new Map();
1250
- live = new Map();
1251
- sweeper;
672
+ // src/worker-pools/worker-pools.store.ts
673
+ var discardSink = () => ({ open: async () => {
674
+ }, write: async () => {
675
+ }, close: async () => ({ written: 0 }) });
676
+ var SWEEP_MS = 3e4;
677
+ var WorkerPools = class {
1252
678
  constructor(settings, now = Date.now) {
1253
679
  this.settings = settings;
1254
680
  this.now = now;
1255
681
  }
682
+ settings;
683
+ now;
684
+ pools = /* @__PURE__ */ new Map();
685
+ /** Each pool's recipe version, known from the moment it starts opening. */
686
+ versions = /* @__PURE__ */ new Map();
687
+ live = /* @__PURE__ */ new Map();
688
+ sweeper;
1256
689
  open(job) {
1257
690
  const current = this.pools.get(job.key);
1258
- if (current !== undefined) return current;
691
+ if (current !== void 0) return current;
1259
692
  this.versions.set(job.key, job.recipe);
1260
693
  const opening = this.create(job);
1261
694
  this.pools.set(job.key, opening);
@@ -1269,36 +702,28 @@ class WorkerPools {
1269
702
  return opening;
1270
703
  }
1271
704
  async create(job) {
1272
- const stored = await findRecipes(this.settings.recipes, job.recipe.name, job.recipe.version);
1273
- const set = await loadRecipes(stored.recipes);
1274
- const windows = capWindows(job.windows ?? this.settings.pools.windows, this.settings.pools.maxWindows, job.jobSize);
705
+ const stored2 = await findRecipes(this.settings.recipes, job.recipe.name, job.recipe.version);
706
+ const set = await loadRecipes4(stored2.recipes);
707
+ const windows2 = capWindows(job.windows ?? this.settings.pools.windows, this.settings.pools.maxWindows, job.jobSize);
1275
708
  const pool = {
1276
709
  key: job.key,
1277
710
  crawlId: job.crawlId,
1278
711
  recipe: job.recipe,
1279
712
  inbox: createWorkInbox(),
1280
- windows: windows.start ?? windows.min ?? 1,
1281
- ended: {
1282
- success: 0,
1283
- failure: 0,
1284
- neutral: 0
1285
- },
713
+ windows: windows2.start ?? windows2.min ?? 1,
714
+ ended: { success: 0, failure: 0, neutral: 0 },
1286
715
  lastActive: this.now(),
1287
- working: Promise.resolve(undefined),
1288
- crawler: createCrawler({
716
+ working: Promise.resolve(void 0),
717
+ crawler: createCrawler3({
1289
718
  ...crawlOptionsFor(this.settings, job.allowedHosts),
1290
719
  sink: discardSink(),
1291
- onEvent: event => {
1292
- if (event.type === 'windows:change') pool.windows = event.to;
720
+ onEvent: (event) => {
721
+ if (event.type === "windows:change") pool.windows = event.to;
1293
722
  }
1294
723
  }),
1295
- ...(job.jobSize !== undefined && {
1296
- jobSize: job.jobSize
1297
- })
724
+ ...job.jobSize !== void 0 && { jobSize: job.jobSize }
1298
725
  };
1299
- pool.working = this.work(pool, pool.crawler.work(set, pool.inbox.source, {
1300
- windows
1301
- }));
726
+ pool.working = this.work(pool, pool.crawler.work(set, pool.inbox.source, { windows: windows2 }));
1302
727
  this.live.set(job.key, pool);
1303
728
  this.sweep();
1304
729
  return pool;
@@ -1309,12 +734,12 @@ class WorkerPools {
1309
734
  return await working;
1310
735
  } catch (error) {
1311
736
  pool.inbox.abort(error);
1312
- return undefined;
737
+ return void 0;
1313
738
  } finally {
1314
739
  pool.inbox.close();
1315
740
  await pool.crawler.close();
1316
741
  const opening = this.pools.get(pool.key);
1317
- if (opening !== undefined && this.live.get(pool.key) === pool) this.forget(pool.key, opening);
742
+ if (opening !== void 0 && this.live.get(pool.key) === pool) this.forget(pool.key, opening);
1318
743
  }
1319
744
  }
1320
745
  forget(key, opening) {
@@ -1325,7 +750,7 @@ class WorkerPools {
1325
750
  }
1326
751
  /** Starts looking for pools that have had no item for `idleTtlMs`, while any is open. */
1327
752
  sweep() {
1328
- if (this.sweeper !== undefined) return;
753
+ if (this.sweeper !== void 0) return;
1329
754
  const ttl = this.settings.pools.idleTtlMs;
1330
755
  const every = Math.max(1, Math.min(SWEEP_MS, Math.floor(ttl / 2)));
1331
756
  this.sweeper = setInterval(() => {
@@ -1335,7 +760,7 @@ class WorkerPools {
1335
760
  }
1336
761
  if (this.live.size === 0) {
1337
762
  clearInterval(this.sweeper);
1338
- this.sweeper = undefined;
763
+ this.sweeper = void 0;
1339
764
  }
1340
765
  }, every);
1341
766
  this.sweeper.unref();
@@ -1355,7 +780,7 @@ class WorkerPools {
1355
780
  */
1356
781
  state(key) {
1357
782
  const pool = this.live.get(key);
1358
- return pool === undefined ? undefined : stateOf(pool);
783
+ return pool === void 0 ? void 0 : stateOf(pool);
1359
784
  }
1360
785
  /**
1361
786
  * Runs an item on its job's pool, opening the pool when it is the first.
@@ -1365,61 +790,34 @@ class WorkerPools {
1365
790
  */
1366
791
  async run(job) {
1367
792
  const running = this.versions.get(job.key);
1368
- if (running !== undefined && (running.name !== job.recipe.name || running.version !== job.recipe.version)) {
1369
- return {
1370
- outcome: 'refused',
1371
- error: `crawl "${job.crawlId}" runs recipes "${running.name}" version "${running.version}"; close it or use another crawl id to run version "${job.recipe.version}"`
1372
- };
793
+ if (running !== void 0 && (running.name !== job.recipe.name || running.version !== job.recipe.version)) {
794
+ return { outcome: "refused", error: `crawl "${job.crawlId}" runs recipes "${running.name}" version "${running.version}"; close it or use another crawl id to run version "${job.recipe.version}"` };
1373
795
  }
1374
796
  let pool;
1375
797
  try {
1376
798
  pool = await this.open(job);
1377
- // A pool that is closing takes no more items: wait for it to go, and open the job's next one.
1378
799
  if (pool.inbox.closed) {
1379
800
  await pool.working;
1380
801
  pool = await this.open(job);
1381
802
  }
1382
803
  } catch (error) {
1383
- return {
1384
- outcome: 'refused',
1385
- error: error instanceof Error ? error.message : String(error)
1386
- };
804
+ return { outcome: "refused", error: error instanceof Error ? error.message : String(error) };
1387
805
  }
1388
806
  pool.lastActive = this.now();
1389
807
  let result;
1390
808
  try {
1391
809
  result = await pool.inbox.submit(job.item);
1392
810
  } catch (error) {
1393
- return {
1394
- outcome: 'failure',
1395
- error: error instanceof Error ? error.message : String(error),
1396
- pool: stateOf(pool)
1397
- };
811
+ return { outcome: "failure", error: error instanceof Error ? error.message : String(error), pool: stateOf(pool) };
1398
812
  } finally {
1399
813
  pool.lastActive = this.now();
1400
814
  }
1401
815
  pool.ended[result.outcome] += 1;
1402
816
  const ended = pool.ended.success + pool.ended.failure + pool.ended.neutral;
1403
- if (pool.jobSize !== undefined && ended >= pool.jobSize) pool.inbox.close();
1404
- if (result.outcome !== 'success') return {
1405
- outcome: result.outcome,
1406
- report: result.report,
1407
- ...(result.report.error !== undefined && {
1408
- error: result.report.error
1409
- }),
1410
- pool: stateOf(pool)
1411
- };
1412
- const packed = await packRecords(result.records, {
1413
- store: this.settings.results,
1414
- inlineLimitBytes: this.settings.inlineLimitBytes,
1415
- name: job.resultName
1416
- });
1417
- return {
1418
- outcome: 'success',
1419
- report: result.report,
1420
- ...packed,
1421
- pool: stateOf(pool)
1422
- };
817
+ if (pool.jobSize !== void 0 && ended >= pool.jobSize) pool.inbox.close();
818
+ if (result.outcome !== "success") return { outcome: result.outcome, report: result.report, ...result.report.error !== void 0 && { error: result.report.error }, pool: stateOf(pool) };
819
+ const packed = await packRecords(result.records, { store: this.settings.results, inlineLimitBytes: this.settings.inlineLimitBytes, name: job.resultName });
820
+ return { outcome: "success", report: result.report, ...packed, pool: stateOf(pool) };
1423
821
  }
1424
822
  /**
1425
823
  * Closes a pool: queued and running items finish, then its browser closes.
@@ -1429,7 +827,7 @@ class WorkerPools {
1429
827
  */
1430
828
  async close(key) {
1431
829
  const opening = this.pools.get(key);
1432
- if (opening === undefined) return false;
830
+ if (opening === void 0) return false;
1433
831
  let pool;
1434
832
  try {
1435
833
  pool = await opening;
@@ -1442,11 +840,11 @@ class WorkerPools {
1442
840
  }
1443
841
  /** Closes every pool, as the host shuts down. */
1444
842
  async closeAll() {
1445
- if (this.sweeper !== undefined) clearInterval(this.sweeper);
1446
- this.sweeper = undefined;
1447
- await Promise.all(Array.from(this.pools.keys(), key => this.close(key)));
843
+ if (this.sweeper !== void 0) clearInterval(this.sweeper);
844
+ this.sweeper = void 0;
845
+ await Promise.all(Array.from(this.pools.keys(), (key) => this.close(key)));
1448
846
  }
1449
- }
847
+ };
1450
848
  function stateOf(pool) {
1451
849
  return {
1452
850
  crawlId: pool.crawlId,
@@ -1455,309 +853,182 @@ function stateOf(pool) {
1455
853
  idle: pool.inbox.idle,
1456
854
  queued: pool.inbox.queued,
1457
855
  running: pool.inbox.running,
1458
- ended: {
1459
- ...pool.ended
1460
- },
856
+ ended: { ...pool.ended },
1461
857
  closing: pool.inbox.closed
1462
858
  };
1463
859
  }
1464
860
 
1465
- /**
1466
- * A pooled item's orchestration: one activity, which hands the item to the
1467
- * warm pool and waits for its result. Durable adds what a plain HTTP call
1468
- * lacks: a status URL to poll past the HTTP time limit, and an instance id
1469
- * that refuses a duplicate while the item runs.
1470
- *
1471
- * @param runItem - The activity.
1472
- * @returns The orchestrator body.
1473
- */
861
+ // src/worker-pools/item-orchestration.use-case.ts
862
+ import { callActivity } from "@mnci/az-durable";
1474
863
  function itemOrchestrator(runItem) {
1475
864
  return function* item(context, job) {
1476
865
  return yield* callActivity(context, runItem, job);
1477
866
  };
1478
867
  }
1479
868
 
1480
- /** Ids end up in Durable instance ids and blob names: letters, digits, `_`, `.`, `-`. */
1481
- const id = z.string().regex(/^[\w.-]{1,64}$/, 'use 1 to 64 letters, digits, "_", "." or "-"');
1482
- const count = z.number().int().positive();
1483
- const after = z.strictObject({
1484
- after: count.optional()
1485
- });
1486
- const idle = z.strictObject({
1487
- afterMs: count
1488
- });
1489
- const windowsPolicy = z.strictObject({
869
+ // src/worker-pools/submit-item.handler.ts
870
+ import { createHash } from "node:crypto";
871
+ import { startOrchestration as startOrchestration3 } from "@mnci/az-durable";
872
+
873
+ // src/worker-pools/item-request.validator.ts
874
+ import { z as z4 } from "zod";
875
+ var id = z4.string().regex(/^[\w.-]{1,64}$/, 'use 1 to 64 letters, digits, "_", "." or "-"');
876
+ var count = z4.number().int().positive();
877
+ var after = z4.strictObject({ after: count.optional() });
878
+ var idle = z4.strictObject({ afterMs: count });
879
+ var windowsPolicy = z4.strictObject({
1490
880
  min: count.optional(),
1491
881
  max: count.optional(),
1492
882
  start: count.optional(),
1493
883
  grow: after.optional(),
1494
- shrink: z.enum(['one', 'half']).optional(),
884
+ shrink: z4.enum(["one", "half"]).optional(),
1495
885
  restart: after.optional(),
1496
886
  idle: idle.optional()
1497
887
  });
1498
- const windows = z.union([count, windowsPolicy]);
1499
- const varValue$1 = z.union([z.string(), z.number(), z.boolean()]);
1500
- const recipeVersion = z.strictObject({
1501
- name: z.string().min(1),
1502
- version: z.string().min(1)
1503
- });
1504
- const workItem$1 = z.strictObject({
1505
- id,
1506
- vars: z.record(z.string(), varValue$1),
1507
- recipe: z.string().optional()
1508
- });
1509
- const crawlIdSchema = id;
1510
- /** A `/jobs/{crawlId}/items` body. */
1511
- const itemRequestSchema = z.strictObject({
888
+ var windows = z4.union([count, windowsPolicy]);
889
+ var varValue = z4.union([z4.string(), z4.number(), z4.boolean()]);
890
+ var recipeVersion = z4.strictObject({ name: z4.string().min(1), version: z4.string().min(1) });
891
+ var workItem = z4.strictObject({ id, vars: z4.record(z4.string(), varValue), recipe: z4.string().optional() });
892
+ var crawlIdSchema = id;
893
+ var itemRequestSchema = z4.strictObject({
1512
894
  recipe: recipeVersion,
1513
- item: workItem$1,
895
+ item: workItem,
1514
896
  /** How many items the job has, about: caps the windows and closes the pool once that many ended. */
1515
897
  jobSize: count.optional(),
1516
898
  /** The pool's windows, when this item opens it; capped by the host's `pools.maxWindows`. */
1517
899
  windows: windows.optional()
1518
900
  });
1519
901
 
1520
- /** Durable statuses of an instance still in progress: a duplicate of it gets its status, not a second run. */
1521
- const IN_PROGRESS = new Set(['Pending', 'Running', 'ContinuedAsNew', 'Suspended']);
1522
- /**
1523
- * `POST /jobs/{crawlId}/items`: one item for the caller's warm pool. The
1524
- * Durable instance id is the caller, crawl id and item id, so the same item
1525
- * sent again while it runs returns the running instance's status.
1526
- *
1527
- * @param request - The request.
1528
- * @param client - The Durable client.
1529
- * @param settings - The host settings.
1530
- * @param pools - This process's pools.
1531
- * @param orchestration - The item orchestration.
1532
- * @returns 202 with the Durable status URLs, or the problem.
1533
- */
902
+ // src/worker-pools/submit-item.handler.ts
903
+ var IN_PROGRESS = /* @__PURE__ */ new Set(["Pending", "Running", "ContinuedAsNew", "Suspended"]);
1534
904
  async function submitItem(request, client, settings, pools, orchestration) {
1535
905
  const access = callerAccess(request, settings);
1536
- if (access === undefined) return problem(403, 'this caller may not run recipes here');
906
+ if (access === void 0) return problem(403, "this caller may not run recipes here");
1537
907
  const crawlId = crawlIdSchema.safeParse(request.params.crawlId);
1538
- if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? 'invalid'}`);
908
+ if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? "invalid"}`);
1539
909
  const parsed = itemRequestSchema.safeParse(await readJson(request));
1540
910
  if (!parsed.success) return problem(400, `the body must be { recipe: { name, version }, item: { id, vars }, jobSize?, windows? }: ${parsed.error.message}`);
1541
- const {
1542
- recipe,
1543
- item,
1544
- jobSize,
1545
- windows
1546
- } = parsed.data;
911
+ const { recipe, item, jobSize, windows: windows2 } = parsed.data;
1547
912
  const key = poolKey(access, crawlId.data);
1548
913
  const running = pools.versionOf(key);
1549
- if (running !== undefined && (running.name !== recipe.name || running.version !== recipe.version)) return problem(409, `crawl "${crawlId.data}" runs recipes "${running.name}" version "${running.version}"`);
914
+ if (running !== void 0 && (running.name !== recipe.name || running.version !== recipe.version)) return problem(409, `crawl "${crawlId.data}" runs recipes "${running.name}" version "${running.version}"`);
1550
915
  try {
1551
916
  await findRecipes(settings.recipes, recipe.name, recipe.version);
1552
917
  } catch (error) {
1553
918
  return problem(error instanceof RecipeStoreError ? error.status : 400, error);
1554
919
  }
1555
920
  const instanceId = `${callerTag(access.caller)}${crawlId.data}:${item.id}`;
1556
- if (IN_PROGRESS.has((await statusOf(client, instanceId)) ?? '')) return client.createCheckStatusResponse(request, instanceId);
921
+ if (IN_PROGRESS.has(await statusOf(client, instanceId) ?? "")) return client.createCheckStatusResponse(request, instanceId);
1557
922
  const job = {
1558
923
  key,
1559
924
  crawlId: crawlId.data,
1560
925
  allowedHosts: [...access.allowedHosts],
1561
926
  recipe,
1562
927
  item,
1563
- resultName: `${instanceId.replaceAll(':', '/')}.jsonl`,
1564
- ...(jobSize !== undefined && {
1565
- jobSize
1566
- }),
1567
- ...(windows !== undefined && {
1568
- windows
1569
- })
928
+ resultName: `${instanceId.replaceAll(":", "/")}.jsonl`,
929
+ ...jobSize !== void 0 && { jobSize },
930
+ ...windows2 !== void 0 && { windows: windows2 }
1570
931
  };
1571
- await startOrchestration(client, orchestration, job, {
1572
- instanceId
1573
- });
932
+ await startOrchestration3(client, orchestration, job, { instanceId });
1574
933
  return client.createCheckStatusResponse(request, instanceId);
1575
934
  }
1576
- /** Two callers may use the same crawl and item ids: a short hash of the caller keeps their instances apart. */
1577
935
  function callerTag(caller) {
1578
- return caller === undefined ? '' : `${createHash('sha256').update(caller).digest('hex').slice(0, 12)}:`;
936
+ return caller === void 0 ? "" : `${createHash("sha256").update(caller).digest("hex").slice(0, 12)}:`;
1579
937
  }
1580
- /** The instance's runtime status; `undefined` when there is none (the client throws for an unknown instance). */
1581
938
  async function statusOf(client, instanceId) {
1582
939
  try {
1583
- const status = await client.getStatus(instanceId);
1584
- return status.runtimeStatus;
940
+ const status2 = await client.getStatus(instanceId);
941
+ return status2.runtimeStatus;
1585
942
  } catch {
1586
- return undefined;
943
+ return void 0;
1587
944
  }
1588
945
  }
1589
946
 
1590
- /**
1591
- * `GET /jobs/{crawlId}`: the pool's state. `DELETE /jobs/{crawlId}`: closes
1592
- * it; queued and running items finish first.
1593
- *
1594
- * @param request - The request.
1595
- * @param settings - The host settings.
1596
- * @param pools - This process's pools.
1597
- * @returns The state, 202 when closing, 404 when there is no pool.
1598
- */
947
+ // src/worker-pools/job.handler.ts
1599
948
  async function jobRequest(request, settings, pools) {
1600
949
  const access = callerAccess(request, settings);
1601
- if (access === undefined) return problem(403, 'this caller may not run recipes here');
950
+ if (access === void 0) return problem(403, "this caller may not run recipes here");
1602
951
  const crawlId = crawlIdSchema.safeParse(request.params.crawlId);
1603
- if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? 'invalid'}`);
952
+ if (!crawlId.success) return problem(400, `crawl id: ${crawlId.error.issues[0]?.message ?? "invalid"}`);
1604
953
  const key = poolKey(access, crawlId.data);
1605
954
  const state = pools.state(key);
1606
- if (state === undefined) return problem(404, `no pool for crawl "${crawlId.data}" in this instance`);
1607
- if (request.method === 'DELETE') {
955
+ if (state === void 0) return problem(404, `no pool for crawl "${crawlId.data}" in this instance`);
956
+ if (request.method === "DELETE") {
1608
957
  void pools.close(key);
1609
- return reply(202, {
1610
- ...state,
1611
- closing: true
1612
- });
958
+ return reply(202, { ...state, closing: true });
1613
959
  }
1614
960
  return reply(200, state);
1615
961
  }
1616
962
 
1617
- const varValue = z.union([z.string(), z.number(), z.boolean()]);
1618
- const workItem = z.object({
1619
- id: z.string(),
1620
- vars: z.record(z.string(), varValue),
1621
- recipe: z.string().optional()
1622
- });
1623
- const itemJobSchema = z.object({
1624
- key: z.string(),
1625
- crawlId: z.string(),
1626
- allowedHosts: z.array(z.string()).min(1),
1627
- recipe: z.object({
1628
- name: z.string(),
1629
- version: z.string()
1630
- }),
1631
- item: workItem,
1632
- jobSize: z.number().optional(),
1633
- windows: z.unknown().optional(),
1634
- resultName: z.string()
963
+ // src/worker-pools/item-job.validator.ts
964
+ import { z as z5 } from "zod";
965
+ var varValue2 = z5.union([z5.string(), z5.number(), z5.boolean()]);
966
+ var workItem2 = z5.object({ id: z5.string(), vars: z5.record(z5.string(), varValue2), recipe: z5.string().optional() });
967
+ var itemJobSchema = z5.object({
968
+ key: z5.string(),
969
+ crawlId: z5.string(),
970
+ allowedHosts: z5.array(z5.string()).min(1),
971
+ recipe: z5.object({ name: z5.string(), version: z5.string() }),
972
+ item: workItem2,
973
+ jobSize: z5.number().optional(),
974
+ windows: z5.unknown().optional(),
975
+ resultName: z5.string()
1635
976
  });
1636
- /**
1637
- * Checks a pooled item's orchestration input as it comes back out of the task hub.
1638
- *
1639
- * @param raw - The stored input.
1640
- * @returns The job.
1641
- * @throws ZodError when it is not one.
1642
- */
1643
977
  function parseItemJob(raw) {
1644
978
  return itemJobSchema.parse(raw);
1645
979
  }
1646
980
 
1647
- /**
1648
- * What in the Function App's `host.json` would make the host misbehave. An
1649
- * item waiting in a pool's inbox holds an activity slot, so a pool can only
1650
- * fill its windows if Durable runs that many activities at once.
1651
- *
1652
- * @param maxWindows - The most windows one pool may have.
1653
- * @param directory - Where `host.json` is; the working directory by default.
1654
- * @returns Warnings, none when `host.json` is missing or fine.
1655
- */
981
+ // src/open-craw-host/host-json.validator.ts
982
+ import { readFileSync } from "node:fs";
983
+ import { join } from "node:path";
1656
984
  function hostJsonWarnings(maxWindows, directory = process.cwd()) {
1657
985
  let host;
1658
986
  try {
1659
- host = JSON.parse(readFileSync(join(directory, 'host.json'), 'utf8'));
987
+ host = JSON.parse(readFileSync(join(directory, "host.json"), "utf8"));
1660
988
  } catch {
1661
989
  return [];
1662
990
  }
1663
991
  const slots = host.extensions?.durableTask?.maxConcurrentActivityFunctions;
1664
- if (typeof slots !== 'number' || slots >= maxWindows + 2) return [];
992
+ if (typeof slots !== "number" || slots >= maxWindows + 2) return [];
1665
993
  return [`host.json: extensions.durableTask.maxConcurrentActivityFunctions is ${slots}, but one pool may run ${maxWindows} windows; items waiting in a pool hold activity slots, so set it to at least ${maxWindows + 2}`];
1666
994
  }
1667
995
 
1668
- /**
1669
- * Registers OpenCraw's HTTP API in this Function App (the v4 programming
1670
- * model): `POST /crawl` runs recipes once, `POST /jobs/{crawlId}/items`
1671
- * sends one item to the caller's warm pool, `GET` and `DELETE
1672
- * /jobs/{crawlId}` read and close it, `POST /recipes/{name}/{version}/promote`
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
1675
- * tools. Call it once, from the app's entry module; nothing is registered by
1676
- * importing the package.
1677
- *
1678
- * @param options - Allowed hosts, callers, recipes, result storage, plugins, pools.
1679
- * @returns The registered pieces.
1680
- * @throws Error when the options cannot work, or when it is called twice (function names are global).
1681
- */
1682
- function registerOpenCraw(options) {
1683
- const settings = resolveHostOptions(options);
996
+ // src/open-craw-host/register-open-craw.use-case.ts
997
+ function registerOpenCraw(options2) {
998
+ const settings = resolveHostOptions(options2);
1684
999
  const pools = new WorkerPools(settings);
1685
1000
  for (const warning of hostJsonWarnings(settings.pools.maxWindows)) console.warn(`[opencraw] ${warning}`);
1686
- const runRecipe = async task => await runRecipeTask(settings, task);
1687
- const runItem = async job => await pools.run(job);
1688
- const crawl = defineOrchestration('OpenCrawCrawl', crawlOrchestrator(defineActivity('OpenCrawRunRecipe', runRecipe), {
1689
- waitMs: settings.callouts?.waitMs
1690
- }), {
1691
- parse: parseCrawlJob
1692
- });
1693
- const item = defineOrchestration('OpenCrawItem', itemOrchestrator(defineActivity('OpenCrawRunItem', runItem)), {
1694
- parse: parseItemJob
1695
- });
1001
+ const runRecipe = async (task) => await runRecipeTask(settings, task);
1002
+ const runItem = async (job) => await pools.run(job);
1003
+ const crawl = defineOrchestration("OpenCrawCrawl", crawlOrchestrator(defineActivity("OpenCrawRunRecipe", runRecipe), { waitMs: settings.callouts?.waitMs }), { parse: parseCrawlJob });
1004
+ const item = defineOrchestration("OpenCrawItem", itemOrchestrator(defineActivity("OpenCrawRunItem", runItem)), { parse: parseItemJob });
1696
1005
  const http = {
1697
1006
  startCrawl: async (request, client) => await startCrawl(request, client, settings, crawl),
1698
1007
  resolveCallout: async (request, client) => await resolveCallout(request, client, settings),
1699
1008
  submitItem: async (request, client) => await submitItem(request, client, settings, pools, item),
1700
- job: async request => await jobRequest(request, settings, pools),
1009
+ job: async (request) => await jobRequest(request, settings, pools),
1701
1010
  mcp: async (request, client) => await mcpEndpoint(request, client, settings, crawl),
1702
- promote: async request => await promoteRecipes(request, settings)
1011
+ promote: async (request) => await promoteRecipes(request, settings)
1703
1012
  };
1704
- const route = path => settings.routePrefix === '' ? path : `${settings.routePrefix}/${path}`;
1705
- df.app.client.http('OpenCrawStartCrawl', {
1706
- route: route('crawl'),
1707
- methods: ['POST'],
1708
- authLevel: settings.authLevel,
1709
- handler: http.startCrawl
1710
- });
1711
- df.app.client.http('OpenCrawSubmitItem', {
1712
- route: route('jobs/{crawlId}/items'),
1713
- methods: ['POST'],
1714
- authLevel: settings.authLevel,
1715
- handler: http.submitItem
1716
- });
1717
- app.http('OpenCrawJob', {
1718
- route: route('jobs/{crawlId}'),
1719
- methods: ['GET', 'DELETE'],
1720
- authLevel: settings.authLevel,
1721
- handler: http.job
1722
- });
1723
- app.http('OpenCrawPromote', {
1724
- route: route('recipes/{name}/{version}/promote'),
1725
- methods: ['POST'],
1726
- authLevel: settings.authLevel,
1727
- handler: http.promote
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
- });
1736
- if (settings.mcp !== undefined) df.app.client.http('OpenCrawMcp', {
1737
- route: route('mcp'),
1738
- methods: ['GET', 'POST', 'DELETE'],
1739
- authLevel: settings.authLevel,
1740
- handler: http.mcp
1741
- });
1013
+ const route = (path) => settings.routePrefix === "" ? path : `${settings.routePrefix}/${path}`;
1014
+ df.app.client.http("OpenCrawStartCrawl", { route: route("crawl"), methods: ["POST"], authLevel: settings.authLevel, handler: http.startCrawl });
1015
+ df.app.client.http("OpenCrawSubmitItem", { route: route("jobs/{crawlId}/items"), methods: ["POST"], authLevel: settings.authLevel, handler: http.submitItem });
1016
+ app.http("OpenCrawJob", { route: route("jobs/{crawlId}"), methods: ["GET", "DELETE"], authLevel: settings.authLevel, handler: http.job });
1017
+ app.http("OpenCrawPromote", { route: route("recipes/{name}/{version}/promote"), methods: ["POST"], authLevel: settings.authLevel, handler: http.promote });
1018
+ if (settings.callouts !== void 0) df.app.client.http("OpenCrawResolveCallout", { route: route("callouts/{token}/resolve"), methods: ["POST"], authLevel: "anonymous", handler: http.resolveCallout });
1019
+ if (settings.mcp !== void 0) df.app.client.http("OpenCrawMcp", { route: route("mcp"), methods: ["GET", "POST", "DELETE"], authLevel: settings.authLevel, handler: http.mcp });
1742
1020
  const close = async () => {
1743
1021
  await pools.closeAll();
1744
1022
  };
1745
1023
  app.hook.appTerminate(close);
1746
- return {
1747
- settings,
1748
- pools,
1749
- orchestrations: {
1750
- crawl,
1751
- item
1752
- },
1753
- activities: {
1754
- runRecipe,
1755
- runItem
1756
- },
1757
- http,
1758
- close
1759
- };
1024
+ return { settings, pools, orchestrations: { crawl, item }, activities: { runRecipe, runItem }, http, close };
1760
1025
  }
1761
-
1762
- export { RecipeStoreError, blobRecipes, blobResults, hostJsonWarnings, memoryRecipes, registerOpenCraw };
1026
+ export {
1027
+ RecipeStoreError,
1028
+ blobRecipes,
1029
+ blobResults,
1030
+ hostJsonWarnings,
1031
+ memoryRecipes,
1032
+ registerOpenCraw
1033
+ };
1763
1034
  //# sourceMappingURL=index.esm.js.map