feat(webapp): runs live updating

## Summary

The Runs list now updates live without requiring a page refresh. Status
changes and other run fields are updated in place while runs are
executing.

When new runs matching the current filters are created, a "New runs
created" refresh button appears above the list.

Root runs now show a live child-run status breakdown directly in the
status tooltip.

### List live update

- Visible runs update in place while they are still running.
- A "New runs created" refresh button appears when new matching runs are
detected.
- Polling stops when all visible runs have finished and a refresh button
is already shown.
- Polling pauses when the browser tab is not visible.
- Runs list status updates and new-run detection share a single
runs/live polling path.

### Child-status tooltip

- Root run tooltips now display a breakdown of child run statuses.
- Child statuses are loaded when the tooltip opens (after a 400ms hover
delay).
- The tooltip stays up to date while child runs are still changing
state.
- Handles cases where child runs continue running after their parent run
has completed, or have not yet been created.

### Supporting changes

- Added hidden-tab awareness to polling.
- Added safeguards around polling inputs (`runIds` deduping and limits).

## Test plan

- [x] pnpm run typecheck --filter webapp passes
- [x] cd apps/webapp && pnpm run test
./test/presenters/mapRunToLiveFields.test.ts --run passes
- [x] cd apps/webapp && pnpm run test
./test/runsRepository.part2.test.ts --run -t "hasNewRuns" passes

### Manual smoke:

- [x] Active runs update without a page refresh.
- [x] A new matching run shows the refresh banner and the banner actions
work as expected.
- [x] Root run tooltips show live child-status updates and stop polling
once child runs settle.

---------

