CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(actions): a failed run is no longer a dead end — re-run, and surface the AI healer #5560

Merged⚡ AI-generatedXSccantynz wants to mergefeat/run-actionsmainopened 5d ago
1 changed file+268−16
Modifiedsrc/routes/workflows.tsx+268−16View fileUnifiedSplit
1818
1919import { INFRA_CONCLUSIONS } from "../lib/ci-outcome";
2020import { Hono } from "hono";
21import { and, desc, eq } from "drizzle-orm";
21import { and, desc, eq, inArray } from "drizzle-orm";
2222import { db } from "../db";
2323import {
24 auditLog,
2425 repositories,
2526 users,
2627 workflowJobs,
2728 workflowRuns,
2829 workflows,
2930} from "../db/schema";
31import { isAiAvailable } from "../lib/ai-client";
3032import { Layout } from "../views/layout";
3133import { RepoHeader, RepoNav } from "../views/components";
3234import { LogTail } from "../views/log-tail";
885887 if (!run) return c.notFound();
886888
887889 const unread = user ? await getUnreadCount(user.id) : 0;
888 const canCancel =
889 !!user &&
890 user.id === repoRow.ownerId &&
891 (run.status === "queued" || run.status === "running");
890 const canWrite = !!user && user.id === repoRow.ownerId;
891 const inFlight = run.status === "queued" || run.status === "running";
892 const canCancel = canWrite && inFlight;
893 // A finished run had exactly one affordance before this: none. Cancel
894 // renders only while a run is still going, so a FAILED run was a dead end
895 // — you could read the logs and nothing else. Re-run is the missing verb.
896 const canRerun = canWrite && !inFlight;
897 const failed = run.status === "failure" || run.conclusion === "failure";
898
899 // The AI CI healer (lib/ai-ci-healer.ts) already analyses failed runs on a
900 // 5-minute autopilot tick and opens patch PRs, recording `ai.ci.healed` or
901 // `ai.ci.gave_up` against the run id. None of that was ever surfaced, so a
902 // failure looked untouched whether or not it had been analysed and given
903 // up on. Read the marker and say which it is.
904 let heal: { action: string; metadata: string | null; createdAt: Date } | null =
905 null;
906 if (failed) {
907 try {
908 const [m] = await db
909 .select({
910 action: auditLog.action,
911 metadata: auditLog.metadata,
912 createdAt: auditLog.createdAt,
913 })
914 .from(auditLog)
915 .where(
916 and(
917 eq(auditLog.targetId, run.id),
918 inArray(auditLog.action, ["ai.ci.healed", "ai.ci.gave_up"])
919 )
920 )
921 .orderBy(desc(auditLog.createdAt))
922 .limit(1);
923 heal = m || null;
924 } catch (err) {
925 // A missing marker must not blank the page — the logs below are the
926 // reason someone opened it.
927 console.error("[actions] heal marker:", err);
928 }
929 }
930 let healMeta: { prNumber?: number; reason?: string } = {};
931 try {
932 healMeta = heal?.metadata ? JSON.parse(heal.metadata) : {};
933 } catch {
934 healMeta = {};
935 }
936 const aiReady = isAiAvailable();
892937
893938 return c.html(
894939 <Layout
9581003 )}
9591004 </div>
9601005 </div>
961 {canCancel && (
962 <form
963 method="post"
964 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
965 onsubmit="return confirm('Cancel this run?')"
966 >
967 <button type="submit" class="wf-btn is-danger">
968 Cancel run
969 </button>
970 </form>
971 )}
1006 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
1007 {canRerun && (
1008 <form
1009 method="post"
1010 action={`/${owner}/${repo}/actions/runs/${run.id}/rerun`}
1011 >
1012 <button type="submit" class="wf-btn">
1013 {failed ? "Re-run" : "Run again"}
1014 </button>
1015 </form>
1016 )}
1017 {canCancel && (
1018 <form
1019 method="post"
1020 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
1021 onsubmit="return confirm('Cancel this run?')"
1022 >
1023 <button type="submit" class="wf-btn is-danger">
1024 Cancel run
1025 </button>
1026 </form>
1027 )}
1028 </div>
9721029 </section>
9731030
1031 {failed && (
1032 <section class="wf-card" style="margin-bottom:14px">
1033 <div style="display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap">
1034 <div style="min-width:0">
1035 <div style="font-weight:600;margin-bottom:4px">
1036 AI failure analysis
1037 </div>
1038 <div style="opacity:0.75;font-size:0.9em;line-height:1.5">
1039 {heal?.action === "ai.ci.healed" ? (
1040 <>
1041 Root-caused and patched
1042 {healMeta.prNumber ? (
1043 <>
1044 {" — "}
1045 <a href={`/${owner}/${repo}/pulls/${healMeta.prNumber}`}>
1046 PR #{healMeta.prNumber}
1047 </a>
1048 </>
1049 ) : null}
1050 . Review the patch before merging; it was written from the
1051 job logs, not from running the suite.
1052 </>
1053 ) : heal?.action === "ai.ci.gave_up" ? (
1054 <>
1055 Analysed and judged not fixable from inside this repo
1056 {healMeta.reason ? <> — {healMeta.reason}</> : null}. This
1057 one needs a human.
1058 </>
1059 ) : aiReady ? (
1060 <>
1061 Not analysed yet. Autopilot picks failed runs up on a
1062 five-minute tick; this runs it now instead of waiting.
1063 </>
1064 ) : (
1065 <>
1066 Unavailable — <code>ANTHROPIC_API_KEY</code> is not set on
1067 this instance, so nothing analyses failures automatically.
1068 Re-run and the job logs below are what you have.
1069 </>
1070 )}
1071 </div>
1072 </div>
1073 {canWrite && aiReady && !heal && (
1074 <form
1075 method="post"
1076 action={`/${owner}/${repo}/actions/runs/${run.id}/heal`}
1077 >
1078 <button type="submit" class="wf-btn">
1079 Analyse failure
1080 </button>
1081 </form>
1082 )}
1083 </div>
1084 </section>
1085 )}
1086
9741087 {jobs.length === 0 ? (
9751088 <div class="wf-empty">
9761089 <div class="wf-empty-orb" aria-hidden="true" />
11571270 return c.redirect(`/${owner}/${repo}/actions`);
11581271});
11591272
1273// ---------- Re-run a finished run ----------
1274//
1275// Re-runs the SAME workflow at the SAME commit, not at the branch head.
1276// Re-running a failure is a question about that commit ("was this flaky, or
1277// is it really broken?"), and silently answering it about different code
1278// makes a green run mean nothing.
1279
1280actions.post(
1281 "/:owner/:repo/actions/runs/:runId/rerun",
1282 requireAuth,
1283 async (c) => {
1284 const user = c.get("user")!;
1285 const { owner, repo, runId } = c.req.param();
1286 const repoRow = await loadRepo(owner, repo);
1287 if (!repoRow) return c.notFound();
1288 if (repoRow.ownerId !== user.id) {
1289 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
1290 }
1291
1292 let prev: typeof workflowRuns.$inferSelect | null = null;
1293 try {
1294 const [r] = await db
1295 .select()
1296 .from(workflowRuns)
1297 .where(
1298 and(
1299 eq(workflowRuns.id, runId),
1300 eq(workflowRuns.repositoryId, repoRow.id)
1301 )
1302 )
1303 .limit(1);
1304 prev = r || null;
1305 } catch (err) {
1306 console.error("[actions] rerun lookup:", err);
1307 }
1308 if (!prev) return c.notFound();
1309
1310 // Re-running something still in flight would leave two runs racing on the
1311 // same commit. Cancel is the verb for that, and it is on the same page.
1312 if (prev.status === "queued" || prev.status === "running") {
1313 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
1314 }
1315
1316 let disabled = false;
1317 try {
1318 const [w] = await db
1319 .select({ disabled: workflows.disabled })
1320 .from(workflows)
1321 .where(eq(workflows.id, prev.workflowId))
1322 .limit(1);
1323 disabled = !!w?.disabled;
1324 } catch (err) {
1325 console.error("[actions] rerun workflow lookup:", err);
1326 }
1327 if (disabled) {
1328 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
1329 }
1330
1331 const newRunId = await enqueueRun({
1332 workflowId: prev.workflowId,
1333 repositoryId: repoRow.id,
1334 event: "rerun",
1335 ref: prev.ref,
1336 commitSha: prev.commitSha,
1337 triggeredBy: user.id,
1338 });
1339
1340 await audit({
1341 userId: user.id,
1342 repositoryId: repoRow.id,
1343 action: "workflow.rerun",
1344 targetType: "workflow_run",
1345 targetId: runId,
1346 metadata: { newRunId, commitSha: prev.commitSha },
1347 });
1348
1349 return c.redirect(
1350 newRunId
1351 ? `/${owner}/${repo}/actions/runs/${newRunId}`
1352 : `/${owner}/${repo}/actions/runs/${runId}`
1353 );
1354 }
1355);
1356
1357// ---------- Analyse a failed run with the CI healer ----------
1358//
1359// The healer already runs on autopilot every five minutes. This is the same
1360// call, on demand, so a failure someone is looking at right now doesn't sit
1361// waiting for a tick. healOneRun is internally idempotent — it no-ops when a
1362// marker already exists — so a double submit cannot open two PRs.
1363
1364actions.post("/:owner/:repo/actions/runs/:runId/heal", requireAuth, async (c) => {
1365 const user = c.get("user")!;
1366 const { owner, repo, runId } = c.req.param();
1367 const back = `/${owner}/${repo}/actions/runs/${runId}`;
1368 const repoRow = await loadRepo(owner, repo);
1369 if (!repoRow) return c.notFound();
1370 if (repoRow.ownerId !== user.id) return c.redirect(back);
1371
1372 // Confirm the run belongs to this repo before handing its id to the healer.
1373 let ok = false;
1374 try {
1375 const [r] = await db
1376 .select({ id: workflowRuns.id })
1377 .from(workflowRuns)
1378 .where(
1379 and(
1380 eq(workflowRuns.id, runId),
1381 eq(workflowRuns.repositoryId, repoRow.id)
1382 )
1383 )
1384 .limit(1);
1385 ok = !!r;
1386 } catch (err) {
1387 console.error("[actions] heal lookup:", err);
1388 }
1389 if (!ok) return c.notFound();
1390
1391 try {
1392 const { healOneRun } = await import("../lib/ai-ci-healer");
1393 const result = await healOneRun(runId);
1394 await audit({
1395 userId: user.id,
1396 repositoryId: repoRow.id,
1397 action: "workflow.heal_requested",
1398 targetType: "workflow_run",
1399 targetId: runId,
1400 metadata: { outcome: result.outcome, prNumber: result.prNumber },
1401 });
1402 } catch (err) {
1403 // The healer writes its own markers; a throw here means it never got far
1404 // enough to write one. Land back on the page either way — it re-reads the
1405 // marker and will simply still say "not analysed yet".
1406 console.error("[actions] heal:", err);
1407 }
1408
1409 return c.redirect(back);
1410});
1411
11601412// ---------- Cancel a run ----------
11611413
11621414actions.post(
11631415
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts