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.
@@ -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 { cancelRun } from "./recipes/runRegistry.js";
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({ run }));
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
- const result = await deps.runReplayFn(seq);
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
- const cancelled = cancelRun(seq);
1253
- res.writeHead(cancelled ? 200 : 404, {
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({ cancelled, seq }));
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
- const run = deps.runDetailFn?.(seq) ?? null;
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.