Co-authored-by: Ekaterina Bulatova <kathiekiwi@Ekaterinas-MacBook-Pro.local>
This commit is contained in:
Katia Bulatova
2026-06-02 19:26:46 +02:00
committed by GitHub
parent 6961004a73
commit 4f8cf4cc63
20 changed files with 1435 additions and 17 deletions
@@ -9,6 +9,7 @@ import {
getTaskRunsQueryBuilder,
getTaskActivityQueryBuilder,
getCurrentRunningStats,
getChildRunStatusCounts,
getAverageDurations,
getTaskUsageByOrganization,
getTaskRunsCountQueryBuilder,
@@ -228,6 +229,7 @@ export class ClickHouse {
pendingVersionIdsQueryBuilder: getPendingVersionIdsQueryBuilder(this.reader),
getTaskActivity: getTaskActivityQueryBuilder(this.reader),
getCurrentRunningStats: getCurrentRunningStats(this.reader),
getChildRunStatusCounts: getChildRunStatusCounts(this.reader),
getAverageDurations: getAverageDurations(this.reader),
getTaskUsageByOrganization: getTaskUsageByOrganization(this.reader),
};
@@ -2,6 +2,8 @@ import { clickhouseTest } from "@internal/testcontainers";
import { z } from "zod";
import { ClickhouseClient } from "./client/client.js";
import {
TASK_RUN_INDEX,
getChildRunStatusCounts,
getTaskRunsQueryBuilder,
insertRawTaskRunPayloadsCompactArrays,
insertTaskRunsCompactArrays,
@@ -416,6 +418,311 @@ describe("Task Runs V2", () => {
}
);
clickhouseTest(
"should aggregate child status counts with FINAL and ignore deleted rows",
async ({ clickhouseContainer }) => {
const client = new ClickhouseClient({
name: "test",
url: clickhouseContainer.getConnectionUrl(),
});
const insert = insertTaskRunsCompactArrays(client, {
async_insert: 0, // turn off async insert for this test
});
const baseCreatedAt = new Date("2025-05-01T12:00:00.000Z").getTime();
const oldCreatedAt = new Date("2025-04-15T12:00:00.000Z").getTime();
const since = baseCreatedAt - 60_000;
const rootRun: TaskRunInsertArray = [
"env_agg", // environment_id
"org_agg", // organization_id
"project_agg", // project_id
"root_run_1", // run_id
baseCreatedAt, // updated_at
baseCreatedAt, // created_at
"EXECUTING", // status
"DEVELOPMENT", // environment_type
"run_root_1", // friendly_id
1, // attempt
"V2", // engine
"root-task", // task_identifier
"task/root-task", // queue
"", // schedule_id
"", // batch_id
null, // completed_at
baseCreatedAt, // started_at
null, // executed_at
null, // delay_until
baseCreatedAt, // queued_at
null, // expired_at
0, // usage_duration_ms
0, // cost_in_cents
0, // base_cost_in_cents
{ data: null }, // output
{ data: null }, // error
"", // error_fingerprint
[], // tags
"", // task_version
"", // sdk_version
"", // cli_version
"", // machine_preset
"", // root_run_id
"", // parent_run_id
0, // depth
"span_root", // span_id
"trace_root", // trace_id
"", // idempotency_key
"", // idempotency_key_user
"", // idempotency_key_scope
"", // expiration_ttl
true, // is_test
"1", // _version
0, // _is_deleted
"", // concurrency_key
[], // bulk_action_group_ids
"", // worker_queue
null, // max_duration_in_seconds
"", // trigger_source
"", // root_trigger_source
"", // task_kind
null, // is_warm_start
];
const childA_v1: TaskRunInsertArray = [
"env_agg",
"org_agg",
"project_agg",
"child_a",
baseCreatedAt + 1_000,
baseCreatedAt + 1_000,
"PENDING",
"DEVELOPMENT",
"run_child_a",
1,
"V2",
"child-task",
"task/child-task",
"",
"",
null,
null,
null,
null,
baseCreatedAt + 1_000,
null,
0,
0,
0,
{ data: null },
{ data: null },
"",
[],
"",
"",
"",
"",
"root_run_1",
"root_run_1",
1,
"span_child_a",
"trace_root",
"",
"",
"",
"",
true,
"1",
0,
"",
[],
"",
null,
"",
"",
"",
null,
];
const childA_v2: TaskRunInsertArray = [
...childA_v1,
];
childA_v2[TASK_RUN_INDEX.status] = "COMPLETED_SUCCESSFULLY";
childA_v2[TASK_RUN_INDEX._version] = "2";
const childB: TaskRunInsertArray = [
"env_agg",
"org_agg",
"project_agg",
"child_b",
baseCreatedAt + 2_000,
baseCreatedAt + 2_000,
"EXECUTING",
"DEVELOPMENT",
"run_child_b",
1,
"V2",
"child-task",
"task/child-task",
"",
"",
null,
baseCreatedAt + 2_000,
null,
null,
baseCreatedAt + 2_000,
null,
0,
0,
0,
{ data: null },
{ data: null },
"",
[],
"",
"",
"",
"",
"root_run_1",
"root_run_1",
1,
"span_child_b",
"trace_root",
"",
"",
"",
"",
true,
"1",
0,
"",
[],
"",
null,
"",
"",
"",
null,
];
const childDeleted_v1: TaskRunInsertArray = [
"env_agg",
"org_agg",
"project_agg",
"child_deleted",
baseCreatedAt + 3_000,
baseCreatedAt + 3_000,
"PENDING",
"DEVELOPMENT",
"run_child_deleted",
1,
"V2",
"child-task",
"task/child-task",
"",
"",
null,
null,
null,
null,
baseCreatedAt + 3_000,
null,
0,
0,
0,
{ data: null },
{ data: null },
"",
[],
"",
"",
"",
"",
"root_run_1",
"root_run_1",
1,
"span_child_deleted",
"trace_root",
"",
"",
"",
"",
true,
"1",
0,
"",
[],
"",
null,
"",
"",
"",
null,
];
const childDeleted_v2: TaskRunInsertArray = [
...childDeleted_v1,
];
childDeleted_v2[TASK_RUN_INDEX._version] = "2";
childDeleted_v2[TASK_RUN_INDEX._is_deleted] = 1;
const childWrongRoot: TaskRunInsertArray = [
...childB,
];
childWrongRoot[TASK_RUN_INDEX.run_id] = "child_wrong_root";
childWrongRoot[TASK_RUN_INDEX.friendly_id] = "run_child_wrong_root";
childWrongRoot[TASK_RUN_INDEX.root_run_id] = "other_root";
childWrongRoot[TASK_RUN_INDEX.parent_run_id] = "other_root";
childWrongRoot[TASK_RUN_INDEX.span_id] = "span_child_wrong_root";
const childOld: TaskRunInsertArray = [
...childB,
];
childOld[TASK_RUN_INDEX.run_id] = "child_old";
childOld[TASK_RUN_INDEX.created_at] = oldCreatedAt;
childOld[TASK_RUN_INDEX.updated_at] = oldCreatedAt;
childOld[TASK_RUN_INDEX.started_at] = oldCreatedAt;
childOld[TASK_RUN_INDEX.queued_at] = oldCreatedAt;
childOld[TASK_RUN_INDEX.friendly_id] = "run_child_old";
childOld[TASK_RUN_INDEX.span_id] = "span_child_old";
const [insertError] = await insert([
rootRun,
childA_v1,
childA_v2,
childB,
childDeleted_v1,
childDeleted_v2,
childWrongRoot,
childOld,
]);
expect(insertError).toBeNull();
const [queryError, result] = await getChildRunStatusCounts(client)({
organizationId: "org_agg",
projectId: "project_agg",
environmentId: "env_agg",
rootRunIds: ["root_run_1"],
since,
});
expect(queryError).toBeNull();
expect(result).toEqual([
{
root_run_id: "root_run_1",
status: "COMPLETED_SUCCESSFULLY",
count: 1,
},
{
root_run_id: "root_run_1",
status: "EXECUTING",
count: 1,
},
]);
}
);
clickhouseTest(
"should be able to insert payloads with a duplicate path",
async ({ clickhouseContainer }) => {
@@ -514,6 +514,51 @@ export function getCurrentRunningStats(ch: ClickhouseReader, settings?: ClickHou
});
}
export const ChildRunStatusCountsQueryResult = z.object({
root_run_id: z.string(),
status: z.string(),
count: z.number().int(),
});
export type ChildRunStatusCountsQueryResult = z.infer<typeof ChildRunStatusCountsQueryResult>;
export const ChildRunStatusCountsQueryParams = z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
rootRunIds: z.array(z.string()).min(1),
since: z.number().int(),
});
export function getChildRunStatusCounts(ch: ClickhouseReader, settings?: ClickHouseSettings) {
return ch.query({
name: "getChildRunStatusCounts",
query: `
SELECT
root_run_id,
status,
count() as count
FROM trigger_dev.task_runs_v2 FINAL
WHERE
organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND root_run_id IN {rootRunIds: Array(String)}
AND created_at >= fromUnixTimestamp64Milli({since: Int64})
AND _is_deleted = 0
GROUP BY
root_run_id,
status
ORDER BY
root_run_id ASC,
status ASC
`,
schema: ChildRunStatusCountsQueryResult,
params: ChildRunStatusCountsQueryParams,
settings,
});
}
export const AverageDurationsQueryResult = z.object({
task_identifier: z.string(),
duration: z.number(),