Steps
@@ -101,7 +130,7 @@ export const RunDetailPage = ({ run, error }: { run: RunDetail | null; error: st
{step.processCount} |
{formatDateTime(step.startedAt)} |
{formatDateTime(step.finishedAt)} |
- {formatDuration(step.startedAt, step.finishedAt)} |
+ {formatDuration(step.startedAt, step.finishedAt, step.durationMs)} |
{formatRelativeTime(run.createdAt)}
|
- {formatDuration(run.startedAt, run.finishedAt)} |
+ {formatDuration(run.startedAt, run.finishedAt, run.durationMs)} |
))}
diff --git a/apps/web/src/pages/StepDetailPage.tsx b/apps/web/src/pages/StepDetailPage.tsx
index c5c1aa3..2a92803 100644
--- a/apps/web/src/pages/StepDetailPage.tsx
+++ b/apps/web/src/pages/StepDetailPage.tsx
@@ -21,12 +21,43 @@ export const StepDetailPage = ({
error: string | null;
}) => {
const [processQuery, setProcessQuery] = useState("");
+ const [locationHash, setLocationHash] = useState(() => window.location.hash);
const deferredProcessQuery = useDeferredValue(processQuery);
useEffect(() => {
setProcessQuery("");
}, [step?.id]);
+ useEffect(() => {
+ const syncHash = () => setLocationHash(window.location.hash);
+ window.addEventListener("hashchange", syncHash);
+ syncHash();
+
+ return () => {
+ window.removeEventListener("hashchange", syncHash);
+ };
+ }, []);
+
+ const highlightedProcessId = locationHash.startsWith("#process-")
+ ? locationHash.slice("#process-".length)
+ : null;
+
+ useEffect(() => {
+ if (!highlightedProcessId) {
+ return;
+ }
+
+ const frame = window.requestAnimationFrame(() => {
+ document
+ .getElementById(`process-${highlightedProcessId}`)
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frame);
+ };
+ }, [highlightedProcessId, step?.id]);
+
if (!run || !step) {
return (
{visibleProcesses.map((process) => (
-
+
|
{process.processDisplayName}
|
@@ -149,7 +186,9 @@ export const StepDetailPage = ({
{process.attemptCount} |
{formatDateTime(process.startedAt)} |
{formatDateTime(process.finishedAt)} |
- {formatDuration(process.startedAt, process.finishedAt)} |
+
+ {formatDuration(process.startedAt, process.finishedAt, process.durationMs)}
+ |
))}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 11d1607..2312966 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -464,6 +464,160 @@ table {
overflow: hidden;
}
+.treemapSection {
+ display: grid;
+ gap: 16px;
+}
+
+.treemapLegend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.treemapLegendItem {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--fg-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.treemapLegendSwatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.treemapLegendSwatch.passed {
+ background: rgba(63, 185, 80, 0.92);
+}
+
+.treemapLegendSwatch.failed {
+ background: rgba(248, 81, 73, 0.92);
+}
+
+.treemapLegendSwatch.reused {
+ background: rgba(47, 129, 247, 0.92);
+}
+
+.treemapLegendSwatch.running {
+ background: rgba(210, 153, 34, 0.92);
+}
+
+.treemapLegendSwatch.queued {
+ background: rgba(139, 148, 158, 0.82);
+}
+
+.treemapLegendSwatch.interrupted {
+ background: rgba(210, 153, 34, 0.76);
+}
+
+.treemapWrap {
+ position: relative;
+}
+
+.treemapSvg {
+ display: block;
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--border-muted);
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(180deg, rgba(22, 27, 34, 0.94), rgba(13, 17, 23, 1)), var(--canvas-default);
+}
+
+.treemapNodeRect {
+ cursor: pointer;
+ stroke: rgba(255, 255, 255, 0.08);
+ stroke-width: 1px;
+ transition:
+ fill 140ms ease,
+ stroke 140ms ease,
+ opacity 140ms ease;
+}
+
+.treemapNodeRect:hover {
+ stroke: rgba(240, 246, 252, 0.52);
+}
+
+.treemapNodeRect.status-passed {
+ fill: rgba(35, 134, 54, 0.88);
+}
+
+.treemapNodeRect.status-failed {
+ fill: rgba(218, 54, 51, 0.92);
+}
+
+.treemapNodeRect.status-reused {
+ fill: rgba(31, 111, 235, 0.88);
+}
+
+.treemapNodeRect.status-running {
+ fill: rgba(154, 103, 0, 0.92);
+}
+
+.treemapNodeRect.status-queued,
+.treemapNodeRect.status-planned,
+.treemapNodeRect.status-skipped {
+ fill: rgba(110, 118, 129, 0.62);
+}
+
+.treemapNodeRect.status-interrupted {
+ fill: rgba(187, 128, 9, 0.88);
+}
+
+.treemapNodeRect.status-muted {
+ fill: rgba(110, 118, 129, 0.52);
+}
+
+.treemapLabel,
+.treemapMeta {
+ pointer-events: none;
+ fill: rgba(240, 246, 252, 0.98);
+}
+
+.treemapLabel {
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.treemapMeta {
+ font-size: 11px;
+ fill: rgba(230, 237, 243, 0.86);
+}
+
+.treemapTooltip {
+ position: fixed;
+ z-index: 40;
+ width: min(380px, calc(100vw - 32px));
+ padding: 14px;
+ border: 1px solid rgba(48, 54, 61, 0.94);
+ border-radius: var(--radius-md);
+ background: rgba(13, 17, 23, 0.96);
+ box-shadow: 0 16px 40px rgba(1, 4, 9, 0.52);
+ backdrop-filter: blur(14px);
+}
+
+.treemapTooltipHeader {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.treemapTooltipGrid {
+ display: grid;
+ grid-template-columns: minmax(0, 110px) minmax(0, 1fr);
+ gap: 8px 12px;
+ align-items: start;
+ font-size: 13px;
+}
+
.panelSection {
padding: 0 16px 16px;
}
@@ -503,6 +657,11 @@ table {
border-bottom: 0;
}
+.dataTable tbody tr:target,
+.dataTable tbody tr.processRowHighlighted {
+ background: rgba(47, 129, 247, 0.18);
+}
+
.clickableRow {
cursor: pointer;
}
diff --git a/package.json b/package.json
index 96eb5ed..111c45b 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@verge/cli": "workspace:*"
},
"devDependencies": {
+ "@types/d3-hierarchy": "^3.1.7",
"@types/node": "^22.17.0",
"@types/pg": "^8.15.4",
"@types/react": "^19.1.8",
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index c77f08c..1670dd7 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -251,6 +251,7 @@ export const processRunSummarySchema = z.object({
attemptCount: z.number().int().nonnegative(),
startedAt: z.string().datetime().nullable(),
finishedAt: z.string().datetime().nullable(),
+ durationMs: z.number().int().nonnegative().nullable(),
});
export const stepRunSummarySchema = z.object({
@@ -266,6 +267,7 @@ export const stepRunSummarySchema = z.object({
createdAt: z.string().datetime(),
startedAt: z.string().datetime().nullable(),
finishedAt: z.string().datetime().nullable(),
+ durationMs: z.number().int().nonnegative().nullable(),
processCount: z.number().int().nonnegative(),
});
@@ -289,6 +291,7 @@ export const runSummarySchema = z.object({
createdAt: z.string().datetime(),
startedAt: z.string().datetime().nullable(),
finishedAt: z.string().datetime().nullable(),
+ durationMs: z.number().int().nonnegative().nullable(),
steps: z.array(stepRunSummarySchema),
});
@@ -356,6 +359,42 @@ export const runDetailSchema = runSummarySchema.extend({
steps: z.array(stepRunSummarySchema),
});
+export const treemapNodeKindSchema = z.enum(["run", "step", "file", "process"]);
+
+export const treemapNodeStatusSchema = z.enum([
+ "planned",
+ "queued",
+ "running",
+ "passed",
+ "failed",
+ "reused",
+ "interrupted",
+ "skipped",
+]);
+
+export const treemapNodeSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ id: z.string().min(1),
+ kind: treemapNodeKindSchema,
+ label: z.string().min(1),
+ valueMs: z.number().int().nonnegative(),
+ wallDurationMs: z.number().int().nonnegative().nullable(),
+ status: treemapNodeStatusSchema,
+ filePath: z.string().nullable(),
+ stepKey: z.string().nullable(),
+ processKey: z.string().nullable(),
+ reused: z.boolean(),
+ attemptCount: z.number().int().nonnegative().nullable(),
+ children: z.array(treemapNodeSchema).optional(),
+ }),
+);
+
+export const runTreemapSchema = z.object({
+ runId: z.string().uuid(),
+ repositorySlug: z.string(),
+ tree: treemapNodeSchema,
+});
+
export const repoAreaStateSchema = z.object({
key: z.string(),
displayName: z.string(),
@@ -388,6 +427,7 @@ export const pullRequestDetailSchema = z.object({
export type RunStatus = z.infer;
export type StepRunStatus = z.infer;
export type ProcessRunStatus = z.infer;
+export type TreemapNode = z.infer;
export type ObservationStatus = z.infer;
export type FreshnessBucket = z.infer;
export type RunTrigger = z.infer;
@@ -415,3 +455,4 @@ export type StepSpecSummary = z.infer;
export type RepositoryHealth = z.infer;
export type CommitDetail = z.infer;
export type PullRequestDetail = z.infer;
+export type RunTreemap = z.infer;
diff --git a/packages/db/src/migrations.ts b/packages/db/src/migrations.ts
index 04df6a4..4ed26b6 100644
--- a/packages/db/src/migrations.ts
+++ b/packages/db/src/migrations.ts
@@ -397,6 +397,14 @@ export const schemaMigrations: SchemaMigration[] = [
create index if not exists idx_checkpoints_step_fingerprint on checkpoints(step_key, fingerprint, created_at desc);
`,
},
+ {
+ id: "003_duration_columns",
+ sql: `
+ alter table runs add column if not exists duration_ms integer;
+ alter table step_runs add column if not exists duration_ms integer;
+ alter table process_runs add column if not exists duration_ms integer;
+ `,
+ },
];
export const runMigrations = async (db: Kysely): Promise => {
diff --git a/packages/db/src/run-reads.ts b/packages/db/src/run-reads.ts
index 5e967e8..773f0f2 100644
--- a/packages/db/src/run-reads.ts
+++ b/packages/db/src/run-reads.ts
@@ -1,3 +1,4 @@
+import path from "node:path";
import { type Kysely } from "kysely";
import type {
@@ -8,15 +9,19 @@ import type {
RunDetail,
RunListItem,
RunListQuery,
+ RunTreemap,
RunTrigger,
StepRunDetail,
StepRunSummary,
+ TreemapNode,
} from "@verge/contracts";
import { determineFreshnessBucket } from "@verge/core";
import {
+ coalesceDurationMs,
iso,
parseJson,
+ summarizeStatuses,
type CheckpointRow,
type ProcessRunRow,
type VergeDatabase,
@@ -144,6 +149,7 @@ const selectStepRunRows = (db: Kysely, repositorySlug?: string) =
"step_runs.created_at as stepCreatedAt",
"step_runs.started_at as stepStartedAt",
"step_runs.finished_at as stepFinishedAt",
+ "step_runs.duration_ms as stepDurationMs",
"repositories.slug as repositorySlug",
"runs.trigger as trigger",
"runs.commit_sha as commitSha",
@@ -175,6 +181,7 @@ const selectRunRows = (db: Kysely, repositorySlug?: string) => {
"runs.created_at as createdAt",
"runs.started_at as startedAt",
"runs.finished_at as finishedAt",
+ "runs.duration_ms as durationMs",
]);
if (repositorySlug) {
@@ -207,10 +214,116 @@ const toStepRunSummary = async (
createdAt: row.stepCreatedAt.toISOString(),
startedAt: iso(row.stepStartedAt),
finishedAt: iso(row.stepFinishedAt),
+ durationMs: coalesceDurationMs(row.stepDurationMs, row.stepStartedAt, row.stepFinishedAt),
processCount: processRuns.length,
};
};
+const readProcessDurationMs = (process: {
+ duration_ms: number | null;
+ started_at: Date | null;
+ finished_at: Date | null;
+}): number => {
+ const liveDurationMs = coalesceDurationMs(
+ process.duration_ms,
+ process.started_at,
+ process.finished_at,
+ );
+ if (liveDurationMs !== null) {
+ return liveDurationMs;
+ }
+
+ if (process.started_at) {
+ return Math.max(0, Date.now() - process.started_at.getTime());
+ }
+
+ return 0;
+};
+
+const normalizeTreemapStatus = (status: string): TreemapNode["status"] =>
+ status === "claimed" ? "running" : (status as TreemapNode["status"]);
+
+const sumValueMs = (children: TreemapNode[]): number =>
+ children.reduce((total, child) => total + child.valueMs, 0);
+
+const sortNodesByValue = (nodes: TreemapNode[]): TreemapNode[] =>
+ [...nodes].sort(
+ (left, right) => right.valueMs - left.valueMs || left.label.localeCompare(right.label),
+ );
+
+const buildProcessTreemapNodes = (processes: ProcessRunRow[]): 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,
+ ),
+ status: normalizeTreemapStatus(process.status),
+ filePath: process.file_path,
+ stepKey: null,
+ processKey: process.process_key,
+ reused: process.status === "reused",
+ attemptCount: process.attempt_count,
+ }));
+
+const shouldGroupProcessesByFile = (processes: ProcessRunRow[]): boolean => {
+ const filePaths = processes
+ .map((process) => process.file_path)
+ .filter((filePath): filePath is string => typeof filePath === "string" && filePath.length > 0);
+
+ if (filePaths.length < 2) {
+ return false;
+ }
+
+ return new Set(filePaths).size < filePaths.length;
+};
+
+const buildStepTreemapChildren = (stepRunId: string, processes: ProcessRunRow[]): TreemapNode[] => {
+ if (!shouldGroupProcessesByFile(processes)) {
+ return sortNodesByValue(buildProcessTreemapNodes(processes));
+ }
+
+ const processesByFile = new Map();
+ const filelessProcesses: ProcessRunRow[] = [];
+
+ for (const process of processes) {
+ if (!process.file_path) {
+ filelessProcesses.push(process);
+ continue;
+ }
+
+ const existing = processesByFile.get(process.file_path) ?? [];
+ existing.push(process);
+ processesByFile.set(process.file_path, existing);
+ }
+
+ const fileNodes = [...processesByFile.entries()].map(([filePath, fileProcesses]) => {
+ const children = sortNodesByValue(buildProcessTreemapNodes(fileProcesses));
+ return {
+ id: `file:${stepRunId}:${filePath}`,
+ kind: "file" as const,
+ label: path.basename(filePath),
+ valueMs: sumValueMs(children),
+ wallDurationMs: null,
+ status: normalizeTreemapStatus(
+ summarizeStatuses(fileProcesses.map((process) => process.status)),
+ ),
+ filePath,
+ stepKey: null,
+ processKey: null,
+ reused: fileProcesses.every((process) => process.status === "reused"),
+ attemptCount: null,
+ children,
+ };
+ });
+
+ return sortNodesByValue([...fileNodes, ...buildProcessTreemapNodes(filelessProcesses)]);
+};
+
export const getStepRunDetail = async (
db: Kysely,
stepRunId: string,
@@ -259,6 +372,7 @@ export const getStepRunDetail = async (
attemptCount: process.attempt_count,
startedAt: iso(process.started_at),
finishedAt: iso(process.finished_at),
+ durationMs: coalesceDurationMs(process.duration_ms, process.started_at, process.finished_at),
})),
observations: observations.map((observation) => ({
id: observation.id,
@@ -329,6 +443,7 @@ export const getRunDetail = async (
createdAt: run.createdAt.toISOString(),
startedAt: iso(run.startedAt),
finishedAt: iso(run.finishedAt),
+ durationMs: coalesceDurationMs(run.durationMs, run.startedAt, run.finishedAt),
steps,
};
};
@@ -364,6 +479,7 @@ export const listRepositoryRuns = async (
createdAt: row.createdAt.toISOString(),
startedAt: iso(row.startedAt),
finishedAt: iso(row.finishedAt),
+ durationMs: coalesceDurationMs(row.durationMs, row.startedAt, row.finishedAt),
steps,
};
}),
@@ -393,6 +509,69 @@ export const listRepositoryRuns = async (
};
};
+export const getRunTreemap = async (
+ db: Kysely,
+ runId: string,
+): Promise => {
+ const run = await selectRunRows(db).where("runs.id", "=", runId).executeTakeFirst();
+ if (!run) {
+ return null;
+ }
+
+ const stepRows = await db
+ .selectFrom("step_runs")
+ .selectAll()
+ .where("run_id", "=", runId)
+ .orderBy("created_at", "asc")
+ .execute();
+
+ const stepChildren = await Promise.all(
+ stepRows.map(async (stepRow): Promise => {
+ const processes = await listProcessRuns(db, stepRow.id);
+ const children = buildStepTreemapChildren(stepRow.id, processes);
+
+ return {
+ id: stepRow.id,
+ kind: "step",
+ label: stepRow.display_name,
+ valueMs: sumValueMs(children),
+ wallDurationMs: coalesceDurationMs(
+ stepRow.duration_ms,
+ stepRow.started_at,
+ stepRow.finished_at,
+ ),
+ status: normalizeTreemapStatus(stepRow.status),
+ filePath: null,
+ stepKey: stepRow.step_key,
+ processKey: null,
+ reused: stepRow.status === "reused",
+ attemptCount: null,
+ children,
+ };
+ }),
+ );
+
+ const sortedChildren = sortNodesByValue(stepChildren);
+ return {
+ runId: run.id,
+ repositorySlug: run.repositorySlug,
+ tree: {
+ id: run.id,
+ kind: "run",
+ label: run.commitSha.slice(0, 7),
+ valueMs: sumValueMs(sortedChildren),
+ wallDurationMs: coalesceDurationMs(run.durationMs, run.startedAt, run.finishedAt),
+ status: normalizeTreemapStatus(run.status),
+ filePath: null,
+ stepKey: null,
+ processKey: null,
+ reused: run.status === "reused",
+ attemptCount: null,
+ children: sortedChildren,
+ },
+ };
+};
+
export const getRepositoryHealth = async (
db: Kysely,
repositorySlug: string,
diff --git a/packages/db/src/run-writes.ts b/packages/db/src/run-writes.ts
index 837c6b9..7d914b3 100644
--- a/packages/db/src/run-writes.ts
+++ b/packages/db/src/run-writes.ts
@@ -14,6 +14,7 @@ import type {
import { listProcessRuns } from "./run-reads.js";
import {
+ durationMsBetween,
json,
parseJson,
summarizeStatuses,
@@ -99,6 +100,7 @@ export const createRun = async (
status: input.status ?? "queued",
started_at: null,
finished_at: null,
+ duration_ms: null,
})
.returningAll()
.executeTakeFirstOrThrow();
@@ -143,6 +145,7 @@ export const createStepRun = async (
input.status === "passed" || input.status === "failed" || input.status === "reused"
? now
: null,
+ duration_ms: null,
})
.returningAll()
.executeTakeFirstOrThrow();
@@ -162,6 +165,7 @@ export const createProcessRuns = async (
selectionPayload: unknown;
status?: string;
attemptCount?: number;
+ durationMs?: number | null;
}>;
},
) => {
@@ -184,6 +188,7 @@ export const createProcessRuns = async (
selection_payload: json(processRun.selectionPayload),
status: processRun.status ?? "queued",
attempt_count: processRun.attemptCount ?? 0,
+ duration_ms: processRun.durationMs ?? null,
created_at: new Date(),
})),
)
@@ -212,6 +217,7 @@ export const cloneStepRunForReuse = async (
selectionPayload: parseJson(process.selection_payload),
status: "reused",
attemptCount: process.attempt_count,
+ durationMs: process.duration_ms,
})),
});
}
@@ -293,6 +299,7 @@ export const cloneCompletedProcessesFromCheckpoint = async (
selectionPayload: parseJson(process.selection_payload),
status: "reused",
attemptCount: process.attempt_count,
+ durationMs: process.duration_ms,
})),
});
@@ -486,6 +493,7 @@ export const refreshRunStatus = async (db: Kysely, runId: string)
const stepRows = await db
.selectFrom("step_runs")
.select(["status", "started_at", "finished_at"])
+ .select("duration_ms")
.where("run_id", "=", runId)
.execute();
@@ -502,16 +510,22 @@ export const refreshRunStatus = async (db: Kysely, runId: string)
.map((row) => row.finished_at)
.filter((value): value is Date => Boolean(value))
.map((value) => value.getTime());
+ const hasIncompleteRows = stepRows.some((row) =>
+ ["queued", "claimed", "running"].includes(row.status),
+ );
+ const runFinishedAt =
+ !hasIncompleteRows && finishedCandidates.length > 0
+ ? new Date(Math.max(...finishedCandidates))
+ : null;
+ const runStartedAt = startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null;
return db
.updateTable("runs")
.set({
status,
- started_at: startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null,
- finished_at:
- finishedCandidates.length === stepRows.length && finishedCandidates.length > 0
- ? new Date(Math.max(...finishedCandidates))
- : null,
+ started_at: runStartedAt,
+ finished_at: runFinishedAt,
+ duration_ms: durationMsBetween(runStartedAt, runFinishedAt),
})
.where("id", "=", runId)
.returningAll()
@@ -533,16 +547,22 @@ export const refreshStepRunStatus = async (db: Kysely, stepRunId:
.map((process) => process.finished_at)
.filter((value): value is Date => Boolean(value))
.map((value) => value.getTime());
+ const hasIncompleteRows = processRows.some((process) =>
+ ["queued", "claimed", "running"].includes(process.status),
+ );
+ const stepFinishedAt =
+ !hasIncompleteRows && finishedCandidates.length > 0
+ ? new Date(Math.max(...finishedCandidates))
+ : null;
+ const stepStartedAt = startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null;
const updated = await db
.updateTable("step_runs")
.set({
status,
- started_at: startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null,
- finished_at:
- finishedCandidates.length === processRows.length && finishedCandidates.length > 0
- ? new Date(Math.max(...finishedCandidates))
- : null,
+ started_at: stepStartedAt,
+ finished_at: stepFinishedAt,
+ duration_ms: durationMsBetween(stepStartedAt, stepFinishedAt),
})
.where("id", "=", stepRunId)
.returningAll()
@@ -583,6 +603,7 @@ export const recordRunEvent = async (
status: "running",
started_at: new Date(),
attempt_count: sql`attempt_count + 1`,
+ duration_ms: null,
})
.where("id", "=", input.processRunId)
.execute();
@@ -615,11 +636,16 @@ export const recordRunEvent = async (
}
if (input.kind === "passed" || input.kind === "failed" || input.kind === "interrupted") {
+ const finishedAt = new Date();
await db
.updateTable("process_runs")
.set({
status: input.kind === "passed" ? "passed" : input.kind,
- finished_at: new Date(),
+ finished_at: finishedAt,
+ duration_ms: sql`greatest(
+ 0,
+ extract(epoch from ${finishedAt} - coalesce(started_at, ${finishedAt})) * 1000
+ )::integer`,
})
.where("id", "=", input.processRunId)
.execute();
diff --git a/packages/db/src/shared.ts b/packages/db/src/shared.ts
index f07397c..921ce9e 100644
--- a/packages/db/src/shared.ts
+++ b/packages/db/src/shared.ts
@@ -80,6 +80,7 @@ type RunsTable = {
created_at: Generated;
started_at: Date | null;
finished_at: Date | null;
+ duration_ms: number | null;
};
type StepRunsTable = {
@@ -103,6 +104,7 @@ type StepRunsTable = {
created_at: Generated;
started_at: Date | null;
finished_at: Date | null;
+ duration_ms: number | null;
};
type ProcessRunsTable = {
@@ -123,6 +125,7 @@ type ProcessRunsTable = {
created_at: Generated;
started_at: Date | null;
finished_at: Date | null;
+ duration_ms: number | null;
};
type RunEventsTable = {
@@ -248,6 +251,23 @@ export const json = (value: unknown): string => JSON.stringify(value);
export const iso = (value: Date | null): string | null => (value ? value.toISOString() : null);
+export const durationMsBetween = (
+ startedAt: Date | null,
+ finishedAt: Date | null,
+): number | null => {
+ if (!startedAt || !finishedAt) {
+ return null;
+ }
+
+ return Math.max(0, finishedAt.getTime() - startedAt.getTime());
+};
+
+export const coalesceDurationMs = (
+ storedDurationMs: number | null,
+ startedAt: Date | null,
+ finishedAt: Date | null,
+): number | null => storedDurationMs ?? durationMsBetween(startedAt, finishedAt);
+
export const parseJson = (value: unknown): T => {
if (typeof value === "string") {
return JSON.parse(value) as T;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 79c7036..3fbe277 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,9 @@ importers:
specifier: workspace:*
version: link:packages/cli
devDependencies:
+ '@types/d3-hierarchy':
+ specifier: ^3.1.7
+ version: 3.1.7
'@types/node':
specifier: ^22.17.0
version: 22.19.17
@@ -81,6 +84,9 @@ importers:
'@verge/contracts':
specifier: workspace:*
version: link:../../packages/contracts
+ d3-hierarchy:
+ specifier: ^3.1.2
+ version: 3.1.2
react:
specifier: ^19.1.1
version: 19.2.5
@@ -776,6 +782,9 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ '@types/d3-hierarchy@3.1.7':
+ resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -960,6 +969,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-hierarchy@3.1.2:
+ resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+ engines: {node: '>=12'}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -2218,6 +2231,8 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
+ '@types/d3-hierarchy@3.1.7': {}
+
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -2406,6 +2421,8 @@ snapshots:
csstype@3.2.3: {}
+ d3-hierarchy@3.1.2: {}
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0