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. + +
+
+ + + Claiming {claimingPaused ? "paused" : "active"} + + + +
+
+ +
+ {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 ? ( + + + No workers are registered. + + + ) : ( + + )} +
+
+ ); +} 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} + /> + )} +
+ + + + + { + setScheduleFilterError(undefined); + setScheduleDraft(event.currentTarget.value); + }} + placeholder="system.worker-smoke" + ref={scheduleFilterInputRef} + value={scheduleDraft} + /> + +
+ + +
+
+ +
{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}
    +                            
    +
    + ) : ( + + {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} + + + + {jobRunStateLabel(run.state)} + + +
    + + {run.actionKey} + +
    + {cancellation !== undefined && ( + + )} +
    + + {run.cancelRequestedAtMs !== undefined && ( + + + Cancellation requested at{" "} + {formatDashboardDateTime(run.cancelRequestedAtMs)}. + + + )} + +
    + + {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 ? ( + + + No durable events recorded. + + + ) : ( +
      + {events.map((event) => ( + + ))} +
    + )} + {additionalEventsAvailable && ( + + + Showing {events.length} newest events. Additional durable + events can be loaded through the bounded history cursor. + + + )} +
    +
    + ); +} + +/* 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. + +
    + +