patchwork-os 1.2.0-beta.2.canary.827 → 1.2.0-beta.2.canary.830
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/recipeOrchestration.js +41 -4
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +23 -13
- package/dist/recipeRoutes.js +159 -47
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/chainedRunner.js +4 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/runRegistry.d.ts +24 -7
- package/dist/recipes/runRegistry.js +41 -16
- package/dist/recipes/runRegistry.js.map +1 -1
- package/dist/recipes/yamlRunner.js +2 -2
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/runLog.d.ts +15 -0
- package/dist/runLog.js +73 -0
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +10 -6
- package/dist/server.js +9 -0
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/dist/recipeRoutes.js
CHANGED
|
@@ -40,7 +40,7 @@ import { respond500 } from "./httpErrorResponse.js";
|
|
|
40
40
|
import { loadConfig as loadPatchworkConfig } from "./patchworkConfig.js";
|
|
41
41
|
import { patchworkPath } from "./patchworkHome.js";
|
|
42
42
|
import { summariseBoundaryReceipts } from "./privacy/boundaryReceipts.js";
|
|
43
|
-
import {
|
|
43
|
+
import { cancelRunChecked } from "./recipes/runRegistry.js";
|
|
44
44
|
import { invalidateRecipesCache } from "./recipesHttp.js";
|
|
45
45
|
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
46
46
|
import { AmbiguousActionRefError } from "./workers/actionRef.js";
|
|
@@ -544,6 +544,57 @@ function fireOnRecipesChanged(deps) {
|
|
|
544
544
|
*
|
|
545
545
|
* Must be called AFTER bearer-auth — none of these routes are public.
|
|
546
546
|
*/
|
|
547
|
+
/**
|
|
548
|
+
* The replay response, shared by `/runs/:seq/replay` and
|
|
549
|
+
* `/runs/by-task/:taskId/replay` so the two cannot drift.
|
|
550
|
+
*/
|
|
551
|
+
function writeReplayResult(res, result) {
|
|
552
|
+
if (result.error === "run_not_found") {
|
|
553
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
554
|
+
}
|
|
555
|
+
else if (result.error === "ambiguous_seq" ||
|
|
556
|
+
result.error?.startsWith("replay_refused_unmocked_step")) {
|
|
557
|
+
// 409 either way — a conflict with the evidence, not a server fault:
|
|
558
|
+
// `ambiguous_seq` means the seq names more than one run (body carries
|
|
559
|
+
// `taskIds`; replay by taskId instead); the replay boundary
|
|
560
|
+
// (src/recipes/replayBoundary.ts) means the original run lacks a usable
|
|
561
|
+
// capture, so nothing ran (body carries `unmockedSteps`).
|
|
562
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
563
|
+
}
|
|
564
|
+
else if (!result.ok) {
|
|
565
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
569
|
+
}
|
|
570
|
+
res.end(JSON.stringify(result));
|
|
571
|
+
}
|
|
572
|
+
/** The dry-run plan for the recipe that produced `run`. Shared by seq/taskId routes. */
|
|
573
|
+
async function writePlanForRun(res, deps, run) {
|
|
574
|
+
if (!run) {
|
|
575
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
576
|
+
res.end(JSON.stringify({ error: "run_not_found" }));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (!deps.runPlanFn) {
|
|
580
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
581
|
+
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// Guard: recipeName may be absent on runs created before the field was
|
|
585
|
+
// added (audit LOW #36). An unsafe cast + immediate .replace() would
|
|
586
|
+
// throw TypeError — return 404 instead.
|
|
587
|
+
if (!run.recipeName) {
|
|
588
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
589
|
+
res.end(JSON.stringify({ error: "recipe_name_missing" }));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// triggerSource appends ":agent" suffix — strip before file lookup
|
|
593
|
+
const recipeName = run.recipeName.replace(/:agent$/, "");
|
|
594
|
+
const plan = await deps.runPlanFn(recipeName);
|
|
595
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
596
|
+
res.end(JSON.stringify({ plan }));
|
|
597
|
+
}
|
|
547
598
|
export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
548
599
|
const recipeNameRunMatch = req.method === "POST"
|
|
549
600
|
? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
|
|
@@ -1175,6 +1226,71 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1175
1226
|
})();
|
|
1176
1227
|
return true;
|
|
1177
1228
|
}
|
|
1229
|
+
// /runs/by-task/:taskId[/replay|/plan] — the same three operations as the
|
|
1230
|
+
// seq routes below, addressed by the run's real identity. A seq is only
|
|
1231
|
+
// unique per bridge (RecipeRunLog.getAllBySeq); a taskId names one run.
|
|
1232
|
+
// The taskId is URL-encoded by the caller (it contains `:`).
|
|
1233
|
+
const byTaskMatch = /^\/runs\/by-task\/([^/]+)(\/replay|\/plan)?$/.exec(parsedUrl.pathname);
|
|
1234
|
+
if (byTaskMatch?.[1]) {
|
|
1235
|
+
let taskId;
|
|
1236
|
+
try {
|
|
1237
|
+
taskId = decodeURIComponent(byTaskMatch[1]);
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1241
|
+
res.end(JSON.stringify({ error: "invalid_task_id" }));
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
const op = byTaskMatch[2];
|
|
1245
|
+
if (op === undefined && req.method === "GET") {
|
|
1246
|
+
try {
|
|
1247
|
+
const run = deps.runDetailByTaskFn?.(taskId) ?? null;
|
|
1248
|
+
res.writeHead(run ? 200 : 404, { "Content-Type": "application/json" });
|
|
1249
|
+
res.end(JSON.stringify(run ? { run } : { error: "not_found" }));
|
|
1250
|
+
}
|
|
1251
|
+
catch (err) {
|
|
1252
|
+
respond500(res, err);
|
|
1253
|
+
}
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
if (op === "/replay" && req.method === "POST") {
|
|
1257
|
+
void (async () => {
|
|
1258
|
+
try {
|
|
1259
|
+
if (!deps.runReplayByTaskFn) {
|
|
1260
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1261
|
+
res.end(JSON.stringify({ error: "replay_unavailable" }));
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
writeReplayResult(res, await deps.runReplayByTaskFn(taskId));
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
respond500(res, err, "runs/by-task replay");
|
|
1268
|
+
}
|
|
1269
|
+
})();
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
if (op === "/plan" && req.method === "GET") {
|
|
1273
|
+
void (async () => {
|
|
1274
|
+
try {
|
|
1275
|
+
await writePlanForRun(res, deps, deps.runDetailByTaskFn?.(taskId) ?? null);
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
const code = err?.code;
|
|
1279
|
+
if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
|
|
1280
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1281
|
+
res.end(JSON.stringify({ error: "Run not found" }));
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
respond500(res, err, "runs/by-task plan");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
})();
|
|
1288
|
+
return true;
|
|
1289
|
+
}
|
|
1290
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
1291
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1178
1294
|
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
1179
1295
|
const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
|
|
1180
1296
|
if (runDetailMatch?.[1]) {
|
|
@@ -1186,8 +1302,17 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1186
1302
|
res.end(JSON.stringify({ error: "not_found" }));
|
|
1187
1303
|
}
|
|
1188
1304
|
else {
|
|
1305
|
+
// A seq can name several runs; this route shows the first it meets.
|
|
1306
|
+
// Say so, with every candidate, so the page can offer a choice and
|
|
1307
|
+
// link by taskId instead of silently showing a different run.
|
|
1308
|
+
const sameSeq = deps.runsBySeqFn?.(seq) ?? [];
|
|
1189
1309
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1190
|
-
res.end(JSON.stringify({
|
|
1310
|
+
res.end(JSON.stringify({
|
|
1311
|
+
run,
|
|
1312
|
+
...(sameSeq.length > 1 && {
|
|
1313
|
+
sameSeqTaskIds: sameSeq.map((r) => r.taskId),
|
|
1314
|
+
}),
|
|
1315
|
+
}));
|
|
1191
1316
|
}
|
|
1192
1317
|
}
|
|
1193
1318
|
catch (err) {
|
|
@@ -1213,24 +1338,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1213
1338
|
res.end(JSON.stringify({ error: "replay_unavailable" }));
|
|
1214
1339
|
return;
|
|
1215
1340
|
}
|
|
1216
|
-
|
|
1217
|
-
if (result.error === "run_not_found") {
|
|
1218
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1219
|
-
}
|
|
1220
|
-
else if (result.error?.startsWith("replay_refused_unmocked_step")) {
|
|
1221
|
-
// Replay boundary (src/recipes/replayBoundary.ts): the original
|
|
1222
|
-
// run lacks a usable capture for one or more steps, so the replay
|
|
1223
|
-
// was refused before anything ran. A conflict with the evidence,
|
|
1224
|
-
// not a server fault — body carries `unmockedSteps`.
|
|
1225
|
-
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1226
|
-
}
|
|
1227
|
-
else if (!result.ok) {
|
|
1228
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1229
|
-
}
|
|
1230
|
-
else {
|
|
1231
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1232
|
-
}
|
|
1233
|
-
res.end(JSON.stringify(result));
|
|
1341
|
+
writeReplayResult(res, await deps.runReplayFn(seq));
|
|
1234
1342
|
}
|
|
1235
1343
|
catch (err) {
|
|
1236
1344
|
respond500(res, err, "runs/:seq detail");
|
|
@@ -1248,12 +1356,39 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1248
1356
|
: null;
|
|
1249
1357
|
if (runCancelMatch?.[1]) {
|
|
1250
1358
|
const seq = Number.parseInt(runCancelMatch[1], 10);
|
|
1359
|
+
const taskId = parsedUrl.searchParams.get("taskId") ?? undefined;
|
|
1251
1360
|
try {
|
|
1252
|
-
|
|
1253
|
-
|
|
1361
|
+
// `seq` is not unique across the bridges sharing one run log. Without a
|
|
1362
|
+
// `taskId`, a number that names more than one RUNNING run cannot say
|
|
1363
|
+
// which run the caller meant, so refuse rather than abort whichever run
|
|
1364
|
+
// this process registered under it.
|
|
1365
|
+
if (taskId === undefined && deps.runsBySeqFn) {
|
|
1366
|
+
const running = deps
|
|
1367
|
+
.runsBySeqFn(seq)
|
|
1368
|
+
.filter((r) => r.status === "running");
|
|
1369
|
+
if (running.length > 1) {
|
|
1370
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1371
|
+
res.end(JSON.stringify({
|
|
1372
|
+
cancelled: false,
|
|
1373
|
+
seq,
|
|
1374
|
+
error: "ambiguous_seq",
|
|
1375
|
+
taskIds: running.map((r) => r.taskId),
|
|
1376
|
+
}));
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const outcome = cancelRunChecked(seq, {
|
|
1381
|
+
...(taskId !== undefined && { taskId }),
|
|
1382
|
+
});
|
|
1383
|
+
const cancelled = outcome === "cancelled";
|
|
1384
|
+
res.writeHead(cancelled ? 200 : outcome === "task_mismatch" ? 409 : 404, {
|
|
1254
1385
|
"Content-Type": "application/json",
|
|
1255
1386
|
});
|
|
1256
|
-
res.end(JSON.stringify({
|
|
1387
|
+
res.end(JSON.stringify({
|
|
1388
|
+
cancelled,
|
|
1389
|
+
seq,
|
|
1390
|
+
...(outcome === "task_mismatch" && { error: "task_mismatch" }),
|
|
1391
|
+
}));
|
|
1257
1392
|
}
|
|
1258
1393
|
catch (err) {
|
|
1259
1394
|
respond500(res, err, "runs/:seq/cancel");
|
|
@@ -1268,30 +1403,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1268
1403
|
const seq = Number.parseInt(runPlanMatch[1], 10);
|
|
1269
1404
|
void (async () => {
|
|
1270
1405
|
try {
|
|
1271
|
-
|
|
1272
|
-
if (!run) {
|
|
1273
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1274
|
-
res.end(JSON.stringify({ error: "run_not_found" }));
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
if (!deps.runPlanFn) {
|
|
1278
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1279
|
-
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
1280
|
-
return;
|
|
1281
|
-
}
|
|
1282
|
-
// Guard: recipeName may be absent on runs created before the field was
|
|
1283
|
-
// added (audit LOW #36). An unsafe cast + immediate .replace() would
|
|
1284
|
-
// throw TypeError — return 404 instead.
|
|
1285
|
-
if (!run.recipeName) {
|
|
1286
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1287
|
-
res.end(JSON.stringify({ error: "recipe_name_missing" }));
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
// triggerSource appends ":agent" suffix — strip before file lookup
|
|
1291
|
-
const recipeName = run.recipeName.replace(/:agent$/, "");
|
|
1292
|
-
const plan = await deps.runPlanFn(recipeName);
|
|
1293
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1294
|
-
res.end(JSON.stringify({ plan }));
|
|
1406
|
+
await writePlanForRun(res, deps, deps.runDetailFn?.(seq) ?? null);
|
|
1295
1407
|
}
|
|
1296
1408
|
catch (err) {
|
|
1297
1409
|
// #605: classify by error code, not message substring.
|