diff --git a/apps/api/src/app.integration.test.ts b/apps/api/src/app.integration.test.ts index 8c5c19c..6e4359a 100644 --- a/apps/api/src/app.integration.test.ts +++ b/apps/api/src/app.integration.test.ts @@ -22,7 +22,9 @@ describe.runIf(runIntegration)("api integration", () => { beforeEach(async () => { await app?.close(); await resetDatabase(connection.db).catch(() => undefined); - app = await bootstrapApiApp(connection); + app = await bootstrapApiApp(connection, { + configPaths: ["verge.config.ts", "test/fixtures/verge-testbed.config.ts"], + }); }); afterAll(async () => { @@ -103,6 +105,20 @@ describe.runIf(runIntegration)("api integration", () => { }); expect(treemapResponse.statusCode).toBe(404); expect(treemapResponse.json()).toMatchObject({ message: "Run not found" }); + + const commitResponse = await app.inject({ + method: "GET", + url: "/repositories/verge/commits/missing-commit", + }); + expect(commitResponse.statusCode).toBe(404); + expect(commitResponse.json()).toMatchObject({ message: "Commit not found" }); + + const commitTreemapResponse = await app.inject({ + method: "GET", + url: "/repositories/verge/commits/missing-commit/treemap", + }); + expect(commitTreemapResponse.statusCode).toBe(404); + expect(commitTreemapResponse.json()).toMatchObject({ message: "Commit not found" }); }, 30_000); it("returns a run treemap with step, file, and process nodes", async () => { @@ -420,4 +436,320 @@ describe.runIf(runIntegration)("api integration", () => { seedAssignment.assignment?.processKey, ); }, 30_000); + + it("returns converged commit detail and treemap across resumed attempts", async () => { + const commitSha = "commit-health-sha"; + const createResponse = await app.inject({ + method: "POST", + url: "/runs/manual", + payload: { + repositorySlug: "verge-testbed", + commitSha, + requestedStepKeys: ["test-resume"], + disableReuse: true, + }, + }); + + expect(createResponse.statusCode).toBe(200); + const createPayload = createResponse.json() as { + runId: string; + stepRunIds: string[]; + }; + const firstStepRunId = createPayload.stepRunIds[0]; + if (!firstStepRunId) { + throw new Error("First step run was not created"); + } + + const firstStepDetailResponse = await app.inject({ + method: "GET", + url: `/runs/${createPayload.runId}/steps/${firstStepRunId}`, + }); + expect(firstStepDetailResponse.statusCode).toBe(200); + const firstStepDetail = firstStepDetailResponse.json() as { + processes: Array<{ processKey: string }>; + }; + const expectedProcessCount = firstStepDetail.processes.length; + if (expectedProcessCount === 0) { + throw new Error("Expected the resume step to materialize at least one process"); + } + + const completedProcessKeys: string[] = []; + let failedAssignment: { + processRunId: string; + processKey: string; + } | null = null; + + while (true) { + const claimResponse = await app.inject({ + method: "POST", + url: "/workers/claim", + payload: { + workerId: "commit-projection-worker", + }, + }); + expect(claimResponse.statusCode).toBe(200); + const claimPayload = claimResponse.json() as { + assignment: { + stepRunId: string; + processRunId: string; + processKey: string; + processDisplayName: string; + } | null; + }; + + if (!claimPayload.assignment) { + break; + } + + expect(claimPayload.assignment.stepRunId).toBe(firstStepRunId); + + await app.inject({ + method: "POST", + url: `/workers/steps/${firstStepRunId}/events`, + payload: { + workerId: "commit-projection-worker", + processRunId: claimPayload.assignment.processRunId, + kind: "started", + message: "Started commit projection process", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 15)); + + if (completedProcessKeys.length === expectedProcessCount - 1) { + failedAssignment = { + processRunId: claimPayload.assignment.processRunId, + processKey: claimPayload.assignment.processKey, + }; + + const checkpointResponse = await app.inject({ + method: "POST", + url: `/workers/steps/${firstStepRunId}/checkpoints`, + payload: { + workerId: "commit-projection-worker", + processRunId: claimPayload.assignment.processRunId, + completedProcessKeys, + pendingProcessKeys: [claimPayload.assignment.processKey], + storagePath: "commit-projection-checkpoint.json", + resumableUntil: new Date(Date.now() + 60_000).toISOString(), + }, + }); + expect(checkpointResponse.statusCode).toBe(200); + + const failedResponse = await app.inject({ + method: "POST", + url: `/workers/steps/${firstStepRunId}/events`, + payload: { + workerId: "commit-projection-worker", + processRunId: claimPayload.assignment.processRunId, + kind: "failed", + message: "Failed commit projection process", + }, + }); + expect(failedResponse.statusCode).toBe(200); + continue; + } + + const passedResponse = await app.inject({ + method: "POST", + url: `/workers/steps/${firstStepRunId}/events`, + payload: { + workerId: "commit-projection-worker", + processRunId: claimPayload.assignment.processRunId, + kind: "passed", + message: "Completed commit projection process", + }, + }); + expect(passedResponse.statusCode).toBe(200); + completedProcessKeys.push(claimPayload.assignment.processKey); + } + + expect(failedAssignment).not.toBeNull(); + expect(completedProcessKeys.length).toBeGreaterThan(0); + + const firstCommitResponse = await app.inject({ + method: "GET", + url: `/repositories/verge-testbed/commits/${commitSha}`, + }); + expect(firstCommitResponse.statusCode).toBe(200); + const firstCommit = firstCommitResponse.json() as { + status: string; + steps: Array<{ stepKey: string; status: string; processCount: number }>; + processes: Array<{ processKey: string; status: string; sourceRunId: string }>; + executionCost: { runCount: number }; + }; + expect(firstCommit.status).toBe("failed"); + expect(firstCommit.executionCost.runCount).toBe(1); + expect(firstCommit.steps).toContainEqual( + expect.objectContaining({ + stepKey: "test-resume", + status: "failed", + }), + ); + expect(firstCommit.processes).toContainEqual( + expect.objectContaining({ + processKey: failedAssignment?.processKey, + status: "failed", + sourceRunId: createPayload.runId, + }), + ); + + const resumeCreateResponse = await app.inject({ + method: "POST", + url: "/runs/manual", + payload: { + repositorySlug: "verge-testbed", + commitSha, + requestedStepKeys: ["test-resume"], + resumeFromCheckpoint: true, + }, + }); + + expect(resumeCreateResponse.statusCode).toBe(200); + const resumeCreatePayload = resumeCreateResponse.json() as { + runId: string; + stepRunIds: string[]; + }; + const resumedStepRunId = resumeCreatePayload.stepRunIds[0]; + if (!resumedStepRunId) { + throw new Error("Resumed step run was not created"); + } + + const resumedStepResponse = await app.inject({ + method: "GET", + url: `/runs/${resumeCreatePayload.runId}/steps/${resumedStepRunId}`, + }); + expect(resumedStepResponse.statusCode).toBe(200); + const resumedStep = resumedStepResponse.json() as { + processes: Array<{ processKey: string; status: string }>; + }; + expect(resumedStep.processes.filter((process) => process.status === "reused")).toHaveLength( + completedProcessKeys.length, + ); + expect( + resumedStep.processes.find((process) => process.processKey === failedAssignment?.processKey) + ?.status, + ).toBe("queued"); + + const resumedClaimResponse = await app.inject({ + method: "POST", + url: "/workers/claim", + payload: { + workerId: "commit-projection-resume-worker", + }, + }); + expect(resumedClaimResponse.statusCode).toBe(200); + const resumedAssignment = resumedClaimResponse.json() as { + assignment: { + stepRunId: string; + processRunId: string; + processKey: string; + } | null; + }; + expect(resumedAssignment.assignment?.stepRunId).toBe(resumedStepRunId); + expect(resumedAssignment.assignment?.processKey).toBe(failedAssignment?.processKey); + + await app.inject({ + method: "POST", + url: `/workers/steps/${resumedStepRunId}/events`, + payload: { + workerId: "commit-projection-resume-worker", + processRunId: resumedAssignment.assignment?.processRunId, + kind: "started", + message: "Started resumed process", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 15)); + + await app.inject({ + method: "POST", + url: `/workers/steps/${resumedStepRunId}/events`, + payload: { + workerId: "commit-projection-resume-worker", + processRunId: resumedAssignment.assignment?.processRunId, + kind: "passed", + message: "Completed resumed process", + }, + }); + + const secondClaimResponse = await app.inject({ + method: "POST", + url: "/workers/claim", + payload: { + workerId: "commit-projection-resume-worker", + }, + }); + expect(secondClaimResponse.statusCode).toBe(200); + expect(secondClaimResponse.json()).toMatchObject({ assignment: null }); + + const convergedCommitResponse = await app.inject({ + method: "GET", + url: `/repositories/verge-testbed/commits/${commitSha}`, + }); + expect(convergedCommitResponse.statusCode).toBe(200); + const convergedCommit = convergedCommitResponse.json() as { + status: string; + steps: Array<{ stepKey: string; status: string }>; + processes: Array<{ processKey: string; status: string; sourceRunId: string }>; + executionCost: { runCount: number; processRunCount: number }; + runs: Array<{ id: string }>; + }; + expect(convergedCommit.status).toBe("passed"); + expect(convergedCommit.executionCost.runCount).toBe(2); + expect(convergedCommit.executionCost.processRunCount).toBeGreaterThan( + convergedCommit.processes.length, + ); + expect(convergedCommit.steps).toContainEqual( + expect.objectContaining({ + stepKey: "test-resume", + status: "passed", + }), + ); + expect(convergedCommit.processes).toContainEqual( + expect.objectContaining({ + processKey: failedAssignment?.processKey, + status: "passed", + sourceRunId: resumeCreatePayload.runId, + }), + ); + expect(convergedCommit.runs.map((run) => run.id)).toEqual([ + resumeCreatePayload.runId, + createPayload.runId, + ]); + + const convergedTreemapResponse = await app.inject({ + method: "GET", + url: `/repositories/verge-testbed/commits/${commitSha}/treemap`, + }); + expect(convergedTreemapResponse.statusCode).toBe(200); + const convergedTreemap = convergedTreemapResponse.json() as { + tree: { + kind: string; + status: string; + children?: Array<{ + kind: string; + stepKey?: string | null; + children?: Array<{ + kind: string; + processKey?: string | null; + sourceRunId?: string | null; + children?: Array<{ processKey?: string | null; sourceRunId?: string | null }>; + }>; + }>; + }; + }; + + expect(convergedTreemap.tree.kind).toBe("commit"); + expect(convergedTreemap.tree.status).toBe("passed"); + const stepNode = convergedTreemap.tree.children?.find((node) => node.stepKey === "test-resume"); + expect(stepNode?.kind).toBe("step"); + const processNodes = stepNode?.children?.flatMap((child) => child.children ?? [child]) ?? []; + expect(processNodes).toContainEqual( + expect.objectContaining({ + processKey: failedAssignment?.processKey, + sourceRunId: resumeCreatePayload.runId, + }), + ); + }, 30_000); }); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index e466694..a15e03b 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { createManualRunInputSchema, runListQuerySchema } from "@verge/contracts"; import { getCommitDetail, + getCommitTreemap, getRepositoryDefinitionBySlug, getPullRequestDetail, getRepositoryBySlug, @@ -101,13 +102,23 @@ export const registerPublicRoutes = (app: FastifyInstance, context: ApiContext): ), ); - app.get("/repositories/:repo/commits/:sha", async (request) => - getCommitDetail( - context.connection.db, - (request.params as { repo: string; sha: string }).repo, - (request.params as { repo: string; sha: string }).sha, - ), - ); + app.get("/repositories/:repo/commits/:sha", async (request, reply) => { + const { repo, sha } = request.params as { repo: string; sha: string }; + const detail = await getCommitDetail(context.connection.db, repo, sha); + if (!detail) { + return reply.code(404).send({ message: "Commit not found" }); + } + return detail; + }); + + app.get("/repositories/:repo/commits/:sha/treemap", async (request, reply) => { + const { repo, sha } = request.params as { repo: string; sha: string }; + const treemap = await getCommitTreemap(context.connection.db, repo, sha); + if (!treemap) { + return reply.code(404).send({ message: "Commit not found" }); + } + return treemap; + }); app.get("/repositories/:repo/pull-requests/:number", async (request) => { const { repo, number } = request.params as { repo: string; number: string }; diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 33ba526..464df4c 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatDuration, statusTone } from "./lib/format.js"; +import { formatDuration, formatDurationMs, statusTone } from "./lib/format.js"; describe("statusTone", () => { it("maps successful states to the good tone", () => { @@ -24,3 +24,9 @@ describe("formatDuration", () => { expect(formatDuration(null, null, 65_000)).toBe("1m 5s"); }); }); + +describe("formatDurationMs", () => { + it("shows sub-second durations in milliseconds", () => { + expect(formatDurationMs(136)).toBe("136ms"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1e6eb42..2954b93 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,10 +4,12 @@ import type { RepositorySummary } from "@verge/contracts"; import { NavLink, StatusPill } from "./components/common.js"; import { useAppRoute } from "./hooks/use-app-route.js"; +import { useCommitDetailData } from "./hooks/use-commit-detail-data.js"; import { useOverviewData } from "./hooks/use-overview-data.js"; import { useRunDetailData } from "./hooks/use-run-detail-data.js"; import { useRunsPageData } from "./hooks/use-runs-page-data.js"; import { + buildCommitPath, buildRepositoryOverviewPath, buildRepositoryRunsPath, buildRunPath, @@ -15,6 +17,7 @@ import { navigate, } from "./lib/routing.js"; import { statusTone } from "./lib/format.js"; +import { CommitDetailPage } from "./pages/CommitDetailPage.js"; import { OverviewPage } from "./pages/OverviewPage.js"; import { RunDetailPage } from "./pages/RunDetailPage.js"; import { RunsPage } from "./pages/RunsPage.js"; @@ -32,6 +35,12 @@ export const App = () => { const { health, processSpecs, error: overviewError } = useOverviewData(currentRepositorySlug); const { runsPage, error: runsError } = useRunsPageData(route, currentRepositorySlug); const { run, treemap, step, error: runError, treemapError } = useRunDetailData(route); + const { + commit, + treemap: commitTreemap, + error: commitError, + treemapError: commitTreemapError, + } = useCommitDetailData(route); const [commitSha, setCommitSha] = useState(""); const [branch, setBranch] = useState("main"); @@ -118,6 +127,11 @@ export const App = () => { return; } + if (route.name === "commit") { + navigate(buildCommitPath(fallbackRepositorySlug, route.commitSha)); + return; + } + navigate(buildStepPath(fallbackRepositorySlug, route.runId, route.stepId)); }, [preferredRepositorySlug, repositories, route, run?.repositorySlug]); @@ -223,6 +237,11 @@ export const App = () => { return; } + if (route.name === "commit") { + navigate(buildCommitPath(nextRepositorySlug, route.commitSha)); + return; + } + navigate(buildRepositoryRunsPath(nextRepositorySlug)); }; @@ -233,7 +252,9 @@ export const App = () => { ? runsError : route.name === "run" || route.name === "step" ? runError - : overviewError); + : route.name === "commit" + ? commitError + : overviewError); return (
@@ -272,7 +293,12 @@ export const App = () => { label="Overview" /> { /> ) : null} + {route.name === "commit" ? ( + + ) : null} + {route.name === "run" ? ( ) : null} diff --git a/apps/web/src/components/RunTreemap.tsx b/apps/web/src/components/RunTreemap.tsx index 9781a1d..33ccebf 100644 --- a/apps/web/src/components/RunTreemap.tsx +++ b/apps/web/src/components/RunTreemap.tsx @@ -6,11 +6,11 @@ import { } from "d3-hierarchy"; import { useState } from "react"; -import type { RunTreemap, TreemapNode } from "@verge/contracts"; +import type { CommitTreemap, RunTreemap, TreemapNode } from "@verge/contracts"; import { EmptyState, StatusPill } from "./common.js"; import { formatDurationMs, statusTone } from "../lib/format.js"; -import { buildStepPath, navigate } from "../lib/routing.js"; +import { navigate } from "../lib/routing.js"; const treemapWidth = 1200; const treemapHeight = 520; @@ -43,19 +43,6 @@ const shouldShowLabel = (node: TreemapLayoutNode): boolean => { return width >= 124 && height >= 48; }; -const buildProcessTargetPath = (input: { - repositorySlug: string; - runId: string; - stepId: string | null; - processId: string; -}): string | null => { - if (!input.stepId) { - return null; - } - - return `${buildStepPath(input.repositorySlug, input.runId, input.stepId)}#process-${input.processId}`; -}; - const buildTreemapLayout = (tree: TreemapNode): TreemapLayoutNode => { const root = hierarchy(tree) .sum((node: TreemapNode) => node.valueMs) @@ -73,16 +60,28 @@ const buildTreemapLayout = (tree: TreemapNode): TreemapLayoutNode => { ); }; -export const RunTreemapView = ({ - runId, - repositorySlug, - treemapData, +type TreemapData = Pick | Pick; + +export const TreemapView = ({ + treeData, treemapError, + errorTitle, + loadingTitle, + loadingBody, + emptyTitle, + emptyBody, + ariaLabel, + buildNodePath, }: { - runId: string; - repositorySlug: string; - treemapData: RunTreemap | null; + treeData: TreemapData | null; treemapError: string | null; + errorTitle: string; + loadingTitle: string; + loadingBody: string; + emptyTitle: string; + emptyBody: string; + ariaLabel: string; + buildNodePath?: (node: TreemapNode) => string | null; }) => { const [hoveredNode, setHoveredNode] = useState<{ node: TreemapNode; @@ -90,25 +89,20 @@ export const RunTreemapView = ({ y: number; } | null>(null); - if (!treemapData) { + if (!treeData) { return ( ); } - if (!treemapData.tree.children?.length || treemapData.tree.valueMs === 0) { - return ( - - ); + if (!treeData.tree.children?.length || treeData.tree.valueMs === 0) { + return ; } - const root = buildTreemapLayout(treemapData.tree); + const root = buildTreemapLayout(treeData.tree); const renderedNodes = root.descendants().filter((node: TreemapLayoutNode) => node.depth > 0); return ( @@ -123,7 +117,7 @@ export const RunTreemapView = ({
{ const width = node.x1 - node.x0; const height = node.y1 - node.y0; - const targetPath = - node.data.kind === "step" - ? buildStepPath(repositorySlug, runId, node.data.id) - : node.data.kind === "process" - ? buildProcessTargetPath({ - repositorySlug, - runId, - stepId: - node.parent?.data.kind === "step" - ? node.parent.data.id - : node.parent?.parent?.data.kind === "step" - ? node.parent.parent.data.id - : null, - processId: node.data.id, - }) - : node.parent?.data.kind === "step" - ? buildStepPath(repositorySlug, runId, node.parent.data.id) - : null; + const targetPath = buildNodePath?.(node.data) ?? null; return ( ); }; + +export const RunTreemapView = TreemapView; diff --git a/apps/web/src/hooks/use-commit-detail-data.ts b/apps/web/src/hooks/use-commit-detail-data.ts new file mode 100644 index 0000000..64b6d5a --- /dev/null +++ b/apps/web/src/hooks/use-commit-detail-data.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; + +import type { CommitDetail, CommitTreemap } from "@verge/contracts"; + +import { describeLoadError, fetchJson } from "../lib/api.js"; +import type { AppRoute } from "../lib/routing.js"; + +export const useCommitDetailData = (route: AppRoute) => { + const [commit, setCommit] = useState(null); + const [treemap, setTreemap] = useState(null); + const [error, setError] = useState(null); + const [treemapError, setTreemapError] = useState(null); + + useEffect(() => { + if (route.name !== "commit" || !route.repositorySlug) { + setCommit(null); + setTreemap(null); + setError(null); + setTreemapError(null); + return; + } + + setCommit(null); + setTreemap(null); + setTreemapError(null); + + const refresh = async (): Promise => { + try { + const nextCommit = await fetchJson( + `/repositories/${route.repositorySlug}/commits/${route.commitSha}`, + ); + setCommit(nextCommit); + try { + const nextTreemap = await fetchJson( + `/repositories/${route.repositorySlug}/commits/${route.commitSha}/treemap`, + ); + setTreemap(nextTreemap); + setTreemapError(null); + } catch (nextTreemapError) { + setTreemap(null); + setTreemapError( + describeLoadError(route, nextTreemapError, "Failed to load commit duration map"), + ); + } + setError(null); + } catch (nextError) { + setError(describeLoadError(route, nextError, "Failed to load commit data")); + } + }; + + void refresh(); + const interval = window.setInterval(() => { + void refresh(); + }, 3000); + return () => window.clearInterval(interval); + }, [route]); + + return { commit, treemap, error, treemapError }; +}; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 837e0f9..88f47dc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -27,7 +27,7 @@ export const fetchJson = async (path: string, init?: RequestInit): Promise export const describeLoadError = ( route: { - name: "overview" | "runs" | "run" | "step"; + name: "overview" | "runs" | "run" | "step" | "commit"; }, error: unknown, fallback: string, @@ -41,6 +41,9 @@ export const describeLoadError = ( if (route.name === "step") { return "Step not found. Old local data may have been deleted."; } + if (route.name === "commit") { + return "Commit not found. Old local data may have been deleted."; + } return error.message; } diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index b44ac20..adb6153 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -28,6 +28,10 @@ export const formatDurationMs = (durationMs: number | null): string => { return "Pending"; } + if (durationMs < 1_000) { + return `${Math.max(0, durationMs)}ms`; + } + const diffSeconds = Math.max(0, Math.round(durationMs / 1000)); if (diffSeconds < 60) { diff --git a/apps/web/src/lib/routing.ts b/apps/web/src/lib/routing.ts index 03e8a9a..d7eee80 100644 --- a/apps/web/src/lib/routing.ts +++ b/apps/web/src/lib/routing.ts @@ -1,5 +1,6 @@ export type AppRoute = | { name: "overview"; repositorySlug: string | null } + | { name: "commit"; repositorySlug: string | null; commitSha: string } | { name: "runs"; repositorySlug: string | null; @@ -46,6 +47,9 @@ export const buildRepositoryRunsPath = ( export const buildRunPath = (repositorySlug: string, runId: string): string => `/repos/${repositorySlug}/runs/${runId}`; +export const buildCommitPath = (repositorySlug: string, commitSha: string): string => + `/repos/${repositorySlug}/commits/${commitSha}`; + export const buildStepPath = (repositorySlug: string, runId: string, stepId: string): string => `/repos/${repositorySlug}/runs/${runId}/steps/${stepId}`; @@ -66,6 +70,15 @@ export const parseRoute = (): AppRoute => { }; } + const repoCommitMatch = path.match(/^\/repos\/([^/]+)\/commits\/([^/]+)$/); + if (repoCommitMatch) { + return { + name: "commit", + repositorySlug: repoCommitMatch[1] ?? null, + commitSha: repoCommitMatch[2]!, + }; + } + const repoStepMatch = path.match(/^\/repos\/([^/]+)\/runs\/([^/]+)\/steps\/([^/]+)$/); if (repoStepMatch) { return { diff --git a/apps/web/src/pages/CommitDetailPage.tsx b/apps/web/src/pages/CommitDetailPage.tsx new file mode 100644 index 0000000..6f5c3b5 --- /dev/null +++ b/apps/web/src/pages/CommitDetailPage.tsx @@ -0,0 +1,314 @@ +import type { CommitDetail, CommitTreemap } from "@verge/contracts"; + +import { EmptyState, StatusPill } from "../components/common.js"; +import { TreemapView } from "../components/RunTreemap.js"; +import { + formatDateTime, + formatDuration, + formatDurationMs, + formatRelativeTime, + shortSha, + summarizeRunExecutionMode, + summarizeRunSteps, +} from "../lib/format.js"; +import { buildRunPath, buildStepPath, navigate } from "../lib/routing.js"; + +export const CommitDetailPage = ({ + commit, + treemap, + treemapError, + error, +}: { + commit: CommitDetail | null; + treemap: CommitTreemap | null; + treemapError: string | null; + error: string | null; +}) => { + if (!commit) { + return ( + + ); + } + + return ( +
+
+
+

Commit {shortSha(commit.commitSha)}

+

+ This page shows the converged health for one commit across all runs, plus the attempt + history that produced it. +

+
+
+ + {commit.steps.length} steps + {commit.processes.length} processes + {commit.runs.length} attempts +
+
+ +
+
+ Commit + {shortSha(commit.commitSha)} + {commit.commitSha} +
+
+ Selected process time + {formatDurationMs(commit.executionCost.selectedProcessDurationMs)} + Used for the converged duration map +
+
+ Execution cost + {formatDurationMs(commit.executionCost.totalExecutionDurationMs)} + + {commit.executionCost.processRunCount} process runs across{" "} + {commit.executionCost.runCount} attempts + +
+
+ Current health + {commit.status} + Selected from the latest evidence per process +
+
+ +
+
+
+

Commit duration map

+

+ Area shows the selected process duration for this commit. Color shows the converged + status of each step and process. +

+
+
+ { + if ( + node.kind === "process" && + node.sourceRunId && + node.sourceStepRunId && + node.sourceProcessRunId + ) { + return `${buildStepPath( + commit.repositorySlug, + node.sourceRunId, + node.sourceStepRunId, + )}#process-${node.sourceProcessRunId}`; + } + + if (node.kind === "file" && node.sourceRunId && node.sourceStepRunId) { + return buildStepPath(commit.repositorySlug, node.sourceRunId, node.sourceStepRunId); + } + + return null; + }} + /> +
+ +
+
+

Steps

+ {commit.steps.length} converged step states +
+
+ + + + + + + + + + + + {commit.steps.map((step) => ( + + + + + + + + ))} + +
StepStatusProcessesSelected timeSource
+
+ {step.stepDisplayName} + {step.stepKey} +
+
+ + {step.processCount}{formatDurationMs(step.durationMs)} + {step.sourceRunId && step.sourceStepRunId ? ( + { + event.preventDefault(); + navigate( + buildStepPath( + commit.repositorySlug, + step.sourceRunId!, + step.sourceStepRunId!, + ), + ); + }} + > + Open source step + + ) : ( + No source step + )} +
+
+
+ +
+
+

Processes

+ {commit.processes.length} converged process states +
+
+ + + + + + + + + + + + + + {commit.processes.map((process) => ( + + + + + + + + + + ))} + +
ProcessStepFileStatusSelected timeAttemptsSource
+
+ {process.processDisplayName} + {process.processKey} +
+
+
+ {process.stepDisplayName} + {process.stepKey} +
+
{process.filePath ?? "No file"} + + {formatDurationMs(process.durationMs)}{process.attemptCount} + { + event.preventDefault(); + navigate( + `${buildStepPath( + commit.repositorySlug, + process.sourceRunId, + process.sourceStepRunId, + )}#process-${process.sourceProcessRunId}`, + ); + }} + > + Open source process + +
+
+
+ +
+
+

Attempt history

+ {commit.runs.length} runs contributed to this view +
+
+ + + + + + + + + + + + + + {commit.runs.map((run) => ( + navigate(buildRunPath(commit.repositorySlug, run.id))} + > + + + + + + + + + ))} + +
StatusTriggerRefStepsExecutionCreatedDuration
+ + {run.trigger} +
+ {run.branch ?? "No branch"} + + {run.pullRequestNumber ? `PR #${run.pullRequestNumber}` : "No PR"} + +
+
+
+ {run.steps.length} steps + + {summarizeRunSteps(run.steps)} + +
+
{summarizeRunExecutionMode(run.steps)} +
+ {formatDateTime(run.createdAt)} + {formatRelativeTime(run.createdAt)} +
+
{formatDuration(run.startedAt, run.finishedAt, run.durationMs)}
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/RunDetailPage.tsx b/apps/web/src/pages/RunDetailPage.tsx index f771a97..7f356ea 100644 --- a/apps/web/src/pages/RunDetailPage.tsx +++ b/apps/web/src/pages/RunDetailPage.tsx @@ -1,7 +1,7 @@ import type { RunDetail, RunTreemap } from "@verge/contracts"; import { EmptyState, StatusPill } from "../components/common.js"; -import { RunTreemapView } from "../components/RunTreemap.js"; +import { TreemapView } from "../components/RunTreemap.js"; import { classifyStepExecutionMode, formatDateTime, @@ -9,7 +9,7 @@ import { shortSha, summarizeRunExecutionMode, } from "../lib/format.js"; -import { buildStepPath, navigate } from "../lib/routing.js"; +import { buildCommitPath, buildStepPath, navigate } from "../lib/routing.js"; export const RunDetailPage = ({ run, @@ -56,7 +56,16 @@ export const RunDetailPage = ({
@@ -87,11 +96,34 @@ export const RunDetailPage = ({

- { + if (node.kind === "step" && node.sourceStepRunId) { + return buildStepPath(run.repositorySlug, run.id, node.sourceStepRunId); + } + + if (node.kind === "process" && node.sourceStepRunId && node.sourceProcessRunId) { + return `${buildStepPath( + run.repositorySlug, + run.id, + node.sourceStepRunId, + )}#process-${node.sourceProcessRunId}`; + } + + if (node.kind === "file" && node.sourceStepRunId) { + return buildStepPath(run.repositorySlug, run.id, node.sourceStepRunId); + } + + return null; + }} /> diff --git a/apps/web/src/pages/RunsPage.tsx b/apps/web/src/pages/RunsPage.tsx index 9578c60..d2ceb0b 100644 --- a/apps/web/src/pages/RunsPage.tsx +++ b/apps/web/src/pages/RunsPage.tsx @@ -9,7 +9,7 @@ import { summarizeRunSteps, shortSha, } from "../lib/format.js"; -import { buildRunPath, navigate } from "../lib/routing.js"; +import { buildCommitPath, buildRunPath, navigate } from "../lib/routing.js"; export const RunsPage = ({ repositorySlug, @@ -132,7 +132,17 @@ export const RunsPage = ({
- {shortSha(run.commitSha)} + { + event.preventDefault(); + event.stopPropagation(); + navigate(buildCommitPath(run.repositorySlug, run.commitSha)); + }} + > + {shortSha(run.commitSha)} + {run.changedFiles.length} changed files diff --git a/apps/web/src/pages/StepDetailPage.tsx b/apps/web/src/pages/StepDetailPage.tsx index 2a92803..e7b14c4 100644 --- a/apps/web/src/pages/StepDetailPage.tsx +++ b/apps/web/src/pages/StepDetailPage.tsx @@ -9,7 +9,7 @@ import { formatDuration, shortSha, } from "../lib/format.js"; -import { buildRunPath, navigate } from "../lib/routing.js"; +import { buildCommitPath, buildRunPath, navigate } from "../lib/routing.js"; export const StepDetailPage = ({ run, @@ -122,7 +122,16 @@ export const StepDetailPage = ({
Trigger diff --git a/packages/contracts/src/runs.ts b/packages/contracts/src/runs.ts index eb1b583..5e42f87 100644 --- a/packages/contracts/src/runs.ts +++ b/packages/contracts/src/runs.ts @@ -127,6 +127,42 @@ export const runDetailSchema = runSummarySchema.extend({ steps: z.array(stepRunSummarySchema), }); +export const commitProcessSummarySchema = z.object({ + stepKey: z.string(), + stepDisplayName: z.string(), + stepKind: z.string(), + sourceRunId: z.string().uuid(), + sourceStepRunId: z.string().uuid(), + sourceProcessRunId: z.string().uuid(), + processKey: z.string(), + processDisplayName: z.string(), + processKind: z.string(), + filePath: z.string().nullable(), + status: processRunStatusSchema, + durationMs: z.number().int().nonnegative().nullable(), + reused: z.boolean(), + attemptCount: z.number().int().nonnegative(), + updatedAt: z.string().datetime(), +}); + +export const commitStepSummarySchema = z.object({ + stepKey: z.string(), + stepDisplayName: z.string(), + stepKind: z.string(), + status: stepRunStatusSchema, + processCount: z.number().int().nonnegative(), + durationMs: z.number().int().nonnegative().nullable(), + sourceRunId: z.string().uuid().nullable(), + sourceStepRunId: z.string().uuid().nullable(), +}); + +export const commitExecutionCostSchema = z.object({ + runCount: z.number().int().nonnegative(), + processRunCount: z.number().int().nonnegative(), + totalExecutionDurationMs: z.number().int().nonnegative(), + selectedProcessDurationMs: z.number().int().nonnegative(), +}); + export const repoAreaStateSchema = z.object({ key: z.string(), displayName: z.string(), @@ -147,7 +183,11 @@ export const repositoryHealthSchema = z.object({ export const commitDetailSchema = z.object({ repositorySlug: z.string(), commitSha: z.string(), - runs: z.array(runDetailSchema), + status: runStatusSchema, + steps: z.array(commitStepSummarySchema), + processes: z.array(commitProcessSummarySchema), + runs: z.array(runSummarySchema), + executionCost: commitExecutionCostSchema, }); export const pullRequestDetailSchema = z.object({ @@ -164,5 +204,8 @@ export type PaginatedRunList = z.infer; export type RunDetail = z.infer; export type StepRunDetail = z.infer; export type RepositoryHealth = z.infer; +export type CommitProcessSummary = z.infer; +export type CommitStepSummary = z.infer; +export type CommitExecutionCost = z.infer; export type CommitDetail = z.infer; export type PullRequestDetail = z.infer; diff --git a/packages/contracts/src/treemap.ts b/packages/contracts/src/treemap.ts index d4f5d53..792d7f1 100644 --- a/packages/contracts/src/treemap.ts +++ b/packages/contracts/src/treemap.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const treemapNodeKindSchema = z.enum(["run", "step", "file", "process"]); +export const treemapNodeKindSchema = z.enum(["run", "commit", "step", "file", "process"]); export const treemapNodeStatusSchema = z.enum([ "planned", @@ -24,6 +24,9 @@ export const treemapNodeSchema: z.ZodType = z.lazy(() => filePath: z.string().nullable(), stepKey: z.string().nullable(), processKey: z.string().nullable(), + sourceRunId: z.string().uuid().nullable(), + sourceStepRunId: z.string().uuid().nullable(), + sourceProcessRunId: z.string().uuid().nullable(), reused: z.boolean(), attemptCount: z.number().int().nonnegative().nullable(), children: z.array(treemapNodeSchema).optional(), @@ -36,5 +39,12 @@ export const runTreemapSchema = z.object({ tree: treemapNodeSchema, }); +export const commitTreemapSchema = z.object({ + repositorySlug: z.string(), + commitSha: z.string(), + tree: treemapNodeSchema, +}); + export type TreemapNode = z.infer; export type RunTreemap = z.infer; +export type CommitTreemap = z.infer; diff --git a/packages/db/src/commit-projection.ts b/packages/db/src/commit-projection.ts new file mode 100644 index 0000000..a59b992 --- /dev/null +++ b/packages/db/src/commit-projection.ts @@ -0,0 +1,220 @@ +import type { Kysely } from "kysely"; + +import { coalesceDurationMs, type VergeDatabase } from "./shared.js"; + +type CommitProjectionContext = { + repositoryId: string; + commitSha: string; +}; + +type CommitProjectionCandidate = { + repositoryId: string; + commitSha: string; + runId: string; + runCreatedAt: Date; + stepRunId: string; + stepKey: string; + stepDisplayName: string; + stepKind: string; + processRunId: string; + processKey: string; + processDisplayName: string; + processKind: string; + filePath: string | null; + status: string; + durationMs: number | null; + startedAt: Date | null; + finishedAt: Date | null; + attemptCount: number; + createdAt: Date; +}; + +const activeStatuses = new Set(["queued", "claimed", "running"]); +const preferredTerminalStatuses = new Set(["passed", "reused", "failed"]); + +const compareCandidateRecency = ( + left: CommitProjectionCandidate, + right: CommitProjectionCandidate, +): number => + right.runCreatedAt.getTime() - left.runCreatedAt.getTime() || + right.createdAt.getTime() - left.createdAt.getTime(); + +const chooseProjectionCandidate = ( + candidates: CommitProjectionCandidate[], +): CommitProjectionCandidate => { + const ordered = [...candidates].sort(compareCandidateRecency); + const fallback = ordered[0]; + if (!fallback) { + throw new Error("Expected at least one commit projection candidate"); + } + + for (const candidate of ordered) { + if (activeStatuses.has(candidate.status)) { + return candidate; + } + + if (preferredTerminalStatuses.has(candidate.status)) { + return candidate; + } + } + + return fallback; +}; + +const computeSelectedDurationMs = (candidate: CommitProjectionCandidate): number | null => { + const durationMs = coalesceDurationMs( + candidate.durationMs, + candidate.startedAt, + candidate.finishedAt, + ); + if (durationMs !== null) { + return durationMs; + } + + if (candidate.startedAt) { + return Math.max(0, Date.now() - candidate.startedAt.getTime()); + } + + return null; +}; + +const listProjectionCandidates = async ( + db: Kysely, + input: CommitProjectionContext, +): Promise => + db + .selectFrom("process_runs") + .innerJoin("step_runs", "step_runs.id", "process_runs.step_run_id") + .innerJoin("runs", "runs.id", "step_runs.run_id") + .select([ + "runs.repository_id as repositoryId", + "runs.commit_sha as commitSha", + "runs.id as runId", + "runs.created_at as runCreatedAt", + "step_runs.id as stepRunId", + "step_runs.step_key as stepKey", + "step_runs.display_name as stepDisplayName", + "step_runs.kind as stepKind", + "process_runs.id as processRunId", + "process_runs.process_key as processKey", + "process_runs.display_name as processDisplayName", + "process_runs.kind as processKind", + "process_runs.file_path as filePath", + "process_runs.status as status", + "process_runs.duration_ms as durationMs", + "process_runs.started_at as startedAt", + "process_runs.finished_at as finishedAt", + "process_runs.attempt_count as attemptCount", + "process_runs.created_at as createdAt", + ]) + .where("runs.repository_id", "=", input.repositoryId) + .where("runs.commit_sha", "=", input.commitSha) + .orderBy("runs.created_at", "desc") + .orderBy("process_runs.created_at", "desc") + .execute(); + +const getCommitProjectionContextByRunId = async ( + db: Kysely, + runId: string, +): Promise => { + const row = await db + .selectFrom("runs") + .select(["repository_id as repositoryId", "commit_sha as commitSha"]) + .where("id", "=", runId) + .executeTakeFirst(); + + return row ?? null; +}; + +const getCommitProjectionContextByStepRunId = async ( + db: Kysely, + stepRunId: string, +): Promise => { + const row = await db + .selectFrom("step_runs") + .innerJoin("runs", "runs.id", "step_runs.run_id") + .select(["runs.repository_id as repositoryId", "runs.commit_sha as commitSha"]) + .where("step_runs.id", "=", stepRunId) + .executeTakeFirst(); + + return row ?? null; +}; + +const syncCommitProjection = async ( + db: Kysely, + context: CommitProjectionContext, +): Promise => { + const candidates = await listProjectionCandidates(db, context); + const chosen = new Map(); + + for (const candidate of candidates) { + const identity = `${candidate.stepKey}\u0000${candidate.processKey}`; + const existing = chosen.get(identity); + if (!existing) { + chosen.set(identity, candidate); + continue; + } + + chosen.set(identity, chooseProjectionCandidate([existing, candidate])); + } + + await db + .deleteFrom("commit_process_state") + .where("repository_id", "=", context.repositoryId) + .where("commit_sha", "=", context.commitSha) + .execute(); + + if (chosen.size === 0) { + return; + } + + const updatedAt = new Date(); + await db + .insertInto("commit_process_state") + .values( + [...chosen.values()].map((candidate) => ({ + repository_id: candidate.repositoryId, + commit_sha: candidate.commitSha, + step_key: candidate.stepKey, + step_display_name: candidate.stepDisplayName, + step_kind: candidate.stepKind, + process_key: candidate.processKey, + process_display_name: candidate.processDisplayName, + process_kind: candidate.processKind, + file_path: candidate.filePath, + selected_run_id: candidate.runId, + selected_step_run_id: candidate.stepRunId, + selected_process_run_id: candidate.processRunId, + status: candidate.status, + duration_ms: computeSelectedDurationMs(candidate), + reused: candidate.status === "reused", + attempt_count: candidate.attemptCount, + updated_at: updatedAt, + })), + ) + .execute(); +}; + +export const syncCommitProcessStateForRun = async ( + db: Kysely, + runId: string, +): Promise => { + const context = await getCommitProjectionContextByRunId(db, runId); + if (!context) { + return; + } + + await syncCommitProjection(db, context); +}; + +export const syncCommitProcessStateForStepRun = async ( + db: Kysely, + stepRunId: string, +): Promise => { + const context = await getCommitProjectionContextByStepRunId(db, stepRunId); + if (!context) { + return; + } + + await syncCommitProjection(db, context); +}; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d73b5e4..5c6608a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,5 @@ export * from "./shared.js"; export * from "./config.js"; +export * from "./commit-projection.js"; export * from "./run-reads.js"; export * from "./run-writes.js"; diff --git a/packages/db/src/migrations.ts b/packages/db/src/migrations.ts index 4ed26b6..abac85f 100644 --- a/packages/db/src/migrations.ts +++ b/packages/db/src/migrations.ts @@ -405,6 +405,36 @@ export const schemaMigrations: SchemaMigration[] = [ alter table process_runs add column if not exists duration_ms integer; `, }, + { + id: "004_commit_process_state", + sql: ` + create table if not exists commit_process_state ( + repository_id uuid not null references repositories(id) on delete cascade, + commit_sha text not null, + step_key text not null, + step_display_name text not null, + step_kind text not null, + process_key text not null, + process_display_name text not null, + process_kind text not null, + file_path text, + selected_run_id uuid not null references runs(id) on delete cascade, + selected_step_run_id uuid not null references step_runs(id) on delete cascade, + selected_process_run_id uuid not null references process_runs(id) on delete cascade, + status text not null, + duration_ms integer, + reused boolean not null default false, + attempt_count integer not null default 0, + updated_at timestamptz not null default now(), + primary key (repository_id, commit_sha, step_key, process_key) + ); + + create index if not exists idx_commit_process_state_repository_commit + on commit_process_state(repository_id, commit_sha, step_key); + create index if not exists idx_commit_process_state_selected_run + on commit_process_state(selected_run_id, selected_step_run_id, selected_process_run_id); + `, + }, ]; export const runMigrations = async (db: Kysely): Promise => { diff --git a/packages/db/src/run-detail-reads.ts b/packages/db/src/run-detail-reads.ts index 2e40b92..4b0db21 100644 --- a/packages/db/src/run-detail-reads.ts +++ b/packages/db/src/run-detail-reads.ts @@ -12,7 +12,13 @@ import type { } from "@verge/contracts"; import { determineFreshnessBucket } from "@verge/core"; -import { coalesceDurationMs, iso, parseJson, type VergeDatabase } from "./shared.js"; +import { + coalesceDurationMs, + iso, + parseJson, + summarizeStatuses, + type VergeDatabase, +} from "./shared.js"; import { listProcessRuns } from "./process-run-reads.js"; import { selectRunRows, @@ -217,22 +223,163 @@ export const getCommitDetail = async ( db: Kysely, repositorySlug: string, commitSha: string, -): Promise => { - const runIds = await db - .selectFrom("runs") - .innerJoin("repositories", "repositories.id", "runs.repository_id") - .select(["runs.id"]) - .where("repositories.slug", "=", repositorySlug) +): Promise => { + const repository = await db + .selectFrom("repositories") + .select(["id", "slug"]) + .where("slug", "=", repositorySlug) + .executeTakeFirst(); + + if (!repository) { + return null; + } + + const runRows = await selectRunRows(db, repositorySlug) .where("runs.commit_sha", "=", commitSha) .orderBy("runs.created_at", "desc") .execute(); + if (runRows.length === 0) { + return null; + } + + const runs = await Promise.all(runRows.map((row) => toRunSummary(db, row))); + const commitProcessRows = await db + .selectFrom("commit_process_state") + .select([ + "step_key as stepKey", + "step_display_name as stepDisplayName", + "step_kind as stepKind", + "process_key as processKey", + "process_display_name as processDisplayName", + "process_kind as processKind", + "file_path as filePath", + "selected_run_id as sourceRunId", + "selected_step_run_id as sourceStepRunId", + "selected_process_run_id as sourceProcessRunId", + "status", + "duration_ms as durationMs", + "reused", + "attempt_count as attemptCount", + "updated_at as updatedAt", + ]) + .where("repository_id", "=", repository.id) + .where("commit_sha", "=", commitSha) + .orderBy("step_key", "asc") + .orderBy("process_key", "asc") + .execute(); + + const processRunsForCommit = await db + .selectFrom("process_runs") + .innerJoin("step_runs", "step_runs.id", "process_runs.step_run_id") + .innerJoin("runs", "runs.id", "step_runs.run_id") + .select([ + "process_runs.duration_ms as durationMs", + "process_runs.started_at as startedAt", + "process_runs.finished_at as finishedAt", + ]) + .where("runs.repository_id", "=", repository.id) + .where("runs.commit_sha", "=", commitSha) + .execute(); + + const processes = commitProcessRows.map((row) => ({ + stepKey: row.stepKey, + stepDisplayName: row.stepDisplayName, + stepKind: row.stepKind, + sourceRunId: row.sourceRunId, + sourceStepRunId: row.sourceStepRunId, + sourceProcessRunId: row.sourceProcessRunId, + processKey: row.processKey, + processDisplayName: row.processDisplayName, + processKind: row.processKind, + filePath: row.filePath, + status: row.status as CommitDetail["processes"][number]["status"], + durationMs: row.durationMs, + reused: row.reused, + attemptCount: row.attemptCount, + updatedAt: row.updatedAt.toISOString(), + })); + + const stepMap = new Map< + string, + { + stepKey: string; + stepDisplayName: string; + stepKind: string; + sourceRunId: string | null; + sourceStepRunId: string | null; + durationMs: number; + processCount: number; + statuses: string[]; + updatedAt: number; + } + >(); + + for (const process of processes) { + const existing = stepMap.get(process.stepKey); + if (!existing) { + stepMap.set(process.stepKey, { + stepKey: process.stepKey, + stepDisplayName: process.stepDisplayName, + stepKind: process.stepKind, + sourceRunId: process.sourceRunId, + sourceStepRunId: process.sourceStepRunId, + durationMs: process.durationMs ?? 0, + processCount: 1, + statuses: [process.status], + updatedAt: Date.parse(process.updatedAt), + }); + continue; + } + + existing.durationMs += process.durationMs ?? 0; + existing.processCount += 1; + existing.statuses.push(process.status); + const processUpdatedAt = Date.parse(process.updatedAt); + if (processUpdatedAt >= existing.updatedAt) { + existing.updatedAt = processUpdatedAt; + existing.sourceRunId = process.sourceRunId; + existing.sourceStepRunId = process.sourceStepRunId; + } + } + + const steps = [...stepMap.values()] + .map((step) => ({ + stepKey: step.stepKey, + stepDisplayName: step.stepDisplayName, + stepKind: step.stepKind, + status: summarizeStatuses(step.statuses) as CommitDetail["steps"][number]["status"], + processCount: step.processCount, + durationMs: step.durationMs, + sourceRunId: step.sourceRunId, + sourceStepRunId: step.sourceStepRunId, + })) + .sort((left, right) => left.stepKey.localeCompare(right.stepKey)); + return { - repositorySlug, + repositorySlug: repository.slug, commitSha, - runs: (await Promise.all(runIds.map((run) => getRunDetail(db, run.id)))).filter( - (run): run is RunDetail => run !== null, - ), + status: (processes.length > 0 + ? summarizeStatuses(processes.map((process) => process.status)) + : summarizeStatuses(runs.map((run) => run.status))) as CommitDetail["status"], + steps, + processes, + runs, + executionCost: { + runCount: runs.length, + processRunCount: processRunsForCommit.length, + totalExecutionDurationMs: processRunsForCommit.reduce( + (total, processRun) => + total + + (coalesceDurationMs(processRun.durationMs, processRun.startedAt, processRun.finishedAt) ?? + 0), + 0, + ), + selectedProcessDurationMs: processes.reduce( + (total, process) => total + (process.durationMs ?? 0), + 0, + ), + }, }; }; diff --git a/packages/db/src/run-record-writes.ts b/packages/db/src/run-record-writes.ts index 7f3e563..d684ef8 100644 --- a/packages/db/src/run-record-writes.ts +++ b/packages/db/src/run-record-writes.ts @@ -10,6 +10,7 @@ import type { RecordObservationInput, } from "@verge/contracts"; +import { syncCommitProcessStateForStepRun } from "./commit-projection.js"; import { json, syncRepoAreaState, type VergeDatabase } from "./shared.js"; import { refreshStepRunStatus } from "./worker-execution-writes.js"; @@ -71,6 +72,8 @@ export const recordRunEvent = async ( .where("id", "=", row.run_id) .execute(); } + + await syncCommitProcessStateForStepRun(db, stepRunId); } if (input.kind === "passed" || input.kind === "failed" || input.kind === "interrupted") { @@ -177,6 +180,7 @@ export const resetDatabase = async (db: Kysely): Promise => "artifacts", "observations", "run_events", + "commit_process_state", "process_runs", "step_runs", "runs", diff --git a/packages/db/src/run-writes.ts b/packages/db/src/run-writes.ts index 95e21ec..765c83b 100644 --- a/packages/db/src/run-writes.ts +++ b/packages/db/src/run-writes.ts @@ -2,3 +2,4 @@ export * from "./run-creation.js"; export * from "./run-recovery.js"; export * from "./worker-execution-writes.js"; export * from "./run-record-writes.js"; +export * from "./commit-projection.js"; diff --git a/packages/db/src/shared.ts b/packages/db/src/shared.ts index 921ce9e..46d3d39 100644 --- a/packages/db/src/shared.ts +++ b/packages/db/src/shared.ts @@ -128,6 +128,26 @@ type ProcessRunsTable = { duration_ms: number | null; }; +type CommitProcessStateTable = { + repository_id: string; + commit_sha: string; + step_key: string; + step_display_name: string; + step_kind: string; + process_key: string; + process_display_name: string; + process_kind: string; + file_path: string | null; + selected_run_id: string; + selected_step_run_id: string; + selected_process_run_id: string; + status: string; + duration_ms: number | null; + reused: boolean; + attempt_count: number; + updated_at: Generated; +}; + type RunEventsTable = { id: string; step_run_id: string; @@ -193,6 +213,7 @@ export type VergeDatabase = { runs: RunsTable; step_runs: StepRunsTable; process_runs: ProcessRunsTable; + commit_process_state: CommitProcessStateTable; run_events: RunEventsTable; observations: ObservationsTable; artifacts: ArtifactsTable; @@ -214,6 +235,7 @@ export type EventIngestionRow = Selectable; export type RunRow = Selectable; export type StepRunRow = Selectable; export type ProcessRunRow = Selectable; +export type CommitProcessStateRow = Selectable; export type ObservationRow = Selectable; export type ArtifactRow = Selectable; export type CheckpointRow = Selectable; diff --git a/packages/db/src/treemap-read.ts b/packages/db/src/treemap-read.ts index 8a5fed0..e229c60 100644 --- a/packages/db/src/treemap-read.ts +++ b/packages/db/src/treemap-read.ts @@ -2,7 +2,7 @@ import path from "node:path"; import type { Kysely } from "kysely"; -import type { RunTreemap, TreemapNode } from "@verge/contracts"; +import type { CommitTreemap, RunTreemap, TreemapNode } from "@verge/contracts"; import { coalesceDurationMs, @@ -13,22 +13,38 @@ import { import { listProcessRuns } from "./process-run-reads.js"; import { selectRunRows } from "./run-read-shared.js"; -const readProcessDurationMs = (process: { - duration_ms: number | null; - started_at: Date | null; - finished_at: Date | null; +type TreemapProcessSource = { + id: string; + label: string; + status: string; + filePath: string | null; + processKey: string; + durationMs: number | null; + startedAt: Date | null; + finishedAt: Date | null; + reused: boolean; + attemptCount: number | null; + sourceRunId: string | null; + sourceStepRunId: string | null; + sourceProcessRunId: string | null; +}; + +const readDurationMs = (process: { + durationMs: number | null; + startedAt: Date | null; + finishedAt: Date | null; }): number => { - const liveDurationMs = coalesceDurationMs( - process.duration_ms, - process.started_at, - process.finished_at, + const storedDurationMs = coalesceDurationMs( + process.durationMs, + process.startedAt, + process.finishedAt, ); - if (liveDurationMs !== null) { - return liveDurationMs; + if (storedDurationMs !== null) { + return storedDurationMs; } - if (process.started_at) { - return Math.max(0, Date.now() - process.started_at.getTime()); + if (process.startedAt) { + return Math.max(0, Date.now() - process.startedAt.getTime()); } return 0; @@ -45,28 +61,27 @@ const sortNodesByValue = (nodes: TreemapNode[]): TreemapNode[] => (left, right) => right.valueMs - left.valueMs || left.label.localeCompare(right.label), ); -const buildProcessTreemapNodes = (processes: ProcessRunRow[]): TreemapNode[] => +const buildProcessTreemapNodes = (processes: TreemapProcessSource[]): TreemapNode[] => processes.map((process) => ({ id: process.id, kind: "process", - label: process.display_name, - valueMs: readProcessDurationMs(process), - wallDurationMs: coalesceDurationMs( - process.duration_ms, - process.started_at, - process.finished_at, - ), + label: process.label, + valueMs: readDurationMs(process), + wallDurationMs: coalesceDurationMs(process.durationMs, process.startedAt, process.finishedAt), status: normalizeTreemapStatus(process.status), - filePath: process.file_path, + filePath: process.filePath, stepKey: null, - processKey: process.process_key, - reused: process.status === "reused", - attemptCount: process.attempt_count, + processKey: process.processKey, + sourceRunId: process.sourceRunId, + sourceStepRunId: process.sourceStepRunId, + sourceProcessRunId: process.sourceProcessRunId, + reused: process.reused, + attemptCount: process.attemptCount, })); -const shouldGroupProcessesByFile = (processes: ProcessRunRow[]): boolean => { +const shouldGroupProcessesByFile = (processes: TreemapProcessSource[]): boolean => { const filePaths = processes - .map((process) => process.file_path) + .map((process) => process.filePath) .filter((filePath): filePath is string => typeof filePath === "string" && filePath.length > 0); if (filePaths.length < 2) { @@ -76,29 +91,35 @@ const shouldGroupProcessesByFile = (processes: ProcessRunRow[]): boolean => { return new Set(filePaths).size < filePaths.length; }; -const buildStepTreemapChildren = (stepRunId: string, processes: ProcessRunRow[]): TreemapNode[] => { - if (!shouldGroupProcessesByFile(processes)) { - return sortNodesByValue(buildProcessTreemapNodes(processes)); +const buildStepTreemapChildren = (input: { + scopeId: string; + stepKey: string; + processes: TreemapProcessSource[]; + fileNodeSourceRunId?: string | null; + fileNodeSourceStepRunId?: string | null; +}): TreemapNode[] => { + if (!shouldGroupProcessesByFile(input.processes)) { + return sortNodesByValue(buildProcessTreemapNodes(input.processes)); } - const processesByFile = new Map(); - const filelessProcesses: ProcessRunRow[] = []; + const processesByFile = new Map(); + const filelessProcesses: TreemapProcessSource[] = []; - for (const process of processes) { - if (!process.file_path) { + for (const process of input.processes) { + if (!process.filePath) { filelessProcesses.push(process); continue; } - const existing = processesByFile.get(process.file_path) ?? []; + const existing = processesByFile.get(process.filePath) ?? []; existing.push(process); - processesByFile.set(process.file_path, existing); + processesByFile.set(process.filePath, existing); } const fileNodes = [...processesByFile.entries()].map(([filePath, fileProcesses]) => { const children = sortNodesByValue(buildProcessTreemapNodes(fileProcesses)); return { - id: `file:${stepRunId}:${filePath}`, + id: `file:${input.scopeId}:${filePath}`, kind: "file" as const, label: path.basename(filePath), valueMs: sumValueMs(children), @@ -107,9 +128,12 @@ const buildStepTreemapChildren = (stepRunId: string, processes: ProcessRunRow[]) summarizeStatuses(fileProcesses.map((process) => process.status)), ), filePath, - stepKey: null, + stepKey: input.stepKey, processKey: null, - reused: fileProcesses.every((process) => process.status === "reused"), + sourceRunId: input.fileNodeSourceRunId ?? children[0]?.sourceRunId ?? null, + sourceStepRunId: input.fileNodeSourceStepRunId ?? children[0]?.sourceStepRunId ?? null, + sourceProcessRunId: null, + reused: fileProcesses.every((process) => process.reused), attemptCount: null, children, }; @@ -118,6 +142,47 @@ const buildStepTreemapChildren = (stepRunId: string, processes: ProcessRunRow[]) return sortNodesByValue([...fileNodes, ...buildProcessTreemapNodes(filelessProcesses)]); }; +const listCommitProjectionRows = async ( + db: Kysely, + repositorySlug: string, + commitSha: string, +) => + db + .selectFrom("commit_process_state") + .innerJoin("repositories", "repositories.id", "commit_process_state.repository_id") + .select([ + "commit_process_state.step_key as stepKey", + "commit_process_state.step_display_name as stepDisplayName", + "commit_process_state.step_kind as stepKind", + "commit_process_state.process_key as processKey", + "commit_process_state.process_display_name as processDisplayName", + "commit_process_state.process_kind as processKind", + "commit_process_state.file_path as filePath", + "commit_process_state.selected_run_id as sourceRunId", + "commit_process_state.selected_step_run_id as sourceStepRunId", + "commit_process_state.selected_process_run_id as sourceProcessRunId", + "commit_process_state.status as status", + "commit_process_state.duration_ms as durationMs", + "commit_process_state.reused as reused", + "commit_process_state.attempt_count as attemptCount", + "commit_process_state.updated_at as updatedAt", + ]) + .where("repositories.slug", "=", repositorySlug) + .where("commit_process_state.commit_sha", "=", commitSha) + .orderBy("commit_process_state.step_key", "asc") + .orderBy("commit_process_state.process_key", "asc") + .execute(); + +const listRunRowsForCommit = async ( + db: Kysely, + repositorySlug: string, + commitSha: string, +) => + selectRunRows(db, repositorySlug) + .where("runs.commit_sha", "=", commitSha) + .orderBy("runs.created_at", "desc") + .execute(); + export const getRunTreemap = async ( db: Kysely, runId: string, @@ -136,8 +201,29 @@ export const getRunTreemap = async ( const stepChildren = await Promise.all( stepRows.map(async (stepRow): Promise => { - const processes = await listProcessRuns(db, stepRow.id); - const children = buildStepTreemapChildren(stepRow.id, processes); + const processRows = await listProcessRuns(db, stepRow.id); + const processes: TreemapProcessSource[] = processRows.map((process: ProcessRunRow) => ({ + id: process.id, + label: process.display_name, + status: process.status, + filePath: process.file_path, + processKey: process.process_key, + durationMs: process.duration_ms, + startedAt: process.started_at, + finishedAt: process.finished_at, + reused: process.status === "reused", + attemptCount: process.attempt_count, + sourceRunId: run.id, + sourceStepRunId: stepRow.id, + sourceProcessRunId: process.id, + })); + const children = buildStepTreemapChildren({ + scopeId: stepRow.id, + stepKey: stepRow.step_key, + processes, + fileNodeSourceRunId: run.id, + fileNodeSourceStepRunId: stepRow.id, + }); return { id: stepRow.id, @@ -153,6 +239,9 @@ export const getRunTreemap = async ( filePath: null, stepKey: stepRow.step_key, processKey: null, + sourceRunId: run.id, + sourceStepRunId: stepRow.id, + sourceProcessRunId: null, reused: stepRow.status === "reused", attemptCount: null, children, @@ -174,9 +263,103 @@ export const getRunTreemap = async ( filePath: null, stepKey: null, processKey: null, + sourceRunId: run.id, + sourceStepRunId: null, + sourceProcessRunId: null, reused: run.status === "reused", attemptCount: null, children: sortedChildren, }, }; }; + +export const getCommitTreemap = async ( + db: Kysely, + repositorySlug: string, + commitSha: string, +): Promise => { + const runRows = await listRunRowsForCommit(db, repositorySlug, commitSha); + if (runRows.length === 0) { + return null; + } + + const projectionRows = await listCommitProjectionRows(db, repositorySlug, commitSha); + const projectionByStep = new Map(); + for (const row of projectionRows) { + const existing = projectionByStep.get(row.stepKey) ?? []; + existing.push(row); + projectionByStep.set(row.stepKey, existing); + } + + const stepChildren = [...projectionByStep.entries()].map(([stepKey, rows]) => { + const processes: TreemapProcessSource[] = rows.map((row) => ({ + id: row.sourceProcessRunId, + label: row.processDisplayName, + status: row.status, + filePath: row.filePath, + processKey: row.processKey, + durationMs: row.durationMs, + startedAt: null, + finishedAt: null, + reused: row.reused, + attemptCount: row.attemptCount, + sourceRunId: row.sourceRunId, + sourceStepRunId: row.sourceStepRunId, + sourceProcessRunId: row.sourceProcessRunId, + })); + const children = buildStepTreemapChildren({ + scopeId: `commit:${commitSha}:${stepKey}`, + stepKey, + processes, + }); + const latestRow = [...rows].sort( + (left, right) => right.updatedAt.getTime() - left.updatedAt.getTime(), + )[0]; + + return { + id: `commit-step:${commitSha}:${stepKey}`, + kind: "step" as const, + label: latestRow?.stepDisplayName ?? stepKey, + valueMs: sumValueMs(children), + wallDurationMs: null, + status: normalizeTreemapStatus(summarizeStatuses(rows.map((row) => row.status))), + filePath: null, + stepKey, + processKey: null, + sourceRunId: latestRow?.sourceRunId ?? null, + sourceStepRunId: latestRow?.sourceStepRunId ?? null, + sourceProcessRunId: null, + reused: rows.every((row) => row.reused), + attemptCount: null, + children, + }; + }); + + const sortedChildren = sortNodesByValue(stepChildren); + const rootStatus = + projectionRows.length > 0 + ? normalizeTreemapStatus(summarizeStatuses(projectionRows.map((row) => row.status))) + : normalizeTreemapStatus(summarizeStatuses(runRows.map((run) => run.status))); + + return { + repositorySlug, + commitSha, + tree: { + id: `commit:${commitSha}`, + kind: "commit", + label: commitSha.slice(0, 7), + valueMs: sumValueMs(sortedChildren), + wallDurationMs: null, + status: rootStatus, + filePath: null, + stepKey: null, + processKey: null, + sourceRunId: null, + sourceStepRunId: null, + sourceProcessRunId: null, + reused: projectionRows.length > 0 && projectionRows.every((row) => row.reused), + attemptCount: null, + children: sortedChildren, + }, + }; +}; diff --git a/packages/db/src/worker-execution-writes.ts b/packages/db/src/worker-execution-writes.ts index 4c0d02f..f02b72f 100644 --- a/packages/db/src/worker-execution-writes.ts +++ b/packages/db/src/worker-execution-writes.ts @@ -2,6 +2,7 @@ import type { Kysely } from "kysely"; import type { ClaimedProcessRun } from "@verge/contracts"; +import { syncCommitProcessStateForStepRun } from "./commit-projection.js"; import { listProcessRuns } from "./process-run-reads.js"; import { durationMsBetween, parseJson, summarizeStatuses, type VergeDatabase } from "./shared.js"; @@ -86,6 +87,7 @@ export const refreshStepRunStatus = async (db: Kysely, stepRunId: if (updated) { await refreshRunStatus(db, updated.run_id); + await syncCommitProcessStateForStepRun(db, stepRunId); } return updated; diff --git a/test/fixtures/verge-testbed.config.ts b/test/fixtures/verge-testbed.config.ts new file mode 100644 index 0000000..130df02 --- /dev/null +++ b/test/fixtures/verge-testbed.config.ts @@ -0,0 +1,68 @@ +import { defineVergeConfig } from "../../packages/core/src/config.js"; + +export default defineVergeConfig({ + repository: { + slug: "verge-testbed", + displayName: "Verge Testbed", + rootPath: "../../", + defaultBranch: "main", + areas: [ + { + key: "resume", + displayName: "Resume", + pathPrefixes: ["fixtures/resume/"], + }, + ], + }, + steps: [ + { + key: "test-resume", + displayName: "Resume Tests", + description: "Integration fixture for converged commit state and checkpoint resume.", + kind: "test", + baseCommand: ["pnpm", "exec", "vitest", "run"], + cwd: ".", + observedAreaKeys: ["resume"], + materialization: { + kind: "namedProcesses", + processes: [ + { + key: "resume::fixtures/resume/flow.resume.test.ts::resume fixture > fails once and then passes on resume", + displayName: "resume fixture > fails once and then passes on resume", + kind: "test", + areaKeys: ["resume"], + filePath: "fixtures/resume/flow.resume.test.ts", + extraArgs: [], + }, + { + key: "resume::fixtures/resume/flow.resume.test.ts::resume fixture > passes after the fail-once process", + displayName: "resume fixture > passes after the fail-once process", + kind: "test", + areaKeys: ["resume"], + filePath: "fixtures/resume/flow.resume.test.ts", + extraArgs: [], + }, + { + key: "resume::fixtures/resume/flow.resume.test.ts::resume fixture > passes the first baseline process", + displayName: "resume fixture > passes the first baseline process", + kind: "test", + areaKeys: ["resume"], + filePath: "fixtures/resume/flow.resume.test.ts", + extraArgs: [], + }, + { + key: "resume::fixtures/resume/flow.resume.test.ts::resume fixture > passes the second baseline process", + displayName: "resume fixture > passes the second baseline process", + kind: "test", + areaKeys: ["resume"], + filePath: "fixtures/resume/flow.resume.test.ts", + extraArgs: [], + }, + ], + }, + reuseEnabled: true, + checkpointEnabled: true, + alwaysRun: false, + }, + ], +});