diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md
index 4c81a1915..8b097d0ff 100644
--- a/greenfield/docs/architecture/greenfield-rewrite/progress.md
+++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md
@@ -7,15 +7,15 @@
This matrix is the living phase status. Update it in the same change that materially advances or
closes a phase; dated entries below provide the evidence, not a second status source.
-| Phase | Status | Current evidence and remaining gate |
-| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. |
-| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. |
-| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. |
-| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented; report, incident, and global notification browser state are also complete. Dashboard-local durable schedules/jobs and real worker execution are implemented. The `/jobs` browser, OpenClaw cron, overview, and cache/metrics remain open. |
-| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. |
-| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. |
-| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. |
+| Phase | Status | Current evidence and remaining gate |
+| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. |
+| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. |
+| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. |
+| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented; report, incident, and global notification browser state are also complete. Dashboard-local durable schedules/jobs, real worker execution, and their `/jobs` operator UI are implemented. OpenClaw cron, overview, and cache/metrics remain open. |
+| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. |
+| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. |
+| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. |
### 2026-08-03 — Phase 0 started
@@ -945,3 +945,26 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o
Gateway, or host-mutation authority. The parity inventory marks the nine Dashboard jobs and
schedules operations implemented. The five `openClawCron.*` operations and `/jobs` browser
remain planned, along with overview and cache/metrics.
+
+### 2026-08-08 — Phase 3 Dashboard-local jobs browser
+
+- Authenticated `/jobs` now exposes the global queue summary, live worker inventory, versioned
+ claim pause/resume, filtered keyset-paginated run history, exact run detail, bounded event
+ history, and confirmed cancellation. Independent validated `runId` and `scheduleId` search
+ parameters preserve direct links outside the currently loaded pages and drop malformed values
+ without issuing detail requests.
+- The code-owned schedule directory provides enabled-state filtering, exact detail, keyset run
+ history, a TanStack Form cadence editor for interval, daily, and five-field cron variants,
+ explicit disable-intent creation/replacement, re-enable, and caller-scoped idempotent manual
+ execution. A failed refresh cannot erase a validated mutation result or already cached detail.
+- TanStack Query owns remote state and mutation repair; TanStack Table plus the shared virtualizer
+ bound maximum-sized run and schedule pages. A separate one-row queue snapshot refreshes live
+ worker freshness every 10 seconds without reloading accumulated history pages or emitting
+ heartbeat realtime spam. `jobs.runs` refreshes both queue/run and embedded schedule projections,
+ while `schedules.records` refreshes the schedule root. Both topics retain shared coalescing,
+ terminal resync, and 30-second fallback behavior.
+- Route-level tests cover auth gating, navigation, independent deep links, malformed search,
+ list/detail isolation, overlapping-page deduplication, realtime refresh, pause, cancellation,
+ versioned schedule updates, and lost-response-safe manual execution. Legacy `/jobs` frontend
+ parity remains `planned` because the required OpenClaw cron half belongs to Phase 4; overview and
+ cache/metrics also remain open.
diff --git a/greenfield/package.json b/greenfield/package.json
index 13785eb61..6580b49d4 100644
--- a/greenfield/package.json
+++ b/greenfield/package.json
@@ -23,7 +23,7 @@
"lint:fix": "oxlint . --fix --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**' && oxlint src/browser --fix --tsconfig tsconfig.browser.json --no-error-on-unmatched-pattern",
"evidence:resources:sse": "bun src/test/integration/resources/runSseMemoryEvidence.ts",
"test": "bun run test:bun && bun run test:browser",
- "test:browser": "bun scripts/runTestSuite.ts --preload ./src/browser/test/setup.ts src/browser",
+ "test:browser": "bun scripts/runBrowserTests.ts",
"test:bun": "bun scripts/runTestSuite.ts scripts src/app src/contracts src/server src/shared src/test src/worker",
"test:coverage": "bun scripts/runCoverage.ts",
"typecheck": "bun run typecheck:browser && bun run typecheck:bun",
diff --git a/greenfield/scripts/runBrowserTests.test.ts b/greenfield/scripts/runBrowserTests.test.ts
new file mode 100644
index 000000000..e758bdedb
--- /dev/null
+++ b/greenfield/scripts/runBrowserTests.test.ts
@@ -0,0 +1,185 @@
+import { describe, expect, test } from "bun:test";
+import path from "node:path";
+
+import {
+ type BrowserTestInventory,
+ type BrowserTestPartition,
+ browserTestPartitionForPath,
+ browserTestPartitionOwnsPath,
+ browserTestPartitions,
+ createBrowserTestArguments,
+ createBrowserTestInventory,
+ discoverBrowserTestInventory,
+ runBrowserTestShards,
+} from "./runBrowserTests.ts";
+
+const projectRoot = path.resolve(import.meta.dir, "..");
+const isolatedJobTestOwners: ReadonlyMap = Object.freeze(
+ new Map([
+ ["src/browser/jobs/ScheduleDetailForm.test.tsx", "schedule-detail-form"],
+ [
+ "src/browser/jobs/ScheduleDetailStateDisable.test.tsx",
+ "schedule-detail-state-disable",
+ ],
+ [
+ "src/browser/jobs/ScheduleDetailStateErrors.test.tsx",
+ "schedule-detail-state-errors",
+ ],
+ [
+ "src/browser/jobs/ScheduleDetailStateVersion.test.tsx",
+ "schedule-detail-state-version",
+ ],
+ [
+ "src/browser/jobs/ScheduleDetailStateCopy.test.tsx",
+ "schedule-detail-state-copy",
+ ],
+ [
+ "src/browser/jobs/ScheduleDetailStateReplay.test.tsx",
+ "schedule-detail-state-replay",
+ ],
+ ] as const)
+);
+const injectedInventory: BrowserTestInventory = createBrowserTestInventory(
+ "/tmp/project",
+ [
+ "src/browser/example.test.ts",
+ "src/browser/jobs/example.test.ts",
+ ...isolatedJobTestOwners.keys(),
+ ]
+);
+
+describe("browser test shard runner", () => {
+ test("builds exact, isolated, deterministic shard arguments", async () => {
+ const discoveredTestPathGroups = await Promise.all(
+ ["src/browser/**/*.test.ts", "src/browser/**/*.test.tsx"].map((pattern) =>
+ Array.fromAsync(
+ new Bun.Glob(pattern).scan({
+ cwd: projectRoot,
+ onlyFiles: true,
+ })
+ )
+ )
+ );
+ const discoveredTestPaths = discoveredTestPathGroups.flat();
+ const testPaths = discoveredTestPaths.toSorted();
+ const inventory = discoverBrowserTestInventory(projectRoot);
+ const partitionPaths: string[] = [];
+ for (const partition of browserTestPartitions) {
+ const ownedPaths = testPaths.filter((testPath) =>
+ browserTestPartitionOwnsPath(partition.name, testPath)
+ );
+ expect(createBrowserTestArguments(partition.name, inventory)).toEqual([
+ "--preload",
+ "./src/browser/test/setup.ts",
+ "--max-concurrency=1",
+ "--bail=1",
+ ...ownedPaths.map((testPath) => path.resolve(projectRoot, testPath)),
+ ]);
+ partitionPaths.push(...ownedPaths);
+ }
+ expect(partitionPaths.toSorted()).toEqual(testPaths);
+ expect(new Set(partitionPaths).size).toBe(testPaths.length);
+ expect(
+ createBrowserTestArguments("core", inventory).some((argument) =>
+ argument.startsWith("--path-ignore-patterns=")
+ )
+ ).toBeFalse();
+ });
+
+ test("rejects invalid, duplicate, or incomplete inventories before execution", () => {
+ expect(() =>
+ createBrowserTestInventory("/tmp/project", ["src/server/example.test.ts"])
+ ).toThrow("Invalid discovered browser-test path");
+ expect(() =>
+ createBrowserTestInventory("/tmp/project", [
+ "src/browser/../server/example.test.ts",
+ ])
+ ).toThrow("Invalid discovered browser-test path");
+ expect(() =>
+ createBrowserTestInventory("/tmp/project", [
+ "src/browser/example.test.ts",
+ "./src/browser/example.test.ts",
+ ])
+ ).toThrow("duplicate paths");
+ expect(() =>
+ createBrowserTestInventory("/tmp/project", ["src/browser/example.test.ts"])
+ ).toThrow("partition has no discovered files");
+ });
+
+ test("assigns every discovered browser test to exactly one shard", async () => {
+ const testPaths = await Array.fromAsync(
+ new Bun.Glob("src/browser/**/*.test.{ts,tsx}").scan({
+ cwd: projectRoot,
+ onlyFiles: true,
+ })
+ );
+
+ expect(testPaths.length).toBeGreaterThan(0);
+ for (const testPath of testPaths) {
+ const owners = browserTestPartitions.filter((partition) =>
+ browserTestPartitionOwnsPath(partition.name, testPath)
+ );
+ const owner = browserTestPartitionForPath(testPath);
+ expect(owners).toHaveLength(1);
+ expect(owner).toBe(owners[0]?.name);
+ const isolatedOwner = isolatedJobTestOwners.get(testPath);
+ if (isolatedOwner !== undefined) {
+ expect(owner).toBe(isolatedOwner);
+ } else if (testPath.startsWith("src/browser/jobs/")) {
+ expect(owner).toBe("jobs");
+ } else {
+ expect(owner).toBe("core");
+ }
+ }
+ expect(browserTestPartitionForPath("src/server/example.test.ts")).toBeUndefined();
+ expect(
+ browserTestPartitionForPath("src/browser/jobs/example.ts")
+ ).toBeUndefined();
+ });
+
+ test("runs fresh shards sequentially and stops on the first failure", async () => {
+ const calls: string[][] = [];
+ let discoveryCalls = 0;
+ const exitCode = await runBrowserTestShards({
+ discoverTests: (receivedProjectRoot) => {
+ discoveryCalls += 1;
+ expect(receivedProjectRoot).toBe("/tmp/project");
+ return injectedInventory;
+ },
+ projectRoot: "/tmp/project",
+ runTests: (arguments_, receivedProjectRoot) => {
+ calls.push([...arguments_, receivedProjectRoot]);
+ return Promise.resolve(calls.length === 2 ? 17 : 0);
+ },
+ });
+
+ expect(exitCode).toBe(17);
+ expect(discoveryCalls).toBe(1);
+ expect(calls).toEqual([
+ [...createBrowserTestArguments("core", injectedInventory), "/tmp/project"],
+ [...createBrowserTestArguments("jobs", injectedInventory), "/tmp/project"],
+ ]);
+ });
+
+ test("runs every later shard only after the previous shard passes", async () => {
+ const calls: string[][] = [];
+ const exitCode = await runBrowserTestShards({
+ discoverTests: () => injectedInventory,
+ projectRoot: "/tmp/project",
+ runTests: (arguments_, receivedProjectRoot) => {
+ calls.push([...arguments_, receivedProjectRoot]);
+ return Promise.resolve(0);
+ },
+ });
+
+ expect(exitCode).toBe(0);
+ expect(calls).toEqual([
+ [...createBrowserTestArguments("core", injectedInventory), "/tmp/project"],
+ [...createBrowserTestArguments("jobs", injectedInventory), "/tmp/project"],
+ ...[...isolatedJobTestOwners.values()].map((partition) => [
+ ...createBrowserTestArguments(partition, injectedInventory),
+ "/tmp/project",
+ ]),
+ ]);
+ });
+});
diff --git a/greenfield/scripts/runBrowserTests.ts b/greenfield/scripts/runBrowserTests.ts
new file mode 100644
index 000000000..3537a34b0
--- /dev/null
+++ b/greenfield/scripts/runBrowserTests.ts
@@ -0,0 +1,262 @@
+import path from "node:path";
+
+import { runTestSuite } from "./runTestSuite.ts";
+
+const projectRoot = path.resolve(import.meta.dir, "..");
+const browserTestPreload = "./src/browser/test/setup.ts";
+const browserJobsRoot = "src/browser/jobs";
+const browserTestGlobs = Object.freeze([
+ "src/browser/**/*.test.ts",
+ "src/browser/**/*.test.tsx",
+] as const);
+const isolatedJobTestDefinitions = Object.freeze([
+ Object.freeze({
+ name: "schedule-detail-form",
+ testRoot: `${browserJobsRoot}/ScheduleDetailForm.test.tsx`,
+ }),
+ Object.freeze({
+ name: "schedule-detail-state-disable",
+ testRoot: `${browserJobsRoot}/ScheduleDetailStateDisable.test.tsx`,
+ }),
+ Object.freeze({
+ name: "schedule-detail-state-errors",
+ testRoot: `${browserJobsRoot}/ScheduleDetailStateErrors.test.tsx`,
+ }),
+ Object.freeze({
+ name: "schedule-detail-state-version",
+ testRoot: `${browserJobsRoot}/ScheduleDetailStateVersion.test.tsx`,
+ }),
+ Object.freeze({
+ name: "schedule-detail-state-copy",
+ testRoot: `${browserJobsRoot}/ScheduleDetailStateCopy.test.tsx`,
+ }),
+ Object.freeze({
+ name: "schedule-detail-state-replay",
+ testRoot: `${browserJobsRoot}/ScheduleDetailStateReplay.test.tsx`,
+ }),
+] as const);
+const isolatedJobTestPaths: readonly string[] = Object.freeze(
+ isolatedJobTestDefinitions.map((definition) => definition.testRoot)
+);
+
+/** Independently executed browser-test process. */
+export type BrowserTestPartition =
+ | "core"
+ | "jobs"
+ | (typeof isolatedJobTestDefinitions)[number]["name"];
+
+/** Browser-test process metadata in deterministic execution order. */
+export const browserTestPartitions = Object.freeze([
+ Object.freeze({ name: "core", testRoot: "src/browser" }),
+ Object.freeze({ name: "jobs", testRoot: browserJobsRoot }),
+ ...isolatedJobTestDefinitions,
+] satisfies readonly Readonly<{
+ name: BrowserTestPartition;
+ testRoot: string;
+}>[]);
+
+/** Injectable process boundary used by the browser shard orchestrator. */
+export interface BrowserTestRunnerDependencies {
+ readonly discoverTests: (projectRoot: string) => BrowserTestInventory;
+ readonly projectRoot: string;
+ readonly runTests: (
+ arguments_: readonly string[],
+ projectRoot: string
+ ) => Promise;
+}
+
+const defaultDependencies: BrowserTestRunnerDependencies = Object.freeze({
+ discoverTests: discoverBrowserTestInventory,
+ projectRoot,
+ runTests: runTestSuite,
+});
+
+/** One immutable browser-test inventory discovered before any child process starts. */
+export interface BrowserTestInventory {
+ readonly projectRoot: string;
+ readonly testPaths: readonly string[];
+}
+
+function normalizeBrowserTestPath(filePath: string): string | undefined {
+ const normalizedPath = filePath.replaceAll("\\", "/").replace(/^\.\//u, "");
+ if (
+ normalizedPath.includes("\0") ||
+ path.posix.isAbsolute(normalizedPath) ||
+ path.posix.normalize(normalizedPath) !== normalizedPath
+ ) {
+ return undefined;
+ }
+ if (!normalizedPath.startsWith("src/browser/")) return undefined;
+ return /\.test\.tsx?$/u.test(normalizedPath) ? normalizedPath : undefined;
+}
+
+/**
+ * Determines whether one browser process owns an exact repository test path.
+ * @param partition Browser-test process whose selection is evaluated.
+ * @param filePath Repository-relative candidate test path.
+ * @returns True only when the path belongs to that process.
+ */
+export function browserTestPartitionOwnsPath(
+ partition: BrowserTestPartition,
+ filePath: string
+): boolean {
+ const normalizedPath = normalizeBrowserTestPath(filePath);
+ if (normalizedPath === undefined) return false;
+ const partitionDefinition = browserTestPartitions.find(
+ (candidate) => candidate.name === partition
+ );
+ if (partitionDefinition === undefined) return false;
+ if (partition !== "core" && partition !== "jobs") {
+ return normalizedPath === partitionDefinition.testRoot;
+ }
+ if (partition === "jobs") {
+ return (
+ normalizedPath.startsWith(`${browserJobsRoot}/`) &&
+ !isolatedJobTestPaths.includes(normalizedPath)
+ );
+ }
+ return !normalizedPath.startsWith(`${browserJobsRoot}/`);
+}
+
+/**
+ * Identifies the one browser-test process that owns a repository-relative path.
+ * @param filePath Repository-relative browser test path.
+ * @returns The owning partition, or undefined for paths outside the browser tests.
+ */
+export function browserTestPartitionForPath(
+ filePath: string
+): BrowserTestPartition | undefined {
+ return browserTestPartitions.find((partition) =>
+ browserTestPartitionOwnsPath(partition.name, filePath)
+ )?.name;
+}
+
+/**
+ * Validates and freezes one repository-relative browser-test inventory.
+ * @param inventoryProjectRoot Repository root used to resolve exact Bun paths.
+ * @param filePaths Discovered repository-relative browser test paths.
+ * @returns Sorted, uniquely owned paths bound to the supplied root.
+ */
+export function createBrowserTestInventory(
+ inventoryProjectRoot: string,
+ filePaths: Iterable
+): BrowserTestInventory {
+ const normalizedPaths = [...filePaths].map((filePath) => {
+ const normalizedPath = normalizeBrowserTestPath(filePath);
+ if (normalizedPath === undefined) {
+ throw new TypeError(`Invalid discovered browser-test path: ${filePath}`);
+ }
+ return normalizedPath;
+ });
+ const uniquePaths = new Set(normalizedPaths);
+ if (uniquePaths.size !== normalizedPaths.length) {
+ throw new TypeError("Browser-test discovery returned duplicate paths");
+ }
+
+ const discoveredPaths = [...uniquePaths].toSorted();
+ for (const filePath of discoveredPaths) {
+ const ownerCount = browserTestPartitions.filter((partition) =>
+ browserTestPartitionOwnsPath(partition.name, filePath)
+ ).length;
+ if (ownerCount !== 1) {
+ throw new TypeError(
+ `Discovered browser test must have exactly one owner: ${filePath}`
+ );
+ }
+ }
+ for (const partition of browserTestPartitions) {
+ if (
+ !discoveredPaths.some((filePath) =>
+ browserTestPartitionOwnsPath(partition.name, filePath)
+ )
+ ) {
+ throw new TypeError(
+ `Browser-test partition has no discovered files: ${partition.name}`
+ );
+ }
+ }
+ return Object.freeze({
+ projectRoot: path.resolve(inventoryProjectRoot),
+ testPaths: Object.freeze(discoveredPaths),
+ });
+}
+
+/**
+ * Discovers one stable browser-test inventory from an injected repository root.
+ * @param inventoryProjectRoot Repository root to scan.
+ * @returns Validated inventory reused by every browser shard.
+ */
+export function discoverBrowserTestInventory(
+ inventoryProjectRoot: string
+): BrowserTestInventory {
+ const discoveredPaths = browserTestGlobs.flatMap((pattern) => [
+ ...new Bun.Glob(pattern).scanSync({
+ cwd: inventoryProjectRoot,
+ onlyFiles: true,
+ }),
+ ]);
+ return createBrowserTestInventory(inventoryProjectRoot, discoveredPaths);
+}
+
+/**
+ * Builds the exact Bun test arguments for one isolated browser process.
+ * @param partition Browser-test process to execute.
+ * @param inventory Stable exact-path inventory shared across every process.
+ * @param leadingArguments Optional Bun test flags placed before shared browser flags.
+ * @returns Complete arguments after `bun test`.
+ */
+export function createBrowserTestArguments(
+ partition: BrowserTestPartition,
+ inventory: BrowserTestInventory,
+ leadingArguments: readonly string[] = []
+): readonly string[] {
+ const partitionDefinition = browserTestPartitions.find(
+ (candidate) => candidate.name === partition
+ );
+ if (partitionDefinition === undefined) {
+ throw new TypeError(`Unknown browser-test partition: ${partition as string}`);
+ }
+
+ const arguments_ = [
+ ...leadingArguments,
+ "--preload",
+ browserTestPreload,
+ "--max-concurrency=1",
+ "--bail=1",
+ ];
+ const partitionTestPaths = inventory.testPaths.filter((filePath) =>
+ browserTestPartitionOwnsPath(partition, filePath)
+ );
+ if (partitionTestPaths.length === 0) {
+ throw new TypeError(
+ `Browser-test partition has no discovered files: ${partition}`
+ );
+ }
+ arguments_.push(
+ ...partitionTestPaths.map((filePath) =>
+ path.resolve(inventory.projectRoot, filePath)
+ )
+ );
+ return Object.freeze(arguments_);
+}
+
+/**
+ * Runs all browser partitions in fresh, sequential, fail-fast Bun processes.
+ * @param dependencies Injectable process runner and repository root.
+ * @returns The first failing exit code, or zero after every partition passes.
+ */
+export async function runBrowserTestShards(
+ dependencies: BrowserTestRunnerDependencies = defaultDependencies
+): Promise {
+ const inventory = dependencies.discoverTests(dependencies.projectRoot);
+ for (const partition of browserTestPartitions) {
+ const exitCode = await dependencies.runTests(
+ createBrowserTestArguments(partition.name, inventory),
+ dependencies.projectRoot
+ );
+ if (exitCode !== 0) return exitCode;
+ }
+ return 0;
+}
+
+if (import.meta.main) process.exitCode = await runBrowserTestShards();
diff --git a/greenfield/scripts/runCoverage.test.ts b/greenfield/scripts/runCoverage.test.ts
index aa65337eb..6d72d5b20 100644
--- a/greenfield/scripts/runCoverage.test.ts
+++ b/greenfield/scripts/runCoverage.test.ts
@@ -1,6 +1,61 @@
import { describe, expect, test } from "bun:test";
+import path from "node:path";
-import { createCoverageTestArguments } from "./runCoverage.ts";
+import {
+ type BrowserTestInventory,
+ createBrowserTestInventory,
+ discoverBrowserTestInventory,
+} from "./runBrowserTests.ts";
+import {
+ type CoverageRunnerDependencies,
+ createCoveragePartitionPlan,
+ createCoverageTestArguments,
+ runCoverage,
+} from "./runCoverage.ts";
+
+const browserProjectRoot = path.resolve(import.meta.dir, "..");
+
+const isolatedBrowserCoverageCases = Object.freeze([
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-form",
+ partition: "browser-schedule-detail-form",
+ testPath: "src/browser/jobs/ScheduleDetailForm.test.tsx",
+ }),
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-state-disable",
+ partition: "browser-schedule-detail-state-disable",
+ testPath: "src/browser/jobs/ScheduleDetailStateDisable.test.tsx",
+ }),
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-state-errors",
+ partition: "browser-schedule-detail-state-errors",
+ testPath: "src/browser/jobs/ScheduleDetailStateErrors.test.tsx",
+ }),
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-state-version",
+ partition: "browser-schedule-detail-state-version",
+ testPath: "src/browser/jobs/ScheduleDetailStateVersion.test.tsx",
+ }),
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-state-copy",
+ partition: "browser-schedule-detail-state-copy",
+ testPath: "src/browser/jobs/ScheduleDetailStateCopy.test.tsx",
+ }),
+ Object.freeze({
+ outputDirectory: "/tmp/coverage-schedule-detail-state-replay",
+ partition: "browser-schedule-detail-state-replay",
+ testPath: "src/browser/jobs/ScheduleDetailStateReplay.test.tsx",
+ }),
+] as const);
+const browserInventory = discoverBrowserTestInventory(browserProjectRoot);
+const injectedBrowserInventory: BrowserTestInventory = createBrowserTestInventory(
+ "/tmp/project",
+ [
+ "src/browser/example.test.ts",
+ "src/browser/jobs/example.test.ts",
+ ...isolatedBrowserCoverageCases.map((testCase) => testCase.testPath),
+ ]
+);
describe("coverage runner", () => {
test("keeps Bun coverage free of browser globals", () => {
@@ -22,18 +77,185 @@ describe("coverage runner", () => {
]);
});
- test("loads the DOM setup only for browser coverage", () => {
- const arguments_ = createCoverageTestArguments("/tmp/coverage-output", "browser");
-
- expect(arguments_).toEqual([
+ test("uses the shared deterministic policy for every browser shard", async () => {
+ const discoveredTestPathGroups = await Promise.all(
+ ["src/browser/**/*.test.ts", "src/browser/**/*.test.tsx"].map((pattern) =>
+ Array.fromAsync(
+ new Bun.Glob(pattern).scan({
+ cwd: browserProjectRoot,
+ onlyFiles: true,
+ })
+ )
+ )
+ );
+ const discoveredTestPaths = discoveredTestPathGroups.flat();
+ const testPaths = discoveredTestPaths.toSorted();
+ const isolatedPaths = new Set(
+ isolatedBrowserCoverageCases.map((testCase) => testCase.testPath)
+ );
+ const sharedArguments = [
+ "--preload",
+ "./src/browser/test/setup.ts",
+ "--max-concurrency=1",
+ "--bail=1",
+ ];
+ expect(
+ createCoverageTestArguments(
+ "/tmp/coverage-core",
+ "browser-core",
+ browserInventory
+ )
+ ).toEqual([
"--coverage",
"--coverage-reporter",
"lcov",
"--coverage-dir",
- "/tmp/coverage-output",
- "--preload",
- "./src/browser/test/setup.ts",
- "src/browser",
+ "/tmp/coverage-core",
+ ...sharedArguments,
+ ...testPaths
+ .filter((testPath) => !testPath.startsWith("src/browser/jobs/"))
+ .map((testPath) => path.resolve(browserProjectRoot, testPath)),
+ ]);
+ expect(
+ createCoverageTestArguments(
+ "/tmp/coverage-jobs",
+ "browser-jobs",
+ browserInventory
+ )
+ ).toEqual([
+ "--coverage",
+ "--coverage-reporter",
+ "lcov",
+ "--coverage-dir",
+ "/tmp/coverage-jobs",
+ ...sharedArguments,
+ ...testPaths
+ .filter(
+ (testPath) =>
+ testPath.startsWith("src/browser/jobs/") &&
+ !isolatedPaths.has(testPath)
+ )
+ .map((testPath) => path.resolve(browserProjectRoot, testPath)),
]);
+ for (const testCase of isolatedBrowserCoverageCases) {
+ expect(
+ createCoverageTestArguments(
+ testCase.outputDirectory,
+ testCase.partition,
+ browserInventory
+ )
+ ).toEqual([
+ "--coverage",
+ "--coverage-reporter",
+ "lcov",
+ "--coverage-dir",
+ testCase.outputDirectory,
+ "--preload",
+ "./src/browser/test/setup.ts",
+ "--max-concurrency=1",
+ "--bail=1",
+ path.resolve(browserProjectRoot, testCase.testPath),
+ ]);
+ }
+ });
+
+ test("runs and merges the nine private LCOV inventories", async () => {
+ const calls: string[][] = [];
+ const mergedInventories: string[][] = [];
+ const mergedPatterns: string[] = [];
+ const writes: string[][] = [];
+ const checks: string[][] = [];
+ const logs: string[] = [];
+ let discoveryCalls = 0;
+ const dependencies: CoverageRunnerDependencies = {
+ checkReport: (...arguments_) => {
+ checks.push(arguments_.map(String));
+ return Promise.resolve({
+ foundLines: 20,
+ hitLines: 18,
+ percent: 90,
+ });
+ },
+ coverageDirectory: "/tmp/coverage",
+ discoverBrowserTests: (receivedProjectRoot) => {
+ discoveryCalls += 1;
+ expect(receivedProjectRoot).toBe("/tmp/project");
+ return injectedBrowserInventory;
+ },
+ log: (message) => logs.push(message),
+ mergeReports: (reportPaths, reportPattern) => {
+ mergedInventories.push([...reportPaths]);
+ mergedPatterns.push(reportPattern);
+ return Promise.resolve("TN:\nend_of_record");
+ },
+ projectRoot: "/tmp/project",
+ resetDirectory: (directory) => {
+ calls.push(["reset", directory]);
+ return Promise.resolve();
+ },
+ runTests: (arguments_, receivedProjectRoot) => {
+ calls.push(["test", ...arguments_, receivedProjectRoot]);
+ return Promise.resolve(0);
+ },
+ writeReport: (filePath, coverage) => {
+ writes.push([filePath, coverage]);
+ return Promise.resolve();
+ },
+ };
+
+ expect(await runCoverage(dependencies)).toBe(0);
+ expect(discoveryCalls).toBe(1);
+
+ const plans = createCoveragePartitionPlan("/tmp/coverage");
+ expect(calls).toEqual([
+ ["reset", "/tmp/coverage"],
+ ...plans.map((plan) => [
+ "test",
+ ...createCoverageTestArguments(
+ plan.outputDirectory,
+ plan.name,
+ injectedBrowserInventory
+ ),
+ "/tmp/project",
+ ]),
+ ]);
+ expect(mergedInventories).toEqual([plans.map((plan) => plan.reportPath)]);
+ expect(mergedPatterns).toEqual(["/tmp/coverage/*/lcov.info"]);
+ expect(writes).toEqual([["/tmp/coverage/lcov.info", "TN:\nend_of_record"]]);
+ expect(checks).toHaveLength(1);
+ expect(checks[0]?.[0]).toBe("/tmp/coverage/lcov.info");
+ expect(logs).toEqual(["Coverage 90.00% meets required 85.00% (18/20 lines)"]);
+ });
+
+ test("stops before later shards and merge after a test failure", async () => {
+ const testCalls: string[][] = [];
+ let mergeCalls = 0;
+ const dependencies: CoverageRunnerDependencies = {
+ checkReport: () => {
+ return Promise.reject(new Error("coverage policy must not run"));
+ },
+ coverageDirectory: "/tmp/coverage",
+ discoverBrowserTests: () => injectedBrowserInventory,
+ log: () => {
+ throw new Error("coverage summary must not be logged");
+ },
+ mergeReports: () => {
+ mergeCalls += 1;
+ return Promise.resolve("");
+ },
+ projectRoot: "/tmp/project",
+ resetDirectory: () => Promise.resolve(),
+ runTests: (arguments_) => {
+ testCalls.push([...arguments_]);
+ return Promise.resolve(testCalls.length === 2 ? 23 : 0);
+ },
+ writeReport: () => {
+ return Promise.reject(new Error("coverage report must not be written"));
+ },
+ };
+
+ expect(await runCoverage(dependencies)).toBe(23);
+ expect(testCalls).toHaveLength(2);
+ expect(mergeCalls).toBe(0);
});
});
diff --git a/greenfield/scripts/runCoverage.ts b/greenfield/scripts/runCoverage.ts
index 610ab38be..9f2a75582 100644
--- a/greenfield/scripts/runCoverage.ts
+++ b/greenfield/scripts/runCoverage.ts
@@ -3,33 +3,120 @@ import path from "node:path";
import { mergeCoverageReportFiles } from "lcov-result-merger";
-import { checkCoverageFile, requiredLineCoveragePercent } from "./checkCoverage.ts";
+import {
+ checkCoverageFile,
+ type LineCoverageSummary,
+ requiredLineCoveragePercent,
+} from "./checkCoverage.ts";
+import {
+ type BrowserTestInventory,
+ type BrowserTestPartition,
+ createBrowserTestArguments,
+ discoverBrowserTestInventory,
+} from "./runBrowserTests.ts";
import { runTestSuite } from "./runTestSuite.ts";
const projectRoot = path.resolve(import.meta.dir, "..");
const coverageDirectory = path.join(projectRoot, "coverage");
-const lcovPath = path.join(coverageDirectory, "lcov.info");
const coveredSourceRoots = Object.freeze(["src"]);
-export type CoveragePartition = "browser" | "bun";
+/** Independently executed test process contributing to the merged LCOV artifact. */
+export type CoveragePartition =
+ | "browser-core"
+ | "browser-jobs"
+ | "browser-schedule-detail-form"
+ | "browser-schedule-detail-state-copy"
+ | "browser-schedule-detail-state-disable"
+ | "browser-schedule-detail-state-errors"
+ | "browser-schedule-detail-state-replay"
+ | "browser-schedule-detail-state-version"
+ | "bun";
-const coveragePartitions = Object.freeze([
- Object.freeze({ name: "bun", outputDirectoryName: "bun" }),
- Object.freeze({ name: "browser", outputDirectoryName: "browser" }),
-] satisfies readonly Readonly<{
- name: CoveragePartition;
- outputDirectoryName: string;
-}>[]);
+const coverageBrowserPartitions = Object.freeze({
+ "browser-core": "core",
+ "browser-jobs": "jobs",
+ "browser-schedule-detail-form": "schedule-detail-form",
+ "browser-schedule-detail-state-copy": "schedule-detail-state-copy",
+ "browser-schedule-detail-state-disable": "schedule-detail-state-disable",
+ "browser-schedule-detail-state-errors": "schedule-detail-state-errors",
+ "browser-schedule-detail-state-replay": "schedule-detail-state-replay",
+ "browser-schedule-detail-state-version": "schedule-detail-state-version",
+} satisfies Readonly, BrowserTestPartition>>);
+
+/** One isolated coverage process and its private artifact paths. */
+export interface CoveragePartitionPlan {
+ readonly name: CoveragePartition;
+ readonly outputDirectory: string;
+ readonly reportPath: string;
+}
+
+/** Injectable side-effect boundary for the coverage orchestrator. */
+export interface CoverageRunnerDependencies {
+ readonly checkReport: (
+ lcovPath: string,
+ thresholdPercent: number,
+ sourceRoots: readonly string[],
+ projectRoot: string
+ ) => Promise;
+ readonly coverageDirectory: string;
+ readonly discoverBrowserTests: (projectRoot: string) => BrowserTestInventory;
+ readonly log: (message: string) => void;
+ readonly mergeReports: (
+ reportPaths: readonly string[],
+ reportPattern: string
+ ) => Promise;
+ readonly projectRoot: string;
+ readonly resetDirectory: (directory: string) => Promise;
+ readonly runTests: (
+ arguments_: readonly string[],
+ projectRoot: string
+ ) => Promise;
+ readonly writeReport: (filePath: string, coverage: string) => Promise;
+}
+
+/**
+ * Creates the deterministic coverage process and merge inventory.
+ * @param directory Root directory for private and merged LCOV artifacts.
+ * @returns Nine process plans in execution and merge order.
+ */
+export function createCoveragePartitionPlan(
+ directory: string
+): readonly CoveragePartitionPlan[] {
+ return Object.freeze(
+ (
+ [
+ "bun",
+ "browser-core",
+ "browser-jobs",
+ "browser-schedule-detail-form",
+ "browser-schedule-detail-state-disable",
+ "browser-schedule-detail-state-errors",
+ "browser-schedule-detail-state-version",
+ "browser-schedule-detail-state-copy",
+ "browser-schedule-detail-state-replay",
+ ] as const
+ ).map((name) => {
+ const outputDirectory = path.join(directory, name);
+ return Object.freeze({
+ name,
+ outputDirectory,
+ reportPath: path.join(outputDirectory, "lcov.info"),
+ });
+ })
+ );
+}
/**
* Builds the exact Bun test arguments used by the coverage gate.
* @param outputDirectory Directory where Bun writes coverage artifacts.
* @param partition Runtime partition whose tests and preload policy are selected.
+ * @param browserInventory Shared exact-path inventory for browser partitions.
* @returns Complete arguments after `bun test`.
*/
export function createCoverageTestArguments(
outputDirectory: string,
- partition: CoveragePartition
+ partition: CoveragePartition,
+ browserInventory?: BrowserTestInventory
): readonly string[] {
const coverageArguments = [
"--coverage",
@@ -38,13 +125,15 @@ export function createCoverageTestArguments(
"--coverage-dir",
outputDirectory,
];
- if (partition === "browser") {
- return Object.freeze([
- ...coverageArguments,
- "--preload",
- "./src/browser/test/setup.ts",
- "src/browser",
- ]);
+ if (partition !== "bun") {
+ if (browserInventory === undefined) {
+ throw new TypeError("Browser coverage requires a discovered test inventory");
+ }
+ return createBrowserTestArguments(
+ coverageBrowserPartitions[partition],
+ browserInventory,
+ coverageArguments
+ );
}
return Object.freeze([
...coverageArguments,
@@ -58,48 +147,91 @@ export function createCoverageTestArguments(
]);
}
-/** @returns Completion after coverage output has one fresh private directory. */
-async function resetCoverageDirectory(): Promise {
- await rm(coverageDirectory, { force: true, recursive: true });
- await mkdir(coverageDirectory, { mode: 0o700, recursive: true });
+/**
+ * Recreates the private coverage root.
+ * @param directory Coverage root to recreate.
+ * @returns Completion after coverage output has one fresh private directory.
+ */
+async function resetCoverageDirectory(directory: string): Promise {
+ await rm(directory, { force: true, recursive: true });
+ await mkdir(directory, { mode: 0o700, recursive: true });
+}
+
+/**
+ * Merges the exact private LCOV reports without filesystem discovery.
+ * @param reportPaths Private LCOV paths in deterministic partition order.
+ * @param reportPattern Real glob covering the private LCOV reports.
+ * @returns One merged LCOV document.
+ */
+async function mergeCoverageReports(
+ reportPaths: readonly string[],
+ reportPattern: string
+): Promise {
+ return mergeCoverageReportFiles([...reportPaths], { pattern: reportPattern });
}
+/**
+ * Writes the final merged LCOV document with a stable trailing newline.
+ * @param filePath Final LCOV artifact path.
+ * @param coverage Merged LCOV document.
+ */
+async function writeCoverageReport(filePath: string, coverage: string): Promise {
+ await writeFile(filePath, `${coverage}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ });
+}
+
+const defaultDependencies: CoverageRunnerDependencies = Object.freeze({
+ checkReport: checkCoverageFile,
+ coverageDirectory,
+ discoverBrowserTests: discoverBrowserTestInventory,
+ log: (message: string) => console.log(message),
+ mergeReports: mergeCoverageReports,
+ projectRoot,
+ resetDirectory: resetCoverageDirectory,
+ runTests: runTestSuite,
+ writeReport: writeCoverageReport,
+});
+
/**
* Runs the complete test set with LCOV and enforces the repository threshold.
+ * @param dependencies Injectable process, filesystem, merge, and policy boundaries.
* @returns Zero when tests, output policy, and line coverage all pass.
*/
-export async function runCoverage(): Promise {
- await resetCoverageDirectory();
-
- const partitionReports: string[] = [];
- for (const partition of coveragePartitions) {
- const outputDirectory = path.join(
- coverageDirectory,
- partition.outputDirectoryName
- );
- const testExitCode = await runTestSuite(
- createCoverageTestArguments(outputDirectory, partition.name),
- projectRoot
+export async function runCoverage(
+ dependencies: CoverageRunnerDependencies = defaultDependencies
+): Promise {
+ const browserInventory = dependencies.discoverBrowserTests(dependencies.projectRoot);
+ await dependencies.resetDirectory(dependencies.coverageDirectory);
+
+ const plans = createCoveragePartitionPlan(dependencies.coverageDirectory);
+ for (const plan of plans) {
+ const testExitCode = await dependencies.runTests(
+ createCoverageTestArguments(
+ plan.outputDirectory,
+ plan.name,
+ browserInventory
+ ),
+ dependencies.projectRoot
);
if (testExitCode !== 0) return testExitCode;
- partitionReports.push(path.join(outputDirectory, "lcov.info"));
}
- const mergedCoverage = await mergeCoverageReportFiles(partitionReports, {
- pattern: "",
- });
- await writeFile(lcovPath, `${mergedCoverage}\n`, {
- encoding: "utf8",
- mode: 0o600,
- });
+ const mergedCoverage = await dependencies.mergeReports(
+ plans.map((plan) => plan.reportPath),
+ path.join(dependencies.coverageDirectory, "*", "lcov.info")
+ );
+ const lcovPath = path.join(dependencies.coverageDirectory, "lcov.info");
+ await dependencies.writeReport(lcovPath, mergedCoverage);
- const summary = await checkCoverageFile(
+ const summary = await dependencies.checkReport(
lcovPath,
requiredLineCoveragePercent,
coveredSourceRoots,
- projectRoot
+ dependencies.projectRoot
);
- console.log(
+ dependencies.log(
`Coverage ${summary.percent.toFixed(2)}% meets required ${requiredLineCoveragePercent.toFixed(2)}% (${summary.hitLines}/${summary.foundLines} lines)`
);
return 0;
diff --git a/greenfield/src/browser/jobs/JobQueuePanel.test.tsx b/greenfield/src/browser/jobs/JobQueuePanel.test.tsx
new file mode 100644
index 000000000..3b9811128
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobQueuePanel.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import type { JobQueueSummary } from "../../contracts/jobs.ts";
+import { JobQueuePanel } from "./JobQueuePanel.tsx";
+
+const { render, screen } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+const timestampMs = 1_800_000_000_000;
+const summary: JobQueueSummary = {
+ activeResourceClasses: ["light"],
+ control: {
+ claimingPaused: false,
+ updatedAtMs: timestampMs,
+ version: 4,
+ },
+ oldestQueuedAtMs: timestampMs - 60_000,
+ stateCounts: {
+ cancelled: 2,
+ failed: 3,
+ queued: 1,
+ running: 1,
+ succeeded: 8,
+ "timed-out": 4,
+ },
+ workers: [
+ {
+ activeRunCount: 1,
+ capacity: 2,
+ heartbeatAtMs: timestampMs,
+ id: "019fdd00-0000-7000-8000-000000000001",
+ releaseId: "a".repeat(40),
+ startedAtMs: timestampMs - 3_600_000,
+ state: "online",
+ },
+ ],
+};
+
+describe("job queue panel", () => {
+ test("presents queue and worker state and requests a versioned pause direction", async () => {
+ const onSetClaimingPaused = jest.fn();
+ render(
+
+ );
+ const user = userEvent.setup();
+
+ expect(screen.getByRole("status")).toHaveTextContent("Claiming active");
+ expect(screen.getByRole("table", { name: "Job workers" })).toBeTruthy();
+ expect(screen.getByText(summary.workers[0]!.id)).toBeTruthy();
+ expect(screen.getByText("version 4", { exact: false })).toBeTruthy();
+ expect(screen.getByText("queued job runs")).toBeTruthy();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Pause claiming for new job runs",
+ })
+ );
+ expect(onSetClaimingPaused).toHaveBeenCalledWith(true);
+ });
+
+ test("disables and labels a pending resume without dispatching another action", async () => {
+ const onSetClaimingPaused = jest.fn();
+ render(
+
+ );
+ const user = userEvent.setup();
+ const control = screen.getByRole("button", {
+ name: "Resume claiming for new job runs",
+ });
+
+ expect(control).toBeDisabled();
+ expect(control).toHaveAttribute("aria-busy", "true");
+ expect(control).toHaveTextContent("Resuming claiming…");
+ await user.click(control);
+ expect(onSetClaimingPaused).not.toHaveBeenCalled();
+ });
+});
diff --git a/greenfield/src/browser/jobs/JobQueuePanel.tsx b/greenfield/src/browser/jobs/JobQueuePanel.tsx
new file mode 100644
index 000000000..c7a3b2d9a
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobQueuePanel.tsx
@@ -0,0 +1,236 @@
+import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table";
+import { PauseCircle, PlayCircle, Server } from "lucide-react";
+
+import type { JobRunState, JobWorkerSummary } from "../../contracts/jobModel.ts";
+import type { JobQueueSummary } from "../../contracts/jobs.ts";
+import { formatDashboardDateTime } from "../lib/formatDateTime.ts";
+import { Badge } from "../ui/Badge.tsx";
+import { Button } from "../ui/Button.tsx";
+import { Card } from "../ui/Card.tsx";
+import { DataTable } from "../ui/DataTable.tsx";
+import { Heading } from "../ui/Heading.tsx";
+import { Icon } from "../ui/Icon.tsx";
+import { Text } from "../ui/Text.tsx";
+import { jobRunStateBadgeVariant, jobRunStateLabel } from "./jobRunPresentation.ts";
+
+const workerTableFeatures = tableFeatures({});
+const queueStateDefinitions = Object.freeze([
+ { label: "Queued", state: "queued" },
+ { label: "Running", state: "running" },
+ { label: "Failed", state: "failed" },
+ { label: "Timed out", state: "timed-out" },
+ { label: "Succeeded", state: "succeeded" },
+ { label: "Cancelled", state: "cancelled" },
+] satisfies readonly Readonly<{ label: string; state: JobRunState }>[]);
+
+function workerStateBadgeVariant(
+ state: JobWorkerSummary["state"]
+): "default" | "success" | "warning" {
+ switch (state) {
+ case "draining": {
+ return "warning";
+ }
+ case "online": {
+ return "success";
+ }
+ case "stopped": {
+ return "default";
+ }
+ }
+}
+
+const workerColumnHelper = createColumnHelper<
+ typeof workerTableFeatures,
+ JobWorkerSummary
+>();
+const workerColumns = workerColumnHelper.columns([
+ workerColumnHelper.accessor("id", {
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ header: "Worker",
+ }),
+ workerColumnHelper.accessor("state", {
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ header: "State",
+ }),
+ workerColumnHelper.accessor("activeRunCount", {
+ cell: ({ getValue, row }) => (
+
+ {getValue()} / {row.original.capacity}
+
+ ),
+ header: "Active / capacity",
+ }),
+ workerColumnHelper.accessor("releaseId", {
+ cell: ({ getValue }) => (
+
+ {getValue().slice(0, 12)}
+
+ ),
+ header: "Release",
+ }),
+ workerColumnHelper.accessor("startedAtMs", {
+ cell: ({ getValue }) => (
+
+ ),
+ header: "Started",
+ }),
+ workerColumnHelper.accessor("heartbeatAtMs", {
+ cell: ({ getValue }) => (
+
+ ),
+ header: "Heartbeat",
+ }),
+]);
+
+export interface JobQueuePanelProps {
+ readonly controlBusy: boolean;
+ readonly controlDisabled?: boolean;
+ readonly onSetClaimingPaused: (paused: boolean) => void;
+ readonly summary: JobQueueSummary;
+}
+
+/** @returns Durable queue counts, worker inventory, and versioned claim control. */
+export function JobQueuePanel({
+ controlBusy,
+ controlDisabled = false,
+ onSetClaimingPaused,
+ summary,
+}: JobQueuePanelProps) {
+ const workerTable = useTable({
+ columns: workerColumns,
+ data: summary.workers,
+ features: workerTableFeatures,
+ getRowId: (worker) => worker.id,
+ });
+ const claimingPaused = summary.control.claimingPaused;
+ const actionLabel = claimingPaused ? "Resume claiming" : "Pause claiming";
+ const actionBusyLabel = claimingPaused ? "Resuming claiming…" : "Pausing claiming…";
+
+ return (
+
+
+
+ Queue and workers
+
+ Durable claim state and the currently registered worker fleet.
+
+
+
+
+
+
+
+
+
+ {queueStateDefinitions.map(({ label, state }) => (
+
+
-
+
+ {jobRunStateLabel(state)}
+
+
+ -
+ {summary.stateCounts[state]}
+
+ {" "}
+ {label.toLowerCase()} job runs
+
+
+
+ ))}
+
+
+
+
+
+ Oldest queued
+
+ {summary.oldestQueuedAtMs === undefined ? (
+ No runs waiting
+ ) : (
+
+ )}
+
+
+
+ Active resource classes
+
+
+ {summary.activeResourceClasses.length === 0
+ ? "None"
+ : summary.activeResourceClasses.join(", ")}
+
+
+
+
+ Claim control updated
+
+
+
+
+
+
+
+
+ Workers
+
+ {summary.workers.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/JobRunBrowser.test.tsx b/greenfield/src/browser/jobs/JobRunBrowser.test.tsx
new file mode 100644
index 000000000..c9a96352b
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunBrowser.test.tsx
@@ -0,0 +1,531 @@
+import { describe, expect, test } from "bun:test";
+
+import { QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ RouterProvider,
+} from "@tanstack/react-router";
+import type { TRPCRequestOptions } from "@trpc/client";
+import { act, Activity } from "react";
+
+import type { JobRunEvent, JobRunSummary } from "../../contracts/jobModel.ts";
+import type {
+ GetJobRunInput,
+ JobQueueSummary,
+ JobRunDetail,
+} from "../../contracts/jobs.ts";
+import { createDashboardQueryClient } from "../api/queryClient.ts";
+import {
+ createDashboardTrpcClient,
+ type DashboardTrpcTransport,
+} from "../api/trpcClient.ts";
+import { DashboardTrpcProvider } from "../api/trpcContext.tsx";
+import { jobRunDetailQueryKey, jobRunEventHistoryQueryKey } from "./jobQueries.ts";
+import { parseJobsRouteSearch } from "./jobRouteSearch.ts";
+import { JobRunBrowser } from "./JobRunBrowser.tsx";
+
+const { render, screen, waitFor } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+const runId = "019fdf70-0000-7000-8000-000000000002";
+const otherRunId = "019fdf70-0000-7000-8000-000000000003";
+const timestampMs = 1_800_000_000_000;
+const eventProjectionWait = { timeout: 3000 } as const;
+
+function runningRun(eventCount: number, id = runId): JobRunSummary {
+ return {
+ actionKey: "system.worker-smoke",
+ attemptCount: 1,
+ attemptLimit: 3,
+ availableAtMs: timestampMs,
+ cancellationPolicy: "cooperative",
+ displayName: "Gap-safe worker smoke",
+ eventCount,
+ firstStartedAtMs: timestampMs + 1000,
+ id,
+ lastAttemptStartedAtMs: timestampMs + 1000,
+ priority: 0,
+ queuedAtMs: timestampMs,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ state: "running",
+ stateVersion: 2,
+ timeoutMs: 60_000,
+ triggerType: "system",
+ updatedAtMs: timestampMs + 1000,
+ };
+}
+
+function runEvent(sequence: number): JobRunEvent {
+ return {
+ attempt: 1,
+ kind: "stdout",
+ message: `Output ${sequence}`,
+ occurredAtMs: timestampMs + sequence,
+ sequence,
+ };
+}
+
+function eventRange(newest: number, oldest: number): JobRunEvent[] {
+ return Array.from({ length: newest - oldest + 1 }, (_, index) =>
+ runEvent(newest - index)
+ );
+}
+
+function detailPage(
+ newest: number,
+ oldest: number,
+ eventCount: number,
+ nextEventCursor = true,
+ id = runId
+): JobRunDetail {
+ return {
+ events: eventRange(newest, oldest),
+ ...(nextEventCursor ? { nextEventCursor: { sequence: oldest } } : {}),
+ run: runningRun(eventCount, id),
+ };
+}
+
+function emptyQueueSummary(): JobQueueSummary {
+ return {
+ activeResourceClasses: [],
+ control: { claimingPaused: false, updatedAtMs: timestampMs, version: 1 },
+ stateCounts: {
+ cancelled: 0,
+ failed: 0,
+ queued: 0,
+ running: 0,
+ succeeded: 0,
+ "timed-out": 0,
+ },
+ workers: [],
+ };
+}
+
+class EventGapTransport implements DashboardTrpcTransport {
+ readonly calls: {
+ readonly input: unknown;
+ readonly path: string;
+ readonly signal: AbortSignal | undefined;
+ }[] = [];
+ #deferredGap = Promise.withResolvers();
+ #deferredGapEnabled = false;
+ #deferredGapUsed = false;
+ gap303FailuresRemaining = 0;
+ newestExactSequence = 202;
+
+ deferFirstGap(): void {
+ this.#deferredGapEnabled = true;
+ }
+
+ resolveDeferredGap(): void {
+ this.#deferredGap.resolve(
+ detailPage(302, 203, Math.max(402, this.newestExactSequence))
+ );
+ }
+
+ mutation(path: string): Promise {
+ return Promise.reject(new TypeError(`Unexpected mutation: ${path}`));
+ }
+
+ query(path: string, input?: unknown, options?: TRPCRequestOptions): Promise {
+ this.calls.push({ input, path, signal: options?.signal });
+ if (path === "jobs.listRuns") {
+ return Promise.resolve({ runs: [], summary: emptyQueueSummary() });
+ }
+ if (path !== "jobs.getRun") {
+ return Promise.reject(new TypeError(`Unexpected query: ${path}`));
+ }
+ const { eventCursor, id } = input as GetJobRunInput;
+ if (id === otherRunId) {
+ return Promise.resolve(detailPage(2, 1, 2, false, otherRunId));
+ }
+ if (eventCursor === undefined) {
+ if (this.newestExactSequence === 202) {
+ return Promise.resolve(detailPage(202, 103, 202));
+ }
+ if (this.newestExactSequence === 402) {
+ return Promise.resolve(detailPage(402, 303, 402));
+ }
+ return Promise.resolve(detailPage(602, 503, 602));
+ }
+ if (eventCursor.sequence === 103) {
+ return Promise.resolve(detailPage(102, 3, 202, false));
+ }
+ if (eventCursor.sequence === 303) {
+ if (this.gap303FailuresRemaining > 0) {
+ this.gap303FailuresRemaining -= 1;
+ return Promise.reject(new TypeError("Event gap unavailable"));
+ }
+ if (this.#deferredGapEnabled && !this.#deferredGapUsed) {
+ this.#deferredGapUsed = true;
+ return this.#deferredGap.promise;
+ }
+ return Promise.resolve(
+ detailPage(302, 203, Math.max(402, this.newestExactSequence))
+ );
+ }
+ if (eventCursor.sequence === 503) {
+ return Promise.resolve(detailPage(502, 403, 602));
+ }
+ return Promise.reject(
+ new TypeError(`Unexpected event cursor: ${eventCursor.sequence}`)
+ );
+ }
+}
+
+function eventCursors(transport: EventGapTransport): (number | undefined)[] {
+ return transport.calls
+ .filter(({ path }) => path === "jobs.getRun")
+ .map(({ input }) => (input as GetJobRunInput).eventCursor?.sequence);
+}
+
+function createJobsTestRouter() {
+ const rootRoute = createRootRoute();
+ const jobsRoute = createRoute({
+ component: () => (
+ {}} onRunFocusHandled={() => {}} />
+ ),
+ getParentRoute: () => rootRoute,
+ path: "/jobs",
+ validateSearch: parseJobsRouteSearch,
+ });
+ return createRouter({
+ history: createMemoryHistory({ initialEntries: [`/jobs?runId=${runId}`] }),
+ routeTree: rootRoute.addChildren([jobsRoute]),
+ });
+}
+
+function createJobBrowserHarness(transport: EventGapTransport) {
+ const queryClient = createDashboardQueryClient();
+ queryClient.setDefaultOptions({
+ ...queryClient.getDefaultOptions(),
+ queries: {
+ ...queryClient.getDefaultOptions().queries,
+ retry: false,
+ },
+ });
+ const client = createDashboardTrpcClient(transport);
+ const router = createJobsTestRouter();
+ const tree = (mode: "hidden" | "visible") => (
+
+
+
+
+
+
+
+ );
+ const view = render(tree("visible"));
+ return { queryClient, router, tree, view };
+}
+
+async function loadInitialEventHistory(): Promise {
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 103: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ await userEvent
+ .setup()
+ .click(screen.getByRole("button", { name: "Load older events" }));
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 3: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+}
+
+async function refreshExactDetail(
+ transport: EventGapTransport,
+ queryClient: ReturnType,
+ newestExactSequence: 402 | 602
+): Promise {
+ transport.newestExactSequence = newestExactSequence;
+ await act(async () => {
+ await queryClient.invalidateQueries({
+ exact: true,
+ queryKey: jobRunDetailQueryKey(runId),
+ });
+ });
+}
+
+describe("job run browser", () => {
+ test("fills a realtime cursor jump without discarding loaded history", async () => {
+ const transport = new EventGapTransport();
+ const { queryClient, view } = createJobBrowserHarness(transport);
+
+ try {
+ await loadInitialEventHistory();
+ await refreshExactDetail(transport, queryClient, 402);
+
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 203: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("article", { name: "Event 202: stdout" })
+ ).toBeTruthy();
+ expect(screen.getByRole("article", { name: "Event 3: stdout" })).toBeTruthy();
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole("article", { name: /^Event \d+: stdout$/u })
+ ).toHaveLength(400)
+ );
+ expect(
+ screen.getAllByRole("article", { name: "Event 203: stdout" })
+ ).toHaveLength(1);
+ const renderedSequences = screen
+ .getAllByRole("article", { name: /^Event \d+: stdout$/u })
+ .map((article) =>
+ Number(
+ article
+ .getAttribute("aria-label")
+ ?.split(" ")[1]
+ ?.replace(":", "") ?? ""
+ )
+ );
+ expect(renderedSequences.at(0)).toBe(402);
+ expect(renderedSequences.slice(99, 101)).toEqual([303, 302]);
+ expect(renderedSequences.slice(199, 201)).toEqual([203, 202]);
+ expect(renderedSequences.at(-1)).toBe(3);
+
+ expect(eventCursors(transport)).toEqual([undefined, 103, undefined, 303]);
+ } finally {
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("repairs a later cursor jump after cached history remounts through A to B to A", async () => {
+ const transport = new EventGapTransport();
+ const { queryClient, router, view } = createJobBrowserHarness(transport);
+
+ try {
+ await loadInitialEventHistory();
+ expect(
+ queryClient.getQueryData(jobRunEventHistoryQueryKey(runId))
+ ).toBeDefined();
+
+ await act(async () => {
+ await router.navigate({
+ search: { runId: otherRunId },
+ to: "/jobs",
+ });
+ });
+ await waitFor(() =>
+ expect(
+ transport.calls.some(
+ ({ input, path }) =>
+ path === "jobs.getRun" &&
+ (input as GetJobRunInput).id === otherRunId
+ )
+ ).toBeTrue()
+ );
+
+ transport.newestExactSequence = 402;
+ await act(async () => {
+ await router.navigate({
+ search: { runId },
+ to: "/jobs",
+ });
+ });
+
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(303),
+ eventProjectionWait
+ );
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 203: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(screen.getByRole("article", { name: "Event 3: stdout" })).toBeTruthy();
+ } finally {
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("serializes realtime gap repairs when another cursor jump arrives in flight", async () => {
+ const transport = new EventGapTransport();
+ transport.deferFirstGap();
+ const { queryClient, view } = createJobBrowserHarness(transport);
+
+ try {
+ await loadInitialEventHistory();
+ await refreshExactDetail(transport, queryClient, 402);
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(303),
+ eventProjectionWait
+ );
+
+ await refreshExactDetail(transport, queryClient, 602);
+ expect(eventCursors(transport)).not.toContain(503);
+ act(() => transport.resolveDeferredGap());
+
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(503),
+ eventProjectionWait
+ );
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 403: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ await waitFor(
+ () =>
+ expect(
+ screen.getAllByRole("article", {
+ name: /^Event \d+: stdout$/u,
+ })
+ ).toHaveLength(600),
+ eventProjectionWait
+ );
+ expect(eventCursors(transport)).toEqual([
+ undefined,
+ 103,
+ undefined,
+ 303,
+ undefined,
+ 503,
+ ]);
+ } finally {
+ transport.resolveDeferredGap();
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("restarts an aborted gap repair after authenticated activity resumes", async () => {
+ const transport = new EventGapTransport();
+ transport.deferFirstGap();
+ const { queryClient, tree, view } = createJobBrowserHarness(transport);
+
+ try {
+ await loadInitialEventHistory();
+ await refreshExactDetail(transport, queryClient, 402);
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(303),
+ eventProjectionWait
+ );
+ const firstGapSignal = transport.calls.find(
+ ({ input, path }) =>
+ path === "jobs.getRun" &&
+ (input as GetJobRunInput).eventCursor?.sequence === 303
+ )?.signal;
+ if (firstGapSignal === undefined) {
+ throw new TypeError("Missing first event-gap signal");
+ }
+ await refreshExactDetail(transport, queryClient, 602);
+ expect(eventCursors(transport)).not.toContain(503);
+
+ view.rerender(tree("hidden"));
+ await waitFor(() => expect(firstGapSignal.aborted).toBeTrue());
+ view.rerender(tree("visible"));
+
+ await waitFor(
+ () =>
+ expect(
+ eventCursors(transport).filter((cursor) => cursor === 303)
+ ).toHaveLength(2),
+ eventProjectionWait
+ );
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 203: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 403: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(eventCursors(transport)).toContain(503);
+ } finally {
+ transport.resolveDeferredGap();
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("offers a dedicated retry after final-history gap repair fails", async () => {
+ const transport = new EventGapTransport();
+ transport.gap303FailuresRemaining = 1;
+ const { queryClient, view } = createJobBrowserHarness(transport);
+
+ try {
+ await loadInitialEventHistory();
+ await refreshExactDetail(transport, queryClient, 402);
+
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(303),
+ eventProjectionWait
+ );
+ const retry = await screen.findByRole(
+ "button",
+ { name: "Retry missing events" },
+ eventProjectionWait
+ );
+ await refreshExactDetail(transport, queryClient, 602);
+ expect(eventCursors(transport)).not.toContain(503);
+ expect(
+ screen.queryByRole("button", { name: "Load older events" })
+ ).toBeNull();
+ await userEvent.setup().click(retry);
+
+ await waitFor(
+ () =>
+ expect(
+ eventCursors(transport).filter((cursor) => cursor === 303)
+ ).toHaveLength(2),
+ eventProjectionWait
+ );
+ await waitFor(
+ () => expect(eventCursors(transport)).toContain(503),
+ eventProjectionWait
+ );
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 203: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole(
+ "article",
+ { name: "Event 403: stdout" },
+ eventProjectionWait
+ )
+ ).toBeTruthy();
+ expect(
+ eventCursors(transport).filter((cursor) => cursor === 303)
+ ).toHaveLength(2);
+ expect(eventCursors(transport)).toContain(503);
+ } finally {
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+});
diff --git a/greenfield/src/browser/jobs/JobRunBrowser.tsx b/greenfield/src/browser/jobs/JobRunBrowser.tsx
new file mode 100644
index 000000000..09117afdc
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunBrowser.tsx
@@ -0,0 +1,551 @@
+import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useNavigate, useSearch } from "@tanstack/react-router";
+import { Filter, RotateCcw } from "lucide-react";
+import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
+import * as v from "valibot";
+
+import {
+ jobResourceClasses,
+ type JobResourceClass,
+ type JobRunState,
+ jobRunStates,
+ type JobTriggerType,
+ jobTriggerTypes,
+ scheduleIdMaximumLength,
+ scheduleIdSchema,
+} from "../../contracts/jobModel.ts";
+import type { ListJobRunsInput } from "../../contracts/jobs.ts";
+import { useDashboardTrpcClient } from "../api/trpcContextValue.ts";
+import { Alert } from "../ui/Alert.tsx";
+import { Button } from "../ui/Button.tsx";
+import { ConfirmModal } from "../ui/ConfirmModal.tsx";
+import { FormField } from "../ui/FormField.tsx";
+import { Heading } from "../ui/Heading.tsx";
+import { Icon } from "../ui/Icon.tsx";
+import { Input } from "../ui/Input.tsx";
+import { PageState } from "../ui/PageState.tsx";
+import { Select, type SelectOption } from "../ui/Select.tsx";
+import { Text } from "../ui/Text.tsx";
+import { jobBrowserFailureMessage } from "./jobBrowserFailure.ts";
+import {
+ useCancelJobRunMutation,
+ useSetJobClaimingPausedMutation,
+} from "./jobMutations.ts";
+import {
+ jobRunDetailQueryOptions,
+ type JobRunEventGapRequest,
+ type JobRunEventGapResult,
+ jobRunEventGapQueryKey,
+ jobRunEventGapQueryOptions,
+ jobRunEventHistoryQueryKey,
+ jobRunEventHistoryQueryOptions,
+ jobRunListQueryOptions,
+ jobQueueSummaryQueryOptions,
+ uniqueJobRunEvents,
+ uniqueJobRows,
+} from "./jobQueries.ts";
+import { JobQueuePanel } from "./JobQueuePanel.tsx";
+import { parseJobsRouteSearch } from "./jobRouteSearch.ts";
+import { JobRunDetail } from "./JobRunDetail.tsx";
+import { jobRunStateLabel } from "./jobRunPresentation.ts";
+import { JobRunTable } from "./JobRunTable.tsx";
+
+type JobStateFilter = JobRunState | "all";
+type ResourceClassFilter = JobResourceClass | "all";
+type TriggerTypeFilter = JobTriggerType | "all";
+
+const jobStateOptions: readonly SelectOption[] = Object.freeze([
+ { label: "All states", value: "all" },
+ ...jobRunStates.map((state) => ({ label: jobRunStateLabel(state), value: state })),
+]);
+const resourceClassOptions: readonly SelectOption[] = Object.freeze([
+ { label: "All resources", value: "all" },
+ ...jobResourceClasses.map((resourceClass) => ({
+ label: resourceClass,
+ value: resourceClass,
+ })),
+]);
+const triggerTypeOptions: readonly SelectOption[] = Object.freeze([
+ { label: "All triggers", value: "all" },
+ ...jobTriggerTypes.map((triggerType) => ({
+ label: triggerType,
+ value: triggerType,
+ })),
+]);
+
+interface SelectedJobRunProps {
+ readonly focusRequested: boolean;
+ readonly id: string;
+ readonly onFocusHandled: (id: string) => void;
+}
+
+function eventGapIdentity(request: JobRunEventGapRequest): string {
+ return `${request.cursor.sequence}:${request.knownSequence}`;
+}
+
+function SelectedJobRun({ focusRequested, id, onFocusHandled }: SelectedJobRunProps) {
+ const client = useDashboardTrpcClient();
+ const queryClient = useQueryClient();
+ const detail = useQuery(jobRunDetailQueryOptions(client, id));
+ const [historyEnabled, setHistoryEnabled] = useState(
+ () => queryClient.getQueryData(jobRunEventHistoryQueryKey(id)) !== undefined
+ );
+ const firstEventCursor = detail.data?.nextEventCursor;
+ const history = useInfiniteQuery(
+ jobRunEventHistoryQueryOptions(client, id, firstEventCursor, historyEnabled)
+ );
+ const [eventGapRequests, setEventGapRequests] = useState<
+ readonly JobRunEventGapRequest[]
+ >([]);
+ const cachedEventGap = queryClient.getQueryData(
+ jobRunEventGapQueryKey(id)
+ );
+ const completedEventGapIndex = eventGapRequests.findIndex(
+ (request) =>
+ cachedEventGap !== undefined &&
+ eventGapIdentity(request) === eventGapIdentity(cachedEventGap.request)
+ );
+ const eventGapRequest = eventGapRequests.at(
+ completedEventGapIndex === -1 ? 0 : completedEventGapIndex + 1
+ );
+ const eventGap = useQuery(jobRunEventGapQueryOptions(client, id, eventGapRequest));
+ const refetchEventGap = eventGap.refetch;
+ const previousDetailEvents = useRef(detail.data?.events ?? []);
+ const [retiredDetailEvents, setRetiredDetailEvents] = useState(
+ detail.data?.events.slice(0, 0) ?? []
+ );
+ const cancellation = useCancelJobRunMutation();
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
+ const loadedEventGapRequest = eventGap.data?.request;
+
+ useEffect(() => {
+ if (eventGapRequest === undefined || eventGap.isFetching) return;
+ const identity = eventGapIdentity(eventGapRequest);
+ const loadedRequest = loadedEventGapRequest;
+ const loadedIdentity =
+ loadedRequest === undefined ? undefined : eventGapIdentity(loadedRequest);
+ if (loadedIdentity === identity) return;
+ if (eventGap.error !== null) return;
+ void refetchEventGap();
+ }, [
+ eventGap.error,
+ eventGap.isFetching,
+ eventGapRequest,
+ loadedEventGapRequest,
+ refetchEventGap,
+ ]);
+
+ useEffect(() => {
+ const currentEvents = detail.data?.events;
+ if (currentEvents === undefined) return;
+ if (historyEnabled) {
+ const currentSequences = new Set(
+ currentEvents.map(({ sequence }) => sequence)
+ );
+ const newlyRetired = previousDetailEvents.current.filter(
+ ({ sequence }) => !currentSequences.has(sequence)
+ );
+ if (newlyRetired.length > 0) {
+ setRetiredDetailEvents((events) =>
+ uniqueJobRunEvents([...events, ...newlyRetired]).toSorted(
+ (left, right) => right.sequence - left.sequence
+ )
+ );
+ }
+ const oldestCurrentSequence = currentEvents.at(-1)?.sequence;
+ if (oldestCurrentSequence !== undefined) {
+ const knownEvents = [
+ ...previousDetailEvents.current,
+ ...retiredDetailEvents,
+ ...(eventGap.data?.events ?? []),
+ ...(history.data?.pages.flatMap((page) => page.events) ?? []),
+ ];
+ let knownSequence: number | undefined;
+ for (const event of knownEvents) {
+ if (event.sequence >= oldestCurrentSequence) continue;
+ if (knownSequence === undefined || event.sequence > knownSequence) {
+ knownSequence = event.sequence;
+ }
+ }
+ if (
+ knownSequence !== undefined &&
+ oldestCurrentSequence > knownSequence + 1
+ ) {
+ const request = {
+ cursor: { sequence: oldestCurrentSequence },
+ knownSequence,
+ } satisfies JobRunEventGapRequest;
+ setEventGapRequests((current) => {
+ const identity = eventGapIdentity(request);
+ if (
+ current.some(
+ (candidate) => eventGapIdentity(candidate) === identity
+ )
+ ) {
+ return current;
+ }
+ return [...current, request];
+ });
+ }
+ }
+ }
+ previousDetailEvents.current = currentEvents;
+ }, [
+ detail.data?.events,
+ eventGap.data,
+ history.data?.pages,
+ historyEnabled,
+ retiredDetailEvents,
+ ]);
+
+ useEffect(() => {
+ if (!focusRequested || detail.data === undefined) return;
+ const heading = document.querySelector(`#job-run-${id}-heading`);
+ if (heading === null) return;
+ heading.focus();
+ onFocusHandled(id);
+ }, [detail.data, focusRequested, id, onFocusHandled]);
+
+ if (detail.isPending && detail.data === undefined) {
+ return ;
+ }
+ if (detail.data === undefined) {
+ return (
+ void detail.refetch()}
+ retryBusy={detail.isFetching}
+ status="error"
+ title="Job run unavailable"
+ />
+ );
+ }
+
+ const mutationError = cancellation.error;
+ const error = mutationError ?? detail.error;
+ const historyPages = history.data?.pages ?? [];
+ const events = uniqueJobRunEvents([
+ ...detail.data.events,
+ ...retiredDetailEvents,
+ ...(eventGap.data?.events ?? []),
+ ...historyPages.flatMap((page) => page.events),
+ ]).toSorted((left, right) => right.sequence - left.sequence);
+ const nextEventCursor = historyEnabled
+ ? (historyPages.at(-1)?.nextEventCursor ??
+ (history.data === undefined ? firstEventCursor : undefined))
+ : detail.data.nextEventCursor;
+ return (
+
+
+
setConfirmingCancel(true)}
+ />
+
+ {eventGap.error !== null && (
+
+ )}
+ {nextEventCursor !== undefined && (
+
+ )}
+ setConfirmingCancel(false)}
+ onConfirm={() =>
+ cancellation.mutate(
+ { id },
+ { onSettled: () => setConfirmingCancel(false) }
+ )
+ }
+ open={confirmingCancel}
+ title="Cancel job run"
+ />
+
+ );
+}
+
+interface JobRunBrowserProps {
+ readonly focusRunId?: string;
+ readonly onRequestRunFocus: (id: string) => void;
+ readonly onRunFocusHandled: (id: string) => void;
+}
+
+/** @returns Queue state, filterable global history, and one exact durable run. */
+export function JobRunBrowser({
+ focusRunId,
+ onRequestRunFocus,
+ onRunFocusHandled,
+}: JobRunBrowserProps) {
+ const client = useDashboardTrpcClient();
+ const navigate = useNavigate({ from: "/jobs" });
+ const search = parseJobsRouteSearch(useSearch({ from: "/jobs" }) as unknown);
+ const [stateDraft, setStateDraft] = useState("all");
+ const [stateFilter, setStateFilter] = useState("all");
+ const [resourceClassDraft, setResourceClassDraft] =
+ useState("all");
+ const [resourceClassFilter, setResourceClassFilter] =
+ useState("all");
+ const [triggerTypeDraft, setTriggerTypeDraft] = useState("all");
+ const [triggerTypeFilter, setTriggerTypeFilter] = useState("all");
+ const [scheduleDraft, setScheduleDraft] = useState("");
+ const [scheduleFilter, setScheduleFilter] = useState();
+ const [scheduleFilterError, setScheduleFilterError] = useState();
+ const scheduleFilterInputRef = useRef(null);
+ const filters: ListJobRunsInput["filters"] = (() => {
+ if (
+ stateFilter === "all" &&
+ resourceClassFilter === "all" &&
+ triggerTypeFilter === "all" &&
+ scheduleFilter === undefined
+ ) {
+ return;
+ }
+ return {
+ ...(resourceClassFilter === "all"
+ ? {}
+ : { resourceClasses: [resourceClassFilter] }),
+ ...(scheduleFilter === undefined ? {} : { scheduleId: scheduleFilter }),
+ ...(stateFilter === "all" ? {} : { states: [stateFilter] }),
+ ...(triggerTypeFilter === "all" ? {} : { triggerTypes: [triggerTypeFilter] }),
+ };
+ })();
+ const query = useInfiniteQuery(jobRunListQueryOptions(client, filters));
+ const summaryQuery = useQuery(jobQueueSummaryQueryOptions(client));
+ const runs = uniqueJobRows(query.data?.pages.flatMap((page) => page.runs) ?? []);
+ const summary = summaryQuery.data ?? query.data?.pages[0]?.summary;
+ const claiming = useSetJobClaimingPausedMutation();
+ const selectRun = (runId: string | undefined) => {
+ if (runId !== undefined) onRequestRunFocus(runId);
+ void navigate({
+ replace: true,
+ search: {
+ ...(search.scheduleId === undefined
+ ? {}
+ : { scheduleId: search.scheduleId }),
+ ...(runId === undefined ? {} : { runId }),
+ },
+ });
+ };
+ const applyFilters = (event: FormEvent) => {
+ event.preventDefault();
+ const candidate = scheduleDraft.trim();
+ let nextScheduleFilter: string | undefined;
+ if (candidate.length > 0) {
+ const parsed = v.safeParse(scheduleIdSchema, candidate);
+ if (!parsed.success) {
+ setScheduleFilterError("Use a canonical Dashboard schedule id.");
+ setTimeout(() => scheduleFilterInputRef.current?.focus(), 0);
+ return;
+ }
+ nextScheduleFilter = parsed.output;
+ }
+ setStateFilter(stateDraft);
+ setResourceClassFilter(resourceClassDraft);
+ setTriggerTypeFilter(triggerTypeDraft);
+ setScheduleFilter(nextScheduleFilter);
+ setScheduleFilterError(undefined);
+ };
+ const resetFilters = () => {
+ setStateDraft("all");
+ setStateFilter("all");
+ setResourceClassDraft("all");
+ setResourceClassFilter("all");
+ setTriggerTypeDraft("all");
+ setTriggerTypeFilter("all");
+ setScheduleDraft("");
+ setScheduleFilter(undefined);
+ setScheduleFilterError(undefined);
+ };
+ let runListContent: ReactNode;
+ if (query.isPending && query.data === undefined) {
+ runListContent = ;
+ } else if (query.data === undefined) {
+ runListContent = (
+ void query.refetch()}
+ retryBusy={query.isFetching}
+ status="error"
+ title="Job history unavailable"
+ />
+ );
+ } else {
+ runListContent = (
+ <>
+
+ {query.hasNextPage && (
+
+ )}
+ >
+ );
+ }
+ let backgroundError: unknown;
+ if (query.data !== undefined) {
+ backgroundError = query.error ?? summaryQuery.error;
+ } else if (summaryQuery.data !== undefined) {
+ backgroundError = summaryQuery.error;
+ }
+
+ return (
+
+
+
+ Queue and run history
+
+
+ Inspect durable execution, worker capacity, bounded output, and
+ cancellation state.
+
+
+
+ {summary !== undefined && (
+
+ claiming.mutate({
+ expectedVersion: summary.control.version,
+ paused,
+ })
+ }
+ summary={summary}
+ />
+ )}
+
+
+ {runListContent}
+
+ {search.runId === undefined ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/JobRunDetail.test.tsx b/greenfield/src/browser/jobs/JobRunDetail.test.tsx
new file mode 100644
index 000000000..8e8a505ea
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunDetail.test.tsx
@@ -0,0 +1,273 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import {
+ jobRunResultMaximumBytes,
+ type JobRunSummary,
+} from "../../contracts/jobModel.ts";
+import type { JobRunDetail as JobRunDetailData } from "../../contracts/jobs.ts";
+import { JobRunDetail } from "./JobRunDetail.tsx";
+
+const { render, screen, waitFor } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+const timestampMs = 1_800_000_000_000;
+const runId = "019fdd00-0000-7000-8000-000000000001";
+
+function runningRun(overrides: Partial = {}): JobRunSummary {
+ return {
+ actionKey: "maintenance.rotate-logs",
+ attemptCount: 1,
+ attemptLimit: 3,
+ availableAtMs: timestampMs,
+ cancellationPolicy: "cooperative",
+ displayName: "Rotate durable logs",
+ eventCount: 3,
+ firstStartedAtMs: timestampMs + 1000,
+ id: runId,
+ lastAttemptStartedAtMs: timestampMs + 1000,
+ priority: 10,
+ queuedAtMs: timestampMs,
+ resourceClass: "host-heavy",
+ resourceKeys: ["host.logs"],
+ retrySafe: true,
+ state: "running",
+ stateVersion: 2,
+ timeoutMs: 3_600_000,
+ triggerType: "system",
+ updatedAtMs: timestampMs + 3000,
+ ...overrides,
+ };
+}
+
+function runningDetail(overrides: Partial = {}): JobRunDetailData {
+ return {
+ events: [
+ {
+ attempt: 1,
+ kind: "progress",
+ occurredAtMs: timestampMs + 3000,
+ progress: { completed: 3, label: "not markup" },
+ sequence: 3,
+ workerInstanceId: "019fdd00-0000-7000-8000-000000000002",
+ },
+ {
+ attempt: 1,
+ kind: "stdout",
+ message: "\nrotation started",
+ occurredAtMs: timestampMs + 2000,
+ sequence: 2,
+ },
+ ],
+ nextEventCursor: { sequence: 2 },
+ run: runningRun(overrides),
+ };
+}
+
+describe("job run detail", () => {
+ test("renders safe bounded event data and dispatches cooperative cancellation", async () => {
+ const onCancel = jest.fn();
+ render(
+
+ );
+ const user = userEvent.setup();
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Rotate durable logs" })
+ ).toBeTruthy();
+ expect(
+ screen.getByText("", { exact: false })
+ ).toBeTruthy();
+ expect(
+ screen.getByText("not markup", { exact: false })
+ ).toBeTruthy();
+ expect(document.querySelector("script")).toBeNull();
+ expect(document.querySelector("strong")).toBeNull();
+ const outputRegion = screen.getByRole("region", {
+ name: "stdout output, event 2, attempt 1",
+ });
+ const progressRegion = screen.getByRole("region", {
+ name: "Progress data, event 3, attempt 1",
+ });
+ expect(outputRegion).toHaveAttribute("tabindex", "0");
+ expect(progressRegion).toHaveAttribute("tabindex", "0");
+ outputRegion.focus();
+ expect(outputRegion).toHaveFocus();
+ progressRegion.focus();
+ expect(progressRegion).toHaveFocus();
+ expect(
+ screen.getByText("Additional durable events", { exact: false })
+ ).toBeTruthy();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Request cancellation: Rotate durable logs",
+ })
+ );
+ expect(onCancel).toHaveBeenCalledWith(runId);
+ });
+
+ test("disables the cancellation control while the request is pending", () => {
+ render( {}} />);
+
+ const busyControl = screen.getByRole("button", {
+ name: "Request cancellation: Rotate durable logs",
+ });
+ expect(busyControl).toBeDisabled();
+ expect(busyControl).toHaveAttribute("aria-busy", "true");
+ expect(busyControl).toHaveTextContent("Requesting cancellation…");
+ });
+
+ test("keeps unsupported or already-requested cancellation unavailable", () => {
+ const onCancel = jest.fn();
+ const view = render(
+
+ );
+
+ expect(
+ screen.getByRole("button", {
+ name: "Cancellation unavailable: Rotate durable logs",
+ })
+ ).toBeDisabled();
+
+ view.rerender(
+
+ );
+ expect(
+ screen.getByRole("button", {
+ name: "Cancellation requested: Rotate durable logs",
+ })
+ ).toBeDisabled();
+ expect(
+ screen.getByText("Cancellation requested at", { exact: false })
+ ).toBeTruthy();
+ });
+
+ test("announces live state atomically and restores heading focus after cancellation", async () => {
+ const view = render(
+ {}} />
+ );
+
+ const runningStatus = screen.getByRole("status", { name: "Run state" });
+ expect(runningStatus).toHaveAttribute("aria-atomic", "true");
+ expect(runningStatus).toHaveAttribute("aria-live", "polite");
+ expect(runningStatus).toHaveTextContent("running");
+
+ view.rerender(
+ {}}
+ />
+ );
+
+ expect(screen.getByRole("status", { name: "Run state" })).toHaveTextContent(
+ "cancelled"
+ );
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Rotate durable logs",
+ })
+ ).toHaveFocus()
+ );
+ });
+
+ test("restores heading focus when a running cancellation request is accepted", async () => {
+ const view = render(
+ {}} />
+ );
+ screen
+ .getByRole("button", {
+ name: "Request cancellation: Rotate durable logs",
+ })
+ .focus();
+
+ view.rerender(
+ {}}
+ />
+ );
+
+ expect(
+ screen.getByRole("button", {
+ name: "Cancellation requested: Rotate durable logs",
+ })
+ ).toBeDisabled();
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Rotate durable logs",
+ })
+ ).toHaveFocus()
+ );
+ });
+
+ test("clamps pretty-printed result text and never creates markup from JSON", () => {
+ const succeededRun = runningRun({
+ eventCount: 1,
+ finishedAtMs: timestampMs + 4000,
+ state: "succeeded",
+ updatedAtMs: timestampMs + 4000,
+ });
+ render(
+ ${"x".repeat(jobRunResultMaximumBytes)}`,
+ },
+ run: succeededRun,
+ }}
+ onCancel={() => {}}
+ />
+ );
+
+ expect(screen.getByText("display truncated", { exact: false })).toBeTruthy();
+ expect(
+ screen.getByText("
", { exact: false })
+ ).toBeTruthy();
+ expect(document.querySelector("img")).toBeNull();
+ const resultRegion = screen.getByRole("region", {
+ name: `Result for job run ${runId}`,
+ });
+ expect(resultRegion).toHaveAttribute("tabindex", "0");
+ resultRegion.focus();
+ expect(resultRegion).toHaveFocus();
+ expect(screen.queryByRole("button", { name: /cancell/iu })).toBeNull();
+ });
+});
diff --git a/greenfield/src/browser/jobs/JobRunDetail.tsx b/greenfield/src/browser/jobs/JobRunDetail.tsx
new file mode 100644
index 000000000..bc8ed191f
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunDetail.tsx
@@ -0,0 +1,463 @@
+import { type ReactNode, useEffect, useRef } from "react";
+
+import {
+ jobRunEventMessageMaximumLength,
+ jobRunEventProgressMaximumBytes,
+ jobRunResultMaximumBytes,
+ type JobRunEvent,
+ type JobRunSummary,
+} from "../../contracts/jobModel.ts";
+import { type JobRunDetail as JobRunDetailData } from "../../contracts/jobs.ts";
+import type { JsonObject } from "../../shared/json.ts";
+import { formatDashboardDateTime } from "../lib/formatDateTime.ts";
+import { Badge } from "../ui/Badge.tsx";
+import { Button } from "../ui/Button.tsx";
+import { Card } from "../ui/Card.tsx";
+import { Heading } from "../ui/Heading.tsx";
+import { Text } from "../ui/Text.tsx";
+import { jobRunStateBadgeVariant, jobRunStateLabel } from "./jobRunPresentation.ts";
+
+/* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- Safari requires explicit tab stops for keyboard access to bounded scroll regions. */
+
+const browserTruncationSuffix = "\n… [display truncated]";
+
+function boundedDisplayText(value: string, maximumCharacters: number): string {
+ if (value.length <= maximumCharacters) return value;
+ return `${value.slice(
+ 0,
+ Math.max(0, maximumCharacters - browserTruncationSuffix.length)
+ )}${browserTruncationSuffix}`;
+}
+
+/**
+ * Pretty printing adds whitespace, so clamp the browser text after serialization.
+ * @returns Safe JSON text bounded for one browser rendering surface.
+ */
+function boundedJsonText(value: JsonObject, maximumCharacters: number): string {
+ return boundedDisplayText(JSON.stringify(value, undefined, 2), maximumCharacters);
+}
+
+function jobRunEventBadgeVariant(
+ kind: JobRunEvent["kind"]
+): "danger" | "default" | "info" | "success" | "warning" {
+ switch (kind) {
+ case "failed":
+ case "stderr":
+ case "timed-out": {
+ return "danger";
+ }
+ case "progress":
+ case "stdout": {
+ return "info";
+ }
+ case "succeeded": {
+ return "success";
+ }
+ case "cancel-requested":
+ case "cancelled":
+ case "lease-expired":
+ case "retry-scheduled": {
+ return "warning";
+ }
+ case "claimed":
+ case "output-truncated":
+ case "queued": {
+ return "default";
+ }
+ }
+}
+
+interface CancellationPresentation {
+ readonly busyLabel: string;
+ readonly enabled: boolean;
+ readonly label: string;
+}
+
+function cancellationPresentation(
+ run: JobRunSummary
+): CancellationPresentation | undefined {
+ if (run.state !== "queued" && run.state !== "running") return undefined;
+ if (run.cancelRequestedAtMs !== undefined) {
+ return {
+ busyLabel: "Requesting cancellation…",
+ enabled: false,
+ label: "Cancellation requested",
+ };
+ }
+ if (run.state === "queued" && run.cancellationPolicy !== "never") {
+ return {
+ busyLabel: "Cancelling run…",
+ enabled: true,
+ label: "Cancel queued run",
+ };
+ }
+ if (run.state === "running" && run.cancellationPolicy === "cooperative") {
+ return {
+ busyLabel: "Requesting cancellation…",
+ enabled: true,
+ label: "Request cancellation",
+ };
+ }
+ return {
+ busyLabel: "Requesting cancellation…",
+ enabled: false,
+ label: "Cancellation unavailable",
+ };
+}
+
+function formatDuration(durationMs: number): string {
+ if (durationMs % 3_600_000 === 0) return `${durationMs / 3_600_000} h`;
+ if (durationMs % 60_000 === 0) return `${durationMs / 60_000} min`;
+ if (durationMs % 1000 === 0) return `${durationMs / 1000} s`;
+ return `${durationMs} ms`;
+}
+
+interface DetailValueProps {
+ readonly children: ReactNode;
+ readonly label: string;
+}
+
+function DetailValue({ children, label }: DetailValueProps) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+interface TimestampValueProps {
+ readonly timestampMs: number | undefined;
+}
+
+function TimestampValue({ timestampMs }: TimestampValueProps) {
+ if (timestampMs === undefined) {
+ return Not yet;
+ }
+ return (
+
+ );
+}
+
+interface JobRunEventItemProps {
+ readonly event: JobRunEvent;
+}
+
+function JobRunEventItem({ event }: JobRunEventItemProps) {
+ const eventLabel = event.kind.replaceAll("-", " ");
+ const attemptLabel =
+ event.attempt === 0 ? "before first attempt" : `attempt ${event.attempt}`;
+ const message =
+ event.message === undefined
+ ? undefined
+ : boundedDisplayText(event.message, jobRunEventMessageMaximumLength);
+ const outputEvent = event.kind === "stderr" || event.kind === "stdout";
+
+ return (
+
+
+
+
+
+ {eventLabel}
+
+
+ Event {event.sequence} ·{" "}
+ {event.attempt === 0
+ ? "Before first attempt"
+ : `Attempt ${event.attempt}`}
+
+
+
+
+
+ {message !== undefined &&
+ (outputEvent ? (
+
+ ) : (
+
+ {message}
+
+ ))}
+ {event.progress !== undefined && (
+
+
+ {boundedJsonText(
+ event.progress,
+ jobRunEventProgressMaximumBytes
+ )}
+
+
+ )}
+ {event.workerInstanceId !== undefined && (
+
+ Worker {event.workerInstanceId}
+
+ )}
+
+
+ );
+}
+
+export interface JobRunDetailProps {
+ readonly cancelBusy: boolean;
+ readonly cancelDisabled?: boolean;
+ readonly detail: JobRunDetailData;
+ readonly onCancel: (id: string) => void;
+}
+
+/** @returns Safe, bounded durable run metadata, result, and newest-first event history. */
+export function JobRunDetail({
+ cancelBusy,
+ cancelDisabled = false,
+ detail,
+ onCancel,
+}: JobRunDetailProps) {
+ const { run } = detail;
+ const cancellation = cancellationPresentation(run);
+ const events = detail.events;
+ const additionalEventsAvailable =
+ detail.nextEventCursor !== undefined || run.eventCount > events.length;
+ const headingId = `job-run-${run.id}-heading`;
+ const cancellationActionable = cancellation?.enabled === true;
+ const previousCancellationAction = useRef({
+ actionable: cancellationActionable,
+ runId: run.id,
+ });
+ const restoreFocusAfterCancellation = useRef(false);
+
+ useEffect(() => {
+ const previous = previousCancellationAction.current;
+ if (previous.runId !== run.id) {
+ restoreFocusAfterCancellation.current = false;
+ } else if (previous.actionable && !cancellationActionable) {
+ restoreFocusAfterCancellation.current = true;
+ }
+ previousCancellationAction.current = {
+ actionable: cancellationActionable,
+ runId: run.id,
+ };
+ if (
+ cancelBusy ||
+ cancellationActionable ||
+ !restoreFocusAfterCancellation.current
+ ) {
+ return;
+ }
+ restoreFocusAfterCancellation.current = false;
+ document.querySelector(`#${headingId}`)?.focus();
+ }, [cancelBusy, cancellationActionable, headingId, run.id]);
+
+ return (
+
+
+
+
+
+ {run.displayName}
+
+
+
+
+ {run.actionKey}
+
+
+ {cancellation !== undefined && (
+
+ )}
+
+
+ {run.cancelRequestedAtMs !== undefined && (
+
+ )}
+
+
+
+ {run.id}
+
+
+ {run.triggerType}
+ {run.scheduledJobId === undefined ? null : (
+
+ {run.scheduledJobId} · version {run.scheduledJobVersion}
+
+ )}
+
+
+ {run.resourceClass} · priority{" "}
+ {run.priority}
+
+
+ {run.attemptCount} of {run.attemptLimit}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatDuration(run.timeoutMs)}
+
+ {run.cancellationPolicy}
+
+
+ {run.retrySafe ? "Yes" : "No"}
+
+ {run.stateVersion}
+
+
+ {run.resourceKeys.length > 0 && (
+
+
+ Resource keys
+
+
+ {run.resourceKeys.map((resourceKey) => (
+ -
+
+
{resourceKey}
+
+
+ ))}
+
+
+ )}
+
+ {run.terminalCode !== undefined && run.terminalMessage !== undefined && (
+
+
+ Terminal status
+
+
+ {run.terminalCode}
+
+
+ {run.terminalMessage}
+
+
+ )}
+
+ {detail.result !== undefined && (
+
+
+ Result
+
+
+
+ {boundedJsonText(detail.result, jobRunResultMaximumBytes)}
+
+
+
+ )}
+
+
+
+
+ Durable events
+
+
+ Newest first · {run.eventCount} total
+
+
+ {events.length === 0 ? (
+
+ ) : (
+
+ {events.map((event) => (
+
+ ))}
+
+ )}
+ {additionalEventsAvailable && (
+
+ )}
+
+
+ );
+}
+
+/* oxlint-enable jsx-a11y/no-noninteractive-tabindex */
diff --git a/greenfield/src/browser/jobs/JobRunTable.test.tsx b/greenfield/src/browser/jobs/JobRunTable.test.tsx
new file mode 100644
index 000000000..dd35ce247
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunTable.test.tsx
@@ -0,0 +1,122 @@
+import { afterAll, beforeAll, describe, expect, jest, test } from "bun:test";
+
+import type { JobRunSummary } from "../../contracts/jobModel.ts";
+import { JobRunTable } from "./JobRunTable.tsx";
+
+const { render, screen } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+const originalOffsetHeight = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetHeight"
+);
+const originalOffsetWidth = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetWidth"
+);
+const hadOwnResizeObserver = Object.hasOwn(globalThis, "ResizeObserver");
+const originalResizeObserver = Reflect.get(globalThis, "ResizeObserver");
+
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
+ configurable: true,
+ get: () => 480,
+ });
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+ configurable: true,
+ get: () => 960,
+ });
+ Reflect.set(globalThis, "ResizeObserver", undefined);
+});
+
+afterAll(() => {
+ if (originalOffsetHeight === undefined) {
+ Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
+ } else {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "offsetHeight",
+ originalOffsetHeight
+ );
+ }
+ if (originalOffsetWidth === undefined) {
+ Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth");
+ } else {
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth);
+ }
+ if (hadOwnResizeObserver) {
+ Reflect.set(globalThis, "ResizeObserver", originalResizeObserver);
+ } else {
+ Reflect.deleteProperty(globalThis, "ResizeObserver");
+ }
+});
+
+const timestampMs = 1_800_000_000_000;
+
+function jobRun(index: number): JobRunSummary {
+ const queuedAtMs = timestampMs - index * 1000;
+ return {
+ actionKey: `maintenance.job-${index}`,
+ attemptCount: 0,
+ attemptLimit: 3,
+ availableAtMs: queuedAtMs,
+ cancellationPolicy: "queued-only",
+ displayName: `Durable job ${index}`,
+ eventCount: 1,
+ id: `019fdd00-0000-7000-8000-${index.toString().padStart(12, "0")}`,
+ priority: index % 3,
+ queuedAtMs,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 60_000,
+ triggerType: "system",
+ updatedAtMs: queuedAtMs,
+ };
+}
+
+describe("job run table", () => {
+ test("renders a clear empty state", () => {
+ render( {}} runs={[]} />);
+
+ expect(screen.getByRole("heading", { name: "No job runs" })).toBeTruthy();
+ expect(screen.queryByRole("table", { name: "Durable job runs" })).toBeNull();
+ });
+
+ test("selects a labelled run and virtualizes a maximum-sized page", async () => {
+ const runs = Array.from({ length: 50 }, (_, index) => jobRun(index));
+ runs[1] = {
+ ...runs[1]!,
+ actionKey: runs[0]!.actionKey,
+ displayName: runs[0]!.displayName,
+ };
+ const onSelect = jest.fn();
+ const view = render(
+
+ );
+ const user = userEvent.setup();
+
+ const firstRun = screen.getByRole("button", {
+ name: `Open run Durable job 0; action maintenance.job-0; id ${runs[0]!.id}`,
+ });
+ expect(screen.getByRole("table", { name: "Durable job runs" })).toBeTruthy();
+ expect(
+ screen.getByRole("button", {
+ name: `Open run Durable job 0; action maintenance.job-0; id ${runs[1].id}`,
+ })
+ ).toHaveAttribute("aria-current", "true");
+ expect(screen.queryByText("Durable job 49")).toBeNull();
+ expect(
+ screen
+ .getByRole("table", { name: "Durable job runs" })
+ .querySelector("td[height]")
+ ).toBeTruthy();
+
+ await user.click(firstRun);
+ expect(onSelect).toHaveBeenCalledWith(runs[0]!.id);
+ view.unmount();
+ });
+});
diff --git a/greenfield/src/browser/jobs/JobRunTable.tsx b/greenfield/src/browser/jobs/JobRunTable.tsx
new file mode 100644
index 000000000..f3cf9f00e
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobRunTable.tsx
@@ -0,0 +1,173 @@
+import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table";
+import { History } from "lucide-react";
+
+import type { JobRunSummary } from "../../contracts/jobModel.ts";
+import { cn } from "../lib/classNames.ts";
+import { formatDashboardDateTime } from "../lib/formatDateTime.ts";
+import { Badge } from "../ui/Badge.tsx";
+import { DataTable } from "../ui/DataTable.tsx";
+import { EmptyState } from "../ui/EmptyState.tsx";
+import { Text } from "../ui/Text.tsx";
+import { Virtualizer, type VirtualizerRenderState } from "../ui/Virtualizer.tsx";
+import { jobRunStateBadgeVariant, jobRunStateLabel } from "./jobRunPresentation.ts";
+
+const minimumVirtualizedRows = 50;
+const jobRunTableFeatures = tableFeatures({});
+
+interface JobRunTableRow {
+ readonly onSelect: (id: string) => void;
+ readonly run: JobRunSummary;
+ readonly selected: boolean;
+}
+
+const jobRunColumnHelper = createColumnHelper<
+ typeof jobRunTableFeatures,
+ JobRunTableRow
+>();
+
+const jobRunColumns = jobRunColumnHelper.columns([
+ jobRunColumnHelper.accessor((row) => row.run.displayName, {
+ cell: ({ getValue, row }) => (
+
+ ),
+ header: "Job run",
+ id: "displayName",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.state, {
+ cell: ({ getValue }) => (
+
+ {jobRunStateLabel(getValue())}
+
+ ),
+ header: "State",
+ id: "state",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.triggerType, {
+ cell: ({ getValue, row }) => (
+
+
+ {getValue()}
+
+ {row.original.run.scheduledJobId !== undefined && (
+
+ {row.original.run.scheduledJobId}
+
+ )}
+
+ ),
+ header: "Trigger",
+ id: "triggerType",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.resourceClass, {
+ cell: ({ getValue, row }) => (
+
+
+ {getValue()}
+
+
+ Priority {row.original.run.priority}
+
+
+ ),
+ header: "Resource",
+ id: "resourceClass",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.attemptCount, {
+ cell: ({ getValue, row }) => (
+
+ {getValue()} / {row.original.run.attemptLimit}
+
+ ),
+ header: "Attempts",
+ id: "attemptCount",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.queuedAtMs, {
+ cell: ({ getValue }) => (
+
+ ),
+ header: "Queued",
+ id: "queuedAtMs",
+ }),
+ jobRunColumnHelper.accessor((row) => row.run.updatedAtMs, {
+ cell: ({ getValue }) => (
+
+ ),
+ header: "Updated",
+ id: "updatedAtMs",
+ }),
+]);
+
+export interface JobRunTableProps {
+ readonly onSelect: (id: string) => void;
+ readonly runs: readonly JobRunSummary[];
+ readonly selectedId?: string;
+}
+
+/** @returns Selectable durable run inventory with bounded virtual rendering. */
+export function JobRunTable({ onSelect, runs, selectedId }: JobRunTableProps) {
+ const table = useTable({
+ columns: jobRunColumns,
+ data: runs.map((run) => ({
+ onSelect,
+ run,
+ selected: run.id === selectedId,
+ })),
+ features: jobRunTableFeatures,
+ getRowId: ({ run }) => run.id,
+ });
+ const rows = table.getRowModel().rows;
+
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
+ const tableElement = (rowWindow?: VirtualizerRenderState) => (
+
+ );
+
+ if (rows.length < minimumVirtualizedRows) return tableElement();
+ return (
+
+ count={rows.length}
+ estimateSize={() => 76}
+ getItemKey={(index) => rows[index]?.id ?? `missing-job-run-${index}`}
+ >
+ {(virtualization) => tableElement(virtualization)}
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/JobsRoute.test.tsx b/greenfield/src/browser/jobs/JobsRoute.test.tsx
new file mode 100644
index 000000000..aa8a24c13
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobsRoute.test.tsx
@@ -0,0 +1,1420 @@
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { createMemoryHistory } from "@tanstack/react-router";
+import { act } from "react";
+
+import type { AuthStatus } from "../../contracts/auth.ts";
+import type { RealtimeStreamOutput } from "../../contracts/events.ts";
+import type {
+ JobRunSummary,
+ JobWorkerControl,
+ ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import { jobRealtimeTopics } from "../../contracts/jobRealtime.ts";
+import type {
+ CancelJobRunInput,
+ GetJobRunInput,
+ JobRunDetail,
+ JobQueueSummary,
+ ListJobRunsInput,
+ SetJobClaimingPausedInput,
+} from "../../contracts/jobs.ts";
+import type {
+ ListScheduleRunsInput,
+ ListSchedulesInput,
+ RunScheduleInput,
+ UpdateScheduleInput,
+} from "../../contracts/schedules.ts";
+import { createDashboardQueryClient } from "../api/queryClient.ts";
+import type { DashboardRealtimeClient } from "../api/realtimeClient.ts";
+import {
+ createDashboardTrpcClient,
+ type DashboardTrpcTransport,
+} from "../api/trpcClient.ts";
+import { DashboardBrowserApplication } from "../application.tsx";
+import {
+ createDashboardBrowserCollections,
+ type DashboardBrowserCollections,
+} from "../data/dashboardCollections.ts";
+import { createDashboardRouter } from "../router.tsx";
+import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts";
+import { emptyNotificationListResult } from "../test/notifications.ts";
+import {
+ ControlledDashboardRealtimeClient,
+ noOpDashboardRealtimeClient,
+} from "../test/realtime.ts";
+
+const { fireEvent, render, screen, waitFor, within } =
+ await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+const scheduleId = "system.worker-smoke";
+const runId = "019fdf70-0000-7000-8000-000000000002";
+const olderRunId = "019fdf60-0000-7000-8000-000000000001";
+const manualRunId = "019fdf80-0000-7000-8000-000000000003";
+const timestampMs = 1_800_000_000_000;
+
+function authenticatedStatus(): Extract {
+ return {
+ session: {
+ authenticatedAtMs: timestampMs,
+ authMethod: "password",
+ createdAtMs: timestampMs,
+ expiresAtMs: timestampMs + 86_400_000,
+ id: "a".repeat(32),
+ isCurrent: true,
+ lastSeenAtMs: timestampMs,
+ userAgent: "Jobs route test",
+ },
+ state: "authenticated",
+ user: {
+ id: "019fd974-54a2-74dd-a64b-d4186f8d8828",
+ username: "operator",
+ },
+ };
+}
+
+interface QueuedRunOptions {
+ readonly displayName: string;
+ readonly id: string;
+ readonly queuedAtMs?: number;
+ readonly scheduledJobId?: string;
+}
+
+function queuedRun({
+ displayName,
+ id,
+ queuedAtMs = timestampMs,
+ scheduledJobId,
+}: QueuedRunOptions): JobRunSummary {
+ return {
+ actionKey: scheduleId,
+ attemptCount: 0,
+ attemptLimit: 3,
+ availableAtMs: queuedAtMs,
+ cancellationPolicy: "cooperative",
+ displayName,
+ eventCount: 1,
+ id,
+ priority: 0,
+ queuedAtMs,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ ...(scheduledJobId === undefined
+ ? { triggerType: "system" as const }
+ : {
+ scheduledJobId,
+ scheduledJobVersion: 1,
+ triggerType: "manual" as const,
+ }),
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 60_000,
+ updatedAtMs: queuedAtMs,
+ };
+}
+
+function runDetail(run: JobRunSummary): JobRunDetail {
+ return {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: run.queuedAtMs,
+ sequence: 1,
+ },
+ ],
+ run,
+ };
+}
+
+function scheduleSummary(
+ id = scheduleId,
+ overrides: Partial = {}
+): ScheduleSummary {
+ return {
+ actionKey: scheduleId,
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the durable worker without host mutation.",
+ enabled: true,
+ id,
+ name: "Worker smoke",
+ nextRunAtMs: timestampMs + 60_000,
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 60_000,
+ updatedAtMs: timestampMs,
+ version: 1,
+ ...overrides,
+ };
+}
+
+function newestFirst(runs: readonly JobRunSummary[]): JobRunSummary[] {
+ return [...runs].toSorted((left, right) => {
+ if (left.queuedAtMs !== right.queuedAtMs) {
+ return left.queuedAtMs > right.queuedAtMs ? -1 : 1;
+ }
+ return right.id.localeCompare(left.id);
+ });
+}
+
+function queueSummary(
+ runs: readonly JobRunSummary[],
+ control: JobWorkerControl
+): JobQueueSummary {
+ const stateCounts: JobQueueSummary["stateCounts"] = {
+ cancelled: 0,
+ failed: 0,
+ queued: 0,
+ running: 0,
+ succeeded: 0,
+ "timed-out": 0,
+ };
+ for (const run of runs) stateCounts[run.state] += 1;
+ const queued = runs.filter((run) => run.state === "queued");
+ const activeResourceClasses = [
+ ...new Set(
+ runs.filter((run) => run.state === "running").map((run) => run.resourceClass)
+ ),
+ ].toSorted();
+ return {
+ activeResourceClasses,
+ control,
+ ...(queued.length === 0
+ ? {}
+ : {
+ oldestQueuedAtMs: Math.min(
+ ...queued.map(({ queuedAtMs }) => queuedAtMs)
+ ),
+ }),
+ stateCounts,
+ workers: [],
+ };
+}
+
+interface TransportCall {
+ readonly input: unknown;
+ readonly kind: "mutation" | "query";
+ readonly path: string;
+}
+
+class JobsRouteTransport implements DashboardTrpcTransport {
+ authStatus: AuthStatus = authenticatedStatus();
+ readonly calls: TransportCall[] = [];
+ control: JobWorkerControl = {
+ claimingPaused: false,
+ updatedAtMs: timestampMs,
+ version: 1,
+ };
+ failNextCommittedScheduleRunResponses = 0;
+ failJobList = false;
+ failScheduleList = false;
+ readonly failNextMutationCounts = new Map();
+ readonly failNextQueryCounts = new Map();
+ nextManualRunId = manualRunId;
+ readonly runEventDetails = new Map();
+ readonly runDetails = new Map();
+ runPages: readonly (readonly JobRunSummary[])[] | undefined;
+ runs: JobRunSummary[] = [];
+ readonly scheduleDetails = new Map();
+ scheduleRuns: JobRunSummary[] = [];
+ schedules: ScheduleSummary[] = [];
+
+ addRunDetail(run: JobRunSummary): void {
+ this.runDetails.set(run.id, runDetail(run));
+ }
+
+ addRunEventDetail(id: string, eventCursor: number, detail: JobRunDetail): void {
+ this.runEventDetails.set(`${id}:${eventCursor}`, detail);
+ }
+
+ addScheduleDetail(schedule: ScheduleSummary): void {
+ this.scheduleDetails.set(schedule.id, schedule);
+ }
+
+ callsFor(path: string): TransportCall[] {
+ return this.calls.filter((call) => call.path === path);
+ }
+
+ mutation(path: string, input?: unknown): Promise {
+ this.calls.push({ input, kind: "mutation", path });
+ const failuresRemaining = this.failNextMutationCounts.get(path) ?? 0;
+ if (failuresRemaining > 0) {
+ if (failuresRemaining === 1) this.failNextMutationCounts.delete(path);
+ else this.failNextMutationCounts.set(path, failuresRemaining - 1);
+ return Promise.reject(new TypeError(`${path} temporarily unavailable`));
+ }
+ switch (path) {
+ case "auth.touch": {
+ return Promise.resolve({ lastSeenAtMs: timestampMs });
+ }
+ case "jobs.cancelRun": {
+ const { id } = input as CancelJobRunInput;
+ const detail = this.runDetails.get(id);
+ if (detail === undefined) {
+ return Promise.reject(new TypeError("Unknown job run"));
+ }
+ const finishedAtMs = detail.run.updatedAtMs + 1000;
+ const cancelled: JobRunSummary = {
+ ...detail.run,
+ eventCount: detail.run.eventCount + 1,
+ finishedAtMs,
+ state: "cancelled",
+ stateVersion: detail.run.stateVersion + 1,
+ terminalCode: "operator-cancelled",
+ terminalMessage: "Cancelled by the operator.",
+ updatedAtMs: finishedAtMs,
+ };
+ this.runDetails.set(id, {
+ events: [
+ {
+ attempt: cancelled.attemptCount,
+ kind: "cancelled",
+ occurredAtMs: finishedAtMs,
+ sequence: cancelled.eventCount,
+ },
+ ...detail.events,
+ ],
+ run: cancelled,
+ });
+ this.runs = this.runs.map((run) => (run.id === id ? cancelled : run));
+ this.scheduleRuns = this.scheduleRuns.map((run) =>
+ run.id === id ? cancelled : run
+ );
+ return Promise.resolve(cancelled);
+ }
+ case "jobs.setClaimingPaused": {
+ const { paused } = input as SetJobClaimingPausedInput;
+ this.control = {
+ claimingPaused: paused,
+ updatedAtMs: this.control.updatedAtMs + 1000,
+ version: this.control.version + 1,
+ };
+ return Promise.resolve(this.control);
+ }
+ case "schedules.update": {
+ const update = input as UpdateScheduleInput;
+ const current = this.scheduleDetails.get(update.id);
+ if (current === undefined) {
+ return Promise.reject(new TypeError("Unknown schedule"));
+ }
+ let updated: ScheduleSummary = {
+ ...current,
+ ...(update.patch.schedule === undefined
+ ? {}
+ : { schedule: update.patch.schedule }),
+ updatedAtMs: current.updatedAtMs + 1000,
+ version: current.version + 1,
+ };
+ if (update.patch.enabled === false) {
+ const {
+ activeDisableIntent: _activeDisableIntent,
+ nextRunAtMs: _nextRunAtMs,
+ ...withoutEnabledState
+ } = updated;
+ const disableIntent = update.patch.disableIntent;
+ if (disableIntent === undefined || disableIntent === null) {
+ return Promise.reject(new TypeError("Missing disable intent"));
+ }
+ updated = {
+ ...withoutEnabledState,
+ activeDisableIntent: {
+ createdAtMs: updated.updatedAtMs,
+ id: "019fdf90-0000-7000-8000-000000000004",
+ reason: disableIntent.reason,
+ ...(disableIntent.expiresAtMs === undefined
+ ? {}
+ : { expiresAtMs: disableIntent.expiresAtMs }),
+ },
+ enabled: false,
+ };
+ } else if (update.patch.enabled === true) {
+ const {
+ activeDisableIntent: _activeDisableIntent,
+ ...withoutDisableIntent
+ } = updated;
+ updated = {
+ ...withoutDisableIntent,
+ enabled: true,
+ nextRunAtMs: updated.updatedAtMs + 60_000,
+ };
+ }
+ this.scheduleDetails.set(updated.id, updated);
+ this.schedules = this.schedules.map((schedule) =>
+ schedule.id === updated.id ? updated : schedule
+ );
+ return Promise.resolve(updated);
+ }
+ case "schedules.run": {
+ const { id } = input as RunScheduleInput;
+ const schedule = this.scheduleDetails.get(id);
+ if (schedule === undefined) {
+ return Promise.reject(new TypeError("Unknown schedule"));
+ }
+ const run = queuedRun({
+ displayName: `${schedule.name} manual run`,
+ id: this.nextManualRunId,
+ queuedAtMs: timestampMs + 20_000,
+ scheduledJobId: schedule.id,
+ });
+ this.addRunDetail(run);
+ this.runs = [
+ run,
+ ...this.runs.filter(({ id: runId }) => runId !== run.id),
+ ];
+ this.scheduleRuns = [
+ run,
+ ...this.scheduleRuns.filter(({ id: runId }) => runId !== run.id),
+ ];
+ const scheduleWithRun = {
+ ...schedule,
+ activeRun: run,
+ latestRun: run,
+ };
+ this.scheduleDetails.set(schedule.id, scheduleWithRun);
+ this.schedules = this.schedules.map((candidate) =>
+ candidate.id === schedule.id ? scheduleWithRun : candidate
+ );
+ if (this.failNextCommittedScheduleRunResponses > 0) {
+ this.failNextCommittedScheduleRunResponses -= 1;
+ return Promise.reject(
+ new TypeError("schedules.run response was lost after commit")
+ );
+ }
+ return Promise.resolve(run);
+ }
+ default: {
+ return Promise.reject(new TypeError(`Unexpected mutation: ${path}`));
+ }
+ }
+ }
+
+ query(path: string, input?: unknown): Promise {
+ this.calls.push({ input, kind: "query", path });
+ const failuresRemaining = this.failNextQueryCounts.get(path) ?? 0;
+ if (failuresRemaining > 0) {
+ if (failuresRemaining === 1) this.failNextQueryCounts.delete(path);
+ else this.failNextQueryCounts.set(path, failuresRemaining - 1);
+ return Promise.reject(new TypeError(`${path} temporarily unavailable`));
+ }
+ switch (path) {
+ case "auth.status": {
+ return Promise.resolve(this.authStatus);
+ }
+ case "notifications.list": {
+ return Promise.resolve(emptyNotificationListResult);
+ }
+ case "jobs.listRuns": {
+ if (this.failJobList) {
+ return Promise.reject(new TypeError("Job list unavailable"));
+ }
+ const request = input as ListJobRunsInput;
+ if (this.runPages !== undefined && request.filters === undefined) {
+ const pageIndex = request.cursor === undefined ? 0 : 1;
+ const runs = this.runPages[pageIndex] ?? [];
+ const last = runs.at(-1);
+ return Promise.resolve({
+ ...(pageIndex < this.runPages.length - 1 && last !== undefined
+ ? {
+ nextCursor: {
+ id: last.id,
+ queuedAtMs: last.queuedAtMs,
+ },
+ }
+ : {}),
+ runs,
+ summary: queueSummary(this.runs, this.control),
+ });
+ }
+ const runs = newestFirst(this.runs).filter((run) => {
+ const filters = request.filters;
+ return (
+ (filters?.resourceClasses === undefined ||
+ filters.resourceClasses.includes(run.resourceClass)) &&
+ (filters?.scheduleId === undefined ||
+ filters.scheduleId === run.scheduledJobId) &&
+ (filters?.states === undefined ||
+ filters.states.includes(run.state)) &&
+ (filters?.triggerTypes === undefined ||
+ filters.triggerTypes.includes(run.triggerType))
+ );
+ });
+ return Promise.resolve({
+ runs,
+ summary: queueSummary(this.runs, this.control),
+ });
+ }
+ case "jobs.getRun": {
+ const request = input as GetJobRunInput;
+ const detail =
+ request.eventCursor === undefined
+ ? this.runDetails.get(request.id)
+ : this.runEventDetails.get(
+ `${request.id}:${request.eventCursor.sequence}`
+ );
+ return detail === undefined
+ ? Promise.reject(new TypeError("Unknown job run"))
+ : Promise.resolve(detail);
+ }
+ case "schedules.list": {
+ if (this.failScheduleList) {
+ return Promise.reject(new TypeError("Schedule list unavailable"));
+ }
+ const request = input as ListSchedulesInput;
+ const schedules = [...this.schedules]
+ .filter(
+ (schedule) =>
+ request.enabled === "all" ||
+ (request.enabled === "enabled" && schedule.enabled) ||
+ (request.enabled === "disabled" && !schedule.enabled)
+ )
+ .toSorted((left, right) => left.id.localeCompare(right.id));
+ return Promise.resolve({ schedules });
+ }
+ case "schedules.get": {
+ const id = (input as { readonly id: string }).id;
+ const schedule = this.scheduleDetails.get(id);
+ return schedule === undefined
+ ? Promise.reject(new TypeError("Unknown schedule"))
+ : Promise.resolve(schedule);
+ }
+ case "schedules.listRuns": {
+ const { id } = input as ListScheduleRunsInput;
+ return Promise.resolve({
+ runs: newestFirst(
+ this.scheduleRuns.filter(
+ ({ scheduledJobId }) => scheduledJobId === id
+ )
+ ),
+ });
+ }
+ default: {
+ return Promise.reject(new TypeError(`Unexpected query: ${path}`));
+ }
+ }
+ }
+}
+
+const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({
+ authenticate: () => Promise.reject(new TypeError("Unexpected authentication")),
+ register: () => Promise.reject(new TypeError("Unexpected registration")),
+});
+const queryClients: ReturnType[] = [];
+const collectionRegistries: DashboardBrowserCollections[] = [];
+const mountedViews: ReturnType[] = [];
+
+function renderJobsRoute(
+ path: string,
+ transport: JobsRouteTransport,
+ realtimeClient: DashboardRealtimeClient = noOpDashboardRealtimeClient
+) {
+ const queryClient = createDashboardQueryClient();
+ queryClient.setDefaultOptions({
+ ...queryClient.getDefaultOptions(),
+ queries: {
+ ...queryClient.getDefaultOptions().queries,
+ retry: false,
+ },
+ });
+ queryClients.push(queryClient);
+ const trpcClient = createDashboardTrpcClient(transport);
+ const collections = createDashboardBrowserCollections(queryClient, trpcClient);
+ collectionRegistries.push(collections);
+ const router = createDashboardRouter(createMemoryHistory({ initialEntries: [path] }));
+ mountedViews.push(
+ render(
+
+ )
+ );
+ return { queryClient, router };
+}
+
+afterEach(async () => {
+ for (const view of mountedViews.splice(0)) view.unmount();
+ await Promise.all(
+ collectionRegistries.splice(0).map((collections) => collections.cleanup())
+ );
+ for (const queryClient of queryClients.splice(0)) queryClient.clear();
+});
+
+describe("Dashboard jobs route", () => {
+ test("loads independent exact deep links and wires navigation and realtime refresh", async () => {
+ const transport = new JobsRouteTransport();
+ const run = queuedRun({
+ displayName: "Deep-linked durable run",
+ id: runId,
+ scheduledJobId: scheduleId,
+ });
+ const schedule = scheduleSummary(scheduleId, { name: "Deep-linked schedule" });
+ transport.addRunDetail(run);
+ transport.addScheduleDetail(schedule);
+ const realtimeClient = new ControlledDashboardRealtimeClient();
+ const { queryClient } = renderJobsRoute(
+ `/jobs?runId=${runId}&scheduleId=${scheduleId}`,
+ transport,
+ realtimeClient
+ );
+
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Deep-linked durable run",
+ })
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Deep-linked schedule",
+ })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ expect(transport.runs).toEqual([]);
+ expect(transport.schedules).toEqual([]);
+ expect(transport.callsFor("jobs.getRun")[0]?.input).toEqual({
+ eventLimit: 100,
+ id: runId,
+ });
+ expect(transport.callsFor("schedules.get")[0]?.input).toEqual({
+ id: scheduleId,
+ });
+ const navigation = screen.getByRole("navigation", {
+ name: "Main navigation",
+ });
+ expect(within(navigation).getByRole("link", { name: "Jobs" })).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ await waitFor(() => {
+ expect(realtimeClient.input?.topics).toContain(jobRealtimeTopics.runs);
+ expect(realtimeClient.input?.topics).toContain(jobRealtimeTopics.schedules);
+ });
+
+ const detailCallsBeforeChange = transport.callsFor("jobs.getRun").length;
+ const change: RealtimeStreamOutput = {
+ data: {
+ event: {
+ entityId: runId,
+ entityType: "job-run",
+ occurredAtMs: timestampMs + 1000,
+ operation: "updated",
+ payload: { id: runId },
+ topic: jobRealtimeTopics.runs,
+ },
+ kind: "change",
+ },
+ id: "41",
+ };
+ act(() => {
+ realtimeClient.emit(change);
+ });
+ await waitFor(() =>
+ expect(transport.callsFor("jobs.getRun").length).toBeGreaterThan(
+ detailCallsBeforeChange
+ )
+ );
+ });
+
+ test("keeps loaded event history stable while realtime shifts the exact-page cursor", async () => {
+ const transport = new JobsRouteTransport();
+ const initialRun = {
+ ...queuedRun({
+ displayName: "Long-lived durable run",
+ id: runId,
+ scheduledJobId: scheduleId,
+ }),
+ eventCount: 202,
+ };
+ const eventPage = (
+ run: JobRunSummary,
+ newestSequence: number,
+ count: number,
+ nextEventCursor: number
+ ): JobRunDetail => ({
+ events: Array.from({ length: count }, (_, index) => {
+ const sequence = newestSequence - index;
+ return {
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: timestampMs + sequence,
+ sequence,
+ };
+ }),
+ nextEventCursor: { sequence: nextEventCursor },
+ run,
+ });
+ transport.runDetails.set(runId, eventPage(initialRun, 202, 100, 103));
+ transport.addRunEventDetail(runId, 103, eventPage(initialRun, 102, 100, 3));
+ const realtimeClient = new ControlledDashboardRealtimeClient();
+ const { queryClient } = renderJobsRoute(
+ `/jobs?runId=${runId}`,
+ transport,
+ realtimeClient
+ );
+ const user = userEvent.setup();
+ const detailCalls = () =>
+ transport
+ .callsFor("jobs.getRun")
+ .filter(
+ ({ input }) => (input as GetJobRunInput).eventCursor === undefined
+ );
+ const historyCalls = () =>
+ transport
+ .callsFor("jobs.getRun")
+ .filter(
+ ({ input }) => (input as GetJobRunInput).eventCursor !== undefined
+ );
+ const emitRunChange = (id: string) => {
+ const change: RealtimeStreamOutput = {
+ data: {
+ event: {
+ entityId: runId,
+ entityType: "job-run",
+ occurredAtMs: timestampMs + 1000,
+ operation: "updated",
+ payload: { id: runId },
+ topic: jobRealtimeTopics.runs,
+ },
+ kind: "change",
+ },
+ id,
+ };
+ act(() => {
+ realtimeClient.emit(change);
+ });
+ };
+
+ await waitFor(() => expect(detailCalls()).toHaveLength(1));
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Long-lived durable run",
+ })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ expect(detailCalls()).toHaveLength(1);
+
+ await user.click(screen.getByRole("button", { name: "Load older events" }));
+ await waitFor(() => expect(historyCalls()).toHaveLength(1));
+ expect(historyCalls()[0]?.input).toEqual({
+ eventCursor: { sequence: 103 },
+ eventLimit: 100,
+ id: runId,
+ });
+ expect(
+ await screen.findByRole("article", { name: "Event 3: queued" })
+ ).toBeTruthy();
+
+ const firstRealtimeRun = {
+ ...initialRun,
+ eventCount: 203,
+ updatedAtMs: timestampMs + 1000,
+ };
+ transport.runDetails.set(runId, eventPage(firstRealtimeRun, 203, 100, 104));
+ emitRunChange("42");
+ await waitFor(() => expect(detailCalls()).toHaveLength(2));
+ expect(historyCalls()).toHaveLength(1);
+ expect(
+ await screen.findByRole("article", { name: "Event 103: queued" })
+ ).toBeTruthy();
+ expect(screen.getByRole("article", { name: "Event 3: queued" })).toBeTruthy();
+
+ const secondRealtimeRun = {
+ ...firstRealtimeRun,
+ eventCount: 204,
+ updatedAtMs: timestampMs + 2000,
+ };
+ transport.runDetails.set(runId, eventPage(secondRealtimeRun, 204, 100, 105));
+ emitRunChange("43");
+ await waitFor(() => expect(detailCalls()).toHaveLength(3));
+ await waitFor(() =>
+ expect(
+ within(
+ screen.getByRole("list", {
+ name: "Durable job run events",
+ })
+ ).getAllByRole("article")
+ ).toHaveLength(202)
+ );
+
+ expect(historyCalls()).toHaveLength(1);
+ expect(historyCalls().map(({ input }) => input)).toEqual([
+ {
+ eventCursor: { sequence: 103 },
+ eventLimit: 100,
+ id: runId,
+ },
+ ]);
+ expect(detailCalls().map(({ input }) => input)).toEqual([
+ { eventLimit: 100, id: runId },
+ { eventLimit: 100, id: runId },
+ { eventLimit: 100, id: runId },
+ ]);
+ expect(screen.getByRole("article", { name: "Event 3: queued" })).toBeTruthy();
+ expect(
+ screen.getAllByRole("article", { name: "Event 103: queued" })
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByRole("article", { name: "Event 104: queued" })
+ ).toHaveLength(1);
+ });
+
+ test("drops malformed selections without issuing exact-detail calls", async () => {
+ const transport = new JobsRouteTransport();
+ const { queryClient } = renderJobsRoute(
+ "/jobs?runId=not-a-run&scheduleId=Bad%20Schedule",
+ transport
+ );
+
+ expect(
+ await screen.findByRole("heading", { level: 1, name: "Jobs" })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ expect(screen.getByRole("heading", { name: "Select a job run" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Select a schedule" })).toBeTruthy();
+ expect(transport.callsFor("jobs.getRun")).toEqual([]);
+ expect(transport.callsFor("schedules.get")).toEqual([]);
+ expect(transport.callsFor("schedules.listRuns")).toEqual([]);
+ });
+
+ test("keeps exact details available when both directory requests fail", async () => {
+ const transport = new JobsRouteTransport();
+ transport.failJobList = true;
+ transport.failScheduleList = true;
+ const run = queuedRun({
+ displayName: "Exact run despite list failure",
+ id: runId,
+ scheduledJobId: scheduleId,
+ });
+ const schedule = scheduleSummary(scheduleId, {
+ name: "Exact schedule despite list failure",
+ });
+ transport.addRunDetail(run);
+ transport.addScheduleDetail(schedule);
+ renderJobsRoute(`/jobs?runId=${runId}&scheduleId=${scheduleId}`, transport);
+
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Exact run despite list failure",
+ })
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Exact schedule despite list failure",
+ })
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole("heading", { name: "Job history unavailable" })
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole("heading", {
+ name: "Schedule directory unavailable",
+ })
+ ).toBeTruthy();
+ expect(transport.callsFor("jobs.getRun")).toHaveLength(1);
+ expect(transport.callsFor("schedules.get")).toHaveLength(1);
+ });
+
+ test("retains cached queue, directory, and exact details after transient refetch failures", async () => {
+ const transport = new JobsRouteTransport();
+ const run = queuedRun({
+ displayName: "Cached durable run",
+ id: runId,
+ scheduledJobId: scheduleId,
+ });
+ const schedule = scheduleSummary(scheduleId, { name: "Cached schedule" });
+ transport.runs = [run];
+ transport.schedules = [schedule];
+ transport.addRunDetail(run);
+ transport.addScheduleDetail(schedule);
+ const { queryClient } = renderJobsRoute(
+ `/jobs?runId=${runId}&scheduleId=${scheduleId}`,
+ transport
+ );
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Cached durable run" })
+ ).toBeTruthy();
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Cached schedule" })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ const applyFilters = screen.getByRole("button", { name: "Apply" });
+ act(() => applyFilters.focus());
+ expect(applyFilters).toHaveFocus();
+ const paths = [
+ "jobs.getRun",
+ "jobs.listRuns",
+ "schedules.get",
+ "schedules.list",
+ "schedules.listRuns",
+ ] as const;
+ const previousCallCounts = new Map(
+ paths.map((path) => [path, transport.callsFor(path).length] as const)
+ );
+ for (const path of paths) {
+ transport.failNextQueryCounts.set(
+ path,
+ path === "jobs.listRuns" ? (previousCallCounts.get(path) ?? 1) : 1
+ );
+ }
+
+ await act(async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["jobs"] }),
+ queryClient.invalidateQueries({ queryKey: ["schedules"] }),
+ ]);
+ });
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ expect(applyFilters).toHaveFocus();
+
+ for (const path of paths) {
+ const expectedAdditionalCalls =
+ path === "jobs.listRuns" ? (previousCallCounts.get(path) ?? 1) : 1;
+ expect(transport.callsFor(path)).toHaveLength(
+ (previousCallCounts.get(path) ?? 0) + expectedAdditionalCalls
+ );
+ }
+ expect(screen.getByRole("heading", { name: "Queue and workers" })).toBeTruthy();
+ expect(
+ screen.getByRole("button", {
+ name: `Open run Cached durable run; action system.worker-smoke; id ${runId}`,
+ })
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Cached durable run" })
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Cached schedule; system.worker-smoke" })
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Cached schedule" })
+ ).toBeTruthy();
+ const nonBlockingErrors = screen
+ .getAllByRole("alert")
+ .filter((alert) =>
+ alert.textContent?.includes(
+ "The request could not be completed. Try again."
+ )
+ );
+ expect(nonBlockingErrors.length).toBeGreaterThanOrEqual(5);
+ expect(
+ screen.queryByRole("heading", { name: "Job history unavailable" })
+ ).toBeNull();
+ expect(
+ screen.queryByRole("heading", { name: "Schedule directory unavailable" })
+ ).toBeNull();
+ expect(screen.queryByRole("heading", { name: "Job run unavailable" })).toBeNull();
+ expect(
+ screen.queryByRole("heading", { name: "Schedule unavailable" })
+ ).toBeNull();
+ });
+
+ test("clears a schedule filter error while the draft is corrected", async () => {
+ const transport = new JobsRouteTransport();
+ const { queryClient } = renderJobsRoute("/jobs", transport);
+ const user = userEvent.setup();
+ const historyCalls = () =>
+ transport
+ .callsFor("jobs.listRuns")
+ .filter(({ input }) => (input as ListJobRunsInput).limit === 100);
+
+ expect(
+ await screen.findByRole("heading", { level: 1, name: "Jobs" })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ const initialHistoryCallCount = historyCalls().length;
+ const scheduleInput = screen.getByLabelText("Schedule id");
+ const applyFilters = screen.getByRole("button", { name: "Apply" });
+
+ await user.type(scheduleInput, "INVALID");
+ await user.click(applyFilters);
+ expect(screen.getByText("Use a canonical Dashboard schedule id.")).toBeTruthy();
+ await waitFor(() => expect(scheduleInput).toHaveFocus());
+ expect(scheduleInput).toHaveAccessibleDescription(
+ "Use a canonical Dashboard schedule id."
+ );
+ expect(historyCalls()).toHaveLength(initialHistoryCallCount);
+
+ await user.click(applyFilters);
+ await waitFor(() => expect(scheduleInput).toHaveFocus());
+ expect(historyCalls()).toHaveLength(initialHistoryCallCount);
+
+ await user.clear(scheduleInput);
+ await waitFor(() =>
+ expect(
+ screen.queryByText("Use a canonical Dashboard schedule id.")
+ ).toBeNull()
+ );
+ expect(historyCalls()).toHaveLength(initialHistoryCallCount);
+ });
+
+ test("applies all run filter drafts as one global-history query", async () => {
+ const transport = new JobsRouteTransport();
+ const { queryClient } = renderJobsRoute("/jobs", transport);
+ const user = userEvent.setup();
+ const historyCalls = () =>
+ transport
+ .callsFor("jobs.listRuns")
+ .filter(({ input }) => (input as ListJobRunsInput).limit === 100);
+
+ expect(
+ await screen.findByRole("heading", { level: 1, name: "Jobs" })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ const initialHistoryCallCount = historyCalls().length;
+ expect(initialHistoryCallCount).toBe(1);
+ const scheduleInput = screen.getByLabelText("Schedule id");
+
+ await user.click(screen.getByLabelText("State"));
+ await user.click(screen.getByRole("option", { name: "running" }));
+ await user.click(screen.getByLabelText("Resource class"));
+ await user.click(screen.getByRole("option", { name: "network" }));
+ await user.click(screen.getByLabelText("Trigger"));
+ await user.click(screen.getByRole("option", { name: "manual" }));
+ await user.type(scheduleInput, scheduleId);
+ expect(historyCalls()).toHaveLength(initialHistoryCallCount);
+
+ await user.click(screen.getByRole("button", { name: "Apply" }));
+ await waitFor(() =>
+ expect(historyCalls()).toHaveLength(initialHistoryCallCount + 1)
+ );
+ expect(historyCalls().at(-1)?.input).toEqual({
+ filters: {
+ resourceClasses: ["network"],
+ scheduleId,
+ states: ["running"],
+ triggerTypes: ["manual"],
+ },
+ limit: 100,
+ });
+ });
+
+ test("deduplicates an overlapping older run page", async () => {
+ const transport = new JobsRouteTransport();
+ const newest = queuedRun({
+ displayName: "Newest paged run",
+ id: runId,
+ queuedAtMs: timestampMs,
+ });
+ const older = queuedRun({
+ displayName: "Older paged run",
+ id: olderRunId,
+ queuedAtMs: timestampMs - 1000,
+ });
+ transport.runs = [newest, older];
+ transport.runPages = [[newest], [newest, older]];
+ renderJobsRoute("/jobs", transport);
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("button", {
+ name: `Open run Newest paged run; action system.worker-smoke; id ${runId}`,
+ })
+ ).toBeTruthy();
+ await user.click(screen.getByRole("button", { name: "Load more runs" }));
+ expect(
+ await screen.findByRole("button", {
+ name: `Open run Older paged run; action system.worker-smoke; id ${olderRunId}`,
+ })
+ ).toBeTruthy();
+ expect(
+ screen.getAllByRole("button", {
+ name: `Open run Newest paged run; action system.worker-smoke; id ${runId}`,
+ })
+ ).toHaveLength(1);
+ expect(
+ transport
+ .callsFor("jobs.listRuns")
+ .find(({ input }) => (input as ListJobRunsInput).cursor?.id === runId)
+ ?.input
+ ).toEqual({
+ cursor: { id: runId, queuedAtMs: timestampMs },
+ limit: 100,
+ });
+ });
+
+ test("executes versioned pause and confirmed run cancellation", async () => {
+ const transport = new JobsRouteTransport();
+ const run = queuedRun({
+ displayName: "Cancellable queued run",
+ id: runId,
+ });
+ transport.runs = [run];
+ transport.addRunDetail(run);
+ renderJobsRoute(`/jobs?runId=${runId}`, transport);
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("heading", {
+ level: 2,
+ name: "Cancellable queued run",
+ })
+ ).toBeTruthy();
+ await user.click(
+ await screen.findByRole("button", {
+ name: "Pause claiming for new job runs",
+ })
+ );
+ await waitFor(() =>
+ expect(transport.callsFor("jobs.setClaimingPaused")).toHaveLength(1)
+ );
+ expect(transport.callsFor("jobs.setClaimingPaused")[0]?.input).toEqual({
+ expectedVersion: 1,
+ paused: true,
+ });
+ expect(
+ await screen.findByRole("button", {
+ name: "Resume claiming for new job runs",
+ })
+ ).toBeTruthy();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Cancel queued run: Cancellable queued run",
+ })
+ );
+ await user.click(screen.getByRole("button", { name: "Cancel run" }));
+ await waitFor(() => expect(transport.callsFor("jobs.cancelRun")).toHaveLength(1));
+ expect(transport.callsFor("jobs.cancelRun")[0]?.input).toEqual({ id: runId });
+ const cancelledLabels = await screen.findAllByText("cancelled", {
+ selector: "span",
+ });
+ expect(cancelledLabels.length).toBeGreaterThan(0);
+ expect(
+ screen.queryByRole("button", {
+ name: "Cancel queued run: Cancellable queued run",
+ })
+ ).toBeNull();
+ });
+
+ test("focuses the global detail for a run selected from schedule history", async () => {
+ const transport = new JobsRouteTransport();
+ const run = queuedRun({
+ displayName: "Schedule-history run",
+ id: runId,
+ scheduledJobId: scheduleId,
+ });
+ const schedule = scheduleSummary();
+ transport.scheduleRuns = [run];
+ transport.addRunDetail(run);
+ transport.addScheduleDetail(schedule);
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: `Open run Schedule-history run; action system.worker-smoke; id ${runId}`,
+ })
+ );
+ const heading = await screen.findByRole("heading", {
+ level: 2,
+ name: "Schedule-history run",
+ });
+ await waitFor(() => expect(heading).toHaveFocus());
+ });
+
+ test("focuses schedule detail selected from the directory", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ renderJobsRoute("/jobs", transport);
+ const user = userEvent.setup();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: "Worker smoke; system.worker-smoke",
+ })
+ );
+ const heading = await screen.findByRole("heading", {
+ level: 2,
+ name: "Worker smoke",
+ });
+ await waitFor(() => expect(heading).toHaveFocus());
+ });
+
+ test("saves cadence through a versioned schedule update", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ const { queryClient } = renderJobsRoute(
+ `/jobs?scheduleId=${scheduleId}`,
+ transport
+ );
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ const interval = screen.getByLabelText("Interval (seconds)");
+ await user.clear(interval);
+ await user.type(interval, "120");
+ expect(interval).toHaveValue(120);
+ fireEvent.submit(
+ screen.getByRole("form", { name: "Edit Worker smoke schedule" })
+ );
+ await waitFor(() =>
+ expect(transport.callsFor("schedules.update")).toHaveLength(1)
+ );
+ expect(transport.callsFor("schedules.update")[0]?.input).toEqual({
+ expectedVersion: 1,
+ id: scheduleId,
+ patch: {
+ schedule: { intervalMs: 120_000, kind: "interval" },
+ },
+ });
+
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(transport.scheduleDetails.get(scheduleId)?.schedule).toEqual({
+ intervalMs: 120_000,
+ kind: "interval",
+ });
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toHaveFocus()
+ );
+ });
+
+ test("creates, replaces, and clears a durable schedule disable intent", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+ await user.type(screen.getByLabelText("Reason"), "Planned maintenance");
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+ await waitFor(() =>
+ expect(transport.callsFor("schedules.update")).toHaveLength(1)
+ );
+ expect(transport.callsFor("schedules.update")[0]?.input).toEqual({
+ expectedVersion: 1,
+ id: scheduleId,
+ patch: {
+ disableIntent: { reason: "Planned maintenance" },
+ enabled: false,
+ },
+ });
+ expect(await screen.findByText("Planned maintenance")).toBeTruthy();
+
+ await user.click(screen.getByRole("button", { name: "Update disable intent" }));
+ const reason = screen.getByLabelText("Reason");
+ await user.clear(reason);
+ await user.type(reason, "Extended maintenance");
+ await user.click(screen.getByRole("button", { name: "Save intent" }));
+ await waitFor(() =>
+ expect(transport.callsFor("schedules.update")).toHaveLength(2)
+ );
+ expect(transport.callsFor("schedules.update")[1]?.input).toEqual({
+ expectedVersion: 2,
+ id: scheduleId,
+ patch: {
+ disableIntent: { reason: "Extended maintenance" },
+ enabled: false,
+ },
+ });
+ expect(await screen.findByText("Extended maintenance")).toBeTruthy();
+ expect(screen.queryByText("Planned maintenance")).toBeNull();
+
+ await user.click(screen.getByRole("button", { name: "Enable" }));
+ await waitFor(() =>
+ expect(transport.callsFor("schedules.update")).toHaveLength(3)
+ );
+ expect(transport.callsFor("schedules.update")[2]?.input).toEqual({
+ expectedVersion: 3,
+ id: scheduleId,
+ patch: { disableIntent: null, enabled: true },
+ });
+ const enabledLabels = await screen.findAllByText("enabled");
+ expect(enabledLabels.length).toBeGreaterThan(0);
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toHaveFocus()
+ );
+ expect(screen.queryByText("Extended maintenance")).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Update disable intent" })
+ ).toBeNull();
+ });
+
+ test("opens a lost-response-safe manual schedule run", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ await user.click(await screen.findByRole("button", { name: "Run now" }));
+ await waitFor(() => expect(transport.callsFor("schedules.run")).toHaveLength(1));
+ const manualRunInput = transport.callsFor("schedules.run")[0]?.input;
+ expect(manualRunInput).toMatchObject({ id: scheduleId });
+ expect((manualRunInput as RunScheduleInput).idempotencyKey).toMatch(
+ /^[A-Fa-f0-9]{32}$/u
+ );
+ const heading = await screen.findByRole("heading", {
+ level: 2,
+ name: "Worker smoke manual run",
+ });
+ await waitFor(() => expect(heading).toHaveFocus());
+ expect(transport.callsFor("jobs.getRun").at(-1)?.input).toEqual({
+ eventLimit: 100,
+ id: manualRunId,
+ });
+ });
+
+ test("retries one committed manual run with the original idempotency key after a lost response", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ transport.failNextCommittedScheduleRunResponses = 1;
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ await user.click(screen.getByRole("button", { name: "Run now" }));
+ await waitFor(() => expect(transport.callsFor("schedules.run")).toHaveLength(1));
+
+ const retry = await screen.findByRole("button", {
+ name: "Retry run request",
+ });
+ expect(retry).toBeEnabled();
+ expect(transport.scheduleDetails.get(scheduleId)?.activeRun?.id).toBe(
+ manualRunId
+ );
+ expect(transport.runs.map(({ id }) => id)).toEqual([manualRunId]);
+ expect(transport.scheduleRuns.map(({ id }) => id)).toEqual([manualRunId]);
+ expect([...transport.runDetails.keys()]).toEqual([manualRunId]);
+
+ await user.click(retry);
+ await waitFor(() => expect(transport.callsFor("schedules.run")).toHaveLength(2));
+ const [firstCall, retryCall] = transport.callsFor("schedules.run");
+ if (firstCall === undefined || retryCall === undefined) {
+ throw new Error("Expected the original and replayed schedule run calls");
+ }
+ expect((firstCall.input as RunScheduleInput).idempotencyKey).toBe(
+ (retryCall.input as RunScheduleInput).idempotencyKey
+ );
+ expect(transport.runs.map(({ id }) => id)).toEqual([manualRunId]);
+ expect(transport.scheduleRuns.map(({ id }) => id)).toEqual([manualRunId]);
+ expect([...transport.runDetails.keys()]).toEqual([manualRunId]);
+
+ const heading = await screen.findByRole("heading", {
+ level: 2,
+ name: "Worker smoke manual run",
+ });
+ await waitFor(() => expect(heading).toHaveFocus());
+ expect(transport.callsFor("jobs.getRun").at(-1)?.input).toEqual({
+ eventLimit: 100,
+ id: manualRunId,
+ });
+ });
+
+ test("clears a stale schedule-update error after a successful manual run", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ transport.failNextMutationCounts.set("schedules.update", 1);
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+ const failureMessage = "The request could not be completed. Try again.";
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ const interval = screen.getByLabelText("Interval (seconds)");
+ await user.clear(interval);
+ await user.type(interval, "120");
+ fireEvent.submit(
+ screen.getByRole("form", { name: "Edit Worker smoke schedule" })
+ );
+ expect(await screen.findByText(failureMessage)).toBeTruthy();
+
+ await user.click(screen.getByRole("button", { name: "Run now" }));
+ await waitFor(() => expect(transport.callsFor("schedules.run")).toHaveLength(1));
+ await waitFor(() => expect(screen.queryByText(failureMessage)).toBeNull());
+ });
+
+ test("clears a stale manual-run error after a successful schedule update", async () => {
+ const transport = new JobsRouteTransport();
+ const schedule = scheduleSummary();
+ transport.schedules = [schedule];
+ transport.addScheduleDetail(schedule);
+ transport.failNextMutationCounts.set("schedules.run", 1);
+ renderJobsRoute(`/jobs?scheduleId=${scheduleId}`, transport);
+ const user = userEvent.setup();
+ const failureMessage = "The request could not be completed. Try again.";
+
+ expect(
+ await screen.findByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toBeTruthy();
+ await user.click(await screen.findByRole("button", { name: "Run now" }));
+ await waitFor(() => expect(transport.callsFor("schedules.run")).toHaveLength(1));
+ expect(await screen.findByText(failureMessage)).toBeTruthy();
+
+ const interval = screen.getByLabelText("Interval (seconds)");
+ await user.clear(interval);
+ await user.type(interval, "120");
+ fireEvent.submit(
+ screen.getByRole("form", { name: "Edit Worker smoke schedule" })
+ );
+ await waitFor(() =>
+ expect(transport.callsFor("schedules.update")).toHaveLength(1)
+ );
+ await waitFor(() => expect(screen.queryByText(failureMessage)).toBeNull());
+ });
+
+ test("redirects anonymous sessions before jobs data or realtime is mounted", async () => {
+ const transport = new JobsRouteTransport();
+ transport.authStatus = { state: "anonymous" };
+ const realtimeClient = new ControlledDashboardRealtimeClient();
+ const { queryClient, router } = renderJobsRoute(
+ `/jobs?runId=${runId}`,
+ transport,
+ realtimeClient
+ );
+
+ expect(
+ await screen.findByRole("heading", { level: 1, name: "Sign in" })
+ ).toBeTruthy();
+ await waitFor(() => expect(queryClient.isFetching()).toBe(0));
+ expect(router.state.location.pathname).toBe("/login");
+ expect(transport.calls.some(({ path }) => path.startsWith("jobs."))).toBeFalse();
+ expect(
+ transport.calls.some(({ path }) => path.startsWith("schedules."))
+ ).toBeFalse();
+ expect(realtimeClient.activeSubscriptionCount).toBe(0);
+ expect(screen.queryByRole("navigation", { name: "Main navigation" })).toBeNull();
+ });
+});
diff --git a/greenfield/src/browser/jobs/JobsRoute.tsx b/greenfield/src/browser/jobs/JobsRoute.tsx
new file mode 100644
index 000000000..d3ce85c71
--- /dev/null
+++ b/greenfield/src/browser/jobs/JobsRoute.tsx
@@ -0,0 +1,33 @@
+import { useState } from "react";
+
+import { PageHeader } from "../ui/PageHeader.tsx";
+import { JobRunBrowser } from "./JobRunBrowser.tsx";
+import { ScheduleBrowser } from "./ScheduleBrowser.tsx";
+import { useJobRealtimeInvalidation } from "./useJobRealtimeInvalidation.ts";
+
+/** @returns Dashboard-local durable schedules, queue state, and run history. */
+export function JobsRoute() {
+ useJobRealtimeInvalidation();
+ const [focusRunId, setFocusRunId] = useState();
+ const handleRunFocus = (id: string) => {
+ setFocusRunId((current) => (current === id ? undefined : current));
+ };
+
+ return (
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/ScheduleBrowser.tsx b/greenfield/src/browser/jobs/ScheduleBrowser.tsx
new file mode 100644
index 000000000..163621465
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleBrowser.tsx
@@ -0,0 +1,311 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { useNavigate, useSearch } from "@tanstack/react-router";
+import { CalendarClock } from "lucide-react";
+import { type ReactNode, useEffect, useState } from "react";
+
+import type { ScheduleConfiguration } from "../../contracts/jobModel.ts";
+import type { ListSchedulesInput } from "../../contracts/schedules.ts";
+import { useDashboardTrpcClient } from "../api/trpcContextValue.ts";
+import { Alert } from "../ui/Alert.tsx";
+import { Button } from "../ui/Button.tsx";
+import { FormField } from "../ui/FormField.tsx";
+import { Heading } from "../ui/Heading.tsx";
+import { PageState } from "../ui/PageState.tsx";
+import { Select, type SelectOption } from "../ui/Select.tsx";
+import { Text } from "../ui/Text.tsx";
+import { jobBrowserFailureMessage } from "./jobBrowserFailure.ts";
+import { useRunScheduleMutation, useUpdateScheduleMutation } from "./jobMutations.ts";
+import {
+ scheduleDetailQueryOptions,
+ scheduleListQueryOptions,
+ scheduleRunListQueryOptions,
+ uniqueJobRows,
+} from "./jobQueries.ts";
+import { parseJobsRouteSearch } from "./jobRouteSearch.ts";
+import { JobRunTable } from "./JobRunTable.tsx";
+import { ScheduleDetail } from "./ScheduleDetail.tsx";
+import { ScheduleTable } from "./ScheduleTable.tsx";
+
+type ScheduleEnabledFilter = NonNullable;
+
+const scheduleEnabledOptions: readonly SelectOption[] =
+ Object.freeze([
+ { label: "All schedules", value: "all" },
+ { label: "Enabled", value: "enabled" },
+ { label: "Disabled", value: "disabled" },
+ ]);
+
+interface SelectedScheduleProps {
+ readonly focusRequested: boolean;
+ readonly id: string;
+ readonly onFocusHandled: (id: string) => void;
+ readonly onSelectRun: (id: string) => void;
+ readonly selectedRunId?: string;
+}
+
+function SelectedSchedule({
+ focusRequested,
+ id,
+ onFocusHandled,
+ onSelectRun,
+ selectedRunId,
+}: SelectedScheduleProps) {
+ const client = useDashboardTrpcClient();
+ const detail = useQuery(scheduleDetailQueryOptions(client, id));
+ const history = useInfiniteQuery(scheduleRunListQueryOptions(client, id));
+ const update = useUpdateScheduleMutation();
+ const run = useRunScheduleMutation();
+ const runs = uniqueJobRows(history.data?.pages.flatMap((page) => page.runs) ?? []);
+
+ useEffect(() => {
+ if (!focusRequested || detail.data === undefined) return;
+ const timer = setTimeout(() => {
+ document.querySelector("#schedule-detail-heading")?.focus();
+ onFocusHandled(id);
+ }, 0);
+ return () => clearTimeout(timer);
+ }, [detail.data, focusRequested, id, onFocusHandled]);
+
+ if (detail.isPending && detail.data === undefined) {
+ return ;
+ }
+ if (detail.data === undefined) {
+ return (
+ void detail.refetch()}
+ retryBusy={detail.isFetching}
+ status="error"
+ title="Schedule unavailable"
+ />
+ );
+ }
+
+ const schedule = detail.data;
+ const mutationError = update.error ?? run.error;
+ let historyContent;
+ if (history.isPending && history.data === undefined) {
+ historyContent = ;
+ } else if (history.data === undefined) {
+ historyContent = (
+ void history.refetch()}
+ retryBusy={history.isFetching}
+ status="error"
+ title="Schedule history unavailable"
+ />
+ );
+ } else {
+ historyContent = (
+ <>
+
+
+ {history.hasNextPage && (
+
+ )}
+ >
+ );
+ }
+
+ const updateSchedule = async (
+ patch: Parameters[0]["patch"],
+ expectedVersion = schedule.version
+ ): Promise => {
+ run.reset();
+ await update.mutateAsync({
+ expectedVersion,
+ id: schedule.id,
+ patch,
+ });
+ };
+
+ return (
+
+ updateSchedule({ disableIntent, enabled: false }, expectedVersion)
+ }
+ onEnable={() => updateSchedule({ disableIntent: null, enabled: true })}
+ onOpenDisable={() => {
+ update.reset();
+ run.reset();
+ }}
+ onRun={async () => {
+ update.reset();
+ const enqueued = await run.mutateAsync({ id: schedule.id });
+ onSelectRun(enqueued.id);
+ }}
+ onSaveConfiguration={(configuration: ScheduleConfiguration) =>
+ updateSchedule({ schedule: configuration })
+ }
+ runBusy={run.isPending}
+ runReplayAvailable={run.hasPendingRequest(schedule.id)}
+ schedule={schedule}
+ updateBusy={update.isPending}
+ />
+ );
+}
+
+interface ScheduleBrowserProps {
+ readonly onRequestRunFocus: (id: string) => void;
+}
+
+/** @returns Filtered schedule directory, exact editor, and schedule-scoped history. */
+export function ScheduleBrowser({ onRequestRunFocus }: ScheduleBrowserProps) {
+ const client = useDashboardTrpcClient();
+ const navigate = useNavigate({ from: "/jobs" });
+ const search = parseJobsRouteSearch(useSearch({ from: "/jobs" }) as unknown);
+ const [enabled, setEnabled] = useState("all");
+ const [focusScheduleId, setFocusScheduleId] = useState();
+ const query = useInfiniteQuery(scheduleListQueryOptions(client, enabled));
+ const schedules = uniqueJobRows(
+ query.data?.pages.flatMap((page) => page.schedules) ?? []
+ );
+ const select = (selection: { runId?: string; scheduleId?: string }) => {
+ void navigate({ replace: true, search: selection });
+ };
+ const selectSchedule = (scheduleId: string | undefined) => {
+ setFocusScheduleId(scheduleId);
+ select({
+ ...(search.runId === undefined ? {} : { runId: search.runId }),
+ ...(scheduleId === undefined ? {} : { scheduleId }),
+ });
+ };
+ const selectRun = (runId: string) => {
+ onRequestRunFocus(runId);
+ select({
+ runId,
+ ...(search.scheduleId === undefined ? {} : { scheduleId: search.scheduleId }),
+ });
+ };
+ let directoryContent: ReactNode;
+ if (query.isPending && query.data === undefined) {
+ directoryContent = ;
+ } else if (query.data === undefined) {
+ directoryContent = (
+ void query.refetch()}
+ retryBusy={query.isFetching}
+ status="error"
+ title="Schedule directory unavailable"
+ />
+ );
+ } else if (schedules.length === 0) {
+ directoryContent = (
+
+ );
+ } else {
+ directoryContent = (
+ <>
+
+ {query.hasNextPage && (
+
+ )}
+ >
+ );
+ }
+
+ return (
+
+
+
+
+ Dashboard schedules
+
+
+ Edit code-owned cadence, explicit disable intent, and
+ lost-response-safe manual runs.
+
+
+
+
+
+
+
+ {directoryContent}
+
+ {search.scheduleId === undefined ? (
+
+ ) : (
+
+ setFocusScheduleId((current) =>
+ current === id ? undefined : current
+ )
+ }
+ onSelectRun={selectRun}
+ selectedRunId={search.runId}
+ />
+ )}
+
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/ScheduleDetail.tsx b/greenfield/src/browser/jobs/ScheduleDetail.tsx
new file mode 100644
index 000000000..b5ef1f773
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetail.tsx
@@ -0,0 +1,461 @@
+import { CalendarClock, Play, Power, PowerOff } from "lucide-react";
+import { type ReactNode, useId, useState } from "react";
+import * as v from "valibot";
+
+import {
+ jobDescriptionMaximumLength,
+ type ScheduleConfiguration,
+ type ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import { scheduleDisableReasonSchema } from "../../contracts/schedules.ts";
+import { formatDashboardDateTime } from "../lib/formatDateTime.ts";
+import { Alert } from "../ui/Alert.tsx";
+import { Badge } from "../ui/Badge.tsx";
+import { Button } from "../ui/Button.tsx";
+import { Card } from "../ui/Card.tsx";
+import { Form } from "../ui/Form.tsx";
+import { FormField } from "../ui/FormField.tsx";
+import { Heading } from "../ui/Heading.tsx";
+import { Icon } from "../ui/Icon.tsx";
+import { Input } from "../ui/Input.tsx";
+import { Modal } from "../ui/Modal.tsx";
+import { Text } from "../ui/Text.tsx";
+import { Textarea } from "../ui/Textarea.tsx";
+import { ScheduleEditor } from "./ScheduleEditor.tsx";
+import { scheduleConfigurationLabel } from "./schedulePresentation.ts";
+
+interface DisableIntentDraft {
+ readonly expiresAtMs?: number;
+ readonly reason: string;
+}
+
+interface DisableIntentSnapshot extends DisableIntentDraft {
+ readonly enabled: boolean;
+ readonly expectedVersion: number;
+}
+
+interface ScheduleDetailProps {
+ readonly disableError?: string;
+ readonly error?: string;
+ readonly errorFocus?: boolean;
+ readonly history: ReactNode;
+ readonly onDisable: (
+ draft: DisableIntentDraft,
+ expectedVersion: number
+ ) => Promise;
+ readonly onEnable: () => Promise;
+ readonly onOpenDisable: () => void;
+ readonly onRun: () => Promise;
+ readonly onSaveConfiguration: (configuration: ScheduleConfiguration) => Promise;
+ readonly runBusy: boolean;
+ readonly runReplayAvailable?: boolean;
+ readonly schedule: ScheduleSummary;
+ readonly updateBusy: boolean;
+}
+
+function localDateTimeValue(timestamp: number | undefined): string {
+ if (timestamp === undefined) return "";
+ const date = new Date(timestamp);
+ const year = date.getFullYear().toString().padStart(4, "0");
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
+ const day = date.getDate().toString().padStart(2, "0");
+ const hours = date.getHours().toString().padStart(2, "0");
+ const minutes = date.getMinutes().toString().padStart(2, "0");
+ const base = `${year}-${month}-${day}T${hours}:${minutes}`;
+ const seconds = date.getSeconds();
+ const milliseconds = date.getMilliseconds();
+ if (seconds === 0 && milliseconds === 0) return base;
+ const secondText = seconds.toString().padStart(2, "0");
+ return milliseconds === 0
+ ? `${base}:${secondText}`
+ : `${base}:${secondText}.${milliseconds.toString().padStart(3, "0")}`;
+}
+
+function localDateTimeTimestamp(value: string): number | undefined {
+ const match =
+ /^(\d{4,6})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?$/u.exec(
+ value
+ );
+ if (match === null) return;
+ const [
+ ,
+ yearText,
+ monthText,
+ dayText,
+ hoursText,
+ minutesText,
+ secondsText,
+ millisecondsText,
+ ] = match;
+ if (
+ yearText === undefined ||
+ monthText === undefined ||
+ dayText === undefined ||
+ hoursText === undefined ||
+ minutesText === undefined
+ ) {
+ return;
+ }
+ const year = Number(yearText);
+ const month = Number(monthText) - 1;
+ const day = Number(dayText);
+ const hours = Number(hoursText);
+ const minutes = Number(minutesText);
+ const seconds = secondsText === undefined ? 0 : Number(secondsText);
+ const milliseconds = millisecondsText === undefined ? 0 : Number(millisecondsText);
+ const date = new Date(0);
+ date.setFullYear(year, month, day);
+ date.setHours(hours, minutes, seconds, milliseconds);
+ const timestamp = date.getTime();
+ if (
+ !Number.isFinite(timestamp) ||
+ date.getFullYear() !== year ||
+ date.getMonth() !== month ||
+ date.getDate() !== day ||
+ date.getHours() !== hours ||
+ date.getMinutes() !== minutes ||
+ date.getSeconds() !== seconds ||
+ date.getMilliseconds() !== milliseconds
+ ) {
+ return;
+ }
+ return timestamp;
+}
+
+function focusScheduleDetailHeading(): void {
+ setTimeout(
+ () => document.querySelector("#schedule-detail-heading")?.focus(),
+ 0
+ );
+}
+
+/** @returns Complete operator controls and history for one code-owned schedule. */
+export function ScheduleDetail({
+ disableError,
+ error,
+ errorFocus = true,
+ history,
+ onDisable,
+ onEnable,
+ onOpenDisable,
+ onRun,
+ onSaveConfiguration,
+ runBusy,
+ runReplayAvailable = false,
+ schedule,
+ updateBusy,
+}: ScheduleDetailProps) {
+ const disableFormId = useId();
+ const actionsBusy = runBusy || updateBusy;
+ const [disableOpen, setDisableOpen] = useState(false);
+ const [disableReason, setDisableReason] = useState(
+ schedule.activeDisableIntent?.reason ?? ""
+ );
+ const [disableExpiry, setDisableExpiry] = useState(
+ localDateTimeValue(schedule.activeDisableIntent?.expiresAtMs)
+ );
+ const [disableExpiryError, setDisableExpiryError] = useState();
+ const [disableReasonError, setDisableReasonError] = useState();
+ const [disableSnapshot, setDisableSnapshot] = useState();
+
+ const snapshotExpiry = disableSnapshot?.expiresAtMs;
+ let draftExpiry: number | undefined;
+ if (disableExpiry.length > 0) {
+ draftExpiry =
+ snapshotExpiry !== undefined &&
+ disableExpiry === localDateTimeValue(snapshotExpiry)
+ ? snapshotExpiry
+ : localDateTimeTimestamp(disableExpiry);
+ }
+ const disableDraftUnchanged =
+ disableSnapshot?.enabled === false &&
+ disableReason === disableSnapshot.reason &&
+ draftExpiry === snapshotExpiry &&
+ (disableExpiry.length === 0 || draftExpiry !== undefined);
+ const disablingEnabledSchedule = disableSnapshot?.enabled ?? schedule.enabled;
+
+ const openDisableIntent = () => {
+ const activeIntent = schedule.activeDisableIntent;
+ onOpenDisable();
+ setDisableSnapshot({
+ enabled: schedule.enabled,
+ ...(activeIntent?.expiresAtMs === undefined
+ ? {}
+ : { expiresAtMs: activeIntent.expiresAtMs }),
+ expectedVersion: schedule.version,
+ reason: activeIntent?.reason ?? "",
+ });
+ setDisableReason(activeIntent?.reason ?? "");
+ setDisableExpiry(localDateTimeValue(activeIntent?.expiresAtMs));
+ setDisableExpiryError(undefined);
+ setDisableReasonError(undefined);
+ setDisableOpen(true);
+ };
+ const submitDisableIntent = async () => {
+ if (disableSnapshot === undefined || disableDraftUnchanged) return;
+ const parsedReason = v.safeParse(scheduleDisableReasonSchema, disableReason);
+ const reasonError = parsedReason.success
+ ? undefined
+ : "Use 1–1000 visible characters without control characters.";
+ const expiryError =
+ disableExpiry.length > 0 &&
+ (draftExpiry === undefined || draftExpiry <= Date.now())
+ ? "Expiry must be a valid future date and time."
+ : undefined;
+ setDisableExpiryError(expiryError);
+ setDisableReasonError(reasonError);
+ if (!parsedReason.success || expiryError !== undefined) {
+ setTimeout(() => {
+ document
+ .querySelector(`[id="${disableFormId}"]`)
+ ?.querySelector("[data-invalid]:is(input, textarea)")
+ ?.focus();
+ }, 0);
+ return;
+ }
+ try {
+ await onDisable(
+ {
+ ...(draftExpiry === undefined ? {} : { expiresAtMs: draftExpiry }),
+ reason: parsedReason.output,
+ },
+ disableSnapshot.expectedVersion
+ );
+ setDisableOpen(false);
+ focusScheduleDetailHeading();
+ } catch {
+ // The owning mutation renders its classified failure without closing the draft.
+ }
+ };
+ const enableSchedule = async () => {
+ await onEnable();
+ focusScheduleDetailHeading();
+ };
+ const saveConfiguration = async (configuration: ScheduleConfiguration) => {
+ await onSaveConfiguration(configuration);
+ focusScheduleDetailHeading();
+ };
+
+ return (
+
+
+
+
+
+ {schedule.enabled ? "enabled" : "disabled"}
+
+ {schedule.resourceClass}
+ {schedule.cancellationPolicy}
+
+
+ {schedule.name}
+
+
+ {schedule.id} · {schedule.actionKey}
+
+
{schedule.description}
+
+
+
+ {schedule.enabled ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
- Cadence
+ -
+ {scheduleConfigurationLabel(schedule.schedule)}
+
+
+
+
- Next run
+ -
+ {schedule.nextRunAtMs === undefined
+ ? "Paused"
+ : formatDashboardDateTime(schedule.nextRunAtMs)}
+
+
+
+
- Attempts / timeout
+ -
+ {schedule.attemptLimit} / {schedule.timeoutMs / 1000}s
+
+
+
+
- Version
+ - {schedule.version}
+
+
+ {schedule.activeDisableIntent !== undefined && (
+
+
+ Disable intent
+
+
+ {schedule.activeDisableIntent.reason}
+
+
+ {schedule.activeDisableIntent.expiresAtMs === undefined
+ ? "No automatic expiry"
+ : `Expires ${formatDashboardDateTime(schedule.activeDisableIntent.expiresAtMs)}`}
+
+
+ )}
+
+
+
+
+ Run history
+
+ {history}
+
+ setDisableOpen(false)}
+ open={disableOpen}
+ title={
+ disablingEnabledSchedule
+ ? "Disable schedule"
+ : "Update disable intent"
+ }
+ >
+
+
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/ScheduleDetailForm.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailForm.test.tsx
new file mode 100644
index 000000000..f24cc931d
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailForm.test.tsx
@@ -0,0 +1,122 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { maxTime } from "date-fns/constants";
+
+import {
+ disabledSchedule,
+ renderScheduleDetail,
+ timestampMs,
+} from "./testSupport/ScheduleDetail.tsx";
+
+const { fireEvent, screen, waitFor } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+describe("schedule detail disable form", () => {
+ test("round-trips a contract-maximum disable expiry without overflowing Date", async () => {
+ const onDisable = jest.fn(async () => {});
+ renderScheduleDetail({ onDisable, schedule: disabledSchedule(maxTime) });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Update disable intent" }));
+
+ expect(screen.getByLabelText("Expiry").value).toMatch(
+ /^\d{4,6}-\d{2}-\d{2}T\d{2}:\d{2}$/u
+ );
+ fireEvent.change(screen.getByLabelText("Reason"), {
+ target: { value: "Updated maintenance" },
+ });
+ await user.click(screen.getByRole("button", { name: "Save intent" }));
+ expect(onDisable).toHaveBeenCalledWith(
+ {
+ expiresAtMs: maxTime,
+ reason: "Updated maintenance",
+ },
+ 2
+ );
+ });
+
+ test("preserves sub-minute precision in an existing disable expiry", async () => {
+ const expiry = timestampMs + 60_123;
+ const onDisable = jest.fn(async () => {});
+ renderScheduleDetail({ onDisable, schedule: disabledSchedule(expiry) });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Update disable intent" }));
+ expect(screen.getByLabelText("Expiry").value).toMatch(
+ /:\d{2}\.123$/u
+ );
+ fireEvent.change(screen.getByLabelText("Reason"), {
+ target: { value: "Updated maintenance" },
+ });
+ await user.click(screen.getByRole("button", { name: "Save intent" }));
+ expect(onDisable).toHaveBeenCalledWith(
+ {
+ expiresAtMs: expiry,
+ reason: "Updated maintenance",
+ },
+ 2
+ );
+ });
+
+ test("associates an invalid expiry with only the expiry control", async () => {
+ const onDisable = jest.fn(async () => {});
+ renderScheduleDetail({ onDisable });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+ await user.type(screen.getByLabelText("Reason"), "Planned maintenance");
+ const expiry = screen.getByLabelText("Expiry");
+ fireEvent.change(expiry, { target: { value: "2000-01-01T00:00" } });
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+
+ const expiryError = screen.getByText(
+ "Expiry must be a valid future date and time."
+ );
+ const describedBy = expiry.getAttribute("aria-describedby")?.split(" ");
+ expect(describedBy).toContain(expiryError.id);
+ expect(expiry).toHaveAttribute("data-invalid");
+ expect(screen.getByLabelText("Reason")).not.toHaveAttribute("data-invalid");
+ expect(onDisable).not.toHaveBeenCalled();
+ await waitFor(() => expect(expiry).toHaveFocus());
+ expect(screen.getByRole("form", { name: "Disable schedule" })).toHaveAttribute(
+ "novalidate"
+ );
+ });
+
+ test("validates disable reasons with the shared control-safe code-point schema", async () => {
+ const onDisable = jest.fn(async () => {});
+ renderScheduleDetail({ onDisable });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+ const reason = screen.getByLabelText("Reason");
+ fireEvent.change(reason, { target: { value: "Unsafe\nreason" } });
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+
+ const error = screen.getByText(
+ "Use 1–1000 visible characters without control characters."
+ );
+ expect(reason.getAttribute("aria-describedby")?.split(" ")).toContain(error.id);
+ expect(reason).toHaveAttribute("data-invalid");
+ expect(onDisable).not.toHaveBeenCalled();
+ await waitFor(() => expect(reason).toHaveFocus());
+
+ const maximumAstralReason = "😀".repeat(1000);
+ fireEvent.change(reason, { target: { value: maximumAstralReason } });
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+ expect(onDisable).toHaveBeenCalledWith({ reason: maximumAstralReason }, 1);
+ });
+
+ test("does not resubmit an unchanged disable intent", async () => {
+ const onDisable = jest.fn(async () => {});
+ renderScheduleDetail({ onDisable, schedule: disabledSchedule(maxTime) });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Update disable intent" }));
+ const save = screen.getByRole("button", { name: "Save intent" });
+ expect(save).toBeDisabled();
+ fireEvent.submit(screen.getByRole("form", { name: "Update disable intent" }));
+ expect(onDisable).not.toHaveBeenCalled();
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleDetailStateCopy.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailStateCopy.test.tsx
new file mode 100644
index 000000000..4eeb3982d
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailStateCopy.test.tsx
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test";
+
+import { maxTime } from "date-fns/constants";
+
+import { disabledSchedule, renderScheduleDetail } from "./testSupport/ScheduleDetail.tsx";
+
+const { screen } = await import("@testing-library/react");
+
+describe("schedule detail interaction state", () => {
+ test("wraps contract-valid schedule copy without horizontal overflow", () => {
+ const longToken = "x".repeat(1000);
+ renderScheduleDetail({
+ schedule: {
+ ...disabledSchedule(maxTime),
+ activeDisableIntent: {
+ ...disabledSchedule(maxTime).activeDisableIntent!,
+ reason: longToken,
+ },
+ description: longToken,
+ name: longToken,
+ },
+ });
+
+ expect(screen.getByRole("heading", { level: 2, name: longToken })).toHaveClass(
+ "wrap-anywhere"
+ );
+ for (const copy of screen.getAllByText(longToken)) {
+ expect(copy).toHaveClass("wrap-anywhere");
+ }
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleDetailStateDisable.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailStateDisable.test.tsx
new file mode 100644
index 000000000..a33fc492d
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailStateDisable.test.tsx
@@ -0,0 +1,55 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { useState } from "react";
+
+import { ScheduleDetail } from "./ScheduleDetail.tsx";
+import { enabledSchedule } from "./testSupport/ScheduleDetail.tsx";
+
+const { render, screen, waitFor, within } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+describe("schedule detail interaction state", () => {
+ test("keeps a rejected disable draft and its classified failure inside the dialog", async () => {
+ const classifiedFailure =
+ "The job state changed. Refresh the page and try again.";
+ const onDisable = jest.fn();
+
+ function RejectedDisableIntent() {
+ const [error, setError] = useState();
+ return (
+ Run history}
+ onDisable={(draft) => {
+ onDisable(draft);
+ setError(classifiedFailure);
+ return Promise.reject(new TypeError("conflict"));
+ }}
+ onEnable={async () => {}}
+ onOpenDisable={() => setError(undefined)}
+ onRun={async () => {}}
+ onSaveConfiguration={async () => {}}
+ runBusy={false}
+ schedule={enabledSchedule()}
+ updateBusy={false}
+ />
+ );
+ }
+
+ render();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+ const reason = screen.getByLabelText("Reason");
+ await user.type(reason, "Keep this draft");
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+
+ const dialog = screen.getByRole("dialog");
+ const alert = await within(dialog).findByRole("alert");
+ expect(alert).toHaveTextContent(classifiedFailure);
+ expect(screen.getAllByRole("alert")).toHaveLength(1);
+ expect(reason).toHaveValue("Keep this draft");
+ expect(onDisable).toHaveBeenCalledWith({ reason: "Keep this draft" });
+ await waitFor(() => expect(alert).toHaveFocus());
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleDetailStateErrors.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailStateErrors.test.tsx
new file mode 100644
index 000000000..4ac30e73c
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailStateErrors.test.tsx
@@ -0,0 +1,24 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { renderScheduleDetail } from "./testSupport/ScheduleDetail.tsx";
+
+const { screen, within } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+describe("schedule detail interaction state", () => {
+ test("keeps unrelated errors outside a freshly opened disable dialog", async () => {
+ const onOpenDisable = jest.fn();
+ renderScheduleDetail({
+ error: "A background refresh failed.",
+ onOpenDisable,
+ });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+
+ expect(onOpenDisable).toHaveBeenCalledTimes(1);
+ expect(within(screen.getByRole("dialog")).queryByRole("alert")).toBeNull();
+ expect(screen.queryByText("A background refresh failed.")).toBeNull();
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleDetailStateReplay.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailStateReplay.test.tsx
new file mode 100644
index 000000000..daca46528
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailStateReplay.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import {
+ enabledSchedule,
+ renderScheduleDetail,
+ scheduleId,
+ timestampMs,
+} from "./testSupport/ScheduleDetail.tsx";
+
+const { screen } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+describe("schedule detail interaction state", () => {
+ test("allows an explicit idempotent retry while the refreshed schedule has an active run", async () => {
+ const onRun = jest.fn(async () => {});
+ renderScheduleDetail({
+ onRun,
+ runReplayAvailable: true,
+ schedule: {
+ ...enabledSchedule(),
+ activeRun: {
+ actionKey: scheduleId,
+ attemptCount: 0,
+ attemptLimit: 3,
+ availableAtMs: timestampMs,
+ cancellationPolicy: "cooperative",
+ displayName: "Worker smoke manual run",
+ eventCount: 1,
+ id: "019fdf90-0000-7000-8000-000000000004",
+ priority: 0,
+ queuedAtMs: timestampMs,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ scheduledJobId: scheduleId,
+ scheduledJobVersion: 1,
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 30_000,
+ triggerType: "manual",
+ updatedAtMs: timestampMs,
+ },
+ },
+ });
+ const user = userEvent.setup();
+
+ const retry = screen.getByRole("button", { name: "Retry run request" });
+ expect(retry).toBeEnabled();
+ await user.click(retry);
+ expect(onRun).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleDetailStateVersion.test.tsx b/greenfield/src/browser/jobs/ScheduleDetailStateVersion.test.tsx
new file mode 100644
index 000000000..d5a2d7297
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleDetailStateVersion.test.tsx
@@ -0,0 +1,47 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { ScheduleDetail } from "./ScheduleDetail.tsx";
+import {
+ enabledSchedule,
+ renderScheduleDetail,
+ timestampMs,
+} from "./testSupport/ScheduleDetail.tsx";
+
+const { fireEvent, screen, waitFor } = await import("@testing-library/react");
+const userEventModule = await import("@testing-library/user-event");
+const userEvent = userEventModule.default;
+
+describe("schedule detail interaction state", () => {
+ test("preserves the modal version and exact reason across a background refresh", async () => {
+ const onDisable = jest.fn(async () => {});
+ const schedule = enabledSchedule();
+ const view = renderScheduleDetail({ onDisable, schedule });
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Disable" }));
+ fireEvent.change(screen.getByLabelText("Reason"), {
+ target: { value: " Planned maintenance " },
+ });
+ view.rerender(
+ Run history}
+ onDisable={onDisable}
+ onEnable={async () => {}}
+ onOpenDisable={() => {}}
+ onRun={async () => {}}
+ onSaveConfiguration={async () => {}}
+ runBusy={false}
+ schedule={{ ...schedule, updatedAtMs: timestampMs + 1000, version: 2 }}
+ updateBusy={false}
+ />
+ );
+ await user.click(screen.getByRole("button", { name: "Disable schedule" }));
+
+ expect(onDisable).toHaveBeenCalledWith({ reason: " Planned maintenance " }, 1);
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Worker smoke" })
+ ).toHaveFocus()
+ );
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleEditor.test.tsx b/greenfield/src/browser/jobs/ScheduleEditor.test.tsx
new file mode 100644
index 000000000..fd0df2ead
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleEditor.test.tsx
@@ -0,0 +1,92 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import type { ScheduleSummary } from "../../contracts/jobModel.ts";
+import { ScheduleEditor } from "./ScheduleEditor.tsx";
+
+const { act, fireEvent, render, screen, waitFor } =
+ await import("@testing-library/react");
+
+const timestampMs = 1_800_000_000_000;
+
+function intervalSchedule(): ScheduleSummary {
+ return {
+ actionKey: "system.worker-smoke",
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the worker without host mutation.",
+ enabled: true,
+ id: "system.worker-smoke",
+ name: "Worker smoke",
+ nextRunAtMs: timestampMs + 60_000,
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 30_000,
+ updatedAtMs: timestampMs,
+ version: 1,
+ };
+}
+
+describe("schedule editor", () => {
+ test("disables normalized cadence no-ops and submits only a semantic change", async () => {
+ const onSave = jest.fn(async () => {});
+ render(
+
+ );
+
+ const form = screen.getByRole("form", {
+ name: "Edit Worker smoke schedule",
+ });
+ const interval = screen.getByLabelText("Interval (seconds)");
+ const save = screen.getByRole("button", { name: "Save schedule" });
+ expect(form).toHaveAttribute("novalidate");
+ expect(save).toBeDisabled();
+
+ act(() => {
+ fireEvent.change(interval, { target: { value: "60.000" } });
+ });
+ expect(screen.getByRole("button", { name: "Save schedule" })).toBeDisabled();
+ expect(onSave).not.toHaveBeenCalled();
+
+ act(() => {
+ fireEvent.change(interval, { target: { value: "120" } });
+ });
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Save schedule" })).toBeEnabled()
+ );
+ act(() => {
+ fireEvent.submit(form);
+ });
+ await waitFor(() =>
+ expect(onSave).toHaveBeenCalledWith({
+ intervalMs: 120_000,
+ kind: "interval",
+ })
+ );
+ });
+
+ test("focuses a schema-invalid control after client-managed submission", async () => {
+ const onSave = jest.fn(async () => {});
+ render(
+
+ );
+
+ const interval = screen.getByLabelText("Interval (seconds)");
+ act(() => {
+ fireEvent.change(interval, { target: { value: "59" } });
+ fireEvent.submit(
+ screen.getByRole("form", { name: "Edit Worker smoke schedule" })
+ );
+ });
+
+ const error = await screen.findByText(
+ "Use an interval from 60 to 31,536,000 seconds"
+ );
+ expect(interval.getAttribute("aria-describedby")?.split(" ")).toContain(error.id);
+ await waitFor(() => expect(interval).toHaveFocus());
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleEditor.tsx b/greenfield/src/browser/jobs/ScheduleEditor.tsx
new file mode 100644
index 000000000..9cc07a528
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleEditor.tsx
@@ -0,0 +1,273 @@
+import { useForm } from "@tanstack/react-form";
+import { Save } from "lucide-react";
+import { useId } from "react";
+import * as v from "valibot";
+
+import {
+ type ScheduleConfiguration,
+ type ScheduleKind,
+ type ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import { canonicalScheduleTimeZones } from "../../contracts/scheduleTimeZones.ts";
+import { Button } from "../ui/Button.tsx";
+import { Form } from "../ui/Form.tsx";
+import { firstFormFieldError } from "../ui/formErrors.ts";
+import { FormField } from "../ui/FormField.tsx";
+import { Icon } from "../ui/Icon.tsx";
+import { Input } from "../ui/Input.tsx";
+import { Select, type SelectOption } from "../ui/Select.tsx";
+import {
+ scheduleConfigurationFromEditor,
+ scheduleEditorFormSchema,
+ scheduleEditorValues,
+} from "./scheduleEditorForm.ts";
+
+const scheduleKindOptions: readonly SelectOption[] = Object.freeze([
+ { description: "Run after a fixed duration.", label: "Interval", value: "interval" },
+ { description: "Run once per local day.", label: "Daily", value: "daily" },
+ { description: "Use a five-field minute cron.", label: "Cron", value: "cron" },
+]);
+
+interface ScheduleEditorProps {
+ readonly busy: boolean;
+ readonly onSave: (configuration: ScheduleConfiguration) => Promise;
+ readonly schedule: ScheduleSummary;
+}
+
+function scheduleConfigurationsMatch(
+ left: ScheduleConfiguration,
+ right: ScheduleConfiguration
+): boolean {
+ if (left.kind !== right.kind) return false;
+ switch (left.kind) {
+ case "cron": {
+ return (
+ right.kind === left.kind &&
+ left.expression === right.expression &&
+ left.timeZone === right.timeZone
+ );
+ }
+ case "daily": {
+ return (
+ right.kind === left.kind &&
+ left.timeOfDay === right.timeOfDay &&
+ left.timeZone === right.timeZone
+ );
+ }
+ case "interval": {
+ return right.kind === left.kind && left.intervalMs === right.intervalMs;
+ }
+ }
+}
+
+/** @returns A contract-validated editor for one code-owned schedule cadence. */
+export function ScheduleEditor({ busy, onSave, schedule }: ScheduleEditorProps) {
+ const editorFormId = useId();
+ const timeZoneListId = useId();
+ const form = useForm({
+ defaultValues: scheduleEditorValues(schedule),
+ onSubmit: async ({ value }) => {
+ const parsed = v.parse(scheduleEditorFormSchema, value);
+ const configuration = scheduleConfigurationFromEditor(parsed);
+ if (scheduleConfigurationsMatch(configuration, schedule.schedule)) return;
+ await onSave(configuration);
+ },
+ validators: { onSubmit: scheduleEditorFormSchema },
+ });
+
+ async function submitEditor(): Promise {
+ try {
+ await form.handleSubmit();
+ } catch {
+ // The owning mutation renders its classified failure.
+ }
+ setTimeout(() => {
+ document
+ .querySelector(`[id="${editorFormId}"]`)
+ ?.querySelector("[data-invalid]:is(button, input)")
+ ?.focus();
+ }, 0);
+ }
+
+ return (
+
+ {(field) => (
+
+
+
+ )}
+
+ state.values.kind}>
+ {(kind) => (
+ <>
+ {kind === "interval" && (
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value
+ )
+ }
+ required
+ step="0.001"
+ type="number"
+ value={field.state.value}
+ />
+
+ )}
+
+ )}
+ {kind === "daily" && (
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value
+ )
+ }
+ required
+ type="time"
+ value={field.state.value}
+ />
+
+ )}
+
+ )}
+ {kind === "cron" && (
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value
+ )
+ }
+ required
+ value={field.state.value}
+ />
+
+ )}
+
+ )}
+ {kind !== "interval" && (
+
+ {(field) => (
+
+
+ field.handleChange(
+ event.currentTarget.value
+ )
+ }
+ required
+ value={field.state.value}
+ />
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ [state.canSubmit, state.values] as const}
+ >
+ {([canSubmit, values]) => {
+ const parsed = v.safeParse(scheduleEditorFormSchema, values);
+ const unchanged =
+ parsed.success &&
+ scheduleConfigurationsMatch(
+ scheduleConfigurationFromEditor(parsed.output),
+ schedule.schedule
+ );
+ return (
+
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/ScheduleTable.test.tsx b/greenfield/src/browser/jobs/ScheduleTable.test.tsx
new file mode 100644
index 000000000..546a5b570
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleTable.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import type { ScheduleSummary } from "../../contracts/jobModel.ts";
+import { scheduleConfigurationLabel } from "./schedulePresentation.ts";
+import { ScheduleTable } from "./ScheduleTable.tsx";
+
+const { render, screen } = await import("@testing-library/react");
+
+function schedule(id: string, index: number): ScheduleSummary {
+ return {
+ actionKey: "system.worker-smoke",
+ attemptLimit: 2,
+ cancellationPolicy: "cooperative",
+ createdAtMs: 1000,
+ description: "Checks the durable worker.",
+ enabled: index % 2 === 0,
+ id,
+ name: `Worker smoke ${index}`,
+ ...(index % 2 === 0 ? { nextRunAtMs: 120_000 } : {}),
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 60_000,
+ updatedAtMs: 1000,
+ version: 1,
+ ...(index % 2 === 0
+ ? {}
+ : {
+ activeDisableIntent: {
+ createdAtMs: 1000,
+ id: `019fdf60-0000-7000-8000-${String(index).padStart(12, "0")}`,
+ reason: "Maintenance",
+ },
+ }),
+ };
+}
+
+describe("schedule table", () => {
+ test("renders stable schedule state and selectable identity", () => {
+ const onSelect = jest.fn();
+ render(
+
+ );
+
+ screen
+ .getByRole("button", {
+ name: "Worker smoke 1; system.worker-smoke-disabled",
+ })
+ .click();
+ expect(onSelect).toHaveBeenCalledWith("system.worker-smoke-disabled");
+ expect(screen.getByText("enabled")).toBeTruthy();
+ expect(screen.getByText("disabled")).toBeTruthy();
+ });
+
+ test("formats every schedule variant without losing contract values", () => {
+ expect(scheduleConfigurationLabel({ intervalMs: 60_500, kind: "interval" })).toBe(
+ "Every 60.5 seconds"
+ );
+ expect(
+ scheduleConfigurationLabel({
+ kind: "daily",
+ timeOfDay: "07:30",
+ timeZone: "Europe/Oslo",
+ })
+ ).toBe("Daily 07:30 · Europe/Oslo");
+ expect(
+ scheduleConfigurationLabel({
+ expression: "0 6 * * 1",
+ kind: "cron",
+ timeZone: "UTC",
+ })
+ ).toBe("0 6 * * 1 · UTC");
+ });
+});
diff --git a/greenfield/src/browser/jobs/ScheduleTable.tsx b/greenfield/src/browser/jobs/ScheduleTable.tsx
new file mode 100644
index 000000000..1cd59a3b3
--- /dev/null
+++ b/greenfield/src/browser/jobs/ScheduleTable.tsx
@@ -0,0 +1,137 @@
+import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table";
+
+import type { JobRunState, ScheduleSummary } from "../../contracts/jobModel.ts";
+import { cn } from "../lib/classNames.ts";
+import { formatDashboardDateTime } from "../lib/formatDateTime.ts";
+import { Badge } from "../ui/Badge.tsx";
+import { DataTable } from "../ui/DataTable.tsx";
+import { Text } from "../ui/Text.tsx";
+import { Virtualizer, type VirtualizerRenderState } from "../ui/Virtualizer.tsx";
+import { scheduleConfigurationLabel } from "./schedulePresentation.ts";
+
+const minimumVirtualizedRows = 50;
+const scheduleTableFeatures = tableFeatures({});
+
+function runStateVariant(state: JobRunState) {
+ if (["failed", "timed-out"].includes(state)) return "danger" as const;
+ if (state === "succeeded") return "success" as const;
+ if (["queued", "running"].includes(state)) return "info" as const;
+ return "default" as const;
+}
+
+interface ScheduleTableRow {
+ readonly onSelect: (id: string) => void;
+ readonly schedule: ScheduleSummary;
+ readonly selected: boolean;
+}
+
+const scheduleColumnHelper = createColumnHelper<
+ typeof scheduleTableFeatures,
+ ScheduleTableRow
+>();
+
+const scheduleColumns = scheduleColumnHelper.columns([
+ scheduleColumnHelper.accessor((row) => row.schedule.name, {
+ cell: ({ getValue, row }) => (
+
+ ),
+ header: "Schedule",
+ id: "name",
+ }),
+ scheduleColumnHelper.accessor((row) => row.schedule.enabled, {
+ cell: ({ getValue }) => (
+
+ {getValue() ? "enabled" : "disabled"}
+
+ ),
+ header: "State",
+ id: "enabled",
+ }),
+ scheduleColumnHelper.accessor((row) => row.schedule.schedule, {
+ cell: ({ getValue }) => (
+
+ {scheduleConfigurationLabel(getValue())}
+
+ ),
+ header: "Cadence",
+ id: "cadence",
+ }),
+ scheduleColumnHelper.accessor((row) => row.schedule.nextRunAtMs, {
+ cell: ({ getValue }) => {
+ const nextRunAtMs = getValue();
+ return nextRunAtMs === undefined ? (
+ —
+ ) : (
+
+ );
+ },
+ header: "Next run",
+ id: "nextRunAtMs",
+ }),
+ scheduleColumnHelper.accessor((row) => row.schedule.activeRun?.state, {
+ cell: ({ getValue }) => {
+ const state = getValue();
+ return state === undefined ? (
+ Idle
+ ) : (
+ {state}
+ );
+ },
+ header: "Active run",
+ id: "activeRun",
+ }),
+]);
+
+interface ScheduleTableProps {
+ readonly onSelect: (id: string) => void;
+ readonly schedules: readonly ScheduleSummary[];
+ readonly selectedId?: string;
+}
+
+/** @returns Selectable, virtualized Dashboard-local schedule directory. */
+export function ScheduleTable({ onSelect, schedules, selectedId }: ScheduleTableProps) {
+ const table = useTable({
+ columns: scheduleColumns,
+ data: schedules.map((schedule) => ({
+ onSelect,
+ schedule,
+ selected: schedule.id === selectedId,
+ })),
+ features: scheduleTableFeatures,
+ getRowId: ({ schedule }) => schedule.id,
+ });
+ const rows = table.getRowModel().rows;
+ const tableElement = (rowWindow?: VirtualizerRenderState) => (
+
+ );
+
+ if (rows.length < minimumVirtualizedRows) return tableElement();
+ return (
+
+ count={rows.length}
+ estimateSize={() => 68}
+ getItemKey={(index) => rows[index]?.id ?? `missing-schedule-${index}`}
+ >
+ {(virtualization) => tableElement(virtualization)}
+
+ );
+}
diff --git a/greenfield/src/browser/jobs/jobBrowserFailure.ts b/greenfield/src/browser/jobs/jobBrowserFailure.ts
new file mode 100644
index 000000000..7b361e3a6
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobBrowserFailure.ts
@@ -0,0 +1,22 @@
+import {
+ classifyDashboardBrowserFailure,
+ dashboardBrowserFailureMessage,
+} from "../api/trpcError.ts";
+
+/**
+ * @param error Unknown rejection.
+ * @returns Fixed jobs-domain failure text without exposing server-controlled content.
+ */
+export function jobBrowserFailureMessage(error: unknown): string {
+ switch (classifyDashboardBrowserFailure(error)) {
+ case "conflict": {
+ return "The job state changed. Refresh the page and try again.";
+ }
+ case "not-found": {
+ return "The selected job record no longer exists.";
+ }
+ default: {
+ return dashboardBrowserFailureMessage(error);
+ }
+ }
+}
diff --git a/greenfield/src/browser/jobs/jobMutations.test.tsx b/greenfield/src/browser/jobs/jobMutations.test.tsx
new file mode 100644
index 000000000..ea1334456
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobMutations.test.tsx
@@ -0,0 +1,989 @@
+import { describe, expect, test } from "bun:test";
+
+import { QueryClientProvider, QueryObserver } from "@tanstack/react-query";
+import type { TRPCRequestOptions } from "@trpc/client";
+import { act, type ReactNode } from "react";
+import * as v from "valibot";
+
+import type { AuthStatus } from "../../contracts/auth.ts";
+import type {
+ JobRunSummary,
+ JobWorkerControl,
+ ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import {
+ jobQueueSummaryIsConsistent,
+ type ListJobRunsResult,
+} from "../../contracts/jobs.ts";
+import { runScheduleInputSchema } from "../../contracts/schedules.ts";
+import { createDashboardQueryClient } from "../api/queryClient.ts";
+import {
+ createDashboardTrpcClient,
+ type DashboardTrpcTransport,
+} from "../api/trpcClient.ts";
+import { DashboardTrpcProvider } from "../api/trpcContext.tsx";
+import { authStatusQueryKey } from "../auth/authQueries.ts";
+import {
+ createScheduleRunIdempotencyKey,
+ patchJobRunInCachedQueries,
+ patchJobWorkerControlInCachedQueries,
+ patchScheduleInCachedQueries,
+ removeJobRunFromCachedQueries,
+ removeScheduleFromCachedQueries,
+ useRunScheduleMutation,
+ useUpdateScheduleMutation,
+} from "./jobMutations.ts";
+import {
+ jobRunDetailQueryKey,
+ jobRunEventGapQueryKey,
+ jobRunEventHistoryQueryKey,
+ jobRunListQueryKey,
+ jobQueueSummaryQueryKey,
+ scheduleDetailQueryKey,
+ scheduleListQueryKey,
+ scheduleRunListQueryKey,
+} from "./jobQueries.ts";
+
+const { renderHook } = await import("@testing-library/react");
+const scheduleId = "system.worker-smoke";
+const runId = "019fdf70-0000-7000-8000-000000000002";
+const nextRunId = "019fdf80-0000-7000-8000-000000000003";
+const newestRunId = "019fdf90-0000-7000-8000-000000000004";
+const timestampMs = 1_800_000_000_000;
+
+function authenticatedStatus(): Extract {
+ return {
+ session: {
+ authenticatedAtMs: timestampMs,
+ authMethod: "password",
+ createdAtMs: timestampMs,
+ expiresAtMs: timestampMs + 86_400_000,
+ id: "a".repeat(32),
+ isCurrent: true,
+ lastSeenAtMs: timestampMs,
+ },
+ state: "authenticated",
+ user: {
+ id: "019fd974-54a2-74dd-a64b-d4186f8d8828",
+ username: "operator",
+ },
+ };
+}
+
+function queuedRun(id = runId, queuedAtMs = timestampMs): JobRunSummary {
+ return {
+ actionKey: scheduleId,
+ attemptCount: 0,
+ attemptLimit: 3,
+ availableAtMs: queuedAtMs,
+ cancellationPolicy: "cooperative",
+ displayName: "Worker smoke",
+ eventCount: 1,
+ id,
+ priority: 0,
+ queuedAtMs,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ scheduledJobId: scheduleId,
+ scheduledJobVersion: 1,
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 30_000,
+ triggerType: "manual",
+ updatedAtMs: queuedAtMs,
+ };
+}
+
+function cancelledRun(id = runId, queuedAtMs = timestampMs): JobRunSummary {
+ return {
+ ...queuedRun(id, queuedAtMs),
+ eventCount: 2,
+ finishedAtMs: queuedAtMs + 1000,
+ state: "cancelled",
+ stateVersion: 2,
+ terminalCode: "operator-cancelled",
+ terminalMessage: "Cancelled by the operator.",
+ updatedAtMs: queuedAtMs + 1000,
+ };
+}
+
+function runningRun(): JobRunSummary {
+ return {
+ ...queuedRun(),
+ attemptCount: 1,
+ eventCount: 2,
+ firstStartedAtMs: timestampMs + 1000,
+ lastAttemptStartedAtMs: timestampMs + 1000,
+ state: "running",
+ stateVersion: 2,
+ updatedAtMs: timestampMs + 1000,
+ };
+}
+
+function succeededRun(): JobRunSummary {
+ return {
+ ...runningRun(),
+ eventCount: 3,
+ finishedAtMs: timestampMs + 2000,
+ state: "succeeded",
+ stateVersion: 3,
+ updatedAtMs: timestampMs + 2000,
+ };
+}
+
+function schedule(overrides: Partial = {}): ScheduleSummary {
+ const run = queuedRun();
+ return {
+ actionKey: scheduleId,
+ activeRun: run,
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the worker without host mutation.",
+ enabled: true,
+ id: scheduleId,
+ latestRun: run,
+ name: "Worker smoke",
+ nextRunAtMs: timestampMs + 60_000,
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 30_000,
+ updatedAtMs: timestampMs,
+ version: 1,
+ ...overrides,
+ };
+}
+
+function queueSummary(): ListJobRunsResult["summary"] {
+ return {
+ activeResourceClasses: [],
+ control: { claimingPaused: false, updatedAtMs: timestampMs, version: 1 },
+ oldestQueuedAtMs: timestampMs,
+ stateCounts: {
+ cancelled: 0,
+ failed: 0,
+ queued: 1,
+ running: 0,
+ succeeded: 0,
+ "timed-out": 0,
+ },
+ workers: [],
+ };
+}
+
+function runningQueueSummary(): ListJobRunsResult["summary"] {
+ return {
+ activeResourceClasses: ["light"],
+ control: { claimingPaused: false, updatedAtMs: timestampMs, version: 1 },
+ stateCounts: {
+ cancelled: 0,
+ failed: 0,
+ queued: 0,
+ running: 1,
+ succeeded: 0,
+ "timed-out": 0,
+ },
+ workers: [
+ {
+ activeRunCount: 1,
+ capacity: 1,
+ heartbeatAtMs: timestampMs + 1000,
+ id: "019fdf60-0000-7000-8000-000000000001",
+ releaseId: "a".repeat(40),
+ startedAtMs: timestampMs,
+ state: "online",
+ },
+ ],
+ };
+}
+
+interface MutationCall {
+ readonly input: unknown;
+ readonly path: string;
+ readonly signal: AbortSignal | undefined;
+}
+
+class JobMutationTransport implements DashboardTrpcTransport {
+ readonly calls: MutationCall[] = [];
+ readonly #outputs: unknown[];
+
+ constructor(outputs: unknown[]) {
+ this.#outputs = outputs;
+ }
+
+ mutation(
+ path: string,
+ input?: unknown,
+ options?: TRPCRequestOptions
+ ): Promise {
+ const output = this.#outputs[this.calls.length];
+ this.calls.push({ input, path, signal: options?.signal });
+ return output instanceof Error ? Promise.reject(output) : Promise.resolve(output);
+ }
+
+ query(path: string): Promise {
+ return Promise.reject(new TypeError(`Unexpected query: ${path}`));
+ }
+}
+
+describe("jobs browser mutations", () => {
+ test("patches materialized run, schedule, and worker projections before refresh", () => {
+ const queryClient = createDashboardQueryClient();
+ const queued = queuedRun();
+ const scheduleRecord = schedule();
+ const globalKey = jobRunListQueryKey(undefined);
+ const queuedFilterKey = jobRunListQueryKey({ states: ["queued"] });
+ const cancelledFilterKey = jobRunListQueryKey({ states: ["cancelled"] });
+ const scheduleRunsKey = scheduleRunListQueryKey(scheduleId);
+ const eventHistoryKey = jobRunEventHistoryQueryKey(runId);
+ const enabledSchedulesKey = scheduleListQueryKey("enabled");
+ const disabledSchedulesKey = scheduleListQueryKey("disabled");
+ const listData = {
+ pageParams: [undefined],
+ pages: [{ runs: [queued], summary: queueSummary() }],
+ };
+ queryClient.setQueryData(globalKey, listData);
+ queryClient.setQueryData(jobQueueSummaryQueryKey, listData.pages[0]);
+ queryClient.setQueryData(queuedFilterKey, listData);
+ queryClient.setQueryData(cancelledFilterKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [], summary: queueSummary() }],
+ });
+ queryClient.setQueryData(scheduleRunsKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [queued] }],
+ });
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), {
+ events: [],
+ run: queued,
+ });
+ queryClient.setQueryData(eventHistoryKey, {
+ pageParams: [{ sequence: 1 }],
+ pages: [{ events: [], run: queued }],
+ });
+ queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), scheduleRecord);
+ queryClient.setQueryData(enabledSchedulesKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [scheduleRecord] }],
+ });
+ queryClient.setQueryData(disabledSchedulesKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [] }],
+ });
+
+ try {
+ const cancelled = cancelledRun();
+ patchJobRunInCachedQueries(queryClient, cancelled);
+
+ expect(
+ queryClient.getQueryData(globalKey)?.pages[0]
+ ).toMatchObject({
+ runs: [cancelled],
+ summary: {
+ stateCounts: { cancelled: 0, queued: 1 },
+ },
+ });
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)?.runs
+ ).toEqual([cancelled]);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary.stateCounts
+ ).toMatchObject({ cancelled: 0, queued: 1 });
+ expect(
+ queryClient.getQueryData(queuedFilterKey)?.pages[0]?.runs
+ ).toEqual([]);
+ expect(
+ queryClient.getQueryData(cancelledFilterKey)?.pages[0]
+ ?.runs
+ ).toEqual([]);
+ expect(
+ queryClient.getQueryData<{ run: JobRunSummary }>(
+ jobRunDetailQueryKey(runId)
+ )?.run
+ ).toEqual(cancelled);
+ expect(
+ queryClient.getQueryData<{
+ pages: { run: JobRunSummary }[];
+ }>(eventHistoryKey)?.pages[0]?.run
+ ).toEqual(cancelled);
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )
+ ).toMatchObject({ latestRun: cancelled });
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )?.activeRun
+ ).toBeUndefined();
+
+ const control: JobWorkerControl = {
+ claimingPaused: true,
+ updatedAtMs: timestampMs + 2000,
+ version: 2,
+ };
+ patchJobWorkerControlInCachedQueries(queryClient, control);
+ expect(
+ queryClient.getQueryData(globalKey)?.pages[0]?.summary
+ .control
+ ).toEqual(control);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary.control
+ ).toEqual(control);
+
+ const disabled = schedule({
+ activeDisableIntent: {
+ createdAtMs: timestampMs + 3000,
+ id: "019fdf90-0000-7000-8000-000000000004",
+ reason: "Maintenance",
+ },
+ activeRun: undefined,
+ enabled: false,
+ latestRun: cancelled,
+ nextRunAtMs: undefined,
+ updatedAtMs: timestampMs + 3000,
+ version: 2,
+ });
+ patchScheduleInCachedQueries(queryClient, disabled);
+ expect(
+ queryClient.getQueryData<{ pages: { schedules: ScheduleSummary[] }[] }>(
+ enabledSchedulesKey
+ )?.pages[0]?.schedules
+ ).toEqual([]);
+ expect(
+ queryClient.getQueryData<{ pages: { schedules: ScheduleSummary[] }[] }>(
+ disabledSchedulesKey
+ )?.pages[0]?.schedules
+ ).toEqual([]);
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )
+ ).toEqual(disabled);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("keeps newer realtime snapshots over delayed mutation responses", () => {
+ const queryClient = createDashboardQueryClient();
+ const currentRun = {
+ ...succeededRun(),
+ eventCount: 4,
+ stateVersion: 4,
+ updatedAtMs: timestampMs + 3000,
+ };
+ const currentControl: JobWorkerControl = {
+ claimingPaused: true,
+ updatedAtMs: timestampMs + 3000,
+ version: 3,
+ };
+ const currentSummary: ListJobRunsResult["summary"] = {
+ activeResourceClasses: [],
+ control: currentControl,
+ stateCounts: {
+ cancelled: 0,
+ failed: 0,
+ queued: 0,
+ running: 0,
+ succeeded: 1,
+ "timed-out": 0,
+ },
+ workers: [],
+ };
+ const currentSchedule = schedule({
+ activeRun: undefined,
+ latestRun: currentRun,
+ updatedAtMs: timestampMs + 3000,
+ version: 3,
+ });
+ const globalKey = jobRunListQueryKey(undefined);
+ const eventHistoryKey = jobRunEventHistoryQueryKey(runId);
+ const scheduleRunsKey = scheduleRunListQueryKey(scheduleId);
+ const schedulesKey = scheduleListQueryKey("all");
+ queryClient.setQueryData(globalKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [currentRun], summary: currentSummary }],
+ });
+ queryClient.setQueryData(jobQueueSummaryQueryKey, {
+ runs: [currentRun],
+ summary: currentSummary,
+ });
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), {
+ events: [],
+ result: { status: "ok" },
+ run: currentRun,
+ });
+ queryClient.setQueryData(eventHistoryKey, {
+ pageParams: [{ sequence: 1 }],
+ pages: [{ events: [], result: { status: "ok" }, run: currentRun }],
+ });
+ queryClient.setQueryData(scheduleRunsKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [currentRun] }],
+ });
+ queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), currentSchedule);
+ queryClient.setQueryData(schedulesKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [currentSchedule] }],
+ });
+
+ try {
+ const delayedRuns = [
+ {
+ ...currentRun,
+ eventCount: currentRun.eventCount + 1,
+ stateVersion: currentRun.stateVersion - 1,
+ updatedAtMs: currentRun.updatedAtMs + 1000,
+ },
+ {
+ ...currentRun,
+ eventCount: currentRun.eventCount - 1,
+ stateVersion: currentRun.stateVersion + 1,
+ updatedAtMs: currentRun.updatedAtMs + 1000,
+ },
+ {
+ ...currentRun,
+ eventCount: currentRun.eventCount + 1,
+ stateVersion: currentRun.stateVersion + 1,
+ updatedAtMs: currentRun.updatedAtMs - 1000,
+ },
+ ];
+ for (const delayed of delayedRuns) {
+ patchJobRunInCachedQueries(queryClient, delayed);
+ }
+
+ const staleByVersion = schedule({
+ activeRun: undefined,
+ description: "Stale version",
+ latestRun: cancelledRun(),
+ updatedAtMs: timestampMs + 4000,
+ version: 2,
+ });
+ const staleByTimestamp = schedule({
+ activeRun: undefined,
+ description: "Stale timestamp",
+ latestRun: cancelledRun(),
+ updatedAtMs: timestampMs + 2000,
+ version: 4,
+ });
+ patchScheduleInCachedQueries(queryClient, staleByVersion);
+ patchScheduleInCachedQueries(queryClient, staleByTimestamp);
+ patchJobWorkerControlInCachedQueries(queryClient, {
+ claimingPaused: false,
+ updatedAtMs: timestampMs + 4000,
+ version: 2,
+ });
+
+ expect(
+ queryClient.getQueryData<{
+ pages: { runs: JobRunSummary[] }[];
+ }>(globalKey)?.pages[0]?.runs
+ ).toEqual([currentRun]);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)?.runs
+ ).toEqual([currentRun]);
+ expect(
+ queryClient.getQueryData<{ run: JobRunSummary }>(
+ jobRunDetailQueryKey(runId)
+ )?.run
+ ).toEqual(currentRun);
+ expect(
+ queryClient.getQueryData<{
+ pages: { run: JobRunSummary }[];
+ }>(eventHistoryKey)?.pages[0]?.run
+ ).toEqual(currentRun);
+ expect(
+ queryClient.getQueryData<{
+ pages: { runs: JobRunSummary[] }[];
+ }>(scheduleRunsKey)?.pages[0]?.runs
+ ).toEqual([currentRun]);
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )
+ ).toEqual(currentSchedule);
+ expect(
+ queryClient.getQueryData<{
+ pages: { schedules: ScheduleSummary[] }[];
+ }>(schedulesKey)?.pages[0]?.schedules
+ ).toEqual([currentSchedule]);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary.control
+ ).toEqual(currentControl);
+ expect(
+ queryClient.getQueryData<{
+ pages: { summary: ListJobRunsResult["summary"] }[];
+ }>(globalKey)?.pages[0]?.summary.control
+ ).toEqual(currentControl);
+
+ const nextControl: JobWorkerControl = {
+ claimingPaused: false,
+ updatedAtMs: timestampMs + 2000,
+ version: 4,
+ };
+ patchJobWorkerControlInCachedQueries(queryClient, nextControl);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary.control
+ ).toEqual(nextControl);
+ expect(
+ queryClient.getQueryData<{
+ pages: { summary: ListJobRunsResult["summary"] }[];
+ }>(globalKey)?.pages[0]?.summary.control
+ ).toEqual(nextControl);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("keeps an advanced due cursor after a delayed update response and failed refresh", async () => {
+ const updateResponse = Promise.withResolvers();
+ const responseCursor = timestampMs + 120_000;
+ const advancedCursor = timestampMs + 240_000;
+ const updateTimestamp = timestampMs + 1000;
+ const delayedResponse = schedule({
+ activeRun: undefined,
+ latestRun: undefined,
+ nextRunAtMs: responseCursor,
+ schedule: { intervalMs: 120_000, kind: "interval" },
+ updatedAtMs: updateTimestamp,
+ version: 2,
+ });
+ const scheduledRun: JobRunSummary = {
+ ...queuedRun(nextRunId, responseCursor),
+ scheduledForAtMs: responseCursor,
+ scheduledJobVersion: 2,
+ triggerType: "schedule",
+ };
+ const advancedSnapshot = schedule({
+ activeRun: scheduledRun,
+ latestRun: scheduledRun,
+ nextRunAtMs: advancedCursor,
+ schedule: { intervalMs: 120_000, kind: "interval" },
+ updatedAtMs: updateTimestamp,
+ version: 2,
+ });
+ const transport = new JobMutationTransport([updateResponse.promise]);
+ const queryClient = createDashboardQueryClient();
+ queryClient.setQueryData(authStatusQueryKey, authenticatedStatus());
+ const detailKey = scheduleDetailQueryKey(scheduleId);
+ const listKey = scheduleListQueryKey("all");
+ queryClient.setQueryData(detailKey, advancedSnapshot);
+ queryClient.setQueryData(listKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [advancedSnapshot] }],
+ });
+ let refreshAttempts = 0;
+ const refreshFailed = Promise.withResolvers();
+ const observer = new QueryObserver(queryClient, {
+ queryFn: () => {
+ refreshAttempts += 1;
+ return Promise.reject(new TypeError("schedule refresh unavailable"));
+ },
+ queryKey: detailKey,
+ retry: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+ const unsubscribe = observer.subscribe((result) => {
+ if (result.isError && result.data !== undefined) refreshFailed.resolve();
+ });
+ const client = createDashboardTrpcClient(transport);
+ const rendered = renderHook(() => useUpdateScheduleMutation(), {
+ wrapper: ({ children }: { readonly children: ReactNode }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ try {
+ const mutation = rendered.result.current.mutateAsync({
+ expectedVersion: 1,
+ id: scheduleId,
+ patch: { schedule: { intervalMs: 120_000, kind: "interval" } },
+ });
+ updateResponse.resolve(delayedResponse);
+ await act(async () => {
+ await mutation;
+ });
+ await refreshFailed.promise;
+
+ expect(refreshAttempts).toBe(1);
+ expect(queryClient.getQueryData(detailKey)).toEqual(
+ advancedSnapshot
+ );
+ expect(
+ queryClient.getQueryData<{
+ pages: { schedules: ScheduleSummary[] }[];
+ }>(listKey)?.pages[0]?.schedules
+ ).toEqual([advancedSnapshot]);
+ expect(queryClient.getQueryState(detailKey)?.error).toBeInstanceOf(TypeError);
+ } finally {
+ unsubscribe();
+ rendered.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("patches only materialized replay rows and preserves newer schedule references and cursors", () => {
+ const queryClient = createDashboardQueryClient();
+ const replayed = cancelledRun();
+ const current = queuedRun(nextRunId, timestampMs + 10_000);
+ const newer = queuedRun(newestRunId, timestampMs + 20_000);
+ const currentSchedule = schedule({
+ activeRun: current,
+ latestRun: current,
+ });
+ const firstPageCursor = {
+ id: current.id,
+ queuedAtMs: current.queuedAtMs,
+ };
+ const globalKey = jobRunListQueryKey(undefined);
+ const scheduleRunsKey = scheduleRunListQueryKey(scheduleId);
+ const schedulesKey = scheduleListQueryKey("all");
+ const globalData = {
+ pageParams: [undefined, firstPageCursor],
+ pages: [
+ {
+ nextCursor: firstPageCursor,
+ runs: [current],
+ summary: {
+ ...queueSummary(),
+ oldestQueuedAtMs: current.queuedAtMs,
+ },
+ },
+ { runs: [queuedRun()], summary: queueSummary() },
+ ],
+ };
+ const scheduleRunData = {
+ pageParams: [undefined, firstPageCursor],
+ pages: [
+ { nextCursor: firstPageCursor, runs: [current] },
+ { runs: [queuedRun()] },
+ ],
+ };
+ queryClient.setQueryData(globalKey, globalData);
+ queryClient.setQueryData(scheduleRunsKey, scheduleRunData);
+ queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), currentSchedule);
+ queryClient.setQueryData(schedulesKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [currentSchedule] }],
+ });
+
+ try {
+ patchJobRunInCachedQueries(queryClient, replayed, true);
+
+ const patchedGlobal = queryClient.getQueryData(globalKey);
+ const patchedScheduleRuns =
+ queryClient.getQueryData(scheduleRunsKey);
+ expect(patchedGlobal?.pageParams).toEqual(globalData.pageParams);
+ expect(patchedGlobal?.pages[0]).toEqual(globalData.pages[0]);
+ expect(patchedGlobal?.pages[1]?.runs).toEqual([replayed]);
+ expect(patchedScheduleRuns?.pageParams).toEqual(scheduleRunData.pageParams);
+ expect(patchedScheduleRuns?.pages[0]).toEqual(scheduleRunData.pages[0]);
+ expect(patchedScheduleRuns?.pages[1]?.runs).toEqual([replayed]);
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )
+ ).toMatchObject({ activeRun: current, latestRun: current });
+
+ patchJobRunInCachedQueries(queryClient, newer, true);
+
+ expect(
+ queryClient
+ .getQueryData(globalKey)
+ ?.pages.flatMap(({ runs }) => runs)
+ ).toEqual([current, replayed]);
+ expect(
+ queryClient
+ .getQueryData(scheduleRunsKey)
+ ?.pages.flatMap(({ runs }) => runs)
+ ).toEqual([current, replayed]);
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )
+ ).toMatchObject({ activeRun: newer, latestRun: newer });
+ expect(
+ queryClient.getQueryData<{
+ pages: { schedules: ScheduleSummary[] }[];
+ }>(schedulesKey)?.pages[0]?.schedules[0]
+ ).toMatchObject({ activeRun: newer, latestRun: newer });
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("removes server-missing runs and schedules from every known cache", () => {
+ const queryClient = createDashboardQueryClient();
+ const run = queuedRun();
+ const scheduleRecord = schedule();
+ const globalKey = jobRunListQueryKey(undefined);
+ const scheduleRunsKey = scheduleRunListQueryKey(scheduleId);
+ const eventGapKey = jobRunEventGapQueryKey(runId);
+ const eventHistoryKey = jobRunEventHistoryQueryKey(runId);
+ const schedulesKey = scheduleListQueryKey("all");
+ queryClient.setQueryData(globalKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [run], summary: queueSummary() }],
+ });
+ queryClient.setQueryData(jobQueueSummaryQueryKey, {
+ runs: [run],
+ summary: queueSummary(),
+ });
+ queryClient.setQueryData(scheduleRunsKey, {
+ pageParams: [undefined],
+ pages: [{ runs: [run] }],
+ });
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), { events: [], run });
+ queryClient.setQueryData(eventHistoryKey, {
+ pageParams: [{ sequence: 1 }],
+ pages: [{ events: [], run }],
+ });
+ queryClient.setQueryData(eventGapKey, {
+ events: [],
+ request: { cursor: { sequence: 2 }, knownSequence: 1 },
+ });
+ queryClient.setQueryData(schedulesKey, {
+ pageParams: [undefined],
+ pages: [{ schedules: [scheduleRecord] }],
+ });
+ queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), scheduleRecord);
+
+ try {
+ removeJobRunFromCachedQueries(queryClient, runId);
+ const cachedGlobal = queryClient.getQueryData<{
+ pages: {
+ runs: JobRunSummary[];
+ summary: ReturnType;
+ }[];
+ }>(globalKey)?.pages[0];
+ expect(cachedGlobal?.runs).toEqual([]);
+ expect(cachedGlobal?.summary.stateCounts.queued).toBe(1);
+ expect(cachedGlobal?.summary.oldestQueuedAtMs).toBe(timestampMs);
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary
+ ).toEqual(queueSummary());
+ expect(queryClient.getQueryData(jobRunDetailQueryKey(runId))).toBeUndefined();
+ expect(queryClient.getQueryData(eventHistoryKey)).toBeUndefined();
+ expect(queryClient.getQueryData(eventGapKey)).toBeUndefined();
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )?.activeRun
+ ).toBeUndefined();
+
+ removeScheduleFromCachedQueries(queryClient, scheduleId);
+ expect(
+ queryClient.getQueryData<{ pages: { schedules: ScheduleSummary[] }[] }>(
+ schedulesKey
+ )?.pages[0]?.schedules
+ ).toEqual([]);
+ expect(
+ queryClient.getQueryData(scheduleDetailQueryKey(scheduleId))
+ ).toBeUndefined();
+ expect(queryClient.getQueryData(scheduleRunsKey)).toBeUndefined();
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("does not double-count a replay outside the queue poll's single materialized row", () => {
+ const queryClient = createDashboardQueryClient();
+ const replayed = queuedRun(nextRunId);
+ queryClient.setQueryData(jobQueueSummaryQueryKey, {
+ runs: [queuedRun()],
+ summary: queueSummary(),
+ });
+
+ try {
+ patchJobRunInCachedQueries(queryClient, replayed, true);
+
+ expect(
+ queryClient.getQueryData(jobQueueSummaryQueryKey)
+ ?.summary
+ ).toMatchObject({
+ oldestQueuedAtMs: timestampMs,
+ stateCounts: { queued: 1 },
+ });
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("keeps each queue snapshot independent from a stale detail projection", () => {
+ const queryClient = createDashboardQueryClient();
+ const running = runningRun();
+ const summary = runningQueueSummary();
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), {
+ events: [],
+ run: queuedRun(),
+ });
+ queryClient.setQueryData(jobQueueSummaryQueryKey, {
+ runs: [running],
+ summary,
+ });
+
+ try {
+ patchJobRunInCachedQueries(queryClient, running);
+
+ const snapshot = queryClient.getQueryData(
+ jobQueueSummaryQueryKey
+ );
+ expect(snapshot?.runs).toEqual([running]);
+ expect(snapshot?.summary).toEqual(summary);
+ expect(
+ snapshot === undefined
+ ? false
+ : jobQueueSummaryIsConsistent(snapshot.summary)
+ ).toBeTrue();
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("retains coherent server-owned aggregates when a running run settles", () => {
+ const queryClient = createDashboardQueryClient();
+ const running = runningRun();
+ const succeeded = succeededRun();
+ const summary = runningQueueSummary();
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), {
+ events: [],
+ run: running,
+ });
+ queryClient.setQueryData(jobQueueSummaryQueryKey, {
+ runs: [running],
+ summary,
+ });
+
+ try {
+ patchJobRunInCachedQueries(queryClient, succeeded);
+
+ const snapshot = queryClient.getQueryData(
+ jobQueueSummaryQueryKey
+ );
+ expect(snapshot?.runs).toEqual([succeeded]);
+ expect(snapshot?.summary).toEqual(summary);
+ expect(
+ snapshot === undefined
+ ? false
+ : jobQueueSummaryIsConsistent(snapshot.summary)
+ ).toBeTrue();
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("retains a manual-run key across lost response and refreshed active state", async () => {
+ const firstKey = "A".repeat(32);
+ const secondKey = "B".repeat(32);
+ const generatedKeys = [firstKey, secondKey];
+ let generationCount = 0;
+ const transport = new JobMutationTransport([
+ new TypeError("ambiguous transport failure"),
+ queuedRun(),
+ queuedRun(nextRunId),
+ ]);
+ const queryClient = createDashboardQueryClient();
+ queryClient.setQueryData(authStatusQueryKey, authenticatedStatus());
+ queryClient.setQueryData(jobRunDetailQueryKey(runId), {
+ events: [],
+ run: queuedRun(),
+ });
+ queryClient.setQueryData(
+ scheduleDetailQueryKey(scheduleId),
+ schedule({ activeRun: undefined, latestRun: undefined })
+ );
+ const client = createDashboardTrpcClient(transport);
+ const renderMutation = () =>
+ renderHook(
+ () =>
+ useRunScheduleMutation(() => {
+ const key = generatedKeys[generationCount];
+ generationCount += 1;
+ if (key === undefined) throw new TypeError("Missing test key");
+ return key;
+ }),
+ {
+ wrapper: ({ children }: { readonly children: ReactNode }) => (
+
+
+ {children}
+
+
+ ),
+ }
+ );
+ let rendered = renderMutation();
+
+ try {
+ let firstFailure: unknown;
+ await act(async () => {
+ firstFailure = await rendered.result.current
+ .mutateAsync({ id: scheduleId })
+ .catch((error: unknown) => error);
+ });
+ expect(firstFailure).toBeInstanceOf(TypeError);
+ expect(rendered.result.current.hasPendingRequest(scheduleId)).toBeTrue();
+ expect(
+ queryClient.getQueryState(jobRunDetailQueryKey(runId))?.isInvalidated
+ ).toBeTrue();
+ expect(
+ queryClient.getQueryState(scheduleDetailQueryKey(scheduleId))
+ ?.isInvalidated
+ ).toBeTrue();
+ queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), schedule());
+ expect(
+ queryClient.getQueryData(
+ scheduleDetailQueryKey(scheduleId)
+ )?.activeRun
+ ).toBeDefined();
+
+ rendered.unmount();
+ rendered = renderMutation();
+ expect(rendered.result.current.hasPendingRequest(scheduleId)).toBeTrue();
+
+ await act(async () => {
+ await rendered.result.current.mutateAsync({ id: scheduleId });
+ });
+ expect(rendered.result.current.hasPendingRequest(scheduleId)).toBeFalse();
+ await act(async () => {
+ await rendered.result.current.mutateAsync({ id: scheduleId });
+ });
+
+ expect(transport.calls.map(({ input }) => input)).toEqual([
+ { id: scheduleId, idempotencyKey: firstKey },
+ { id: scheduleId, idempotencyKey: firstKey },
+ { id: scheduleId, idempotencyKey: secondKey },
+ ]);
+ expect(
+ transport.calls.every(({ signal }) => signal instanceof AbortSignal)
+ ).toBeTrue();
+ expect(generationCount).toBe(2);
+ } finally {
+ rendered.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("creates a contract-shaped random idempotency key", () => {
+ const idempotencyKey = createScheduleRunIdempotencyKey();
+ expect(idempotencyKey).toMatch(/^[A-Fa-f0-9]{32}$/u);
+ expect(
+ v.parse(runScheduleInputSchema, { id: scheduleId, idempotencyKey })
+ .idempotencyKey
+ ).toBe(idempotencyKey);
+ });
+});
diff --git a/greenfield/src/browser/jobs/jobMutations.ts b/greenfield/src/browser/jobs/jobMutations.ts
new file mode 100644
index 000000000..bbaabbe34
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobMutations.ts
@@ -0,0 +1,675 @@
+import { type InfiniteData, type QueryClient, useMutation } from "@tanstack/react-query";
+
+import type {
+ JobRunSummary,
+ JobWorkerControl,
+ ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import type { JobRunDetail, ListJobRunsResult } from "../../contracts/jobs.ts";
+import type {
+ ListScheduleRunsResult,
+ ListSchedulesInput,
+ ListSchedulesResult,
+} from "../../contracts/schedules.ts";
+import type {
+ DashboardProcedureInput,
+ DashboardProcedureOutput,
+} from "../api/trpcClient.ts";
+import { useDashboardTrpcClient } from "../api/trpcContextValue.ts";
+import { classifyDashboardBrowserFailure } from "../api/trpcError.ts";
+import { authenticatedBrowserCacheGeneration } from "../auth/authQueries.ts";
+import { useAuthenticatedMutationBoundary } from "../auth/useAuthenticatedMutationBoundary.ts";
+import {
+ jobRunDetailQueryKey,
+ jobRunEventGapQueryKey,
+ jobRunEventHistoryQueryRoot,
+ jobRunListQueryRoot,
+ jobQueueSummaryQueryKey,
+ refreshJobAndScheduleQueries,
+ refreshJobQueries,
+ scheduleDetailQueryKey,
+ scheduleDetailQueryRoot,
+ scheduleListQueryRoot,
+ scheduleRunListQueryKey,
+ scheduleRunListQueryRoot,
+} from "./jobQueries.ts";
+
+type JobRunListData = InfiniteData;
+type JobRunEventHistoryData = InfiniteData;
+type ScheduleListData = InfiniteData;
+type ScheduleRunListData = InfiniteData;
+type JobRunFilters = DashboardProcedureInput<"jobs.listRuns">["filters"];
+type ScheduleEnabledFilter = ListSchedulesInput["enabled"];
+
+export const jobMutationKey = ["jobs", "mutation"] as const;
+
+interface PendingScheduleRunKeys {
+ readonly cacheGeneration: number;
+ readonly keys: Map;
+}
+
+const pendingScheduleRunKeys = new WeakMap();
+
+function scheduleRunKeysForCurrentCache(queryClient: QueryClient): Map {
+ const cacheGeneration = authenticatedBrowserCacheGeneration(queryClient);
+ const current = pendingScheduleRunKeys.get(queryClient);
+ if (current?.cacheGeneration === cacheGeneration) return current.keys;
+ const keys = new Map();
+ pendingScheduleRunKeys.set(queryClient, { cacheGeneration, keys });
+ return keys;
+}
+
+function refreshBestEffort(refresh: () => Promise): void {
+ void refresh().catch(() => {
+ // Cache-first mutation results remain visible until a later refresh succeeds.
+ });
+}
+
+function runMatchesFilters(run: JobRunSummary, filters: JobRunFilters): boolean {
+ return (
+ (filters?.resourceClasses === undefined ||
+ filters.resourceClasses.includes(run.resourceClass)) &&
+ (filters?.scheduleId === undefined ||
+ filters.scheduleId === run.scheduledJobId) &&
+ (filters?.states === undefined || filters.states.includes(run.state)) &&
+ (filters?.triggerTypes === undefined ||
+ filters.triggerTypes.includes(run.triggerType))
+ );
+}
+
+function scheduleMatchesFilter(
+ schedule: ScheduleSummary,
+ enabled: ScheduleEnabledFilter
+): boolean {
+ return (
+ enabled === "all" ||
+ (enabled === "enabled" && schedule.enabled) ||
+ (enabled === "disabled" && !schedule.enabled)
+ );
+}
+
+function jobRunSnapshotIsNotOlder(
+ candidate: JobRunSummary,
+ current: JobRunSummary
+): boolean {
+ return (
+ candidate.id === current.id &&
+ candidate.stateVersion >= current.stateVersion &&
+ candidate.eventCount >= current.eventCount &&
+ candidate.updatedAtMs >= current.updatedAtMs
+ );
+}
+
+function currentJobRunSnapshot(
+ current: JobRunSummary,
+ candidate: JobRunSummary
+): JobRunSummary {
+ return jobRunSnapshotIsNotOlder(candidate, current) ? candidate : current;
+}
+
+function jobRunIsActive(run: JobRunSummary): boolean {
+ return run.state === "queued" || run.state === "running";
+}
+
+function jobRunIsNewer(left: JobRunSummary, right: JobRunSummary): boolean {
+ return (
+ left.queuedAtMs > right.queuedAtMs ||
+ (left.queuedAtMs === right.queuedAtMs && left.id > right.id)
+ );
+}
+
+function mergeJobRuns(runs: readonly (JobRunSummary | undefined)[]): JobRunSummary[] {
+ const merged = new Map();
+ for (const run of runs) {
+ if (run === undefined) continue;
+ const current = merged.get(run.id);
+ merged.set(
+ run.id,
+ current === undefined ? run : currentJobRunSnapshot(current, run)
+ );
+ }
+ return [...merged.values()];
+}
+
+function mergeScheduleRunRelations(
+ schedule: ScheduleSummary,
+ runs: readonly (JobRunSummary | undefined)[]
+): ScheduleSummary {
+ const relatedRuns = mergeJobRuns([
+ schedule.activeRun,
+ schedule.latestRun,
+ ...runs,
+ ]).toSorted((left, right) => {
+ if (jobRunIsNewer(left, right)) return -1;
+ if (jobRunIsNewer(right, left)) return 1;
+ return 0;
+ });
+ const latestRun = relatedRuns[0];
+ const activeRun =
+ latestRun !== undefined && jobRunIsActive(latestRun) ? latestRun : undefined;
+ const {
+ activeRun: _previousActiveRun,
+ latestRun: _previousLatestRun,
+ ...withoutRunRelations
+ } = schedule;
+ return {
+ ...withoutRunRelations,
+ ...(activeRun === undefined ? {} : { activeRun }),
+ ...(latestRun === undefined ? {} : { latestRun }),
+ };
+}
+
+function scheduleSnapshotIsNotOlder(
+ candidate: ScheduleSummary,
+ current: ScheduleSummary
+): boolean {
+ if (
+ candidate.version < current.version ||
+ candidate.updatedAtMs < current.updatedAtMs
+ ) {
+ return false;
+ }
+ if (
+ candidate.version !== current.version ||
+ candidate.updatedAtMs !== current.updatedAtMs
+ ) {
+ return true;
+ }
+ if (
+ candidate.enabled !== current.enabled ||
+ candidate.nextRunAtMs === undefined ||
+ current.nextRunAtMs === undefined
+ ) {
+ return (
+ candidate.enabled === current.enabled &&
+ candidate.nextRunAtMs === current.nextRunAtMs
+ );
+ }
+ return candidate.nextRunAtMs >= current.nextRunAtMs;
+}
+
+function mergeScheduleSnapshots(
+ current: ScheduleSummary,
+ candidate: ScheduleSummary
+): ScheduleSummary {
+ const base = scheduleSnapshotIsNotOlder(candidate, current) ? candidate : current;
+ return mergeScheduleRunRelations(base, [
+ current.activeRun,
+ current.latestRun,
+ candidate.activeRun,
+ candidate.latestRun,
+ ]);
+}
+
+function currentJobWorkerControl(
+ current: JobWorkerControl,
+ candidate: JobWorkerControl
+): JobWorkerControl {
+ return candidate.version >= current.version ? candidate : current;
+}
+
+function patchRunInBoundedSnapshot(
+ result: ListJobRunsResult,
+ run: JobRunSummary
+): ListJobRunsResult {
+ // Aggregate fields form one server-owned snapshot. A bounded row projection
+ // cannot safely reconstruct workers, resource classes, or the oldest queued run.
+ if (!result.runs.some((candidate) => candidate.id === run.id)) return result;
+ return {
+ ...result,
+ runs: result.runs.map((candidate) =>
+ candidate.id === run.id ? currentJobRunSnapshot(candidate, run) : candidate
+ ),
+ };
+}
+
+function patchRunPages(
+ data: InfiniteData,
+ run: JobRunSummary,
+ includesRun: (candidate: JobRunSummary) => boolean
+): InfiniteData {
+ const matchingRuns = data.pages.flatMap((page) =>
+ page.runs.filter((candidate) => candidate.id === run.id)
+ );
+ const firstMaterializedRun = matchingRuns[0];
+ if (firstMaterializedRun === undefined) return data;
+ let materializedRun = firstMaterializedRun;
+ for (const candidate of matchingRuns.slice(1)) {
+ materializedRun = currentJobRunSnapshot(materializedRun, candidate);
+ }
+ const currentRun = currentJobRunSnapshot(materializedRun, run);
+ const include = includesRun(currentRun);
+ let replaced = false;
+ const pages = data.pages.map((page) => ({
+ ...page,
+ runs: page.runs.flatMap((candidate) => {
+ if (candidate.id !== run.id) return [candidate];
+ if (replaced || !include) return [];
+ replaced = true;
+ return [currentRun];
+ }),
+ }));
+ return { ...data, pages };
+}
+
+function patchJobRunDetail(detail: JobRunDetail, run: JobRunSummary): JobRunDetail {
+ const currentRun = currentJobRunSnapshot(detail.run, run);
+ if (currentRun === detail.run) return detail;
+ if (currentRun.state === "succeeded" && detail.result === undefined) return detail;
+ return { ...detail, run: currentRun };
+}
+
+function removeRunFromPages(
+ data: InfiniteData,
+ id: string
+): InfiniteData {
+ return {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ runs: page.runs.filter((run) => run.id !== id),
+ })),
+ };
+}
+
+function patchScheduleRunReferences(
+ schedule: ScheduleSummary,
+ run: JobRunSummary,
+ newRun: boolean
+): ScheduleSummary {
+ if (run.scheduledJobId !== schedule.id) return schedule;
+ if (
+ !newRun &&
+ schedule.activeRun?.id !== run.id &&
+ schedule.latestRun?.id !== run.id
+ ) {
+ return schedule;
+ }
+ return mergeScheduleRunRelations(schedule, [run]);
+}
+
+function patchScheduleProjectionsForRun(
+ queryClient: QueryClient,
+ run: JobRunSummary,
+ newRun: boolean
+): void {
+ if (run.scheduledJobId === undefined) return;
+ queryClient.setQueryData(
+ scheduleDetailQueryKey(run.scheduledJobId),
+ (schedule) =>
+ schedule === undefined
+ ? undefined
+ : patchScheduleRunReferences(schedule, run, newRun)
+ );
+ queryClient.setQueriesData(
+ { queryKey: scheduleListQueryRoot },
+ (data) =>
+ data === undefined
+ ? undefined
+ : {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ schedules: page.schedules.map((schedule) =>
+ patchScheduleRunReferences(schedule, run, newRun)
+ ),
+ })),
+ }
+ );
+}
+
+/** Patches one validated run only where that identity is already materialized. */
+export function patchJobRunInCachedQueries(
+ queryClient: QueryClient,
+ run: JobRunSummary,
+ newRun = false
+): void {
+ for (const [queryKey, data] of queryClient.getQueriesData({
+ queryKey: jobRunListQueryRoot,
+ })) {
+ if (data === undefined) continue;
+ const filters = queryKey.at(-1) as JobRunFilters | null;
+ const patched = patchRunPages(data, run, (candidate) =>
+ runMatchesFilters(candidate, filters ?? undefined)
+ );
+ queryClient.setQueryData(queryKey, patched);
+ }
+ queryClient.setQueryData(jobQueueSummaryQueryKey, (result) =>
+ result === undefined ? undefined : patchRunInBoundedSnapshot(result, run)
+ );
+ queryClient.setQueryData(jobRunDetailQueryKey(run.id), (detail) =>
+ detail === undefined ? undefined : patchJobRunDetail(detail, run)
+ );
+ queryClient.setQueriesData(
+ { queryKey: [...jobRunEventHistoryQueryRoot, run.id] },
+ (data) =>
+ data === undefined
+ ? undefined
+ : {
+ ...data,
+ pages: data.pages.map((page) => patchJobRunDetail(page, run)),
+ }
+ );
+ if (run.scheduledJobId !== undefined) {
+ queryClient.setQueryData(
+ scheduleRunListQueryKey(run.scheduledJobId),
+ (data) =>
+ data === undefined ? undefined : patchRunPages(data, run, () => true)
+ );
+ }
+ patchScheduleProjectionsForRun(queryClient, run, newRun);
+}
+
+/** Removes one server-missing run from cached histories, details, and schedule links. */
+export function removeJobRunFromCachedQueries(
+ queryClient: QueryClient,
+ id: string
+): void {
+ queryClient.setQueriesData(
+ { queryKey: jobRunListQueryRoot },
+ (data) => (data === undefined ? undefined : removeRunFromPages(data, id))
+ );
+ queryClient.setQueryData(jobQueueSummaryQueryKey, (result) =>
+ result === undefined
+ ? undefined
+ : {
+ ...result,
+ runs: result.runs.filter((run) => run.id !== id),
+ }
+ );
+ queryClient.setQueriesData(
+ { queryKey: scheduleRunListQueryRoot },
+ (data) => (data === undefined ? undefined : removeRunFromPages(data, id))
+ );
+ queryClient.removeQueries({ exact: true, queryKey: jobRunDetailQueryKey(id) });
+ queryClient.removeQueries({ queryKey: [...jobRunEventHistoryQueryRoot, id] });
+ queryClient.removeQueries({ exact: true, queryKey: jobRunEventGapQueryKey(id) });
+ const removeScheduleReference = (schedule: ScheduleSummary): ScheduleSummary => {
+ const {
+ activeRun: previousActiveRun,
+ latestRun: previousLatestRun,
+ ...withoutRunReferences
+ } = schedule;
+ const activeRun = previousActiveRun?.id === id ? undefined : previousActiveRun;
+ const latestRun = previousLatestRun?.id === id ? undefined : previousLatestRun;
+ return {
+ ...withoutRunReferences,
+ ...(activeRun === undefined ? {} : { activeRun }),
+ ...(latestRun === undefined ? {} : { latestRun }),
+ };
+ };
+ queryClient.setQueriesData(
+ { queryKey: scheduleDetailQueryRoot },
+ (schedule) =>
+ schedule === undefined ? undefined : removeScheduleReference(schedule)
+ );
+ queryClient.setQueriesData(
+ { queryKey: scheduleListQueryRoot },
+ (data) =>
+ data === undefined
+ ? undefined
+ : {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ schedules: page.schedules.map(removeScheduleReference),
+ })),
+ }
+ );
+}
+
+/** Patches one validated schedule in materialized directories and exact detail. */
+export function patchScheduleInCachedQueries(
+ queryClient: QueryClient,
+ schedule: ScheduleSummary
+): void {
+ for (const [queryKey, data] of queryClient.getQueriesData({
+ queryKey: scheduleListQueryRoot,
+ })) {
+ if (data === undefined) continue;
+ const enabled = queryKey.at(-1) as ScheduleEnabledFilter;
+ const existingSchedules = data.pages.flatMap((page) =>
+ page.schedules.filter((candidate) => candidate.id === schedule.id)
+ );
+ const firstMaterializedSchedule = existingSchedules[0];
+ if (firstMaterializedSchedule === undefined) continue;
+ let materializedSchedule = firstMaterializedSchedule;
+ for (const candidate of existingSchedules.slice(1)) {
+ materializedSchedule = mergeScheduleSnapshots(
+ materializedSchedule,
+ candidate
+ );
+ }
+ const currentSchedule = mergeScheduleSnapshots(materializedSchedule, schedule);
+ const include = scheduleMatchesFilter(currentSchedule, enabled);
+ let replaced = false;
+ const pages = data.pages.map((page) => ({
+ ...page,
+ schedules: page.schedules.flatMap((candidate) => {
+ if (candidate.id !== schedule.id) return [candidate];
+ if (replaced || !include) return [];
+ replaced = true;
+ return [currentSchedule];
+ }),
+ }));
+ queryClient.setQueryData(queryKey, { ...data, pages });
+ }
+ queryClient.setQueryData(
+ scheduleDetailQueryKey(schedule.id),
+ (current) =>
+ current === undefined ? schedule : mergeScheduleSnapshots(current, schedule)
+ );
+}
+
+/** Removes one server-missing schedule and its schedule-scoped history. */
+export function removeScheduleFromCachedQueries(
+ queryClient: QueryClient,
+ id: string
+): void {
+ queryClient.setQueriesData(
+ { queryKey: scheduleListQueryRoot },
+ (data) =>
+ data === undefined
+ ? undefined
+ : {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ schedules: page.schedules.filter(
+ (schedule) => schedule.id !== id
+ ),
+ })),
+ }
+ );
+ queryClient.removeQueries({ exact: true, queryKey: scheduleDetailQueryKey(id) });
+ queryClient.removeQueries({ queryKey: scheduleRunListQueryKey(id) });
+}
+
+/** Patches the versioned claim-control singleton before queue refresh. */
+export function patchJobWorkerControlInCachedQueries(
+ queryClient: QueryClient,
+ control: JobWorkerControl
+): void {
+ queryClient.setQueriesData(
+ { queryKey: jobRunListQueryRoot },
+ (data) =>
+ data === undefined
+ ? undefined
+ : {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ summary: {
+ ...page.summary,
+ control: currentJobWorkerControl(
+ page.summary.control,
+ control
+ ),
+ },
+ })),
+ }
+ );
+ queryClient.setQueryData(jobQueueSummaryQueryKey, (result) =>
+ result === undefined
+ ? undefined
+ : {
+ ...result,
+ summary: {
+ ...result.summary,
+ control: currentJobWorkerControl(result.summary.control, control),
+ },
+ }
+ );
+}
+
+/** @returns One caller-scoped, contract-valid lost-response idempotency key. */
+export function createScheduleRunIdempotencyKey(): string {
+ return globalThis.crypto.randomUUID().replaceAll("-", "");
+}
+
+/** @returns Authenticated run cancellation with cache-first state repair. */
+export function useCancelJobRunMutation() {
+ const client = useDashboardTrpcClient();
+ const boundary = useAuthenticatedMutationBoundary();
+ return useMutation<
+ DashboardProcedureOutput<"jobs.cancelRun">,
+ Error,
+ DashboardProcedureInput<"jobs.cancelRun">
+ >({
+ mutationKey: jobMutationKey,
+ mutationFn: (input) =>
+ boundary.run((signal) =>
+ client.mutation("jobs.cancelRun", input, { signal })
+ ),
+ onError: (error, input) => {
+ if (
+ boundary.completionIsCurrent() &&
+ classifyDashboardBrowserFailure(error) === "not-found"
+ ) {
+ removeJobRunFromCachedQueries(boundary.queryClient, input.id);
+ }
+ },
+ onSettled: () => {
+ if (!boundary.completionIsCurrent()) return;
+ refreshBestEffort(() => refreshJobAndScheduleQueries(boundary.queryClient));
+ },
+ onSuccess: (run) => {
+ if (!boundary.completionIsCurrent()) return;
+ patchJobRunInCachedQueries(boundary.queryClient, run);
+ },
+ });
+}
+
+/** @returns Authenticated versioned claim pause/resume mutation. */
+export function useSetJobClaimingPausedMutation() {
+ const client = useDashboardTrpcClient();
+ const boundary = useAuthenticatedMutationBoundary();
+ return useMutation<
+ DashboardProcedureOutput<"jobs.setClaimingPaused">,
+ Error,
+ DashboardProcedureInput<"jobs.setClaimingPaused">
+ >({
+ mutationKey: jobMutationKey,
+ mutationFn: (input) =>
+ boundary.run((signal) =>
+ client.mutation("jobs.setClaimingPaused", input, { signal })
+ ),
+ onSettled: () => {
+ if (!boundary.completionIsCurrent()) return;
+ refreshBestEffort(() => refreshJobQueries(boundary.queryClient));
+ },
+ onSuccess: (control) => {
+ if (!boundary.completionIsCurrent()) return;
+ patchJobWorkerControlInCachedQueries(boundary.queryClient, control);
+ },
+ });
+}
+
+/** @returns Authenticated versioned schedule update with cache-first projection repair. */
+export function useUpdateScheduleMutation() {
+ const client = useDashboardTrpcClient();
+ const boundary = useAuthenticatedMutationBoundary();
+ return useMutation<
+ DashboardProcedureOutput<"schedules.update">,
+ Error,
+ DashboardProcedureInput<"schedules.update">
+ >({
+ mutationKey: jobMutationKey,
+ mutationFn: (input) =>
+ boundary.run((signal) =>
+ client.mutation("schedules.update", input, { signal })
+ ),
+ onError: (error, input) => {
+ if (
+ boundary.completionIsCurrent() &&
+ classifyDashboardBrowserFailure(error) === "not-found"
+ ) {
+ removeScheduleFromCachedQueries(boundary.queryClient, input.id);
+ }
+ },
+ onSettled: () => {
+ if (!boundary.completionIsCurrent()) return;
+ refreshBestEffort(() => refreshJobAndScheduleQueries(boundary.queryClient));
+ },
+ onSuccess: (schedule) => {
+ if (!boundary.completionIsCurrent()) return;
+ patchScheduleInCachedQueries(boundary.queryClient, schedule);
+ },
+ });
+}
+
+/** Variables intentionally omit the authenticated cache-owned idempotency key. */
+export interface RunScheduleMutationInput {
+ readonly id: string;
+}
+
+/**
+ * @param createIdempotencyKey Injectable secure generator used once per successful cycle.
+ * @returns Authenticated manual-run mutation retaining one key across ambiguous retries.
+ */
+export function useRunScheduleMutation(
+ createIdempotencyKey: () => string = createScheduleRunIdempotencyKey
+) {
+ const client = useDashboardTrpcClient();
+ const boundary = useAuthenticatedMutationBoundary();
+ const mutation = useMutation<
+ DashboardProcedureOutput<"schedules.run">,
+ Error,
+ RunScheduleMutationInput
+ >({
+ mutationKey: jobMutationKey,
+ mutationFn: ({ id }) =>
+ boundary.run((signal) => {
+ const pendingKeys = scheduleRunKeysForCurrentCache(boundary.queryClient);
+ const idempotencyKey = pendingKeys.get(id) ?? createIdempotencyKey();
+ pendingKeys.set(id, idempotencyKey);
+ return client.mutation(
+ "schedules.run",
+ { id, idempotencyKey },
+ { signal }
+ );
+ }),
+ onError: (error, input) => {
+ if (
+ boundary.completionIsCurrent() &&
+ classifyDashboardBrowserFailure(error) === "not-found"
+ ) {
+ removeScheduleFromCachedQueries(boundary.queryClient, input.id);
+ }
+ },
+ onSettled: () => {
+ if (!boundary.completionIsCurrent()) return;
+ refreshBestEffort(() => refreshJobAndScheduleQueries(boundary.queryClient));
+ },
+ onSuccess: (run, input) => {
+ if (!boundary.completionIsCurrent()) return;
+ scheduleRunKeysForCurrentCache(boundary.queryClient).delete(input.id);
+ patchJobRunInCachedQueries(boundary.queryClient, run, true);
+ },
+ });
+ return {
+ ...mutation,
+ hasPendingRequest: (id: string): boolean =>
+ scheduleRunKeysForCurrentCache(boundary.queryClient).has(id),
+ };
+}
diff --git a/greenfield/src/browser/jobs/jobQueries.test.ts b/greenfield/src/browser/jobs/jobQueries.test.ts
new file mode 100644
index 000000000..58de7f28e
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobQueries.test.ts
@@ -0,0 +1,644 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { InfiniteQueryObserver, QueryObserver } from "@tanstack/react-query";
+import type { TRPCRequestOptions } from "@trpc/client";
+
+import type {
+ JobRunSummary,
+ JobWorkerSummary,
+ ScheduleSummary,
+} from "../../contracts/jobModel.ts";
+import type { JobQueueSummary } from "../../contracts/jobs.ts";
+import { createDashboardQueryClient } from "../api/queryClient.ts";
+import {
+ createDashboardTrpcClient,
+ type DashboardTrpcTransport,
+} from "../api/trpcClient.ts";
+import {
+ jobRunDetailQueryOptions,
+ jobRunEventGapQueryKey,
+ jobRunEventGapQueryOptions,
+ jobRunEventHistoryQueryKey,
+ jobRunEventHistoryQueryOptions,
+ jobRunListQueryOptions,
+ jobQueueSummaryQueryOptions,
+ jobQueueSummaryRefreshIntervalMs,
+ refreshJobQueries,
+ refreshScheduleQueries,
+ scheduleDetailQueryOptions,
+ scheduleListQueryOptions,
+ scheduleRunListQueryOptions,
+ uniqueJobRunEvents,
+ uniqueJobRows,
+} from "./jobQueries.ts";
+
+const newestRunId = "019fdf70-0000-7000-8000-000000000002";
+const oldestRunId = "019fdf60-0000-7000-8000-000000000001";
+const timestampMs = 1_800_000_000_000;
+const scheduleId = "system.worker-smoke";
+
+function queuedRun(id: string, queuedAtMs: number): JobRunSummary {
+ return {
+ actionKey: scheduleId,
+ attemptCount: 0,
+ attemptLimit: 3,
+ availableAtMs: queuedAtMs,
+ cancellationPolicy: "cooperative",
+ displayName: "Worker smoke",
+ eventCount: 1,
+ id,
+ priority: 0,
+ queuedAtMs,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ scheduledJobId: scheduleId,
+ scheduledJobVersion: 1,
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 30_000,
+ triggerType: "manual",
+ updatedAtMs: queuedAtMs,
+ };
+}
+
+function schedule(id: string, enabled = true): ScheduleSummary {
+ return {
+ actionKey: scheduleId,
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the worker without host mutation.",
+ enabled,
+ id,
+ name: "Worker smoke",
+ ...(enabled ? { nextRunAtMs: timestampMs + 60_000 } : {}),
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 30_000,
+ updatedAtMs: timestampMs,
+ version: 1,
+ };
+}
+
+function queueSummary(workers: readonly JobWorkerSummary[] = []): JobQueueSummary {
+ return {
+ activeResourceClasses: [],
+ control: { claimingPaused: false, updatedAtMs: timestampMs, version: 1 },
+ oldestQueuedAtMs: timestampMs - 1000,
+ stateCounts: {
+ cancelled: 0,
+ failed: 0,
+ queued: 2,
+ running: 0,
+ succeeded: 0,
+ "timed-out": 0,
+ },
+ workers: [...workers],
+ };
+}
+
+function onlineWorker(): JobWorkerSummary {
+ return {
+ activeRunCount: 0,
+ capacity: 1,
+ heartbeatAtMs: timestampMs,
+ id: "019fdf50-0000-7000-8000-000000000004",
+ releaseId: "a".repeat(40),
+ startedAtMs: timestampMs - 10_000,
+ state: "online",
+ };
+}
+
+interface QueryCall {
+ readonly input: unknown;
+ readonly path: string;
+ readonly signal: AbortSignal | undefined;
+}
+
+class JobQueryTransport implements DashboardTrpcTransport {
+ readonly calls: QueryCall[] = [];
+ readonly #outputs: Readonly>;
+
+ constructor(outputs: Readonly>) {
+ this.#outputs = outputs;
+ }
+
+ mutation(path: string): Promise {
+ return Promise.reject(new TypeError(`Unexpected mutation: ${path}`));
+ }
+
+ query(path: string, input?: unknown, options?: TRPCRequestOptions): Promise {
+ const callIndex = this.calls.filter((call) => call.path === path).length;
+ this.calls.push({ input, path, signal: options?.signal });
+ const output = this.#outputs[path]?.[callIndex];
+ if (output === undefined) {
+ return Promise.reject(new TypeError(`Unexpected query: ${path}`));
+ }
+ return output instanceof Error ? Promise.reject(output) : Promise.resolve(output);
+ }
+}
+
+describe("jobs browser queries", () => {
+ test("refreshes the mounted queue summary so idle stale workers expire", async () => {
+ jest.useFakeTimers();
+ const worker = onlineWorker();
+ const transport = new JobQueryTransport({
+ "jobs.listRuns": [
+ { runs: [], summary: queueSummary([worker]) },
+ { runs: [], summary: queueSummary() },
+ ],
+ });
+ const queryClient = createDashboardQueryClient();
+ const observer = new QueryObserver(
+ queryClient,
+ jobQueueSummaryQueryOptions(createDashboardTrpcClient(transport))
+ );
+ const firstResult = Promise.withResolvers();
+ const refreshedResult = Promise.withResolvers();
+ const unsubscribe = observer.subscribe((result) => {
+ if (!result.isSuccess) return;
+ const workers = result.data.workers;
+ if (workers?.[0]?.id === worker.id) firstResult.resolve();
+ if (workers?.length === 0 && transport.calls.length === 2) {
+ refreshedResult.resolve();
+ }
+ });
+
+ try {
+ await firstResult.promise;
+ expect(transport.calls).toHaveLength(1);
+ jest.advanceTimersByTime(jobQueueSummaryRefreshIntervalMs);
+ await refreshedResult.promise;
+
+ expect(transport.calls.map(({ path }) => path)).toEqual([
+ "jobs.listRuns",
+ "jobs.listRuns",
+ ]);
+ expect(transport.calls.map(({ input }) => input)).toEqual([
+ { limit: 1 },
+ { limit: 1 },
+ ]);
+ expect(observer.getCurrentResult().data?.workers).toEqual([]);
+ } finally {
+ unsubscribe();
+ queryClient.clear();
+ jest.useRealTimers();
+ }
+ });
+
+ test("forwards global run filters, keyset cursors, and cancellation signals", async () => {
+ const newest = queuedRun(newestRunId, timestampMs);
+ const oldest = queuedRun(oldestRunId, timestampMs - 1000);
+ const transport = new JobQueryTransport({
+ "jobs.listRuns": [
+ {
+ nextCursor: { id: newest.id, queuedAtMs: newest.queuedAtMs },
+ runs: [newest],
+ summary: queueSummary(),
+ },
+ { runs: [oldest], summary: queueSummary() },
+ ],
+ });
+ const queryClient = createDashboardQueryClient();
+
+ try {
+ await queryClient.fetchInfiniteQuery({
+ ...jobRunListQueryOptions(createDashboardTrpcClient(transport), {
+ resourceClasses: ["light"],
+ states: ["queued"],
+ triggerTypes: ["manual"],
+ }),
+ pages: 2,
+ });
+
+ expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([
+ {
+ input: {
+ filters: {
+ resourceClasses: ["light"],
+ states: ["queued"],
+ triggerTypes: ["manual"],
+ },
+ limit: 100,
+ },
+ path: "jobs.listRuns",
+ },
+ {
+ input: {
+ cursor: { id: newest.id, queuedAtMs: newest.queuedAtMs },
+ filters: {
+ resourceClasses: ["light"],
+ states: ["queued"],
+ triggerTypes: ["manual"],
+ },
+ limit: 100,
+ },
+ path: "jobs.listRuns",
+ },
+ ]);
+ expect(
+ transport.calls.every(({ signal }) => signal instanceof AbortSignal)
+ ).toBeTrue();
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("paginates schedule inventory and schedule-scoped run history independently", async () => {
+ const firstSchedule = schedule("alpha", false);
+ const secondSchedule = schedule(scheduleId);
+ const newest = queuedRun(newestRunId, timestampMs);
+ const oldest = queuedRun(oldestRunId, timestampMs - 1000);
+ const transport = new JobQueryTransport({
+ "schedules.list": [
+ {
+ nextCursor: { id: firstSchedule.id },
+ schedules: [firstSchedule],
+ },
+ { schedules: [secondSchedule] },
+ ],
+ "schedules.listRuns": [
+ {
+ nextCursor: { id: newest.id, queuedAtMs: newest.queuedAtMs },
+ runs: [newest],
+ },
+ { runs: [oldest] },
+ ],
+ });
+ const queryClient = createDashboardQueryClient();
+ const client = createDashboardTrpcClient(transport);
+
+ try {
+ await queryClient.fetchInfiniteQuery({
+ ...scheduleListQueryOptions(client, "all"),
+ pages: 2,
+ });
+ await queryClient.fetchInfiniteQuery({
+ ...scheduleRunListQueryOptions(client, scheduleId),
+ pages: 2,
+ });
+
+ expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([
+ {
+ input: { enabled: "all", limit: 100 },
+ path: "schedules.list",
+ },
+ {
+ input: {
+ cursor: { id: firstSchedule.id },
+ enabled: "all",
+ limit: 100,
+ },
+ path: "schedules.list",
+ },
+ {
+ input: { id: scheduleId, limit: 100 },
+ path: "schedules.listRuns",
+ },
+ {
+ input: {
+ cursor: { id: newest.id, queuedAtMs: newest.queuedAtMs },
+ id: scheduleId,
+ limit: 100,
+ },
+ path: "schedules.listRuns",
+ },
+ ]);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("loads exact run and schedule details without depending on list pages", async () => {
+ const run = queuedRun(newestRunId, timestampMs);
+ const scheduleRecord = schedule(scheduleId);
+ const transport = new JobQueryTransport({
+ "jobs.getRun": [
+ {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued",
+ occurredAtMs: timestampMs,
+ sequence: 1,
+ },
+ ],
+ run,
+ },
+ ],
+ "schedules.get": [scheduleRecord],
+ });
+ const queryClient = createDashboardQueryClient();
+ const client = createDashboardTrpcClient(transport);
+
+ try {
+ const runDetail = await queryClient.fetchQuery(
+ jobRunDetailQueryOptions(client, run.id)
+ );
+ const exactSchedule = await queryClient.fetchQuery(
+ scheduleDetailQueryOptions(client, scheduleId)
+ );
+ expect(runDetail.run.id).toBe(run.id);
+ expect(exactSchedule.id).toBe(scheduleId);
+ expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([
+ {
+ input: { eventLimit: 100, id: run.id },
+ path: "jobs.getRun",
+ },
+ { input: { id: scheduleId }, path: "schedules.get" },
+ ]);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("pages and deduplicates older events outside the exact detail cache", async () => {
+ const run = { ...queuedRun(newestRunId, timestampMs), eventCount: 4 };
+ const exactDetail = {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: timestampMs,
+ sequence: 4,
+ },
+ ],
+ nextEventCursor: { sequence: 4 },
+ run,
+ };
+ const transport = new JobQueryTransport({
+ "jobs.getRun": [
+ exactDetail,
+ {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued",
+ occurredAtMs: timestampMs - 1,
+ sequence: 3,
+ },
+ ],
+ nextEventCursor: { sequence: 3 },
+ run,
+ },
+ {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued",
+ occurredAtMs: timestampMs - 2,
+ sequence: 2,
+ },
+ ],
+ run,
+ },
+ ],
+ });
+ const queryClient = createDashboardQueryClient();
+ const client = createDashboardTrpcClient(transport);
+
+ try {
+ const detail = await queryClient.fetchQuery(
+ jobRunDetailQueryOptions(client, run.id)
+ );
+ const history = await queryClient.fetchInfiniteQuery({
+ ...jobRunEventHistoryQueryOptions(client, run.id, detail.nextEventCursor),
+ pages: 2,
+ });
+ const duplicate = history.pages[0]?.events[0];
+ if (duplicate === undefined) throw new TypeError("Missing history event");
+ const events = uniqueJobRunEvents([
+ ...detail.events,
+ ...history.pages.flatMap((page) => page.events),
+ duplicate,
+ ]);
+
+ expect(events.map(({ sequence }) => sequence)).toEqual([4, 3, 2]);
+ expect(transport.calls.map(({ input }) => input)).toEqual([
+ { eventLimit: 100, id: run.id },
+ {
+ eventCursor: { sequence: 4 },
+ eventLimit: 100,
+ id: run.id,
+ },
+ {
+ eventCursor: { sequence: 3 },
+ eventLimit: 100,
+ id: run.id,
+ },
+ ]);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("bridges a multi-page realtime event gap to the nearest known sequence", async () => {
+ const run = { ...queuedRun(newestRunId, timestampMs), eventCount: 602 };
+ const eventPage = (
+ newestSequence: number,
+ count: number,
+ nextSequence: number
+ ) => ({
+ events: Array.from({ length: count }, (_, index) => ({
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: timestampMs + newestSequence - index,
+ sequence: newestSequence - index,
+ })),
+ nextEventCursor: { sequence: nextSequence },
+ run,
+ });
+ const transport = new JobQueryTransport({
+ "jobs.getRun": [
+ eventPage(302, 100, 203),
+ eventPage(202, 100, 103),
+ eventPage(502, 100, 403),
+ ],
+ });
+ const queryClient = createDashboardQueryClient();
+ const client = createDashboardTrpcClient(transport);
+ const request = {
+ cursor: { sequence: 303 },
+ knownSequence: 102,
+ };
+
+ try {
+ const gap = await queryClient.fetchQuery(
+ jobRunEventGapQueryOptions(client, run.id, request)
+ );
+
+ expect(gap.events).toHaveLength(200);
+ expect(gap.events.at(0)?.sequence).toBe(302);
+ expect(gap.events.at(-1)?.sequence).toBe(103);
+ const nextRequest = {
+ cursor: { sequence: 503 },
+ knownSequence: 402,
+ };
+ await queryClient.invalidateQueries({
+ exact: true,
+ queryKey: jobRunEventGapQueryKey(run.id),
+ });
+ await queryClient.fetchQuery(
+ jobRunEventGapQueryOptions(client, run.id, nextRequest)
+ );
+ const accumulatedGap = queryClient.getQueryData<{
+ readonly events: readonly { readonly sequence: number }[];
+ }>(jobRunEventGapQueryKey(run.id));
+ if (accumulatedGap === undefined) {
+ throw new TypeError("Missing accumulated event gap");
+ }
+ expect(accumulatedGap.events).toHaveLength(300);
+ expect(accumulatedGap.events.at(0)?.sequence).toBe(502);
+ expect(accumulatedGap.events.at(-1)?.sequence).toBe(103);
+ expect(transport.calls.map(({ input }) => input)).toEqual([
+ {
+ eventCursor: { sequence: 303 },
+ eventLimit: 100,
+ id: run.id,
+ },
+ {
+ eventCursor: { sequence: 203 },
+ eventLimit: 100,
+ id: run.id,
+ },
+ {
+ eventCursor: { sequence: 503 },
+ eventLimit: 100,
+ id: run.id,
+ },
+ ]);
+ expect(
+ jobRunEventGapQueryOptions(client, run.id, nextRequest).queryKey
+ ).toEqual(jobRunEventGapQueryKey(run.id));
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("retains immutable event history across a short observer teardown", async () => {
+ const run = { ...queuedRun(newestRunId, timestampMs), eventCount: 2 };
+ const historyPage = {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: timestampMs - 1,
+ sequence: 1,
+ },
+ ],
+ run,
+ };
+ const transport = new JobQueryTransport({
+ "jobs.getRun": [historyPage, historyPage],
+ });
+ const queryClient = createDashboardQueryClient();
+ const options = jobRunEventHistoryQueryOptions(
+ createDashboardTrpcClient(transport),
+ run.id,
+ { sequence: 2 }
+ );
+ const firstObserver = new InfiniteQueryObserver(queryClient, options);
+ const loaded = Promise.withResolvers();
+ const unsubscribeFirst = firstObserver.subscribe((result) => {
+ if (result.isSuccess) loaded.resolve();
+ });
+
+ try {
+ await loaded.promise;
+ unsubscribeFirst();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(queryClient.getQueryData(options.queryKey)).toBeDefined();
+ const restoredObserver = new InfiniteQueryObserver(queryClient, options);
+ const unsubscribeRestored = restoredObserver.subscribe(() => {});
+ try {
+ expect(restoredObserver.getCurrentResult().data?.pages).toEqual([
+ historyPage,
+ ]);
+ await Promise.resolve();
+ expect(transport.calls).toHaveLength(1);
+ } finally {
+ unsubscribeRestored();
+ }
+ } finally {
+ unsubscribeFirst();
+ queryClient.clear();
+ }
+ });
+
+ test("retains cached exact detail when an older event page fails", async () => {
+ const run = { ...queuedRun(newestRunId, timestampMs), eventCount: 2 };
+ const exactDetail = {
+ events: [
+ {
+ attempt: 0,
+ kind: "queued" as const,
+ occurredAtMs: timestampMs,
+ sequence: 2,
+ },
+ ],
+ nextEventCursor: { sequence: 2 },
+ run,
+ };
+ const transport = new JobQueryTransport({
+ "jobs.getRun": [exactDetail, new TypeError("history unavailable")],
+ });
+ const queryClient = createDashboardQueryClient();
+ const client = createDashboardTrpcClient(transport);
+
+ try {
+ const detail = await queryClient.fetchQuery(
+ jobRunDetailQueryOptions(client, run.id)
+ );
+ const failure = await queryClient
+ .fetchInfiniteQuery({
+ ...jobRunEventHistoryQueryOptions(
+ client,
+ run.id,
+ detail.nextEventCursor
+ ),
+ retry: false,
+ })
+ .catch((error: unknown) => error);
+
+ expect(failure).toBeInstanceOf(TypeError);
+ expect(
+ queryClient.getQueryData(
+ jobRunDetailQueryOptions(client, run.id).queryKey
+ )
+ ).toEqual(exactDetail);
+ } finally {
+ queryClient.clear();
+ }
+ });
+
+ test("deduplicates overlapping pages and invalidates only the requested root", async () => {
+ const run = queuedRun(newestRunId, timestampMs);
+ expect(
+ uniqueJobRows([run, run, queuedRun(oldestRunId, timestampMs - 1)])
+ ).toEqual([run, queuedRun(oldestRunId, timestampMs - 1)]);
+
+ const queryClient = createDashboardQueryClient();
+ const jobKey = ["jobs", "runs", "detail", newestRunId] as const;
+ const eventHistoryKey = jobRunEventHistoryQueryKey(newestRunId);
+ const scheduleKey = ["schedules", "detail", scheduleId] as const;
+ queryClient.setQueryData(jobKey, { run });
+ queryClient.setQueryData(eventHistoryKey, { pages: [] });
+ queryClient.setQueryData(scheduleKey, schedule(scheduleId));
+ try {
+ await refreshJobQueries(queryClient);
+ expect(queryClient.getQueryState(jobKey)?.isInvalidated).toBeTrue();
+ expect(queryClient.getQueryState(eventHistoryKey)?.isInvalidated).toBeFalse();
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeFalse();
+
+ await refreshScheduleQueries(queryClient);
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue();
+ } finally {
+ queryClient.clear();
+ }
+ });
+});
diff --git a/greenfield/src/browser/jobs/jobQueries.ts b/greenfield/src/browser/jobs/jobQueries.ts
new file mode 100644
index 000000000..f6386c1aa
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobQueries.ts
@@ -0,0 +1,377 @@
+import {
+ infiniteQueryOptions,
+ queryOptions,
+ type QueryClient,
+} from "@tanstack/react-query";
+
+import type { JobRunEvent, ScheduleSummary } from "../../contracts/jobModel.ts";
+import { jobRunEventPageMaximum } from "../../contracts/jobs.ts";
+import type {
+ GetJobRunInput,
+ JobRunDetail,
+ ListJobRunsInput,
+ ListJobRunsResult,
+} from "../../contracts/jobs.ts";
+import type {
+ ListScheduleRunsResult,
+ ListSchedulesInput,
+ ListSchedulesResult,
+} from "../../contracts/schedules.ts";
+import type { DashboardTrpcClient } from "../api/trpcClient.ts";
+
+export type JobRunCursor = NonNullable;
+export type JobRunEventCursor = NonNullable;
+export type ScheduleCursor = NonNullable;
+
+export const jobQueryKey = ["jobs"] as const;
+export const jobRunQueryKey = [...jobQueryKey, "runs"] as const;
+export const jobRunListQueryRoot = [...jobRunQueryKey, "list"] as const;
+export const jobRunDetailQueryRoot = [...jobRunQueryKey, "detail"] as const;
+export const jobRunEventHistoryQueryRoot = [...jobRunQueryKey, "events"] as const;
+export const jobRunEventGapQueryRoot = [...jobRunEventHistoryQueryRoot, "gap"] as const;
+export const jobQueueSummaryQueryKey = [...jobQueryKey, "queue-summary"] as const;
+export const scheduleQueryKey = ["schedules"] as const;
+export const scheduleListQueryRoot = [...scheduleQueryKey, "list"] as const;
+export const scheduleDetailQueryRoot = [...scheduleQueryKey, "detail"] as const;
+export const scheduleRunListQueryRoot = [...scheduleQueryKey, "runs"] as const;
+
+/** Foreground refresh cadence for time-derived queue and live-worker summaries. */
+export const jobQueueSummaryRefreshIntervalMs = 10_000;
+
+/**
+ * Removes repeated identities while preserving the first, newest-page occurrence.
+ * @param rows Rows flattened from stable keyset pages.
+ * @returns Rows with each durable identity represented once.
+ */
+export function uniqueJobRows(
+ rows: readonly TRow[]
+): TRow[] {
+ const identities = new Set();
+ return rows.filter(({ id }) => {
+ if (identities.has(id)) return false;
+ identities.add(id);
+ return true;
+ });
+}
+
+/**
+ * Removes repeated event sequences while preserving newest-page order.
+ * @param events Events flattened from the exact detail and older pages.
+ * @returns Events with each durable sequence represented once.
+ */
+export function uniqueJobRunEvents(
+ events: readonly TEvent[]
+): TEvent[] {
+ const sequences = new Set();
+ return events.filter(({ sequence }) => {
+ if (sequences.has(sequence)) return false;
+ sequences.add(sequence);
+ return true;
+ });
+}
+
+/**
+ * @param filters Server-owned run filters.
+ * @returns Exact key for one filtered global run history.
+ */
+export function jobRunListQueryKey(filters: ListJobRunsInput["filters"]) {
+ return [...jobRunListQueryRoot, filters ?? null] as const;
+}
+
+/**
+ * @param id Durable run identity.
+ * @returns Exact key for one durable run detail.
+ */
+export function jobRunDetailQueryKey(id: string) {
+ return [...jobRunDetailQueryRoot, id] as const;
+}
+
+/**
+ * @param id Durable run identity.
+ * @returns Stable key for one run's immutable older-event history.
+ */
+export function jobRunEventHistoryQueryKey(id: string) {
+ return [...jobRunEventHistoryQueryRoot, id] as const;
+}
+
+/**
+ * @param id Durable run identity.
+ * @returns Stable key for transient gap repair within one run's immutable events.
+ */
+export function jobRunEventGapQueryKey(id: string) {
+ return [...jobRunEventGapQueryRoot, id] as const;
+}
+
+/**
+ * @param enabled Server-owned enabled-state filter.
+ * @returns Exact key for one enabled-state schedule directory.
+ */
+export function scheduleListQueryKey(enabled: ListSchedulesInput["enabled"]) {
+ return [...scheduleListQueryRoot, enabled] as const;
+}
+
+/**
+ * @param id Code-owned schedule identity.
+ * @returns Exact key for one code-owned schedule detail.
+ */
+export function scheduleDetailQueryKey(id: string) {
+ return [...scheduleDetailQueryRoot, id] as const;
+}
+
+/**
+ * @param id Code-owned schedule identity.
+ * @returns Exact key for one schedule-scoped durable run history.
+ */
+export function scheduleRunListQueryKey(id: string) {
+ return [...scheduleRunListQueryRoot, id] as const;
+}
+
+/** @returns Cursor-paginated global run history and queue-summary options. */
+export function jobRunListQueryOptions(
+ client: DashboardTrpcClient,
+ filters: ListJobRunsInput["filters"]
+) {
+ return infiniteQueryOptions({
+ initialPageParam: undefined as JobRunCursor | undefined,
+ queryFn: ({ pageParam, signal }): Promise =>
+ client.query(
+ "jobs.listRuns",
+ {
+ ...(pageParam === undefined ? {} : { cursor: pageParam }),
+ ...(filters === undefined ? {} : { filters }),
+ limit: 100,
+ },
+ { signal }
+ ),
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ queryKey: jobRunListQueryKey(filters),
+ staleTime: 10_000,
+ });
+}
+
+/**
+ * Polls one bounded foreground snapshot so heartbeat-only worker expiry does not
+ * refetch every materialized page in the infinite run history.
+ * @returns Global queue and live-worker summary options.
+ */
+export function jobQueueSummaryQueryOptions(client: DashboardTrpcClient) {
+ return queryOptions({
+ queryFn: ({ signal }): Promise =>
+ client.query("jobs.listRuns", { limit: 1 }, { signal }),
+ queryKey: jobQueueSummaryQueryKey,
+ refetchInterval: jobQueueSummaryRefreshIntervalMs,
+ select: ({ summary }) => summary,
+ staleTime: jobQueueSummaryRefreshIntervalMs,
+ });
+}
+
+/** @returns Exact first-page run detail with bounded newest-first events. */
+export function jobRunDetailQueryOptions(client: DashboardTrpcClient, id: string) {
+ return queryOptions({
+ queryFn: ({ signal }): Promise =>
+ client.query("jobs.getRun", { eventLimit: 100, id }, { signal }),
+ queryKey: jobRunDetailQueryKey(id),
+ staleTime: 0,
+ });
+}
+
+/**
+ * Defines older event pages independently from the exact detail cache. A failed
+ * history request therefore cannot replace a successfully cached run detail.
+ * @param client Validated browser tRPC client.
+ * @param id Durable run identity.
+ * @param firstCursor Cursor returned by the exact detail page.
+ * @param enabled Whether the operator has requested older events.
+ * @returns Cursor-paginated older event history options.
+ */
+export function jobRunEventHistoryQueryOptions(
+ client: DashboardTrpcClient,
+ id: string,
+ firstCursor: JobRunEventCursor | undefined,
+ enabled = true
+) {
+ return infiniteQueryOptions({
+ enabled: enabled && firstCursor !== undefined,
+ initialPageParam: firstCursor,
+ queryFn: ({ pageParam, signal }): Promise => {
+ if (pageParam === undefined) {
+ return Promise.reject(
+ new TypeError("Job event history requires a continuation cursor")
+ );
+ }
+ return client.query(
+ "jobs.getRun",
+ { eventCursor: pageParam, eventLimit: 100, id },
+ { signal }
+ );
+ },
+ getNextPageParam: (lastPage) => lastPage.nextEventCursor,
+ queryKey: jobRunEventHistoryQueryKey(id),
+ staleTime: 10_000,
+ });
+}
+
+/** One missing event interval bounded by a current cursor and a known older sequence. */
+export interface JobRunEventGapRequest {
+ readonly cursor: JobRunEventCursor;
+ readonly knownSequence: number;
+}
+
+/** Immutable events fetched to bridge one realtime cursor jump. */
+export interface JobRunEventGapResult {
+ readonly events: JobRunEvent[];
+ readonly request: JobRunEventGapRequest;
+}
+
+/**
+ * Loads only the missing interval between a moved exact-page cursor and known history.
+ * The stable per-run key prevents each realtime cursor from creating another cache.
+ * @param client Validated browser tRPC client.
+ * @param id Durable run identity.
+ * @param request Current gap bounds, or nothing before a gap is observed.
+ * @returns Disabled query options invoked explicitly when exact detail advances.
+ */
+export function jobRunEventGapQueryOptions(
+ client: DashboardTrpcClient,
+ id: string,
+ request: JobRunEventGapRequest | undefined
+) {
+ return queryOptions({
+ enabled: false,
+ queryFn: async ({ signal }): Promise => {
+ if (request === undefined) {
+ throw new TypeError("Job event gap repair requires exact bounds");
+ }
+ const events: JobRunEvent[] = [];
+ let cursor = request.cursor;
+ while (cursor.sequence > request.knownSequence + 1) {
+ const missingCount = cursor.sequence - request.knownSequence - 1;
+ const page = await client.query(
+ "jobs.getRun",
+ {
+ eventCursor: cursor,
+ eventLimit: Math.min(jobRunEventPageMaximum, missingCount),
+ id,
+ },
+ { signal }
+ );
+ events.push(...page.events);
+ const nextCursor = page.nextEventCursor;
+ if (nextCursor === undefined) {
+ const oldestSequence = page.events.at(-1)?.sequence;
+ if (
+ oldestSequence === undefined ||
+ oldestSequence > request.knownSequence + 1
+ ) {
+ throw new TypeError(
+ "Job event gap ended before reaching known history"
+ );
+ }
+ break;
+ }
+ if (nextCursor.sequence >= cursor.sequence) {
+ throw new TypeError("Job event gap cursor did not advance");
+ }
+ if (nextCursor.sequence <= request.knownSequence + 1) break;
+ cursor = nextCursor;
+ }
+ return {
+ events: uniqueJobRunEvents(events),
+ request,
+ };
+ },
+ queryKey: jobRunEventGapQueryKey(id),
+ staleTime: Number.POSITIVE_INFINITY,
+ structuralSharing: (oldData, newData) => {
+ const previous = oldData as JobRunEventGapResult | undefined;
+ const current = newData as JobRunEventGapResult;
+ if (previous === undefined) return current;
+ return {
+ ...current,
+ events: uniqueJobRunEvents([
+ ...current.events,
+ ...previous.events,
+ ]).toSorted((left, right) => right.sequence - left.sequence),
+ };
+ },
+ });
+}
+
+/** @returns Cursor-paginated code-owned schedule directory options. */
+export function scheduleListQueryOptions(
+ client: DashboardTrpcClient,
+ enabled: ListSchedulesInput["enabled"] = "all"
+) {
+ return infiniteQueryOptions({
+ initialPageParam: undefined as ScheduleCursor | undefined,
+ queryFn: ({ pageParam, signal }): Promise =>
+ client.query(
+ "schedules.list",
+ {
+ ...(pageParam === undefined ? {} : { cursor: pageParam }),
+ enabled,
+ limit: 100,
+ },
+ { signal }
+ ),
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ queryKey: scheduleListQueryKey(enabled),
+ staleTime: 10_000,
+ });
+}
+
+/** @returns Exact code-owned schedule detail options. */
+export function scheduleDetailQueryOptions(client: DashboardTrpcClient, id: string) {
+ return queryOptions({
+ queryFn: ({ signal }): Promise =>
+ client.query("schedules.get", { id }, { signal }),
+ queryKey: scheduleDetailQueryKey(id),
+ staleTime: 0,
+ });
+}
+
+/** @returns Cursor-paginated newest-first history for one schedule. */
+export function scheduleRunListQueryOptions(client: DashboardTrpcClient, id: string) {
+ return infiniteQueryOptions({
+ initialPageParam: undefined as JobRunCursor | undefined,
+ queryFn: ({ pageParam, signal }): Promise =>
+ client.query(
+ "schedules.listRuns",
+ {
+ ...(pageParam === undefined ? {} : { cursor: pageParam }),
+ id,
+ limit: 100,
+ },
+ { signal }
+ ),
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ queryKey: scheduleRunListQueryKey(id),
+ staleTime: 10_000,
+ });
+}
+
+/** Invalidates every queue, global history, and exact run projection. */
+export async function refreshJobQueries(queryClient: QueryClient): Promise {
+ await queryClient.invalidateQueries({
+ predicate: ({ queryKey }) =>
+ queryKey[0] === jobQueryKey[0] &&
+ !jobRunEventHistoryQueryRoot.every(
+ (segment, index) => queryKey[index] === segment
+ ),
+ });
+}
+
+/** Invalidates the schedule directory, details, and schedule-scoped histories. */
+export async function refreshScheduleQueries(queryClient: QueryClient): Promise {
+ await queryClient.invalidateQueries({ queryKey: scheduleQueryKey });
+}
+
+/** Invalidates projections shared by durable run and schedule mutations. */
+export async function refreshJobAndScheduleQueries(
+ queryClient: QueryClient
+): Promise {
+ await Promise.all([
+ refreshJobQueries(queryClient),
+ refreshScheduleQueries(queryClient),
+ ]);
+}
diff --git a/greenfield/src/browser/jobs/jobRouteSearch.test.ts b/greenfield/src/browser/jobs/jobRouteSearch.test.ts
new file mode 100644
index 000000000..86ee17865
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobRouteSearch.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+
+import { parseJobsRouteSearch } from "./jobRouteSearch.ts";
+
+const runId = "019fdf60-0000-7000-8000-000000000001";
+
+describe("jobs route search", () => {
+ test("preserves independent canonical schedule and run deep links", () => {
+ expect(
+ parseJobsRouteSearch({
+ runId,
+ scheduleId: "system.worker-smoke",
+ unknown: "dropped",
+ })
+ ).toEqual({
+ runId,
+ scheduleId: "system.worker-smoke",
+ });
+ });
+
+ test("drops malformed selections independently", () => {
+ expect(
+ parseJobsRouteSearch({
+ runId: "not-a-run",
+ scheduleId: "system.worker-smoke",
+ })
+ ).toEqual({ scheduleId: "system.worker-smoke" });
+ expect(parseJobsRouteSearch({ runId, scheduleId: "Bad Schedule" })).toEqual({
+ runId,
+ });
+ expect(parseJobsRouteSearch("invalid")).toEqual({});
+ expect(parseJobsRouteSearch(null)).toEqual({});
+ expect(parseJobsRouteSearch([runId, "system.worker-smoke"])).toEqual({});
+ expect(parseJobsRouteSearch({ runId: 1, scheduleId: false })).toEqual({});
+ });
+});
diff --git a/greenfield/src/browser/jobs/jobRouteSearch.ts b/greenfield/src/browser/jobs/jobRouteSearch.ts
new file mode 100644
index 000000000..572fbf56b
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobRouteSearch.ts
@@ -0,0 +1,34 @@
+import * as v from "valibot";
+
+import { jobRunIdSchema, scheduleIdSchema } from "../../contracts/jobModel.ts";
+
+const jobsRouteSearchSchema = v.strictObject({
+ runId: v.optional(jobRunIdSchema),
+ scheduleId: v.optional(scheduleIdSchema),
+});
+
+/** Validated independent selections owned by the Dashboard-local jobs route. */
+export type JobsRouteSearch = v.InferOutput;
+
+function searchString(search: unknown, key: "runId" | "scheduleId"): string | undefined {
+ if (typeof search !== "object" || search === null) return undefined;
+ const value = (search as Record)[key];
+ return typeof value === "string" ? value : undefined;
+}
+
+/**
+ * Drops each malformed or unknown selection without discarding an independent valid one.
+ * @param search Untrusted search object parsed by TanStack Router.
+ * @returns Safe schedule and run deep-link state.
+ */
+export function parseJobsRouteSearch(search: unknown): JobsRouteSearch {
+ const rawRunId = searchString(search, "runId");
+ const rawScheduleId = searchString(search, "scheduleId");
+ const runId = v.safeParse(jobRunIdSchema, rawRunId);
+ const scheduleId = v.safeParse(scheduleIdSchema, rawScheduleId);
+
+ return {
+ ...(runId.success ? { runId: runId.output } : {}),
+ ...(scheduleId.success ? { scheduleId: scheduleId.output } : {}),
+ };
+}
diff --git a/greenfield/src/browser/jobs/jobRunPresentation.ts b/greenfield/src/browser/jobs/jobRunPresentation.ts
new file mode 100644
index 000000000..95e4b1bc1
--- /dev/null
+++ b/greenfield/src/browser/jobs/jobRunPresentation.ts
@@ -0,0 +1,30 @@
+import type { JobRunState } from "../../contracts/jobModel.ts";
+
+/** @returns Human-readable durable run state text. */
+export function jobRunStateLabel(state: JobRunState): string {
+ return state.replaceAll("-", " ");
+}
+
+/** @returns Shared semantic badge treatment for a durable run state. */
+export function jobRunStateBadgeVariant(
+ state: JobRunState
+): "danger" | "default" | "info" | "success" | "warning" {
+ switch (state) {
+ case "cancelled": {
+ return "default";
+ }
+ case "failed":
+ case "timed-out": {
+ return "danger";
+ }
+ case "queued": {
+ return "info";
+ }
+ case "running": {
+ return "warning";
+ }
+ case "succeeded": {
+ return "success";
+ }
+ }
+}
diff --git a/greenfield/src/browser/jobs/scheduleEditorForm.test.ts b/greenfield/src/browser/jobs/scheduleEditorForm.test.ts
new file mode 100644
index 000000000..46e4eeb7a
--- /dev/null
+++ b/greenfield/src/browser/jobs/scheduleEditorForm.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, test } from "bun:test";
+
+import * as v from "valibot";
+
+import type { ScheduleSummary } from "../../contracts/jobModel.ts";
+import {
+ scheduleConfigurationFromEditor,
+ scheduleEditorFormSchema,
+ scheduleEditorValues,
+} from "./scheduleEditorForm.ts";
+
+const runId = "019fdf60-0000-7000-8000-000000000001";
+
+function schedule(configuration: ScheduleSummary["schedule"]): ScheduleSummary {
+ return {
+ actionKey: "system.worker-smoke",
+ attemptLimit: 2,
+ cancellationPolicy: "cooperative",
+ createdAtMs: 1000,
+ description: "Checks the durable worker.",
+ enabled: true,
+ id: "system.worker-smoke",
+ latestRun: {
+ actionKey: "system.worker-smoke",
+ attemptCount: 0,
+ attemptLimit: 2,
+ availableAtMs: 2000,
+ cancellationPolicy: "cooperative",
+ displayName: "Worker smoke",
+ eventCount: 1,
+ id: runId,
+ priority: 0,
+ queuedAtMs: 2000,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ scheduledJobId: "system.worker-smoke",
+ scheduledJobVersion: 1,
+ state: "queued",
+ stateVersion: 1,
+ timeoutMs: 60_000,
+ triggerType: "manual",
+ updatedAtMs: 2000,
+ },
+ name: "Worker smoke",
+ nextRunAtMs: 120_000,
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: [],
+ retrySafe: true,
+ schedule: configuration,
+ timeoutMs: 60_000,
+ updatedAtMs: 1000,
+ version: 1,
+ };
+}
+
+describe("schedule editor form", () => {
+ test("round-trips each schedule variant", () => {
+ const configurations = [
+ { intervalMs: 64_001, kind: "interval" },
+ { kind: "daily", timeOfDay: "07:30", timeZone: "Europe/Oslo" },
+ { expression: "0 6 * * MON", kind: "cron", timeZone: "UTC" },
+ ] as const;
+
+ expect(
+ configurations.map((configuration) =>
+ scheduleConfigurationFromEditor(
+ v.parse(
+ scheduleEditorFormSchema,
+ scheduleEditorValues(schedule(configuration))
+ )
+ )
+ )
+ ).toEqual([
+ { intervalMs: 64_001, kind: "interval" },
+ { kind: "daily", timeOfDay: "07:30", timeZone: "Europe/Oslo" },
+ { expression: "0 6 * * 1", kind: "cron", timeZone: "UTC" },
+ ]);
+ });
+
+ test("validates only fields owned by the selected variant", () => {
+ const interval = scheduleEditorValues(
+ schedule({ intervalMs: 60_000, kind: "interval" })
+ );
+ interval.cronExpression = "invalid";
+ interval.timeOfDay = "invalid";
+ interval.timeZone = "invalid";
+ expect(v.safeParse(scheduleEditorFormSchema, interval).success).toBeTrue();
+
+ for (const values of [
+ { ...interval, intervalSeconds: "59.999" },
+ { ...interval, intervalSeconds: "60.0001" },
+ { ...interval, intervalSeconds: "31536001" },
+ { ...interval, kind: "cron" as const },
+ { ...interval, kind: "daily" as const },
+ ]) {
+ expect(v.safeParse(scheduleEditorFormSchema, values).success).toBeFalse();
+ }
+ });
+});
diff --git a/greenfield/src/browser/jobs/scheduleEditorForm.ts b/greenfield/src/browser/jobs/scheduleEditorForm.ts
new file mode 100644
index 000000000..775a04724
--- /dev/null
+++ b/greenfield/src/browser/jobs/scheduleEditorForm.ts
@@ -0,0 +1,131 @@
+import * as v from "valibot";
+
+import {
+ type ScheduleConfiguration,
+ type ScheduleSummary,
+ scheduleConfigurationSchema,
+ scheduleCronExpressionSchema,
+ scheduleIntervalMaximumMilliseconds,
+ scheduleIntervalMinimumMilliseconds,
+ scheduleKinds,
+ scheduleTimeOfDaySchema,
+ scheduleTimeZoneSchema,
+} from "../../contracts/jobModel.ts";
+
+const editorTextSchema = v.pipe(
+ v.string("Schedule value is invalid"),
+ v.maxLength(400, "Schedule value is outside its budget")
+);
+
+const scheduleEditorObjectSchema = v.strictObject({
+ cronExpression: editorTextSchema,
+ intervalSeconds: editorTextSchema,
+ kind: v.picklist(scheduleKinds, "Schedule kind is invalid"),
+ timeOfDay: editorTextSchema,
+ timeZone: editorTextSchema,
+});
+
+function intervalMilliseconds(value: string): number | undefined {
+ const normalized = value.trim();
+ if (!/^(?:0|[1-9]\d*)(?:\.\d{1,3})?$/u.test(normalized)) return;
+ const [seconds = "", fraction = ""] = normalized.split(".");
+ const milliseconds = Number(seconds) * 1000 + Number(fraction.padEnd(3, "0"));
+ if (!Number.isSafeInteger(milliseconds)) return;
+ if (
+ milliseconds < scheduleIntervalMinimumMilliseconds ||
+ milliseconds > scheduleIntervalMaximumMilliseconds
+ ) {
+ return;
+ }
+ return milliseconds;
+}
+
+/** Browser schedule values with validation conditional on the selected variant. */
+export const scheduleEditorFormSchema = v.pipe(
+ scheduleEditorObjectSchema,
+ v.forward(
+ v.check(
+ (value) =>
+ value.kind !== "interval" ||
+ intervalMilliseconds(value.intervalSeconds) !== undefined,
+ "Use an interval from 60 to 31,536,000 seconds"
+ ),
+ ["intervalSeconds"]
+ ),
+ v.forward(
+ v.check(
+ (value) =>
+ value.kind !== "cron" ||
+ v.safeParse(scheduleCronExpressionSchema, value.cronExpression).success,
+ "Use a valid five-field cron expression"
+ ),
+ ["cronExpression"]
+ ),
+ v.forward(
+ v.check(
+ (value) =>
+ value.kind !== "daily" ||
+ v.safeParse(scheduleTimeOfDaySchema, value.timeOfDay).success,
+ "Use a canonical 24-hour time"
+ ),
+ ["timeOfDay"]
+ ),
+ v.forward(
+ v.check(
+ (value) =>
+ value.kind === "interval" ||
+ v.safeParse(scheduleTimeZoneSchema, value.timeZone).success,
+ "Use UTC or a canonical IANA time zone"
+ ),
+ ["timeZone"]
+ )
+);
+
+export type ScheduleEditorValues = v.InferOutput;
+
+/** @returns Stable editor values that preserve inactive variant drafts. */
+export function scheduleEditorValues(schedule: ScheduleSummary): ScheduleEditorValues {
+ return {
+ cronExpression:
+ schedule.schedule.kind === "cron"
+ ? schedule.schedule.expression
+ : "0 * * * *",
+ intervalSeconds:
+ schedule.schedule.kind === "interval"
+ ? String(schedule.schedule.intervalMs / 1000)
+ : "60",
+ kind: schedule.schedule.kind,
+ timeOfDay:
+ schedule.schedule.kind === "daily" ? schedule.schedule.timeOfDay : "00:00",
+ timeZone:
+ schedule.schedule.kind === "interval" ? "UTC" : schedule.schedule.timeZone,
+ };
+}
+
+/** @returns One validated, mutually exclusive schedule configuration. */
+export function scheduleConfigurationFromEditor(
+ values: ScheduleEditorValues
+): ScheduleConfiguration {
+ switch (values.kind) {
+ case "cron": {
+ return v.parse(scheduleConfigurationSchema, {
+ expression: values.cronExpression,
+ kind: values.kind,
+ timeZone: values.timeZone,
+ });
+ }
+ case "daily": {
+ return v.parse(scheduleConfigurationSchema, {
+ kind: values.kind,
+ timeOfDay: values.timeOfDay,
+ timeZone: values.timeZone,
+ });
+ }
+ case "interval": {
+ return v.parse(scheduleConfigurationSchema, {
+ intervalMs: intervalMilliseconds(values.intervalSeconds),
+ kind: values.kind,
+ });
+ }
+ }
+}
diff --git a/greenfield/src/browser/jobs/schedulePresentation.ts b/greenfield/src/browser/jobs/schedulePresentation.ts
new file mode 100644
index 000000000..fca6d27ae
--- /dev/null
+++ b/greenfield/src/browser/jobs/schedulePresentation.ts
@@ -0,0 +1,16 @@
+import type { ScheduleConfiguration } from "../../contracts/jobModel.ts";
+
+/** @returns A compact operator-facing description of one schedule variant. */
+export function scheduleConfigurationLabel(configuration: ScheduleConfiguration): string {
+ switch (configuration.kind) {
+ case "cron": {
+ return `${configuration.expression} · ${configuration.timeZone}`;
+ }
+ case "daily": {
+ return `Daily ${configuration.timeOfDay} · ${configuration.timeZone}`;
+ }
+ case "interval": {
+ return `Every ${configuration.intervalMs / 1000} seconds`;
+ }
+ }
+}
diff --git a/greenfield/src/browser/jobs/testSupport/ScheduleDetail.tsx b/greenfield/src/browser/jobs/testSupport/ScheduleDetail.tsx
new file mode 100644
index 000000000..55a7510aa
--- /dev/null
+++ b/greenfield/src/browser/jobs/testSupport/ScheduleDetail.tsx
@@ -0,0 +1,110 @@
+import type { ScheduleSummary } from "../../../contracts/jobModel.ts";
+import { ScheduleDetail } from "../ScheduleDetail.tsx";
+
+const { render } = await import("@testing-library/react");
+
+export const scheduleId = "system.worker-smoke";
+export const timestampMs = 1_800_000_000_000;
+
+/** @returns One enabled ScheduleDetail fixture with no active run. */
+export function enabledSchedule(): ScheduleSummary {
+ return {
+ actionKey: scheduleId,
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the worker without host mutation.",
+ enabled: true,
+ id: scheduleId,
+ name: "Worker smoke",
+ nextRunAtMs: timestampMs + 60_000,
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 30_000,
+ updatedAtMs: timestampMs,
+ version: 1,
+ };
+}
+
+/**
+ * Creates a disabled schedule fixture.
+ * @param expiresAtMs Exact active-intent expiry.
+ * @returns One disabled schedule with a durable intent.
+ */
+export function disabledSchedule(expiresAtMs: number): ScheduleSummary {
+ return {
+ actionKey: scheduleId,
+ activeDisableIntent: {
+ createdAtMs: timestampMs,
+ expiresAtMs,
+ id: "019fdf80-0000-7000-8000-000000000001",
+ reason: "Planned maintenance",
+ },
+ attemptLimit: 3,
+ cancellationPolicy: "cooperative",
+ createdAtMs: timestampMs - 10_000,
+ description: "Checks the worker without host mutation.",
+ enabled: false,
+ id: scheduleId,
+ name: "Worker smoke",
+ priority: 0,
+ resourceClass: "light",
+ resourceKeys: ["database"],
+ retrySafe: true,
+ schedule: { intervalMs: 60_000, kind: "interval" },
+ timeoutMs: 30_000,
+ updatedAtMs: timestampMs,
+ version: 2,
+ };
+}
+
+interface RenderScheduleDetailOptions {
+ readonly disableError?: string;
+ readonly error?: string;
+ readonly onDisable?: (
+ draft: {
+ readonly expiresAtMs?: number;
+ readonly reason: string;
+ },
+ expectedVersion: number
+ ) => Promise;
+ readonly onOpenDisable?: () => void;
+ readonly onRun?: () => Promise;
+ readonly runReplayAvailable?: boolean;
+ readonly schedule?: ScheduleSummary;
+}
+
+/**
+ * Renders ScheduleDetail with inert defaults around the behavior under test.
+ * @param options Optional schedule and callback overrides.
+ * @returns Testing Library render controls.
+ */
+export function renderScheduleDetail({
+ disableError,
+ error,
+ onDisable = async () => {},
+ onOpenDisable = () => {},
+ onRun = async () => {},
+ runReplayAvailable,
+ schedule = enabledSchedule(),
+}: RenderScheduleDetailOptions = {}) {
+ return render(
+ Run history}
+ onDisable={onDisable}
+ onEnable={async () => {}}
+ onOpenDisable={onOpenDisable}
+ onRun={onRun}
+ onSaveConfiguration={async () => {}}
+ runBusy={false}
+ runReplayAvailable={runReplayAvailable}
+ schedule={schedule}
+ updateBusy={false}
+ />
+ );
+}
diff --git a/greenfield/src/browser/jobs/useJobRealtimeInvalidation.test.tsx b/greenfield/src/browser/jobs/useJobRealtimeInvalidation.test.tsx
new file mode 100644
index 000000000..d130c5db0
--- /dev/null
+++ b/greenfield/src/browser/jobs/useJobRealtimeInvalidation.test.tsx
@@ -0,0 +1,153 @@
+import { describe, expect, jest, test } from "bun:test";
+
+import { QueryClientProvider } from "@tanstack/react-query";
+import { act, StrictMode } from "react";
+
+import type { RealtimeStreamOutput } from "../../contracts/events.ts";
+import { jobRealtimeTopics } from "../../contracts/jobRealtime.ts";
+import { createDashboardQueryClient } from "../api/queryClient.ts";
+import { DashboardRealtimeProvider } from "../api/realtimeContext.tsx";
+import { ControlledDashboardRealtimeClient } from "../test/realtime.ts";
+import { jobRunDetailQueryKey, scheduleDetailQueryKey } from "./jobQueries.ts";
+import {
+ jobRealtimeFallbackRefreshIntervalMs,
+ jobRealtimeRefreshDelayMs,
+ useJobRealtimeInvalidation,
+} from "./useJobRealtimeInvalidation.ts";
+
+const { render, waitFor } = await import("@testing-library/react");
+const runId = "019fdf70-0000-7000-8000-000000000002";
+const scheduleId = "system.worker-smoke";
+const unrelatedKey = ["monitoring", "reports", "test"] as const;
+
+function JobRealtimeProbe() {
+ useJobRealtimeInvalidation();
+ return null;
+}
+
+function runChange(): RealtimeStreamOutput {
+ return {
+ data: {
+ event: {
+ entityId: runId,
+ entityType: "job-run",
+ occurredAtMs: 1_800_000_000_000,
+ operation: "updated",
+ payload: { id: runId },
+ topic: jobRealtimeTopics.runs,
+ },
+ kind: "change",
+ },
+ id: "31",
+ };
+}
+
+function scheduleChange(): RealtimeStreamOutput {
+ return {
+ data: {
+ event: {
+ entityId: scheduleId,
+ entityType: "schedule",
+ occurredAtMs: 1_800_000_000_100,
+ operation: "updated",
+ payload: { id: scheduleId },
+ topic: jobRealtimeTopics.schedules,
+ },
+ kind: "change",
+ },
+ id: "32",
+ };
+}
+
+describe("jobs realtime invalidation", () => {
+ test("coalesces run events into both job and schedule roots", async () => {
+ const queryClient = createDashboardQueryClient();
+ const realtimeClient = new ControlledDashboardRealtimeClient();
+ const runKey = jobRunDetailQueryKey(runId);
+ const scheduleKey = scheduleDetailQueryKey(scheduleId);
+ queryClient.setQueryData(runKey, { id: runId });
+ queryClient.setQueryData(scheduleKey, { id: scheduleId });
+ queryClient.setQueryData(unrelatedKey, { reports: [] });
+ const view = render(
+
+
+
+
+
+
+
+ );
+
+ try {
+ expect(realtimeClient.input).toEqual({
+ lastEventId: "0",
+ topics: [jobRealtimeTopics.runs, jobRealtimeTopics.schedules],
+ });
+ expect(realtimeClient.activeSubscriptionCount).toBe(1);
+ act(() => {
+ realtimeClient.emit(runChange());
+ realtimeClient.emit(runChange());
+ });
+ await waitFor(() => {
+ expect(queryClient.getQueryState(runKey)?.isInvalidated).toBeTrue();
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue();
+ });
+ expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBeFalse();
+ } finally {
+ view.unmount();
+ queryClient.clear();
+ }
+ });
+
+ test("keeps schedule events precise and starts fallback after terminal resync", async () => {
+ jest.useFakeTimers();
+ const queryClient = createDashboardQueryClient();
+ const realtimeClient = new ControlledDashboardRealtimeClient();
+ const runKey = jobRunDetailQueryKey(runId);
+ const scheduleKey = scheduleDetailQueryKey(scheduleId);
+ queryClient.setQueryData(runKey, { id: runId });
+ queryClient.setQueryData(scheduleKey, { id: scheduleId });
+ const view = render(
+
+
+
+
+
+ );
+
+ try {
+ await act(async () => {
+ realtimeClient.emit(scheduleChange());
+ jest.advanceTimersByTime(jobRealtimeRefreshDelayMs);
+ await Promise.resolve();
+ });
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue();
+ expect(queryClient.getQueryState(runKey)?.isInvalidated).toBeFalse();
+
+ queryClient.setQueryData(runKey, { id: runId });
+ queryClient.setQueryData(scheduleKey, { id: scheduleId });
+ await act(async () => {
+ realtimeClient.requireResync();
+ jest.advanceTimersByTime(jobRealtimeRefreshDelayMs);
+ await Promise.resolve();
+ });
+ expect(queryClient.getQueryState(runKey)?.isInvalidated).toBeTrue();
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue();
+
+ queryClient.setQueryData(runKey, { id: runId });
+ queryClient.setQueryData(scheduleKey, { id: scheduleId });
+ await act(async () => {
+ jest.advanceTimersByTime(
+ jobRealtimeFallbackRefreshIntervalMs + jobRealtimeRefreshDelayMs
+ );
+ await Promise.resolve();
+ });
+ expect(queryClient.getQueryState(runKey)?.isInvalidated).toBeTrue();
+ expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue();
+ } finally {
+ view.unmount();
+ queryClient.clear();
+ jest.useRealTimers();
+ }
+ });
+});
diff --git a/greenfield/src/browser/jobs/useJobRealtimeInvalidation.ts b/greenfield/src/browser/jobs/useJobRealtimeInvalidation.ts
new file mode 100644
index 000000000..228ca2e64
--- /dev/null
+++ b/greenfield/src/browser/jobs/useJobRealtimeInvalidation.ts
@@ -0,0 +1,25 @@
+import { jobRealtimeTopics } from "../../contracts/jobRealtime.ts";
+import { useRealtimeQueryInvalidation } from "../api/useRealtimeQueryInvalidation.ts";
+import { refreshJobAndScheduleQueries, refreshScheduleQueries } from "./jobQueries.ts";
+
+export const jobRealtimeRefreshDelayMs = 100;
+export const jobRealtimeFallbackRefreshIntervalMs = 30_000;
+
+/**
+ * Subscribes the jobs browser to durable run, queue, and schedule invalidations.
+ * Run changes refresh embedded schedule projections as well as the queue/history.
+ */
+export function useJobRealtimeInvalidation(): void {
+ useRealtimeQueryInvalidation({
+ fallbackRefreshIntervalMs: jobRealtimeFallbackRefreshIntervalMs,
+ refreshDelayMs: jobRealtimeRefreshDelayMs,
+ refreshQueries: refreshJobAndScheduleQueries,
+ topic: jobRealtimeTopics.runs,
+ });
+ useRealtimeQueryInvalidation({
+ fallbackRefreshIntervalMs: jobRealtimeFallbackRefreshIntervalMs,
+ refreshDelayMs: jobRealtimeRefreshDelayMs,
+ refreshQueries: refreshScheduleQueries,
+ topic: jobRealtimeTopics.schedules,
+ });
+}
diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx
index a3e43e627..3505401a1 100644
--- a/greenfield/src/browser/layout/DashboardShell.tsx
+++ b/greenfield/src/browser/layout/DashboardShell.tsx
@@ -2,6 +2,7 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/re
import { Outlet, useLocation } from "@tanstack/react-router";
import {
Bot,
+ CalendarClock,
Home,
ListTodo,
Menu,
@@ -31,6 +32,7 @@ const navigationItems: readonly NavigationItem[] = Object.freeze([
{ icon: Home, label: "Dashboard", to: "/" },
{ icon: Bot, label: "Agents", to: "/agents" },
{ icon: ListTodo, label: "Tasks", to: "/tasks" },
+ { icon: CalendarClock, label: "Jobs", to: "/jobs" },
{ icon: Newspaper, label: "Reports", to: "/reports" },
{ icon: ShieldCheck, label: "Account security", to: "/account-security" },
]);
diff --git a/greenfield/src/browser/lib/dashboardRoutes.ts b/greenfield/src/browser/lib/dashboardRoutes.ts
index 9e5aa7427..9e911d1d2 100644
--- a/greenfield/src/browser/lib/dashboardRoutes.ts
+++ b/greenfield/src/browser/lib/dashboardRoutes.ts
@@ -4,6 +4,7 @@ export const dashboardRoutePaths = Object.freeze([
"/account-security",
"/agents",
"/incidents",
+ "/jobs",
"/login",
"/reports",
"/tasks",
diff --git a/greenfield/src/browser/router.tsx b/greenfield/src/browser/router.tsx
index 77d8dac33..cba77da7f 100644
--- a/greenfield/src/browser/router.tsx
+++ b/greenfield/src/browser/router.tsx
@@ -5,6 +5,7 @@ import {
type RouterHistory,
} from "@tanstack/react-router";
+import { parseJobsRouteSearch } from "./jobs/jobRouteSearch.ts";
import { DashboardShell } from "./layout/DashboardShell.tsx";
import {
parseIncidentsRouteSearch,
@@ -33,6 +34,11 @@ const tasksRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/tasks",
}).lazy(() => import("./routes/tasks.lazy.tsx").then((module) => module.Route));
+const jobsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/jobs",
+ validateSearch: parseJobsRouteSearch,
+}).lazy(() => import("./routes/jobs.lazy.tsx").then((module) => module.Route));
const reportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/reports",
@@ -49,6 +55,7 @@ const routeTree = rootRoute.addChildren([
accountSecurityRoute,
agentsRoute,
incidentsRoute,
+ jobsRoute,
reportsRoute,
tasksRoute,
]);
diff --git a/greenfield/src/browser/routes/jobs.lazy.tsx b/greenfield/src/browser/routes/jobs.lazy.tsx
new file mode 100644
index 000000000..62ab2a309
--- /dev/null
+++ b/greenfield/src/browser/routes/jobs.lazy.tsx
@@ -0,0 +1,14 @@
+import { createLazyRoute } from "@tanstack/react-router";
+
+import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx";
+import { JobsRoute } from "../jobs/JobsRoute.tsx";
+
+export const Route = createLazyRoute("/jobs")({
+ component: function JobsRouteBoundary() {
+ return (
+
+
+
+ );
+ },
+});
diff --git a/greenfield/src/browser/test/setup.test.ts b/greenfield/src/browser/test/setup.test.ts
new file mode 100644
index 000000000..68917cab5
--- /dev/null
+++ b/greenfield/src/browser/test/setup.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+
+let fileReaderResult: string | undefined;
+let nestedTimerCompleted = false;
+
+describe("browser test teardown", () => {
+ test("leaves HappyDOM-owned nested zero-delay work for teardown to drain", () => {
+ const reader = new FileReader();
+ reader.addEventListener("load", () => {
+ if (typeof reader.result === "string") fileReaderResult = reader.result;
+ });
+ // oxlint-disable-next-line unicorn/prefer-blob-reading-methods -- Exercises HappyDOM's internal nested timer path.
+ reader.readAsText(new Blob(["nested HappyDOM timer"]));
+ expect(fileReaderResult).toBeUndefined();
+ });
+
+ test("finishes HappyDOM-owned work before the following test", async () => {
+ expect(fileReaderResult).toBe("nested HappyDOM timer");
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 0));
+ });
+
+ test("leaves arbitrary nested zero-delay work for teardown to drain", () => {
+ const schedule = globalThis.setTimeout.bind(globalThis);
+ schedule(() => {
+ schedule(() => {
+ schedule(() => {
+ nestedTimerCompleted = true;
+ }, 0);
+ }, 0);
+ }, 0);
+ expect(true).toBeTrue();
+ });
+
+ test("keeps zero-delay timers usable in the following test", async () => {
+ expect(nestedTimerCompleted).toBeTrue();
+ let completed = false;
+ await new Promise((resolve) => {
+ globalThis.setTimeout(() => {
+ completed = true;
+ resolve();
+ }, 0);
+ });
+ expect(completed).toBeTrue();
+ });
+});
diff --git a/greenfield/src/browser/test/setup.ts b/greenfield/src/browser/test/setup.ts
index d04f665cf..7fd0aa99d 100644
--- a/greenfield/src/browser/test/setup.ts
+++ b/greenfield/src/browser/test/setup.ts
@@ -1,14 +1,48 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { PropertySymbol } from "happy-dom";
import { act } from "react";
interface HappyDomAsyncTaskOwner {
readonly abort: () => Promise;
}
+interface HappyDomTimerOwner {
+ readonly setTimeout: typeof globalThis.setTimeout;
+}
+
+function isHappyDomTimerOwner(value: unknown): value is HappyDomTimerOwner {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof Reflect.get(value, "setTimeout") === "function"
+ );
+}
+
GlobalRegistrator.register({ url: "https://dashboard.test/" });
+const happyDomTimerOwner: unknown = Reflect.get(document, PropertySymbol.window);
+if (!isHappyDomTimerOwner(happyDomTimerOwner)) {
+ throw new TypeError("HappyDOM did not expose its owning window timer");
+}
+const browserSetTimeout = globalThis.setTimeout;
+let zeroDelayGeneration = 0;
+const trackedBrowserSetTimeout = (
+ callback: Parameters[0],
+ delay?: number,
+ ...arguments_: unknown[]
+): ReturnType => {
+ if (!delay) zeroDelayGeneration += 1;
+ return Reflect.apply(browserSetTimeout, happyDomTimerOwner, [
+ callback,
+ delay,
+ ...arguments_,
+ ]) as ReturnType;
+};
+Reflect.set(globalThis, "setTimeout", trackedBrowserSetTimeout);
+Reflect.set(happyDomTimerOwner, "setTimeout", trackedBrowserSetTimeout);
+
const [matcherModule, animationMocks, testingLibrary] = await Promise.all([
import("@testing-library/jest-dom/matchers"),
import("jsdom-testing-mocks"),
@@ -46,9 +80,20 @@ function isHappyDomAsyncTaskOwner(value: unknown): value is HappyDomAsyncTaskOwn
);
}
+async function drainHappyDomZeroDelayQueue(): Promise {
+ let expectedGeneration: number;
+ do {
+ expectedGeneration = zeroDelayGeneration + 1;
+ await new Promise((resolve) => trackedBrowserSetTimeout(resolve, 0));
+ } while (zeroDelayGeneration !== expectedGeneration);
+}
+
afterEach(async () => {
cleanup();
document.body.replaceChildren();
+ // HappyDOM groups zero-delay callbacks. Drain every finite nested batch before
+ // aborting longer-lived tasks, or abort can strand its private callback queue.
+ await drainHappyDomZeroDelayQueue();
const happyDom: unknown = Reflect.get(globalThis, "happyDOM");
if (isHappyDomAsyncTaskOwner(happyDom)) await happyDom.abort();
});
diff --git a/greenfield/src/browser/ui/DataTable.test.tsx b/greenfield/src/browser/ui/DataTable.test.tsx
index 2cfecac8c..4c6588343 100644
--- a/greenfield/src/browser/ui/DataTable.test.tsx
+++ b/greenfield/src/browser/ui/DataTable.test.tsx
@@ -115,6 +115,11 @@ describe("Dashboard data table and virtualizer", () => {
expect(screen.getByText("First row")).toBeTruthy();
expect(screen.getByText("Second row")).toBeTruthy();
+ const scrollRegion = screen.getByRole("region", { name: "Fixture rows" });
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
+ scrollRegion.focus();
+ expect(scrollRegion).toHaveFocus();
+ expect(scrollRegion).toHaveClass("focus-visible:ring-2");
});
test("composes the table with a bounded virtual row window", () => {
diff --git a/greenfield/src/browser/ui/DataTable.tsx b/greenfield/src/browser/ui/DataTable.tsx
index 777a6d69c..4de767e20 100644
--- a/greenfield/src/browser/ui/DataTable.tsx
+++ b/greenfield/src/browser/ui/DataTable.tsx
@@ -75,14 +75,17 @@ export function DataTable
);
+ /* oxlint-enable jsx-a11y/no-noninteractive-tabindex */
}
diff --git a/greenfield/src/contracts/schedules.ts b/greenfield/src/contracts/schedules.ts
index c75f78e52..6fa6d9a65 100644
--- a/greenfield/src/contracts/schedules.ts
+++ b/greenfield/src/contracts/schedules.ts
@@ -101,12 +101,15 @@ export const listSchedulesResultSchema = v.pipe(
/** Exact schedule lookup request. */
export const getScheduleInputSchema = v.strictObject({ id: scheduleIdSchema });
+/** Operator-supplied reason shared by the schedule contract and browser form. */
+export const scheduleDisableReasonSchema = boundedControlSafeTextSchema(
+ jobDescriptionMaximumLength,
+ "Schedule disable reason is invalid"
+);
+
const scheduleDisableIntentInputSchema = v.strictObject({
expiresAtMs: v.optional(jobTimestampSchema),
- reason: boundedControlSafeTextSchema(
- jobDescriptionMaximumLength,
- "Schedule disable reason is invalid"
- ),
+ reason: scheduleDisableReasonSchema,
});
const updateSchedulePatchObjectSchema = v.strictObject({