From 540de3ca1ba504ffcbb0d2082d541b83b34723ec Mon Sep 17 00:00:00 2001 From: eti Date: Thu, 27 Aug 2026 20:23:40 +0200 Subject: [PATCH] web/pipelines: add workflow steps Signed-off-by: eti --- web/.storybook/WorkflowRunReplay.svelte | 60 +++++ web/eslint.config.js | 3 +- web/src/fixtures/pipelines.ts | 202 ++++++++++++++++- .../repo/pipelines/Elapsed.stories.svelte | 31 +++ .../components/repo/pipelines/Elapsed.svelte | 16 +- .../pipelines/StepStatusIcon.stories.svelte | 40 ++++ .../repo/pipelines/StepStatusIcon.svelte | 116 ++++++++++ .../pipelines/WorkflowLogs.stories.svelte | 142 +++++++++++- .../repo/pipelines/WorkflowLogs.svelte | 212 ++++++++++++++---- .../repo/pipelines/WorkflowRunView.svelte | 23 +- .../pipelines/WorkflowSidebar.stories.svelte | 18 +- .../repo/pipelines/WorkflowSidebar.svelte | 83 ++++--- .../pipelines/WorkflowSteps.stories.svelte | 163 ++++++++++++++ .../repo/pipelines/WorkflowSteps.svelte | 142 ++++++++++++ .../pipelines/WorkflowView.stories.svelte | 63 +++++- .../repo/pipelines/WorkflowView.svelte | 27 ++- .../components/repo/pipelines/logs.test.ts | 119 +++++++++- web/src/lib/components/repo/pipelines/logs.ts | 42 ++++ web/src/lib/components/welcome/Steps.svelte | 2 +- .../lib/components/welcome/WelcomeCard.svelte | 2 +- web/src/lib/components/welcome/spring.ts | 33 --- web/src/lib/motion/spring.ts | 55 +++++ 22 files changed, 1458 insertions(+), 136 deletions(-) create mode 100644 web/.storybook/WorkflowRunReplay.svelte create mode 100644 web/src/lib/components/repo/pipelines/Elapsed.stories.svelte create mode 100644 web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte create mode 100644 web/src/lib/components/repo/pipelines/StepStatusIcon.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowSteps.svelte delete mode 100644 web/src/lib/components/welcome/spring.ts create mode 100644 web/src/lib/motion/spring.ts diff --git a/web/.storybook/WorkflowRunReplay.svelte b/web/.storybook/WorkflowRunReplay.svelte new file mode 100644 index 000000000..52fbd2941 --- /dev/null +++ b/web/.storybook/WorkflowRunReplay.svelte @@ -0,0 +1,60 @@ + + +
+ {@render children({ steps, status })} +
+ {status} + +
+
diff --git a/web/eslint.config.js b/web/eslint.config.js index c13cd4782..cd9022542 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -37,7 +37,8 @@ export default defineConfig( projectService: { allowDefaultProject: [ ".storybook/MockAuth.svelte", - ".storybook/BoneCaptureDecorator.svelte" + ".storybook/BoneCaptureDecorator.svelte", + ".storybook/WorkflowRunReplay.svelte" ] }, extraFileExtensions: [".svelte"], diff --git a/web/src/fixtures/pipelines.ts b/web/src/fixtures/pipelines.ts index 4dbd7c036..73ede6062 100644 --- a/web/src/fixtures/pipelines.ts +++ b/web/src/fixtures/pipelines.ts @@ -1,7 +1,7 @@ // fixture pipelines for the pipeline and commit-list stories. the app reads real // runs from a spindle, so nothing here ever reaches the bundle -import type { PipelineSummary } from "$lib/components/repo/types"; +import type { PipelineStatus, PipelineSummary } from "$lib/components/repo/types"; import type { LogControl, LogFrame } from "$lib/api/spindleLogs"; import { createLogAccumulator, type LogStep } from "$lib/components/repo/pipelines/logs"; @@ -178,6 +178,53 @@ export const runningWorkflowLog: LogStep[] = drain([ output(1, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n") ]); +/** a run that died on its last step: every step closed, and nothing after it */ +export const failedWorkflowLog: LogStep[] = drain([ + control(-1, "microVM setup", { kind: "system" }), + output(-1, "starting microVM image alpine\n"), + finish(-1, 3), + control(0, "Clone repository into workspace", { + kind: "system", + command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD" + }), + output(0, "HEAD is now at 9f2c1ab\n"), + finish(0, 9), + control(1, "install dependencies", { command: "pnpm install --frozen-lockfile" }), + output(1, "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n"), + output(1, "\u001b[90m+\u001b[0m package-b \u001b[32m2.1.0\u001b[0m\n"), + output(1, "\u001b[90m+\u001b[0m package-c \u001b[32m0.4.2\u001b[0m\n"), + finish(1, 22), + control(2, "run tests", { command: "pnpm test" }), + output(2, "\u001b[1mRUN\u001b[0m v4.1.10\n"), + output(2, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n"), + output(2, "\u001b[31m✗\u001b[0m src/lib/api/spindleLogs.test.ts (1 failed)\n"), + output(2, "\u001b[31mAssertionError: expected 3 to be 4\u001b[0m\n"), + output( + 2, + "\u001b[2mTests\u001b[0m \u001b[31m1 failed\u001b[0m | \u001b[32m11 passed\u001b[0m (12)\n" + ), + finish(2, 58) +]); + +/** killed mid-step: the last step never got its end frame */ +export const cancelledWorkflowLog: LogStep[] = drain([ + control(-1, "microVM setup", { kind: "system" }), + output(-1, "starting microVM image alpine\n"), + finish(-1, 3), + control(0, "Clone repository into workspace", { + kind: "system", + command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD" + }), + output(0, "HEAD is now at 9f2c1ab\n"), + finish(0, 9), + control(1, "install dependencies", { command: "pnpm install --frozen-lockfile" }), + output(1, "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n"), + finish(1, 22), + control(2, "build the site", { command: "pnpm build" }), + output(2, "[0] \u001b[36mbundle\u001b[0m src/module-0.ts\n"), + output(2, "[1] \u001b[36mbundle\u001b[0m src/module-1.ts\n") +]); + const tailFrames: LogFrame[] = [ control(-1, "microVM setup", { kind: "system" }), output(-1, "starting microVM image alpine\n"), @@ -236,3 +283,156 @@ export const longWorkflowLog: LogStep[] = drain([ output(1, `[${index}] \u001b[36mCC\u001b[0m src/module-${index}.c\n`) ) ]); + +// a whole run, scripted step by step, for the stories that watch the rail fill in +const runScript = [ + { + name: "microVM setup", + system: true, + seconds: 3, + lines: ["starting microVM image alpine\n", "agent connected\n"] + }, + { + name: "Clone repository into workspace", + system: true, + command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD", + seconds: 9, + lines: ["warning: templates not found\n", "HEAD is now at 9f2c1ab\n"] + }, + { + name: "install dependencies", + command: "pnpm install --frozen-lockfile", + seconds: 41, + lines: [ + "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n", + "\u001b[90m+\u001b[0m package-b \u001b[32m2.1.0\u001b[0m\n" + ] + }, + { + name: "run tests", + command: "pnpm test", + seconds: 96, + lines: [ + "\u001b[1mRUN\u001b[0m v4.1.10\n", + "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n" + ] + }, + { + name: "build the site", + command: "pnpm build", + seconds: 58, + lines: ["[0] \u001b[36mbundle\u001b[0m src/module-0.ts\n", "wrote dist/index.html\n"] + } +]; + +const FAILURE_LINE = "\u001b[31merror: step failed with exit code 1\u001b[0m\n"; + +type RunAction = + | { kind: "start"; step: number; content: string; command?: string; system?: boolean } + | { kind: "line"; step: number; content: string } + | { kind: "end"; step: number; seconds: number }; + +export interface WorkflowRun { + /** pushes the next frame and hands back the log as the view sees it */ + advance: () => LogStep[]; + exhausted: () => boolean; + /** running until the last frame lands, then whatever the run came to */ + status: () => PipelineStatus; +} + +/** + * replays a workflow frame by frame, the way a live tail arrives. `failAt` is the + * index of the step that dies — the spindle closes it and starts nothing after it — + * and `cancelAt` is a step killed mid-flight, so it never gets its end frame. + */ +export const createWorkflowRun = ( + options: { failAt?: number; cancelAt?: number; steps?: number } = {} +): WorkflowRun => { + const { failAt, cancelAt, steps = runScript.length } = options; + const actions: RunAction[] = []; + for (const [index, step] of runScript.slice(0, steps).entries()) { + // the runner's own setup step is numbered below the workflow's first + const id = index - 1; + actions.push({ + kind: "start", + step: id, + content: step.name, + command: step.command, + system: step.system + }); + for (const line of step.lines) actions.push({ kind: "line", step: id, content: line }); + if (index === cancelAt) break; + if (index === failAt) { + actions.push({ kind: "line", step: id, content: FAILURE_LINE }); + actions.push({ kind: "end", step: id, seconds: step.seconds }); + break; + } + actions.push({ kind: "end", step: id, seconds: step.seconds }); + } + + const accumulator = createLogAccumulator(); + const startedAt = new Map(); + const at = (ms: number) => new Date(ms).toISOString(); + let cursor = 0; + + const push = (action: RunAction) => { + if (action.kind === "line") { + accumulator.push({ + kind: "data", + data: { + time: at(Date.now()), + workflow: "build", + step: action.step, + content: action.content, + stream: "stdout" + } + }); + return; + } + if (action.kind === "start") { + startedAt.set(action.step, Date.now()); + accumulator.push({ + kind: "control", + control: { + time: at(Date.now()), + workflow: "build", + step: action.step, + content: action.content, + command: action.command, + kind: action.system ? "system" : "user", + status: "start" + } + }); + return; + } + // the scripted duration is what the rail reads out, so the end stamp is measured + // off the step's own start and not off the replay's wall clock + accumulator.push({ + kind: "control", + control: { + time: at((startedAt.get(action.step) ?? Date.now()) + action.seconds * 1000), + workflow: "build", + step: action.step, + content: "", + status: "end" + } + }); + }; + + return { + advance() { + const action = actions[cursor]; + if (action) { + cursor += 1; + push(action); + } + return [...accumulator.steps]; + }, + exhausted: () => cursor >= actions.length, + status: () => { + if (cursor < actions.length) return "running"; + if (cancelAt !== undefined) return "cancelled"; + return failAt !== undefined ? "failed" : "success"; + } + }; +}; diff --git a/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte b/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte new file mode 100644 index 000000000..4a9451eca --- /dev/null +++ b/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/web/src/lib/components/repo/pipelines/Elapsed.svelte b/web/src/lib/components/repo/pipelines/Elapsed.svelte index 42204187a..e55d08d3f 100644 --- a/web/src/lib/components/repo/pipelines/Elapsed.svelte +++ b/web/src/lib/components/repo/pipelines/Elapsed.svelte @@ -7,22 +7,28 @@ start?: number; /** epoch ms; while absent the timer keeps counting */ end?: number; + /** off once the step can no longer finish; a step that never reported an end then shows nothing, because nobody knows when it died */ + running?: boolean; class?: ClassValue | null; } - let { start, end, class: className }: Props = $props(); + let { start, end, running = true, class: className }: Props = $props(); let now = $state(Date.now()); $effect(() => { - if (end !== undefined || start === undefined) return; + if (!running || end !== undefined || start === undefined) return; const tick = setInterval(() => (now = Date.now()), 1000); return () => clearInterval(tick); }); - const elapsed = $derived( - start === undefined || Number.isNaN(start) ? null : Math.max(0, (end ?? now) - start) - ); + const elapsed = $derived.by(() => { + if (start === undefined || Number.isNaN(start)) return null; + // counting from a start whose end never came measures how long ago the step + // began, which grows forever and is not a duration + if (end === undefined && !running) return null; + return Math.max(0, (end ?? now) - start); + }); {#if elapsed !== null} diff --git a/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte b/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte new file mode 100644 index 000000000..7000edc36 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte @@ -0,0 +1,40 @@ + + + + + + + + + + + {#snippet template()} +
+ + {cycling} +
+ {/snippet} +
diff --git a/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte b/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte new file mode 100644 index 000000000..e581386e1 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte @@ -0,0 +1,116 @@ + + + + + + {#if status === "done"} + + + {:else if status === "error"} + + + {:else if status === "active"} + + {:else} + + {/if} + + + diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte index e2ebd527c..ebb53aca4 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte @@ -1,20 +1,38 @@ @@ -44,6 +62,41 @@ }; }; + // a flush has to land after the click, so the play function drives it rather than + // a timer + const drivenLog = (initial: LogStep[]) => { + let steps = $state.raw(initial); + const last = () => steps[steps.length - 1]; + return { + get steps() { + return steps; + }, + flush: (line: string) => { + steps = [...steps.slice(0, -1), { ...last(), lines: [...last().lines, line] }]; + }, + startStep: (name: string) => { + const id = Math.max(...steps.map((step) => step.id)) + 1; + steps = [...steps, { id, name, system: false, startedAt: Date.now(), lines: [] }]; + } + }; + }; + + const unfoldedByHand = drivenLog(runningWorkflowLog); + const foldedByHand = drivenLog(runningWorkflowLog); + + // the rail drives the log through this, the way WorkflowView wires it + let railJump: ((id: number) => void) | undefined; + + const landedOnTop = (element: Element | null) => { + if (!(element instanceof HTMLElement)) return false; + const scroller = element.closest(".overflow-y-auto"); + if (!scroller) return false; + return ( + Math.abs(element.getBoundingClientRect().top - scroller.getBoundingClientRect().top) <= + 2 + ); + }; + const live = feed(); const replayed = feed(); // the app flushes once a frame, so a busy step lands several lines a tick @@ -52,11 +105,17 @@ - + - + - + @@ -66,21 +125,90 @@ you scroll or select, then hands the viewport over and offers the way back. the steps go through the template, because args are a snapshot: storybook reads them once per story and never sees the feed move --> - + {#snippet template(args)} {/snippet} - + {#snippet template(args)} {/snippet} - + {#snippet template(args)} {/snippet} + + + + + + { + const header = () => canvas.getAllByRole("button", { name: /install dependencies/ })[0]; + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false")); + await waitFor(() => expect(railJump).toBeTruthy()); + railJump?.(1); + await waitFor(() => + expect(canvas.getByText("pnpm install --frozen-lockfile")).toBeVisible() + ); + await waitFor(() => { + expect(document.activeElement?.textContent).toContain("install dependencies"); + expect(landedOnTop(document.activeElement)).toBe(true); + }); + }} +> + {#snippet template(args)} + (railJump = jump)} /> + {/snippet} + + + { + const header = () => + canvas.getAllByRole("button", { name: /Clone repository into workspace/ })[0]; + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false")); + await userEvent.click(header()); + await waitFor(() => expect(canvas.getByText("git init")).toBeVisible()); + unfoldedByHand.flush("one more line"); + await waitFor(() => expect(canvas.getByText("one more line")).toBeVisible()); + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "true")); + expect(canvas.getByText("git init")).toBeVisible(); + }} +> + {#snippet template(args)} + + {/snippet} + + + { + const header = () => canvas.getAllByRole("button", { name: /run tests/ })[0]; + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "true")); + await userEvent.click(header()); + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false")); + foldedByHand.startStep("build the site"); + await waitFor(() => + expect(canvas.getByRole("button", { name: /build the site/ })).toBeVisible() + ); + await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false")); + expect(canvas.queryByText(/format.test.ts/)).toBeNull(); + }} +> + {#snippet template(args)} + + {/snippet} + diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte index 3933b4f08..53ad1ace5 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte @@ -9,7 +9,7 @@ row: "absolute top-0 left-0 flex w-full", header: "sticky top-0 z-10 flex w-full cursor-pointer items-center gap-2 rounded-t border-x border-t border-border-default bg-background-default px-3 text-left font-sans typography-paragraph-regular", chevron: "size-4 shrink-0 text-foreground-muted", - name: "truncate font-semibold text-foreground-default", + name: "truncate text-foreground-default", elapsed: "ml-auto shrink-0 text-foreground-muted tabular-nums", body: "w-full min-w-0 border-x border-border-default bg-background-default px-3", command: "break-all whitespace-pre-wrap text-foreground-info", @@ -22,10 +22,20 @@ collapsed: { true: { header: "rounded-b border-b" }, false: { header: "border-b" } }, first: { true: { body: "pt-1.5" }, false: {} }, // last row rounds bottom corners - last: { true: { body: "rounded-b border-b pb-1.5" }, false: {} } + last: { true: { body: "rounded-b border-b pb-1.5" }, false: {} }, + // the chip and the label carry the state on their own. tinting the row + // would too: background-danger-subtle is far too loud over the card + stepStatus: { + pending: { name: "text-foreground-muted" }, + active: { name: "font-semibold" }, + done: { name: "text-foreground-default" }, + error: { name: "font-semibold text-foreground-danger" } + } } }); + type HeaderSlots = ReturnType; + // initial row heights, measurement corrects wrapped lines after mount const ROW_HEIGHT: Record = { step: 44, @@ -50,8 +60,8 @@ + + + + + {#snippet template(args)} +
+ + {#snippet activeExtra()} + + {/snippet} + +
+ {/snippet} +
diff --git a/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte index b578ae66f..b6d738136 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte @@ -1,24 +1,35 @@ +{#snippet cardBody(workflow: WorkflowSummary)} +
+ {workflow.name} + +
+
+
+ + {STATUS_LABELS[workflow.status]} +
+ {#if workflow.duration > 0} + {formatDuration(workflow.duration)} + {/if} +
+{/snippet} +
{#each workflows as workflow (workflow.name)} - {@const slots = classes({ active: workflow.name === active })} - -
- {workflow.name} - -
-
diff --git a/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte new file mode 100644 index 000000000..98b4d41e8 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + console.log("select step", id) + }} +/> + + + { + await waitFor(() => expect(canvas.getByText("14s")).toBeVisible()); + }} +> + {#snippet template(args)} + + {/snippet} + + + + + {#snippet template()} + createWorkflowRun()}> + {#snippet children({ steps, status })} + + {/snippet} + + {/snippet} + + + + + {#snippet template()} + createWorkflowRun({ failAt: 3 })}> + {#snippet children({ steps, status })} + + {/snippet} + + {/snippet} + + + + + {#snippet template()} + createWorkflowRun({ failAt: 0 })}> + {#snippet children({ steps, status })} + + {/snippet} + + {/snippet} + + + + + {#snippet template()} + createWorkflowRun({ cancelAt: 3 })}> + {#snippet children({ steps, status })} + + {/snippet} + + {/snippet} + + + + + {#snippet template()} + createWorkflowRun()} from={9}> + {#snippet children({ steps, status })} + + {/snippet} + + {/snippet} + diff --git a/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte b/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte new file mode 100644 index 000000000..56d0edb0f --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte @@ -0,0 +1,142 @@ + + + + +{#if steps.length > 0} +
    + {#each rows as row (row.id)} + {@const revealed = row.duration !== ""} + {@const slots = classes({ + interactive: Boolean(onselect), + status: row.status, + revealed + })} + {#snippet content()} + + + {#if row.status === "active"} + {row.name} + {:else} + {row.name} + {/if} + + + + {row.duration} + + {/snippet} +
  1. + {#if onselect} + + {:else} +
    + {@render content()} +
    + {/if} +
  2. + {/each} +
+{/if} + + +{spoken} diff --git a/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte index 9a499cf41..b3fed12d6 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte @@ -3,6 +3,9 @@ import type { WorkflowSummary } from "$lib/components/repo/types"; import WorkflowView from "./WorkflowView.svelte"; import { + cancelledWorkflowLog, + createLogTail, + failedWorkflowLog, longWorkflowLog, pipelines, runningWorkflowLog, @@ -10,6 +13,7 @@ } from "$fixtures/pipelines"; const [running, mixed, passed] = pipelines; + const killed = pipelines[5]; // the spindle rejected this one before it could produce output, so the banner // carries the whole story and the log pane stays empty @@ -36,8 +40,35 @@ }); + + + - + - - + + + + @@ -64,3 +105,19 @@ + + + {#snippet template(args)} + + {/snippet} + diff --git a/web/src/lib/components/repo/pipelines/WorkflowView.svelte b/web/src/lib/components/repo/pipelines/WorkflowView.svelte index 52cf8a6da..715693e5c 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowView.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowView.svelte @@ -3,7 +3,8 @@ import type { PipelineSummary, WorkflowSummary } from "$lib/components/repo/types"; import WorkflowLogs from "./WorkflowLogs.svelte"; import WorkflowSidebar from "./WorkflowSidebar.svelte"; - import { errorDetails, errorMessage, isLive } from "./pipeline"; + import WorkflowSteps from "./WorkflowSteps.svelte"; + import { errorDetails, errorMessage } from "./pipeline"; import type { LogStep } from "./logs"; import type { LogStreamStatus } from "./logStream.svelte"; @@ -29,9 +30,21 @@ error = null }: Props = $props(); - const live = $derived(isLive(workflow.status)); + // the two levels talk through here: the log says what it is showing, the rail says + // where to go + let visibleStep = $state(undefined); + let scrollToStep = $state<((id: number) => void) | undefined>(undefined); +{#snippet stepRail()} + scrollToStep?.(id)} + /> +{/snippet} +
0 ? stepRail : undefined} />
@@ -62,6 +76,13 @@
{/if} - + (scrollToStep = jump)} + onvisiblestep={(id) => (visibleStep = id)} + />
diff --git a/web/src/lib/components/repo/pipelines/logs.test.ts b/web/src/lib/components/repo/pipelines/logs.test.ts index b8a175919..87961bf68 100644 --- a/web/src/lib/components/repo/pipelines/logs.test.ts +++ b/web/src/lib/components/repo/pipelines/logs.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import type { LogFrame } from "$lib/api/spindleLogs"; -import { createLogAccumulator, isLastRow, toRows, type LogStep } from "./logs"; +import { + createLogAccumulator, + isLastRow, + narrate, + stepStatuses, + toRows, + type LogStep +} from "./logs"; const start = (step: number, name: string, over: Record = {}): LogFrame => ({ kind: "control", @@ -142,3 +149,113 @@ describe("toRows", () => { expect(isLastRow(rows, rows.length - 1)).toBe(true); }); }); + +describe("stepStatuses", () => { + it("marks closed steps done and the open last step active on a running workflow", () => { + const steps = drain([start(1, "One"), end(1, "2026-08-06T12:00:10Z"), start(2, "Two")]); + expect(stepStatuses(steps, "running")).toEqual(["done", "active"]); + }); + + it("marks the last step an error on a failed workflow, even though it has a finishedAt", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "Two"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(stepStatuses(steps, "failed")).toEqual(["done", "error"]); + }); + + it("treats cancelled like failed", () => { + const steps = drain([start(1, "One"), start(2, "Two"), end(2, "2026-08-06T12:00:20Z")]); + expect(stepStatuses(steps, "cancelled")).toEqual(["done", "error"]); + }); + + it("treats timeout like failed", () => { + const steps = drain([start(1, "One"), start(2, "Two"), end(2, "2026-08-06T12:00:20Z")]); + expect(stepStatuses(steps, "timeout")).toEqual(["done", "error"]); + }); + + it("marks every step done on a successful workflow", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "Two"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(stepStatuses(steps, "success")).toEqual(["done", "done"]); + }); + + it("treats an unclosed step that isn't last as a lost end frame, not the active one", () => { + const steps = drain([start(1, "One"), start(2, "Two"), start(3, "Three")]); + expect(stepStatuses(steps, "running")).toEqual(["done", "done", "active"]); + }); + + it("returns an empty list when there are no steps", () => { + expect(stepStatuses([], "running")).toEqual([]); + }); +}); + +describe("narrate", () => { + it("says it's waiting for the spindle when no steps have arrived on a live workflow", () => { + expect(narrate([], "pending")).toBe("Waiting for the spindle"); + }); + + it("says nothing when a settled workflow never produced a step", () => { + expect(narrate([], "cancelled")).toBe(""); + }); + + it("names the active step and its position", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "Run tests") + ]); + expect(narrate(steps, "running")).toBe("Run tests, step 2 of 2"); + }); + + it("names the step that failed", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "Run tests"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(narrate(steps, "failed")).toBe("Failed at Run tests"); + }); + + it("names the step a cancelled run was killed on, not a generic failure", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "build the site"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(narrate(steps, "cancelled")).toBe("Cancelled at build the site"); + }); + + it("names the step a timed-out run was killed on, not a generic failure", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "build the site"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(narrate(steps, "timeout")).toBe("Timed out at build the site"); + }); + + it("announces completion once every step is done and the workflow succeeded", () => { + const steps = drain([ + start(1, "One"), + end(1, "2026-08-06T12:00:10Z"), + start(2, "Two"), + end(2, "2026-08-06T12:00:20Z") + ]); + expect(narrate(steps, "success")).toBe("All 2 steps complete"); + }); + + it("keeps the completion sentence singular for a one-step run", () => { + const steps = drain([start(1, "One"), end(1, "2026-08-06T12:00:10Z")]); + expect(narrate(steps, "success")).toBe("All 1 step complete"); + }); +}); diff --git a/web/src/lib/components/repo/pipelines/logs.ts b/web/src/lib/components/repo/pipelines/logs.ts index 71270b472..6de7143b4 100644 --- a/web/src/lib/components/repo/pipelines/logs.ts +++ b/web/src/lib/components/repo/pipelines/logs.ts @@ -2,6 +2,8 @@ import { AnsiUp } from "ansi_up"; import type { LogFrame } from "$lib/api/spindleLogs"; +import type { PipelineStatus } from "$lib/components/repo/types"; +import { isLive, STATUS_LABELS } from "./pipeline"; export interface LogStep { id: number; @@ -120,3 +122,43 @@ export const rowKey = (row: LogRow): string => { return `gap:${row.stepId}`; } }; + +// "pending" never comes out of a live stream — a step only exists once its first frame arrives — it's here for a future plan-driven skeleton +export type StepStatus = "pending" | "active" | "done" | "error"; + +// the spindle writes a step's end frame unconditionally, before it checks whether the step errored, +// and stops starting new steps the moment one does — so the failing step is always the last one present +export const stepStatuses = (steps: LogStep[], workflow: PipelineStatus): StepStatus[] => { + if (steps.length === 0) return []; + const last = steps.length - 1; + + if (isLive(workflow)) { + return steps.map((step, i) => (step.finishedAt || i !== last ? "done" : "active")); + } + + if (workflow === "success") return steps.map(() => "done"); + + return steps.map((_, i) => (i === last ? "error" : "done")); +}; + +// one sentence for a screen-reader live region +export const narrate = (steps: LogStep[], workflow: PipelineStatus): string => { + if (steps.length === 0) return isLive(workflow) ? "Waiting for the spindle" : ""; + + const statuses = stepStatuses(steps, workflow); + + const activeIndex = statuses.indexOf("active"); + if (activeIndex !== -1) { + return `${steps[activeIndex].name}, step ${activeIndex + 1} of ${steps.length}`; + } + + const errorIndex = statuses.indexOf("error"); + if (errorIndex !== -1) return `${STATUS_LABELS[workflow]} at ${steps[errorIndex].name}`; + + if (workflow === "success" && statuses.every((status) => status === "done")) { + const step = steps.length === 1 ? "step" : "steps"; + return `All ${steps.length} ${step} complete`; + } + + return ""; +}; diff --git a/web/src/lib/components/welcome/Steps.svelte b/web/src/lib/components/welcome/Steps.svelte index 3855d9ba6..107adc67b 100644 --- a/web/src/lib/components/welcome/Steps.svelte +++ b/web/src/lib/components/welcome/Steps.svelte @@ -54,7 +54,7 @@