From 2402423b5c2565b09b3b9d1b209f48f03ea22dcf Mon Sep 17 00:00:00 2001 From: Matt Gill Date: Tue, 28 Apr 2026 13:25:51 +0100 Subject: [PATCH 1/4] Add auto reloading to the runs table --- .server-changes/runs-live-and-row-polling.md | 8 + .../components/runs/v3/LiveToggleButton.tsx | 33 +++ apps/webapp/app/hooks/useAutoRevalidate.ts | 13 +- apps/webapp/app/hooks/useRunsRowPolling.ts | 108 ++++++++++ .../v3/NextRunListPresenter.server.ts | 162 ++++++++++---- .../route.tsx | 31 ++- ...projectParam.env.$envParam.runs.refresh.ts | 52 +++++ apps/webapp/app/utils/pathBuilder.ts | 10 + references/hello-world/CLAUDE.md | 202 ++++++++++++++++++ 9 files changed, 568 insertions(+), 51 deletions(-) create mode 100644 .server-changes/runs-live-and-row-polling.md create mode 100644 apps/webapp/app/components/runs/v3/LiveToggleButton.tsx create mode 100644 apps/webapp/app/hooks/useRunsRowPolling.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh.ts create mode 100644 references/hello-world/CLAUDE.md diff --git a/.server-changes/runs-live-and-row-polling.md b/.server-changes/runs-live-and-row-polling.md new file mode 100644 index 00000000000..beaeb102530 --- /dev/null +++ b/.server-changes/runs-live-and-row-polling.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: feature +--- + +Add a "Live" toggle on the Runs index page that auto-revalidates page 1 every 3s, prepending new runs as they arrive. Visible only when no `cursor` param is present. Pauses when the tab is hidden. + +Add per-row polling for runs in non-terminal statuses on every page (always on, regardless of the Live toggle). Visible non-terminal rows are refreshed in place via a new resource route `…/runs/refresh?ids=…`, which returns the current row data from Postgres (skipping ClickHouse to avoid replication lag). Polling pauses when the tab is hidden. diff --git a/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx b/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx new file mode 100644 index 00000000000..9f181f0ccdf --- /dev/null +++ b/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx @@ -0,0 +1,33 @@ +import { Button } from "~/components/primitives/Buttons"; + +export function LiveToggleButton({ + isLive, + onChange, +}: { + isLive: boolean; + onChange: (next: boolean) => void; +}) { + return ( + + ); +} + +function LiveDot({ isLive }: { isLive: boolean }) { + if (!isLive) { + return ; + } + + return ( + + + + + ); +} diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts index 4205b03bcc0..38ba798bae0 100644 --- a/apps/webapp/app/hooks/useAutoRevalidate.ts +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -5,10 +5,16 @@ type UseAutoRevalidateOptions = { interval?: number; // in milliseconds onFocus?: boolean; disabled?: boolean; + pauseWhenHidden?: boolean; }; export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { - const { interval = 5000, onFocus = true, disabled = false } = options; + const { + interval = 5000, + onFocus = true, + disabled = false, + pauseWhenHidden = true, + } = options; const revalidator = useRevalidator(); useEffect(() => { @@ -18,11 +24,14 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { if (revalidator.state === "loading") { return; } + if (pauseWhenHidden && document.visibilityState !== "visible") { + return; + } revalidator.revalidate(); }, interval); return () => clearInterval(intervalId); - }, [interval, disabled]); + }, [interval, disabled, pauseWhenHidden]); useEffect(() => { if (!onFocus || disabled) return; diff --git a/apps/webapp/app/hooks/useRunsRowPolling.ts b/apps/webapp/app/hooks/useRunsRowPolling.ts new file mode 100644 index 00000000000..aad099b3281 --- /dev/null +++ b/apps/webapp/app/hooks/useRunsRowPolling.ts @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { type NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server"; +import { type loader as runsRefreshLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +type UseRunsRowPollingOptions = { + runs: NextRunListItem[]; + refreshUrl: string; + intervalMs?: number; +}; + +const DEFAULT_INTERVAL_MS = 3000; + +/** + * Polls the runs.refresh resource route for the visible non-terminal rows + * and returns a map of overrides keyed by internal run id. Consumers should + * merge the overrides on top of the loader's runs array. + * + * The hook pauses when the tab is hidden and triggers an immediate fetch + * when it becomes visible again. + */ +export function useRunsRowPolling({ + runs, + refreshUrl, + intervalMs = DEFAULT_INTERVAL_MS, +}: UseRunsRowPollingOptions): Map { + const fetcher = useTypedFetcher(); + const [overrides, setOverrides] = useState>(() => new Map()); + + // Compute pollable IDs against the *merged* view, so that once an override + // flips a row to a terminal status, we stop polling it. + const nonTerminalIds = useMemo(() => { + const ids: string[] = []; + for (const run of runs) { + const merged = overrides.get(run.id) ?? run; + if (!isFinalRunStatus(merged.status)) { + ids.push(merged.id); + } + } + ids.sort(); + return ids; + }, [runs, overrides]); + + const idsKey = nonTerminalIds.join(","); + + // Drop overrides for IDs that are no longer visible to keep the map bounded. + useEffect(() => { + setOverrides((prev) => { + if (prev.size === 0) return prev; + const visibleIds = new Set(runs.map((r) => r.id)); + let changed = false; + const next = new Map(); + for (const [id, run] of prev) { + if (visibleIds.has(id)) { + next.set(id, run); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [runs]); + + // Apply incoming fetcher data to the overrides map. + const lastAppliedDataRef = useRef(null); + useEffect(() => { + if (!fetcher.data) return; + if (fetcher.data === lastAppliedDataRef.current) return; + lastAppliedDataRef.current = fetcher.data; + const incoming = fetcher.data.runs; + setOverrides((prev) => { + const next = new Map(prev); + for (const run of incoming) { + next.set(run.id, run); + } + return next; + }); + }, [fetcher.data]); + + // Schedule polling. + useEffect(() => { + if (!idsKey) return; + if (typeof document === "undefined") return; + + const tick = () => { + if (fetcher.state !== "idle") return; + if (document.visibilityState !== "visible") return; + fetcher.load(`${refreshUrl}?ids=${idsKey}`); + }; + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + tick(); + } + }; + + const intervalId = setInterval(tick, intervalMs); + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [idsKey, refreshUrl, intervalMs]); + + return overrides; +} diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index de111abd279..5913b29afb3 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -9,11 +9,13 @@ import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { getTaskIdentifiers } from "~/models/task.server"; -import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { type ListedRun, RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; +type DisplayableEnvironmentForRow = NonNullable>>; + export type RunListOptions = { userId?: string; projectId: string; @@ -208,50 +210,7 @@ export class NextRunListPresenter { } return { - runs: runs.map((run) => { - const hasFinished = isFinalRunStatus(run.status); - - const startedAt = run.startedAt ?? run.lockedAt; - - return { - id: run.id, - number: 1, - friendlyId: run.friendlyId, - createdAt: run.createdAt.toISOString(), - updatedAt: run.updatedAt.toISOString(), - startedAt: startedAt ? startedAt.toISOString() : undefined, - delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined, - hasFinished, - finishedAt: hasFinished - ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() - : undefined, - isTest: run.isTest, - status: run.status, - version: run.taskVersion, - taskIdentifier: run.taskIdentifier, - spanId: run.spanId, - isReplayable: true, - isCancellable: isCancellableRunStatus(run.status), - isPending: isPendingRunStatus(run.status), - environment: displayableEnvironment, - idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined, - ttl: run.ttl ? run.ttl : undefined, - expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, - costInCents: run.costInCents, - baseCostInCents: run.baseCostInCents, - usageDurationMs: Number(run.usageDurationMs), - tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [], - depth: run.depth, - rootTaskRunId: run.rootTaskRunId, - metadata: run.metadata, - metadataType: run.metadataType, - machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, - queue: { - name: run.queue.replace("task/", ""), - type: run.queue.startsWith("task/") ? "task" : "custom", - }, - }; - }), + runs: runs.map((run) => NextRunListPresenter.toRunListItem(run, displayableEnvironment)), pagination: { next: pagination.nextCursor ?? undefined, previous: pagination.previousCursor ?? undefined, @@ -274,4 +233,117 @@ export class NextRunListPresenter { hasAnyRuns, }; } + + /** + * Returns the latest data for the given internal run UUIDs, in the same shape + * as `call()`'s `runs[]`. Used by the runs.refresh resource route to refresh + * non-terminal rows in place. Hits Postgres directly (not ClickHouse) so we + * don't read replication-lagged values when polling. + */ + public async callByIds( + organizationId: string, + environmentId: string, + { + userId, + runIds, + }: { + userId?: string; + runIds: string[]; + } + ) { + if (runIds.length === 0) { + return { runs: [] }; + } + + const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); + if (!displayableEnvironment) { + throw new ServiceValidationError("No environment found"); + } + + const rows = await this.replica.taskRun.findMany({ + where: { + id: { in: runIds }, + runtimeEnvironmentId: environmentId, + organizationId, + }, + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + taskVersion: true, + runtimeEnvironmentId: true, + status: true, + createdAt: true, + startedAt: true, + lockedAt: true, + delayUntil: true, + updatedAt: true, + completedAt: true, + isTest: true, + spanId: true, + idempotencyKey: true, + ttl: true, + expiredAt: true, + costInCents: true, + baseCostInCents: true, + usageDurationMs: true, + runTags: true, + depth: true, + rootTaskRunId: true, + batchId: true, + metadata: true, + metadataType: true, + machinePreset: true, + queue: true, + }, + }); + + return { + runs: rows.map((run) => NextRunListPresenter.toRunListItem(run, displayableEnvironment)), + }; + } + + private static toRunListItem(run: ListedRun, displayableEnvironment: DisplayableEnvironmentForRow) { + const hasFinished = isFinalRunStatus(run.status); + const startedAt = run.startedAt ?? run.lockedAt; + + return { + id: run.id, + number: 1, + friendlyId: run.friendlyId, + createdAt: run.createdAt.toISOString(), + updatedAt: run.updatedAt.toISOString(), + startedAt: startedAt ? startedAt.toISOString() : undefined, + delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined, + hasFinished, + finishedAt: hasFinished + ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() + : undefined, + isTest: run.isTest, + status: run.status, + version: run.taskVersion, + taskIdentifier: run.taskIdentifier, + spanId: run.spanId, + isReplayable: true, + isCancellable: isCancellableRunStatus(run.status), + isPending: isPendingRunStatus(run.status), + environment: displayableEnvironment, + idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined, + ttl: run.ttl ? run.ttl : undefined, + expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + usageDurationMs: Number(run.usageDurationMs), + tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [], + depth: run.depth, + rootTaskRunId: run.rootTaskRunId, + metadata: run.metadata, + metadataType: run.metadataType, + machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, + queue: { + name: run.queue.replace("task/", ""), + type: run.queue.startsWith("task/") ? "task" : "custom", + }, + }; + } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index ca7e8b7b0c1..7fafc542e99 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -1,7 +1,7 @@ import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { TypedAwait, typeddefer, @@ -31,13 +31,16 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; +import { LiveToggleButton } from "~/components/runs/v3/LiveToggleButton"; import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useRunsRowPolling } from "~/hooks/useRunsRowPolling"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { findProjectBySlug } from "~/models/project.server"; @@ -56,6 +59,7 @@ import { EnvironmentParamSchema, v3CreateBulkActionPath, v3ProjectPath, + v3RunsRefreshPath, v3TestPath, v3TestTaskPath, } from "~/utils/pathBuilder"; @@ -199,7 +203,8 @@ function RunsList({ const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { has, replace } = useSearchParams(); + const { has, replace, value } = useSearchParams(); + const isFirstPage = !list.pagination.previous; // Shortcut keys for bulk actions useShortcutKeys({ @@ -223,6 +228,23 @@ function RunsList({ }, }); + const isLiveAvailable = isFirstPage; + const isLive = isLiveAvailable && value("live") === "1"; + const setLive = (next: boolean) => replace({ live: next ? "1" : undefined }); + + useAutoRevalidate({ interval: 3000, disabled: !isLive }); + + const refreshUrl = v3RunsRefreshPath(organization, project, environment); + const overrides = useRunsRowPolling({ + runs: list.runs, + refreshUrl, + }); + + const mergedRuns = useMemo( + () => list.runs.map((r) => overrides.get(r.id) ?? r), + [list.runs, overrides] + ); + const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns; return ( @@ -256,6 +278,7 @@ function RunsList({ rootOnlyDefault={rootOnlyDefault} />
+ {isLiveAvailable && } {!isShowingBulkActionInspector && ( { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const url = new URL(request.url); + const idsParam = url.searchParams.get("ids") ?? ""; + const candidateIds = idsParam + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + .slice(0, MAX_IDS); + const runIds = candidateIds.filter((id) => RunIdSchema.safeParse(id).success); + + const presenter = new NextRunListPresenter($replica, clickhouseClient); + const { runs } = await presenter.callByIds(project.organizationId, environment.id, { + userId, + runIds, + }); + + return typedjson( + { runs }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); +}; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 7a151053f5a..4f8659ca5c6 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -325,6 +325,16 @@ export function v3RunsPath( return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`; } +export function v3RunsRefreshPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/runs/refresh`; +} + export function v3CreateBulkActionPath( organization: OrgForPath, project: ProjectForPath, diff --git a/references/hello-world/CLAUDE.md b/references/hello-world/CLAUDE.md new file mode 100644 index 00000000000..9edd252c73c --- /dev/null +++ b/references/hello-world/CLAUDE.md @@ -0,0 +1,202 @@ + +# Trigger.dev Basic Tasks (v4) + +**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`** + +## Basic Task + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload: { userId: string; data: any[] }) => { + // Task logic - runs for long time, no timeouts + console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}, age ${payload.age}` }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +// Single trigger +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }, { id: 2 }], +}); + +// Batch trigger (up to 1,000 items, 3MB per payload) +const batchHandle = await tasks.batchTrigger("process-data", [ + { payload: { userId: "123", data: [{ id: 1 }] } }, + { payload: { userId: "456", data: [{ id: 2 }] } }, +]); +``` + +### Debounced Triggering + +Consolidate multiple triggers into a single execution: + +```ts +// Multiple rapid triggers with same key = single execution +await myTask.trigger( + { userId: "123" }, + { + debounce: { + key: "user-123-update", // Unique key for debounce group + delay: "5s", // Wait before executing + }, + } +); + +// Trailing mode: use payload from LAST trigger +await myTask.trigger( + { data: "latest-value" }, + { + debounce: { + key: "trailing-example", + delay: "10s", + mode: "trailing", // Default is "leading" (first payload) + }, + } +); +``` + +**Debounce modes:** +- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule +- `trailing`: Uses payload from most recent trigger + +### From Inside Tasks (with Result handling) + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and continue + const handle = await childTask.trigger({ data: "value" }); + + // Trigger and wait - returns Result object, NOT task output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Task output:", result.output); // Actual task return value + } else { + console.error("Task failed:", result.error); + } + + // Quick unwrap (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + + // Batch trigger and wait + const results = await childTask.batchTriggerAndWait([ + { payload: { data: "item1" } }, + { payload: { data: "item2" } }, + ]); + + for (const run of results) { + if (run.ok) { + console.log("Success:", run.output); + } else { + console.log("Failed:", run.error); + } + } + }, +}); + +export const childTask = task({ + id: "child-task", + run: async (payload: { data: string }) => { + return { processed: payload.data }; + }, +}); +``` + +> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Waits + +```ts +import { task, wait } from "@trigger.dev/sdk"; + +export const taskWithWaits = task({ + id: "task-with-waits", + run: async (payload) => { + console.log("Starting task"); + + // Wait for specific duration + await wait.for({ seconds: 30 }); + await wait.for({ minutes: 5 }); + await wait.for({ hours: 1 }); + await wait.for({ days: 1 }); + + // Wait until specific date + await wait.until({ date: new Date("2024-12-25") }); + + // Wait for token (from external system) + await wait.forToken({ + token: "user-approval-token", + timeoutInSeconds: 3600, // 1 hour timeout + }); + + console.log("All waits completed"); + return { status: "completed" }; + }, +}); +``` + +> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Key Points + +- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output +- **Type safety**: Use `import type` for task references when triggering from backend +- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage +- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings + +## NEVER Use (v2 deprecated) + +```ts +// BREAKS APPLICATION +client.defineJob({ + id: "job-id", + run: async (payload, io) => { + /* ... */ + }, +}); +``` + +Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` + + \ No newline at end of file From af557b976d41c576366c7811fd2393b36e91e0e6 Mon Sep 17 00:00:00 2001 From: Matt Gill Date: Tue, 28 Apr 2026 13:29:17 +0100 Subject: [PATCH 2/4] Remove unused files --- .server-changes/runs-live-and-row-polling.md | 2 +- references/hello-world/CLAUDE.md | 202 ------------------- 2 files changed, 1 insertion(+), 203 deletions(-) delete mode 100644 references/hello-world/CLAUDE.md diff --git a/.server-changes/runs-live-and-row-polling.md b/.server-changes/runs-live-and-row-polling.md index beaeb102530..86e4489ccfa 100644 --- a/.server-changes/runs-live-and-row-polling.md +++ b/.server-changes/runs-live-and-row-polling.md @@ -3,6 +3,6 @@ area: webapp type: feature --- -Add a "Live" toggle on the Runs index page that auto-revalidates page 1 every 3s, prepending new runs as they arrive. Visible only when no `cursor` param is present. Pauses when the tab is hidden. +Add a "Live" toggle on the Runs index page that auto-revalidates page 1 every 3s, prepending new runs as they arrive. Visible only when on the first page. Add per-row polling for runs in non-terminal statuses on every page (always on, regardless of the Live toggle). Visible non-terminal rows are refreshed in place via a new resource route `…/runs/refresh?ids=…`, which returns the current row data from Postgres (skipping ClickHouse to avoid replication lag). Polling pauses when the tab is hidden. diff --git a/references/hello-world/CLAUDE.md b/references/hello-world/CLAUDE.md deleted file mode 100644 index 9edd252c73c..00000000000 --- a/references/hello-world/CLAUDE.md +++ /dev/null @@ -1,202 +0,0 @@ - -# Trigger.dev Basic Tasks (v4) - -**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`** - -## Basic Task - -```ts -import { task } from "@trigger.dev/sdk"; - -export const processData = task({ - id: "process-data", - retry: { - maxAttempts: 10, - factor: 1.8, - minTimeoutInMs: 500, - maxTimeoutInMs: 30_000, - randomize: false, - }, - run: async (payload: { userId: string; data: any[] }) => { - // Task logic - runs for long time, no timeouts - console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); - return { processed: payload.data.length }; - }, -}); -``` - -## Schema Task (with validation) - -```ts -import { schemaTask } from "@trigger.dev/sdk"; -import { z } from "zod"; - -export const validatedTask = schemaTask({ - id: "validated-task", - schema: z.object({ - name: z.string(), - age: z.number(), - email: z.string().email(), - }), - run: async (payload) => { - // Payload is automatically validated and typed - return { message: `Hello ${payload.name}, age ${payload.age}` }; - }, -}); -``` - -## Triggering Tasks - -### From Backend Code - -```ts -import { tasks } from "@trigger.dev/sdk"; -import type { processData } from "./trigger/tasks"; - -// Single trigger -const handle = await tasks.trigger("process-data", { - userId: "123", - data: [{ id: 1 }, { id: 2 }], -}); - -// Batch trigger (up to 1,000 items, 3MB per payload) -const batchHandle = await tasks.batchTrigger("process-data", [ - { payload: { userId: "123", data: [{ id: 1 }] } }, - { payload: { userId: "456", data: [{ id: 2 }] } }, -]); -``` - -### Debounced Triggering - -Consolidate multiple triggers into a single execution: - -```ts -// Multiple rapid triggers with same key = single execution -await myTask.trigger( - { userId: "123" }, - { - debounce: { - key: "user-123-update", // Unique key for debounce group - delay: "5s", // Wait before executing - }, - } -); - -// Trailing mode: use payload from LAST trigger -await myTask.trigger( - { data: "latest-value" }, - { - debounce: { - key: "trailing-example", - delay: "10s", - mode: "trailing", // Default is "leading" (first payload) - }, - } -); -``` - -**Debounce modes:** -- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule -- `trailing`: Uses payload from most recent trigger - -### From Inside Tasks (with Result handling) - -```ts -export const parentTask = task({ - id: "parent-task", - run: async (payload) => { - // Trigger and continue - const handle = await childTask.trigger({ data: "value" }); - - // Trigger and wait - returns Result object, NOT task output - const result = await childTask.triggerAndWait({ data: "value" }); - if (result.ok) { - console.log("Task output:", result.output); // Actual task return value - } else { - console.error("Task failed:", result.error); - } - - // Quick unwrap (throws on error) - const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); - - // Batch trigger and wait - const results = await childTask.batchTriggerAndWait([ - { payload: { data: "item1" } }, - { payload: { data: "item2" } }, - ]); - - for (const run of results) { - if (run.ok) { - console.log("Success:", run.output); - } else { - console.log("Failed:", run.error); - } - } - }, -}); - -export const childTask = task({ - id: "child-task", - run: async (payload: { data: string }) => { - return { processed: payload.data }; - }, -}); -``` - -> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. - -## Waits - -```ts -import { task, wait } from "@trigger.dev/sdk"; - -export const taskWithWaits = task({ - id: "task-with-waits", - run: async (payload) => { - console.log("Starting task"); - - // Wait for specific duration - await wait.for({ seconds: 30 }); - await wait.for({ minutes: 5 }); - await wait.for({ hours: 1 }); - await wait.for({ days: 1 }); - - // Wait until specific date - await wait.until({ date: new Date("2024-12-25") }); - - // Wait for token (from external system) - await wait.forToken({ - token: "user-approval-token", - timeoutInSeconds: 3600, // 1 hour timeout - }); - - console.log("All waits completed"); - return { status: "completed" }; - }, -}); -``` - -> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. - -## Key Points - -- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output -- **Type safety**: Use `import type` for task references when triggering from backend -- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage -- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings - -## NEVER Use (v2 deprecated) - -```ts -// BREAKS APPLICATION -client.defineJob({ - id: "job-id", - run: async (payload, io) => { - /* ... */ - }, -}); -``` - -Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` - - \ No newline at end of file From f969e24d761de6f0bfcd25ec6b0dfdb5a0d696b8 Mon Sep 17 00:00:00 2001 From: Matt Gill Date: Tue, 28 Apr 2026 14:58:09 +0100 Subject: [PATCH 3/4] Add a non-live streaming option --- .server-changes/runs-table-new-runs-banner.md | 6 ++ .../components/runs/v3/LiveToggleButton.tsx | 33 ------- .../components/runs/v3/RunsLiveControl.tsx | 74 +++++++++++++++ apps/webapp/app/hooks/useNewRunsCount.ts | 92 +++++++++++++++++++ .../route.tsx | 22 +++-- ...ojectParam.env.$envParam.runs.count-new.ts | 77 ++++++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 10 ++ 7 files changed, 275 insertions(+), 39 deletions(-) create mode 100644 .server-changes/runs-table-new-runs-banner.md delete mode 100644 apps/webapp/app/components/runs/v3/LiveToggleButton.tsx create mode 100644 apps/webapp/app/components/runs/v3/RunsLiveControl.tsx create mode 100644 apps/webapp/app/hooks/useNewRunsCount.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.count-new.ts diff --git a/.server-changes/runs-table-new-runs-banner.md b/.server-changes/runs-table-new-runs-banner.md new file mode 100644 index 00000000000..219b9c32de4 --- /dev/null +++ b/.server-changes/runs-table-new-runs-banner.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add a "X new runs, click to update" banner to the runs table. Shows on the first page when Live mode is off, polling every 3s for runs newer than the top visible row. Clicking revalidates the loader to bring in the latest runs. Acts as a halfway house between manual refresh and Live mode auto-refresh — the two are mutually exclusive in the UI. diff --git a/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx b/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx deleted file mode 100644 index 9f181f0ccdf..00000000000 --- a/apps/webapp/app/components/runs/v3/LiveToggleButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Button } from "~/components/primitives/Buttons"; - -export function LiveToggleButton({ - isLive, - onChange, -}: { - isLive: boolean; - onChange: (next: boolean) => void; -}) { - return ( - - ); -} - -function LiveDot({ isLive }: { isLive: boolean }) { - if (!isLive) { - return ; - } - - return ( - - - - - ); -} diff --git a/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx new file mode 100644 index 00000000000..022cbdf22a6 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx @@ -0,0 +1,74 @@ +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { useRevalidator } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { useNewRunsCount } from "~/hooks/useNewRunsCount"; + +const LIVE_INTERVAL_MS = 3000; + +export function RunsLiveControl({ + isLive, + onChange, + topRowId, + countNewUrl, +}: { + isLive: boolean; + onChange: (next: boolean) => void; + topRowId: string | undefined; + countNewUrl: string; +}) { + const revalidator = useRevalidator(); + + // When live, the loader auto-revalidates and the count banner is hidden. + // When not live, we poll for new runs and show the banner if any exist. + useAutoRevalidate({ interval: LIVE_INTERVAL_MS, disabled: !isLive }); + + const { count, hasMore } = useNewRunsCount({ + sinceId: topRowId, + countNewUrl, + intervalMs: LIVE_INTERVAL_MS, + disabled: isLive, + }); + + const showCountBanner = !isLive && count > 0; + const label = hasMore ? `${count}+` : String(count); + const noun = count === 1 && !hasMore ? "run" : "runs"; + + return ( + <> + {showCountBanner && ( + + )} + + + ); +} + +function LiveDot({ isLive }: { isLive: boolean }) { + if (!isLive) { + return ; + } + + return ( + + + + + ); +} diff --git a/apps/webapp/app/hooks/useNewRunsCount.ts b/apps/webapp/app/hooks/useNewRunsCount.ts new file mode 100644 index 00000000000..7f93fca5737 --- /dev/null +++ b/apps/webapp/app/hooks/useNewRunsCount.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; + +type CountNewResponse = { count: number; hasMore: boolean }; + +type UseNewRunsCountOptions = { + sinceId: string | undefined; + countNewUrl: string; + intervalMs?: number; + disabled?: boolean; +}; + +const DEFAULT_INTERVAL_MS = 3000; + +/** + * Polls the runs.count-new resource route to count runs newer than the + * top visible row. Uses a plain `fetch` rather than `useFetcher` so Remix's + * automatic fetcher revalidation (e.g. from useAutoRevalidate in Live mode) + * does not re-fire the request when the hook is disabled. + */ +export function useNewRunsCount({ + sinceId, + countNewUrl, + intervalMs = DEFAULT_INTERVAL_MS, + disabled = false, +}: UseNewRunsCountOptions): { count: number; hasMore: boolean } { + const [state, setState] = useState({ count: 0, hasMore: false }); + const inFlightRef = useRef(false); + + // Reset baseline whenever the cursor or url changes, or when disabling. + useEffect(() => { + setState({ count: 0, hasMore: false }); + }, [sinceId, countNewUrl, disabled]); + + useEffect(() => { + if (disabled) return; + if (!sinceId) return; + if (typeof document === "undefined") return; + + const url = appendSinceParam(countNewUrl, sinceId); + let cancelled = false; + const controller = new AbortController(); + + const tick = async () => { + if (inFlightRef.current) return; + if (document.visibilityState !== "visible") return; + inFlightRef.current = true; + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!res.ok) return; + const data = (await res.json()) as CountNewResponse; + if (!cancelled) { + setState({ count: data.count, hasMore: data.hasMore }); + } + } catch { + // Ignore aborts and transient network errors; next tick will retry. + } finally { + inFlightRef.current = false; + } + }; + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + tick(); + } + }; + + const intervalId = setInterval(tick, intervalMs); + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + cancelled = true; + controller.abort(); + clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [sinceId, countNewUrl, intervalMs, disabled]); + + if (disabled || !sinceId) { + return { count: 0, hasMore: false }; + } + + return state; +} + +function appendSinceParam(url: string, sinceId: string): string { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}since=${encodeURIComponent(sinceId)}`; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 7fafc542e99..042d1534fe6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -1,5 +1,5 @@ import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, useNavigation } from "@remix-run/react"; +import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useMemo } from "react"; import { @@ -31,12 +31,11 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; -import { LiveToggleButton } from "~/components/runs/v3/LiveToggleButton"; import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsLiveControl } from "~/components/runs/v3/RunsLiveControl"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; -import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -59,6 +58,7 @@ import { EnvironmentParamSchema, v3CreateBulkActionPath, v3ProjectPath, + v3RunsCountNewPath, v3RunsRefreshPath, v3TestPath, v3TestTaskPath, @@ -204,6 +204,7 @@ function RunsList({ const project = useProject(); const environment = useEnvironment(); const { has, replace, value } = useSearchParams(); + const location = useLocation(); const isFirstPage = !list.pagination.previous; // Shortcut keys for bulk actions @@ -232,8 +233,6 @@ function RunsList({ const isLive = isLiveAvailable && value("live") === "1"; const setLive = (next: boolean) => replace({ live: next ? "1" : undefined }); - useAutoRevalidate({ interval: 3000, disabled: !isLive }); - const refreshUrl = v3RunsRefreshPath(organization, project, environment); const overrides = useRunsRowPolling({ runs: list.runs, @@ -245,6 +244,10 @@ function RunsList({ [list.runs, overrides] ); + const countNewUrl = `${v3RunsCountNewPath(organization, project, environment)}${ + location.search + }`; + const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns; return ( @@ -278,7 +281,14 @@ function RunsList({ rootOnlyDefault={rootOnlyDefault} />
- {isLiveAvailable && } + {isLiveAvailable && ( + + )} {!isShowingBulkActionInspector && ( { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const url = new URL(request.url); + const sinceParam = url.searchParams.get("since"); + const since = sinceParam && RunIdSchema.safeParse(sinceParam).success ? sinceParam : null; + + if (!since) { + return typedjson( + { count: 0, hasMore: false }, + { headers: { "Cache-Control": "no-store" } } + ); + } + + const filters = await getRunFiltersFromRequest(request); + + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: $replica as PrismaClient, + }); + + const ids = await runsRepository.listRunIds({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + tasks: filters.tasks, + versions: filters.versions, + statuses: filters.statuses, + tags: filters.tags, + period: filters.period, + from: filters.from, + to: filters.to, + batchId: filters.batchId, + runId: filters.runId, + bulkId: filters.bulkId, + scheduleId: filters.scheduleId, + rootOnly: filters.rootOnly, + queues: filters.queues, + machines: filters.machines, + errorId: filters.errorId, + page: { cursor: since, direction: "backward", size: COUNT_CAP }, + }); + + return typedjson( + { + count: Math.min(ids.length, COUNT_CAP), + hasMore: ids.length > COUNT_CAP, + }, + { headers: { "Cache-Control": "no-store" } } + ); +}; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4f8659ca5c6..57e2a3ab5cb 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -335,6 +335,16 @@ export function v3RunsRefreshPath( )}/env/${environmentParam(environment)}/runs/refresh`; } +export function v3RunsCountNewPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/runs/count-new`; +} + export function v3CreateBulkActionPath( organization: OrgForPath, project: ProjectForPath, From 149e2c080150f8b927dc5837603f3af0ce2f3420 Mon Sep 17 00:00:00 2001 From: Matt Gill Date: Tue, 28 Apr 2026 15:03:53 +0100 Subject: [PATCH 4/4] Copy fix --- .server-changes/runs-table-new-runs-banner.md | 2 +- apps/webapp/app/components/runs/v3/RunsLiveControl.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.server-changes/runs-table-new-runs-banner.md b/.server-changes/runs-table-new-runs-banner.md index 219b9c32de4..6171f87e4c9 100644 --- a/.server-changes/runs-table-new-runs-banner.md +++ b/.server-changes/runs-table-new-runs-banner.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -Add a "X new runs, click to update" banner to the runs table. Shows on the first page when Live mode is off, polling every 3s for runs newer than the top visible row. Clicking revalidates the loader to bring in the latest runs. Acts as a halfway house between manual refresh and Live mode auto-refresh — the two are mutually exclusive in the UI. +Add a "X new runs" banner to the runs table. Shows on the first page when Live mode is off, polling every 3s for runs newer than the top visible row. Clicking revalidates the loader to bring in the latest runs. Acts as a halfway house between manual refresh and Live mode auto-refresh — the two are mutually exclusive in the UI. diff --git a/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx index 022cbdf22a6..ced48a267d9 100644 --- a/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx +++ b/apps/webapp/app/components/runs/v3/RunsLiveControl.tsx @@ -44,7 +44,7 @@ export function RunsLiveControl({ tooltip="Load new runs. Click the Live button to enable auto-refresh." > - {label} new {noun}, click to update + {label} new {noun} )}