From dbd8a0981a2d3edd16cfe1b18eaaf13e27633022 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:50:19 -0700 Subject: [PATCH] fix(cron): align list ordering and visible-name search (#116834) Co-authored-by: Peter Steinberger --- .../service.list-page-sort-guards.test.ts | 58 ++++++++++++++++++- src/cron/service/ops-read.ts | 10 +++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts index 60fa7fc20880..22776c27ef13 100644 --- a/src/cron/service.list-page-sort-guards.test.ts +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createMockCronStateForJobs } from "./service.test-harness.js"; -import { listPage } from "./service/ops-read.js"; +import { list, listPage } from "./service/ops-read.js"; import type { CronJob } from "./types.js"; function createBaseJob(overrides?: Partial): CronJob { @@ -86,6 +86,62 @@ describe("cron listPage sort guards", () => { }, ); + it("keeps unscheduled jobs after scheduled jobs in the unpaginated list", async () => { + const jobs = [ + createBaseJob({ id: "paused-z", enabled: false, state: {} }), + createBaseJob({ id: "later", state: { nextRunAtMs: 200 } }), + createBaseJob({ id: "paused-a", enabled: false, state: {} }), + createBaseJob({ id: "earlier", state: { nextRunAtMs: 100 } }), + ]; + const state = createMockCronStateForJobs({ jobs }); + + const unpaginated = await list(state, { includeDisabled: true }); + const page = await listPage(state, { enabled: "all", sortBy: "nextRunAtMs" }); + + expect(unpaginated.map((job) => job.id)).toEqual(["earlier", "later", "paused-a", "paused-z"]); + expect(unpaginated.map((job) => job.id)).toEqual(page.jobs.map((job) => job.id)); + }); + + it("applies the same stable id tiebreaker to unpaginated cron jobs", async () => { + const nextRunAtMs = Date.parse("2026-02-27T15:30:00.000Z"); + const jobs = [ + createBaseJob({ id: "scheduled-z", state: { nextRunAtMs } }), + createBaseJob({ id: "scheduled-a", state: { nextRunAtMs } }), + ]; + const state = createMockCronStateForJobs({ jobs }); + + const unpaginated = await list(state); + + expect(unpaginated.map((job) => job.id)).toEqual(["scheduled-a", "scheduled-z"]); + }); + + it("matches the operator-visible display name when filtering cron jobs", async () => { + const job = createBaseJob({ + id: "report-job", + name: "internal-report-name", + displayName: "Daily summary", + }); + const state = createMockCronStateForJobs({ jobs: [job] }); + + const page = await listPage(state, { query: "Daily summary" }); + + expect(page.jobs.map((entry) => entry.id)).toEqual(["report-job"]); + }); + + it("preserves phrase searches across existing cron job fields", async () => { + const job = createBaseJob({ + id: "report-job", + name: "Daily report", + description: "Quarterly summary", + displayName: "Executive overview", + }); + const state = createMockCronStateForJobs({ jobs: [job] }); + + const page = await listPage(state, { query: "report Quarterly" }); + + expect(page.jobs.map((entry) => entry.id)).toEqual(["report-job"]); + }); + it("normalizes requested agent ids before filtering", async () => { const jobs = [ createBaseJob({ id: "job-main", agentId: "main", name: "main" }), diff --git a/src/cron/service/ops-read.ts b/src/cron/service/ops-read.ts index c33510a27a16..3ab5a55214e5 100644 --- a/src/cron/service/ops-read.ts +++ b/src/cron/service/ops-read.ts @@ -50,7 +50,7 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b await ensureLoadedForRead(state); const includeDisabled = opts?.includeDisabled === true; const jobs = (state.store?.jobs ?? []).filter((j) => includeDisabled || isJobEnabled(j)); - return jobs.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); + return sortCronJobs(jobs, "nextRunAtMs", "asc"); }); } @@ -289,7 +289,13 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio return true; } const haystack = normalizeLowercaseStringOrEmpty( - [job.id, job.name, job.description ?? "", job.agentId ?? ""].join(" "), + [ + job.id, + job.name, + job.description ?? "", + job.agentId ?? "", + ...(job.displayName ? [job.displayName] : []), + ].join(" "), ); return haystack.includes(query); });