@opencraw/azure-durable 0.0.48 → 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.
- package/dist/caller-access/caller-access.policy.d.ts +28 -0
- package/dist/caller-access/index.d.ts +3 -0
- package/dist/callout-resume/callout-event.mapper.d.ts +30 -0
- package/dist/callout-resume/callout-ticket.algorithm.d.ts +36 -0
- package/dist/callout-resume/index.d.ts +5 -0
- package/dist/callout-resume/resolve-callout.handler.d.ts +17 -0
- package/dist/crawl-run/crawl-job.model.d.ts +52 -0
- package/dist/crawl-run/crawl-job.validator.d.ts +11 -0
- package/dist/crawl-run/crawl-orchestration.use-case.d.ts +22 -0
- package/dist/crawl-run/crawl-request.validator.d.ts +25 -0
- package/dist/crawl-run/index.d.ts +8 -0
- package/dist/crawl-run/run-recipe.use-case.d.ts +18 -0
- package/dist/crawl-run/start-crawl.handler.d.ts +18 -0
- package/dist/host-options/host-options.config.d.ts +100 -0
- package/dist/host-options/index.d.ts +3 -0
- package/dist/http-reply/http-reply.mapper.d.ts +24 -0
- package/dist/http-reply/index.d.ts +2 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.esm.js +428 -1157
- package/dist/open-craw-host/host-json.validator.d.ts +11 -0
- package/dist/open-craw-host/index.d.ts +4 -0
- package/dist/open-craw-host/register-open-craw.use-case.d.ts +48 -0
- package/dist/recipe-authoring/authoring-server.use-case.d.ts +13 -0
- package/dist/recipe-authoring/authoring-tools.use-case.d.ts +96 -0
- package/dist/recipe-authoring/index.d.ts +5 -0
- package/dist/recipe-authoring/mcp-endpoint.handler.d.ts +18 -0
- package/dist/recipe-authoring/promote.handler.d.ts +13 -0
- package/dist/recipe-store/blob-recipe.store.d.ts +20 -0
- package/dist/recipe-store/find-recipes.use-case.d.ts +13 -0
- package/dist/recipe-store/index.d.ts +7 -0
- package/dist/recipe-store/memory-recipe.store.d.ts +12 -0
- package/dist/recipe-store/recipe-store.contract.d.ts +28 -0
- package/dist/recipe-store/recipe-store.error.d.ts +6 -0
- package/dist/result-store/blob-result.store.d.ts +18 -0
- package/dist/result-store/index.d.ts +6 -0
- package/dist/result-store/result-packing.policy.d.ts +22 -0
- package/dist/result-store/result-store.contract.d.ts +13 -0
- package/dist/src/crawl-run/crawl-request.validator.d.ts +2 -2
- package/dist/src/worker-pools/item-request.validator.d.ts +1 -1
- package/dist/worker-pools/index.d.ts +10 -0
- package/dist/worker-pools/item-job.validator.d.ts +10 -0
- package/dist/worker-pools/item-orchestration.use-case.d.ts +14 -0
- package/dist/worker-pools/item-request.validator.d.ts +35 -0
- package/dist/worker-pools/job.handler.d.ts +14 -0
- package/dist/worker-pools/pool-job.model.d.ts +43 -0
- package/dist/worker-pools/submit-item.handler.d.ts +20 -0
- package/dist/worker-pools/windows-cap.policy.d.ts +12 -0
- package/dist/worker-pools/worker-pools.store.d.ts +58 -0
- package/package.json +10 -38
package/dist/index.esm.js
CHANGED
|
@@ -1,228 +1,100 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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
|
-
|
|
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(
|
|
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),
|
|
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 ===
|
|
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,
|
|
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 !==
|
|
62
|
-
|
|
63
|
-
|
|
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 !==
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 ===
|
|
135
|
-
return reply(
|
|
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
|
|
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 ===
|
|
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 ===
|
|
173
|
-
const reading = readCalloutTicket(secret, request.params.token ??
|
|
174
|
-
if (!reading.ok) return problem(reading.reason ===
|
|
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"
|
|
177
|
-
const {
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
...
|
|
235
|
-
|
|
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 !==
|
|
260
|
-
const stopped = new Set([...slot.events, ...
|
|
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 =
|
|
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 ===
|
|
134
|
+
if (owner === void 0) continue;
|
|
274
135
|
const slot = slots[owner.index];
|
|
275
|
-
if (owner.kind ===
|
|
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 ===
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
...
|
|
375
|
-
|
|
376
|
-
}),
|
|
377
|
-
...(callouts !== undefined && {
|
|
378
|
-
callouts
|
|
379
|
-
}),
|
|
202
|
+
...mcp !== void 0 && { mcp },
|
|
203
|
+
...callouts !== void 0 && { callouts },
|
|
380
204
|
canPromote: canPromote ?? (() => false),
|
|
381
|
-
inlineLimitBytes:
|
|
382
|
-
routePrefix: (
|
|
205
|
+
inlineLimitBytes: options2.inlineLimitBytes ?? 256 * KIB,
|
|
206
|
+
routePrefix: (options2.routePrefix ?? "").replaceAll(/^\/+|\/+$/g, ""),
|
|
383
207
|
authLevel,
|
|
384
208
|
pools: {
|
|
385
|
-
idleTtlMs:
|
|
386
|
-
windows:
|
|
387
|
-
|
|
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(
|
|
401
|
-
const key = process.env[
|
|
402
|
-
if (key ===
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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(
|
|
447
|
-
await blob.upload(body, Buffer.byteLength(body), {
|
|
448
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
|
265
|
+
const run2 = async () => {
|
|
506
266
|
const result = await crawler.run(set);
|
|
507
267
|
return result.recipes;
|
|
508
268
|
};
|
|
509
|
-
reports = waiter ===
|
|
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
|
-
|
|
515
|
-
|
|
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 ===
|
|
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 +
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 ===
|
|
558
|
-
if (allowedHosts ===
|
|
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 ??
|
|
302
|
+
return `${access.caller ?? ""}\0${crawlId}`;
|
|
575
303
|
}
|
|
576
304
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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 =
|
|
583
|
-
this.name =
|
|
309
|
+
this.status = status2;
|
|
310
|
+
this.name = "RecipeStoreError";
|
|
584
311
|
}
|
|
585
|
-
|
|
312
|
+
status;
|
|
313
|
+
};
|
|
586
314
|
|
|
587
|
-
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
639
|
-
if (!allowDraft && found.state ===
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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 !==
|
|
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 !==
|
|
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 [...
|
|
380
|
+
return [...await shipped.list(), ...published];
|
|
698
381
|
},
|
|
699
382
|
async put(entry) {
|
|
700
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
745
|
-
|
|
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([
|
|
413
|
+
dedupe: z.enum(["recipe", "off"]).optional()
|
|
748
414
|
}).optional();
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 ===
|
|
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 (
|
|
783
|
-
const
|
|
784
|
-
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
|
|
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
|
|
437
|
+
const output = recipes.find((recipe) => kindOf(recipe) === "output");
|
|
793
438
|
const job = {
|
|
794
|
-
...
|
|
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
|
-
...
|
|
801
|
-
|
|
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
|
|
811
|
-
return typeof recipe ===
|
|
449
|
+
function kindOf(recipe) {
|
|
450
|
+
return typeof recipe === "object" && recipe !== null && "kind" in recipe ? recipe.kind : void 0;
|
|
812
451
|
}
|
|
813
452
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
...
|
|
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
|
|
507
|
+
await loadRecipes3(recipes);
|
|
909
508
|
} catch (error) {
|
|
910
509
|
return failed(error);
|
|
911
510
|
}
|
|
912
|
-
const output = recipes.find(recipe =>
|
|
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
|
-
|
|
917
|
-
|
|
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 =
|
|
931
|
-
const crawler =
|
|
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
|
|
938
|
-
const finished = await Promise.race([crawler.run(set),
|
|
939
|
-
|
|
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
|
-
...
|
|
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 ===
|
|
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
|
|
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 ===
|
|
1035
|
-
if (args.recipes !==
|
|
1036
|
-
const
|
|
1037
|
-
return
|
|
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 !==
|
|
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
|
|
1052
|
-
return typeof recipe ===
|
|
581
|
+
function kindOf2(recipe) {
|
|
582
|
+
return typeof recipe === "object" && recipe !== null && "kind" in recipe ? recipe.kind : void 0;
|
|
1053
583
|
}
|
|
1054
584
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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(
|
|
1076
|
-
description:
|
|
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:
|
|
1079
|
-
browser:
|
|
1080
|
-
access:
|
|
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(
|
|
1084
|
-
description:
|
|
1085
|
-
inputSchema: {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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:
|
|
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
|
|
1103
|
-
server.registerTool(
|
|
1104
|
-
description:
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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 ===
|
|
1142
|
-
const server = createAuthoringServer({
|
|
1143
|
-
|
|
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 ===
|
|
1158
|
-
const response = await transport.handleRequest(new Request(request.url, {
|
|
1159
|
-
|
|
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 ===
|
|
1185
|
-
const {
|
|
1186
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
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 ===
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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 !==
|
|
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
|
|
1273
|
-
const set = await
|
|
1274
|
-
const
|
|
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:
|
|
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(
|
|
1288
|
-
crawler:
|
|
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 ===
|
|
720
|
+
onEvent: (event) => {
|
|
721
|
+
if (event.type === "windows:change") pool.windows = event.to;
|
|
1293
722
|
}
|
|
1294
723
|
}),
|
|
1295
|
-
...
|
|
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
|
|
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 !==
|
|
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 !==
|
|
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 =
|
|
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 ===
|
|
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 !==
|
|
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 !==
|
|
1404
|
-
if (result.outcome !==
|
|
1405
|
-
|
|
1406
|
-
|
|
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 ===
|
|
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 !==
|
|
1446
|
-
this.sweeper =
|
|
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
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
});
|
|
1489
|
-
|
|
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:
|
|
884
|
+
shrink: z4.enum(["one", "half"]).optional(),
|
|
1495
885
|
restart: after.optional(),
|
|
1496
886
|
idle: idle.optional()
|
|
1497
887
|
});
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
|
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
|
-
|
|
1521
|
-
|
|
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 ===
|
|
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 ??
|
|
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 !==
|
|
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(
|
|
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(
|
|
1564
|
-
...
|
|
1565
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
1584
|
-
return
|
|
940
|
+
const status2 = await client.getStatus(instanceId);
|
|
941
|
+
return status2.runtimeStatus;
|
|
1585
942
|
} catch {
|
|
1586
|
-
return
|
|
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 ===
|
|
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 ??
|
|
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 ===
|
|
1607
|
-
if (request.method ===
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
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,
|
|
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 !==
|
|
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
|
-
|
|
1670
|
-
|
|
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(
|
|
1689
|
-
|
|
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 ===
|
|
1705
|
-
df.app.client.http(
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
1026
|
+
export {
|
|
1027
|
+
RecipeStoreError,
|
|
1028
|
+
blobRecipes,
|
|
1029
|
+
blobResults,
|
|
1030
|
+
hostJsonWarnings,
|
|
1031
|
+
memoryRecipes,
|
|
1032
|
+
registerOpenCraw
|
|
1033
|
+
};
|
|
1763
1034
|
//# sourceMappingURL=index.esm.js.map
|