diff --git a/web/src/lib/api/lexicons/types/sh/tangled/ci/subscribePipelineLogs.ts b/web/src/lib/api/lexicons/types/sh/tangled/ci/subscribePipelineLogs.ts index 4b5723337..b7bba9d7f 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/ci/subscribePipelineLogs.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/ci/subscribePipelineLogs.ts @@ -17,6 +17,12 @@ const _controlSchema = /*#__PURE__*/ v.object({ kind: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literalEnum(["system", "user"]), ), + /** + * How the step ended, only set on end frames for steps that can fail + */ + outcome: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literalEnum(["failed", "success"]), + ), /** * Step status */ diff --git a/web/src/lib/components/repo/pipelines/logs.test.ts b/web/src/lib/components/repo/pipelines/logs.test.ts index 6e0551918..626dda1dd 100644 --- a/web/src/lib/components/repo/pipelines/logs.test.ts +++ b/web/src/lib/components/repo/pipelines/logs.test.ts @@ -21,9 +21,9 @@ const start = (step: number, name: string, over: Record = {}): } }); -const end = (step: number, time: string): LogFrame => ({ +const end = (step: number, time: string, outcome?: "success" | "failed"): LogFrame => ({ kind: "control", - control: { time, workflow: "build", step, content: "", status: "end" } + control: { time, workflow: "build", step, content: "", status: "end", outcome } }); const line = (step: number, content: string): LogFrame => ({ @@ -194,6 +194,42 @@ describe("stepStatuses", () => { it("returns an empty list when there are no steps", () => { expect(stepStatuses([], "running")).toEqual([]); }); + + const debugged = () => + drain([ + start(3, "build"), + end(3, "2026-08-06T12:00:10Z", "success"), + start(4, "deploy"), + start(6, "Debug shell", { kind: "system" }), + end(6, "2026-08-06T12:00:20Z"), + end(4, "2026-08-06T12:00:20Z", "failed") + ]); + + it("marks the step the spindle says failed, not the runner step after it", () => { + expect(stepStatuses(debugged(), "failed")).toEqual(["done", "error", "done"]); + }); + + it("shows a reported failure while the workflow still says running", () => { + expect(stepStatuses(debugged(), "running")).toEqual(["done", "error", "done"]); + }); + + it("keeps the open last step active when outcomes are reported", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z", "success"), + start(2, "Two") + ]); + expect(stepStatuses(steps, "running")).toEqual(["done", "active"]); + }); + + it("blames an open last step when a reporting run ends badly without saying why", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z", "success"), + start(2, "Two") + ]); + expect(stepStatuses(steps, "timeout")).toEqual(["done", "error"]); + }); }); describe("narrate", () => { @@ -244,6 +280,16 @@ describe("narrate", () => { expect(narrate(steps, "timeout")).toBe("Timed out at build the site"); }); + it("says failed, not running, when a step failed before the workflow status caught up", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z", "success"), + start(2, "deploy"), + end(2, "2026-08-06T12:00:20Z", "failed") + ]); + expect(narrate(steps, "running")).toBe("Failed at deploy"); + }); + it("announces completion once every step is done and the workflow succeeded", () => { const steps = drain([ start(1, "One"), diff --git a/web/src/lib/components/repo/pipelines/logs.ts b/web/src/lib/components/repo/pipelines/logs.ts index 4a57e0cde..98b7a9f7f 100644 --- a/web/src/lib/components/repo/pipelines/logs.ts +++ b/web/src/lib/components/repo/pipelines/logs.ts @@ -13,6 +13,7 @@ export interface LogStep { system: boolean; startedAt: number; finishedAt?: number; + outcome?: "success" | "failed"; // ansi resolved to html lines: string[]; } @@ -63,6 +64,7 @@ export const createLogAccumulator = (): LogAccumulator => { } if (c.status === "end") { step.finishedAt = stamp(c.time); + step.outcome = c.outcome; return true; } return false; @@ -129,6 +131,15 @@ export const stepStatuses = (steps: LogStep[], workflow: PipelineStatus): StepSt if (steps.length === 0) return []; const last = steps.length - 1; + if (steps.some((step) => step.outcome)) { + return steps.map((step, i) => { + if (step.outcome === "failed") return "error"; + if (step.finishedAt || i !== last) return "done"; + if (isLive(workflow)) return "active"; + return workflow === "success" ? "done" : "error"; + }); + } + if (isLive(workflow)) { return steps.map((step, i) => (step.finishedAt || i !== last ? "done" : "active")); } @@ -149,7 +160,11 @@ export const narrate = (steps: LogStep[], workflow: PipelineStatus): string => { } const errorIndex = statuses.indexOf("error"); - if (errorIndex !== -1) return `${STATUS_LABELS[workflow]} at ${steps[errorIndex].name}`; + if (errorIndex !== -1) { + // a step can report its failure before the workflow status catches up + const label = isLive(workflow) ? STATUS_LABELS.failed : STATUS_LABELS[workflow]; + return `${label} at ${steps[errorIndex].name}`; + } if (workflow === "success" && statuses.every((status) => status === "done")) { const step = steps.length === 1 ? "step" : "steps";