From a4b3f63a87282ea360905e56a6a35c24c5f1bb6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 21:28:44 -0700 Subject: [PATCH] fix(ui): use active scheduler trigger capability (#126945) Control UI automation trigger authoring now reflects the running scheduler across unsaved and saved-but-unapplied config edits and reconnects. --- scripts/control-ui-mock-cron.ts | 1 + src/cron/service.stream-validation.test.ts | 29 +++- src/cron/service/ops-read.ts | 1 + src/cron/service/state.ts | 1 + src/gateway/server.cron.test.ts | 4 + .../scheduled-turns.contract.test.ts | 1 + ui/src/api/types.ts | 1 + ui/src/e2e/cron-trigger-authoring.e2e.test.ts | 125 +++++++++++++++++- ui/src/e2e/cron-trigger-filter.e2e.test.ts | 2 +- ui/src/pages/agents/view.test.ts | 10 +- ui/src/pages/cron/cron-page.test.ts | 80 ++++++++++- ui/src/pages/cron/cron-page.ts | 10 -- ui/src/pages/cron/view-run-history.test.ts | 2 +- ui/src/pages/cron/view.test-support.ts | 7 +- ui/src/pages/cron/view.test.ts | 20 ++- ui/src/pages/cron/view.ts | 6 +- 16 files changed, 267 insertions(+), 33 deletions(-) diff --git a/scripts/control-ui-mock-cron.ts b/scripts/control-ui-mock-cron.ts index 77edce9864e9..2017a0a92caa 100644 --- a/scripts/control-ui-mock-cron.ts +++ b/scripts/control-ui-mock-cron.ts @@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) { })); const status: CronStatus = { enabled: true, + triggersEnabled: true, jobs: jobs.length, nextWakeAtMs: overdueJob.state?.nextRunAtMs, }; diff --git a/src/cron/service.stream-validation.test.ts b/src/cron/service.stream-validation.test.ts index c350f626bbd0..110483d387ba 100644 --- a/src/cron/service.stream-validation.test.ts +++ b/src/cron/service.stream-validation.test.ts @@ -21,12 +21,14 @@ function streamJob(overrides: Partial = {}): CronJobCreate { }; } -async function createCron(triggersEnabled: boolean) { +async function createCron(triggersEnabled: boolean | undefined, cronEnabled = true) { const { storePath } = await makeStorePath(); const cron = new CronService({ storePath, - cronEnabled: true, - cronConfig: { triggers: { enabled: triggersEnabled } }, + cronEnabled, + ...(triggersEnabled === undefined + ? {} + : { cronConfig: { triggers: { enabled: triggersEnabled } } }), log: logger, enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), @@ -37,6 +39,27 @@ async function createCron(triggersEnabled: boolean) { } describe("cron stream schedule validation", () => { + it.each([ + { cronEnabled: true, configured: undefined, triggersEnabled: true }, + { cronEnabled: true, configured: true, triggersEnabled: true }, + { cronEnabled: true, configured: false, triggersEnabled: false }, + { cronEnabled: false, configured: true, triggersEnabled: true }, + { cronEnabled: false, configured: false, triggersEnabled: false }, + ])( + "reports active trigger capability independently of scheduler enablement ($cronEnabled/$configured)", + async ({ cronEnabled, configured, triggersEnabled }) => { + const cron = await createCron(configured, cronEnabled); + try { + await expect(cron.status()).resolves.toMatchObject({ + enabled: cronEnabled, + triggersEnabled, + }); + } finally { + cron.stop(); + } + }, + ); + it("rejects creation while cron triggers are disabled", async () => { const cron = await createCron(false); try { diff --git a/src/cron/service/ops-read.ts b/src/cron/service/ops-read.ts index e8687b792b8f..fcb24fd99df0 100644 --- a/src/cron/service/ops-read.ts +++ b/src/cron/service/ops-read.ts @@ -42,6 +42,7 @@ export async function status(state: CronServiceState) { const sqlitePath = resolveOpenClawStateSqlitePath(); return { enabled: state.deps.cronEnabled, + triggersEnabled: state.deps.cronConfig?.triggers?.enabled !== false, storePath: sqlitePath, storage: "sqlite" as const, sqlitePath, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 11ad33b656d9..a52dfadf671d 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -372,6 +372,7 @@ export type CronWakeMode = "now" | "next-heartbeat"; /** Lightweight service status returned to gateway/control surfaces. */ export type CronStatusSummary = { enabled: boolean; + triggersEnabled: boolean; /** @deprecated Alias for `sqlitePath`. */ storePath: string; /** Storage backend identifier. */ diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index fbff3a6f0962..77a3b91dd58d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -701,6 +701,10 @@ describe("gateway server cron", () => { const cronState = await createDirectCronState(); try { + await expect(directCronReq(cronState, "cron.status", {})).resolves.toMatchObject({ + ok: true, + payload: { enabled: false, triggersEnabled: false }, + }); const response = await directCronReq(cronState, "cron.add", { name: "disabled watcher", enabled: true, diff --git a/src/plugins/contracts/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts index 48fb0dbf4169..74566ff1dc61 100644 --- a/src/plugins/contracts/scheduled-turns.contract.test.ts +++ b/src/plugins/contracts/scheduled-turns.contract.test.ts @@ -93,6 +93,7 @@ function createMockCronService(): CronServiceContract { stop: vi.fn(), status: vi.fn(async () => ({ enabled: true, + triggersEnabled: true, storePath: "/tmp/openclaw-test-cron.json", storage: "sqlite" as const, sqlitePath: "/tmp/openclaw-test-state/state/openclaw.sqlite", diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index da6395a5f8e8..464d5cc32089 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -566,6 +566,7 @@ export type CronPayload = ProtocolCronJob["payload"]; export type CronStatus = { enabled: boolean; + triggersEnabled: boolean; jobs: number; nextWakeAtMs?: number | null; }; diff --git a/ui/src/e2e/cron-trigger-authoring.e2e.test.ts b/ui/src/e2e/cron-trigger-authoring.e2e.test.ts index 9f867c75cc0b..972b4573c878 100644 --- a/ui/src/e2e/cron-trigger-authoring.e2e.test.ts +++ b/ui/src/e2e/cron-trigger-authoring.e2e.test.ts @@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import type { Page } from "playwright"; import { expect, it } from "vitest"; +import type { ApplicationContext } from "../app/context.ts"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -14,6 +15,7 @@ const suite = createControlUiE2eSuite({ const proofDirectory = process.env.OPENCLAW_TRIGGER_UI_PROOF_DIR; const proofStage = process.env.OPENCLAW_TRIGGER_UI_PROOF_STAGE ?? "after"; +type CronTriggerTestApp = HTMLElement & { runtime?: { context: ApplicationContext } }; const scriptJob = { id: "existing-script-automation", @@ -52,6 +54,14 @@ async function captureProof(page: Page, name: string) { }); } +async function captureTriggerCapabilityProof(page: Page, name: string) { + await page + .locator(".settings-row__title") + .filter({ hasText: "Condition trigger" }) + .evaluate((element) => element.scrollIntoView({ block: "center" })); + await captureProof(page, name); +} + async function selectSeconds(page: Page) { const unit = page.locator("wa-select").filter({ has: page.locator('[slot="label"]', { hasText: "Unit" }), @@ -82,7 +92,7 @@ suite.define(() => { hasMore: false, nextOffset: null, }, - "cron.status": { enabled: true, jobs: 1, nextWakeAtMs: null }, + "cron.status": { enabled: true, triggersEnabled: true, jobs: 1, nextWakeAtMs: null }, }, }); @@ -162,4 +172,117 @@ suite.define(() => { }, ); }); + + it("keeps saved and unsaved trigger drafts separate from reconnect-refreshed scheduler capability", async () => { + await suite.withPage( + { locale: "en-US", serviceWorkers: "block", viewport: { height: 1_050, width: 1_440 } }, + async ({ page }) => { + const initialConfig = { cron: { triggers: { enabled: true } } }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + appliedConfigHash: "trigger-config-1", + config: initialConfig, + configRevisionHash: "trigger-config-1", + hash: "trigger-config-1", + issues: [], + raw: JSON.stringify(initialConfig), + valid: true, + }, + "cron.list": listResponse([]), + "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, + "cron.status": { enabled: true, triggersEnabled: true, jobs: 0, nextWakeAtMs: null }, + }, + }); + + await page.goto(`${suite.server.baseUrl}cron`); + await page.locator('[data-test-id="cron-new-task"]').click(); + await page.locator("details.cron-advanced > summary").click(); + const triggerToggle = page + .locator(".settings-row--toggle") + .filter({ hasText: "Condition trigger" }); + await expect.poll(() => triggerToggle.count()).toBe(1); + + const unsaved = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + await config.ensureLoaded(); + config.setWritesSuspended(true); + config.patchForm(["cron", "triggers", "enabled"], false); + return { dirty: config.state.configFormDirty, needsApply: config.state.configNeedsApply }; + }); + expect(unsaved).toEqual({ dirty: true, needsApply: false }); + expect(await gateway.getRequests("config.set")).toHaveLength(0); + await expect.poll(() => triggerToggle.count()).toBe(1); + await captureTriggerCapabilityProof(page, "05-unsaved-disable-keeps-active-trigger"); + + const saveResult = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + config.setWritesSuspended(false); + const saved = await config.save(); + return { + dirty: config.state.configFormDirty, + needsApply: config.state.configNeedsApply, + saved, + }; + }); + expect(saveResult).toEqual({ dirty: false, needsApply: true, saved: true }); + const savedRequest = await gateway.waitForRequest("config.set"); + expect(JSON.parse(String((savedRequest.params as { raw?: string }).raw))).toEqual({ + cron: { triggers: { enabled: false } }, + }); + expect(await gateway.getRequests("config.apply")).toHaveLength(0); + await expect.poll(() => triggerToggle.count()).toBe(1); + await captureTriggerCapabilityProof(page, "06-saved-unapplied-keeps-active-trigger"); + + const previousStatuses = (await gateway.getRequests("cron.status")).length; + await gateway.setMethodResponse("cron.status", { + enabled: true, + triggersEnabled: false, + jobs: 0, + nextWakeAtMs: null, + }); + await gateway.closeLatest(1012, "refresh effective trigger capability"); + await expect + .poll(async () => (await gateway.getRequests("cron.status")).length) + .toBeGreaterThan(previousStatuses); + await page.locator('[data-test-id="cron-new-task"]').click(); + await page.locator("details.cron-advanced > summary").click(); + await expect.poll(() => triggerToggle.count()).toBe(0); + await page.getByText("Condition triggers are disabled by cron.triggers.enabled.").waitFor(); + await captureTriggerCapabilityProof(page, "07-reconnect-refreshes-disabled-trigger"); + + const oppositeDraft = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + await config.ensureLoaded(); + config.setWritesSuspended(true); + config.patchForm(["cron", "triggers", "enabled"], true); + return config.state.configFormDirty; + }); + expect(oppositeDraft).toBe(true); + await expect.poll(() => triggerToggle.count()).toBe(0); + await captureTriggerCapabilityProof( + page, + "08-unsaved-enable-cannot-author-disabled-trigger", + ); + await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + await config?.discardDraft(); + config?.setWritesSuspended(false); + }); + }, + ); + }); }); diff --git a/ui/src/e2e/cron-trigger-filter.e2e.test.ts b/ui/src/e2e/cron-trigger-filter.e2e.test.ts index c83167ce33b0..9ee9651a2551 100644 --- a/ui/src/e2e/cron-trigger-filter.e2e.test.ts +++ b/ui/src/e2e/cron-trigger-filter.e2e.test.ts @@ -61,7 +61,7 @@ suite.define(() => { ], }, "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, - "cron.status": { enabled: true, jobs: 2, nextWakeAtMs: null }, + "cron.status": { enabled: true, triggersEnabled: false, jobs: 2, nextWakeAtMs: null }, }, }); diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 85cfc845f269..ce5831d3fcbd 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -145,8 +145,8 @@ describe("renderAgents", () => { const job = createCronJob("implicit-default-job", { name: "Implicit default-agent reminder", }); - const globalNextWakeAtMs = Date.now() + 60_000; - const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const nextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = nextWakeAtMs + 3_600_000; const container = document.createElement("div"); render( renderAgents( @@ -154,7 +154,7 @@ describe("renderAgents", () => { activePanel: "cron", selectedAgentId: "alpha", cron: { - status: { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }, + status: { enabled: true, triggersEnabled: true, jobs: 51, nextWakeAtMs }, jobs: [job], jobsTotal: 1, jobsHasMore: false, @@ -188,7 +188,7 @@ describe("renderAgents", () => { expect(nextWakeRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe( formatNextRun(scopedNextWakeAtMs), ); - expect(nextWakeRow?.textContent).not.toContain(formatNextRun(globalNextWakeAtMs)); + expect(nextWakeRow?.textContent).not.toContain(formatNextRun(nextWakeAtMs)); }); it("loads and renders the selected agent's 51st cron job when Load more is clicked", async () => { @@ -227,7 +227,7 @@ describe("renderAgents", () => { activePanel: "cron", selectedAgentId: "alpha", cron: { - status: { enabled: true, jobs: 80, nextWakeAtMs: null }, + status: { enabled: true, triggersEnabled: true, jobs: 80, nextWakeAtMs: null }, jobs: cronState.cronJobs, jobsTotal: cronState.cronJobsTotal, jobsHasMore: cronState.cronJobsHasMore, diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 9fea134c88f1..7b2c48e2ed0d 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -148,8 +148,17 @@ function cronListResponse(jobs: CronJob[]): CronJobsListResult { }; } -function createRequest() { +function createRequest( + cronStatus: { enabled: boolean; jobs: number; triggersEnabled: boolean } = { + enabled: true, + jobs: 0, + triggersEnabled: true, + }, +) { return vi.fn(async (method: string) => { + if (method === "cron.status") { + return { ...cronStatus }; + } if (method === "cron.list") { return cronListResponse([]); } @@ -169,6 +178,39 @@ afterEach(() => { }); describe("CronPage editor state sync", () => { + it.each([ + { scenario: "an unsaved enable edit", active: false, edited: true, saved: false }, + { scenario: "an unsaved disable edit", active: true, edited: false, saved: false }, + { scenario: "a saved-but-unapplied enable edit", active: false, edited: true, saved: true }, + { scenario: "a saved-but-unapplied disable edit", active: true, edited: false, saved: true }, + ])("keeps trigger authoring owned by cron.status during $scenario", async (scenario) => { + const request = createRequest({ enabled: true, jobs: 0, triggersEnabled: scenario.active }); + const gateway = createGateway({ request } as unknown as GatewayBrowserClient, true); + const context = createContext(gateway); + const editedConfig = { cron: { triggers: { enabled: scenario.edited } } }; + Object.assign(context.runtimeConfig.state, { + configForm: editedConfig, + configFormDirty: !scenario.saved, + configNeedsApply: scenario.saved, + configSnapshot: scenario.saved ? { config: editedConfig, sourceConfig: editedConfig } : null, + }); + const page = createPage(context, { render: true }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: scenario.active }), + ); + (page.querySelector('[data-test-id="cron-new-task"]') as HTMLButtonElement).click(); + await waitForCronPage(() => expect(page.querySelector("fieldset.cron-editor")).not.toBeNull()); + + const triggerToggle = Array.from(page.querySelectorAll("wa-switch.settings-toggle")).find( + (toggle) => toggle.textContent?.includes("Condition trigger"), + ); + expect(Boolean(triggerToggle)).toBe(scenario.active); + if (!scenario.active) { + expect(page.textContent).toContain("disabled by cron.triggers.enabled"); + } + }); + it("keeps conflict detail attached to the authoritative job outside active filters", async () => { const staleJob: CronJob = { id: "filtered-conflict-job", @@ -663,7 +705,7 @@ describe("CronPage lifecycle", () => { const connectedState = page.cron; page.cron = { ...connectedState, - cronStatus: { enabled: true, jobs: 1 }, + cronStatus: { enabled: true, triggersEnabled: true, jobs: 1 }, cronJobs: [{ id: "old" } as never], cronCreateOpen: true, }; @@ -682,6 +724,40 @@ describe("CronPage lifecycle", () => { expect(page.cron).not.toBe(disconnectedState); }); + it("refreshes trigger authoring from scheduler status after reconnect", async () => { + const schedulerStatus = { enabled: true, jobs: 0, triggersEnabled: true }; + const request = createRequest(schedulerStatus); + const client = { request } as unknown as GatewayBrowserClient; + const gateway = createGateway(client, true); + const context = createContext(gateway); + Object.assign(context.runtimeConfig.state, { + configForm: { cron: { triggers: { enabled: true } } }, + configNeedsApply: true, + }); + const page = createPage(context, { render: true }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: true }), + ); + schedulerStatus.triggersEnabled = false; + gateway.emitSnapshot({ phase: "stopped" }); + expect(page.cron.cronStatus).toBeNull(); + gateway.emitSnapshot({ phase: "connected" }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: false }), + ); + expect(request.mock.calls.filter(([method]) => method === "cron.status")).toHaveLength(2); + (page.querySelector('[data-test-id="cron-new-task"]') as HTMLButtonElement).click(); + await waitForCronPage(() => expect(page.querySelector("fieldset.cron-editor")).not.toBeNull()); + + const triggerToggle = Array.from(page.querySelectorAll("wa-switch.settings-toggle")).find( + (toggle) => toggle.textContent?.includes("Condition trigger"), + ); + expect(triggerToggle).toBeUndefined(); + expect(page.textContent).toContain("disabled by cron.triggers.enabled"); + }); + it("rejects model suggestions from an earlier connection epoch", async () => { const staleModels = createDeferred<{ models: Array<{ id: string }> }>(); let modelRequestCount = 0; diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts index 665cb0aeb41a..4296f73fca33 100644 --- a/ui/src/pages/cron/cron-page.ts +++ b/ui/src/pages/cron/cron-page.ts @@ -1,5 +1,4 @@ import { consume } from "@lit/context"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html } from "lit"; import { state } from "lit/decorators.js"; import type { AgentsListResult, CronJob } from "../../api/types.ts"; @@ -11,7 +10,6 @@ import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; import { watchAgentScope } from "../../lib/agents/index.ts"; -import { currentConfigObject } from "../../lib/config/config-state-model.ts"; import { addCronJob, cancelCronEdit, @@ -48,13 +46,6 @@ import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { buildCronSuggestions, THINKING_SUGGESTIONS } from "./form-suggestions.ts"; import { renderCron, type CronDetailTab, type CronListTab } from "./view.ts"; -function resolveCronTriggersEnabled(context: ApplicationContext): boolean { - const config = currentConfigObject(context.runtimeConfig.state) ?? {}; - const cron = isRecord(config.cron) ? config.cron : undefined; - const triggers = cron && isRecord(cron.triggers) ? cron.triggers : undefined; - return triggers?.enabled !== false; -} - class CronPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -408,7 +399,6 @@ class CronPage extends OpenClawLightDomElement { agentId: fallbackAgentId, loading: this.cron.cronLoading, canManage, - triggersEnabled: resolveCronTriggersEnabled(this.context), status: this.cron.cronStatus, failingCount: this.cron.cronFailingCount, agentScoped: this.cron.cronAgentId !== null, diff --git a/ui/src/pages/cron/view-run-history.test.ts b/ui/src/pages/cron/view-run-history.test.ts index 2e7d5530bd69..4669fa97359f 100644 --- a/ui/src/pages/cron/view-run-history.test.ts +++ b/ui/src/pages/cron/view-run-history.test.ts @@ -24,7 +24,7 @@ describe("cron view run history", () => { { ts: 1_000, jobId: "job-1", action: "finished", status: "ok", summary: "older run" }, { ts: 2_000, jobId: "job-2", action: "finished", status: "ok", summary: "newer run" }, ], - status: { enabled: true, jobs: 2 }, + status: { enabled: true, triggersEnabled: true, jobs: 2 }, }); const titles = Array.from(container.querySelectorAll(".cron-run-entry__title")).map((el) => diff --git a/ui/src/pages/cron/view.test-support.ts b/ui/src/pages/cron/view.test-support.ts index c3f0111f3e9e..03855b93db3c 100644 --- a/ui/src/pages/cron/view.test-support.ts +++ b/ui/src/pages/cron/view.test-support.ts @@ -26,9 +26,12 @@ function createCronViewProps(overrides: Partial = {}): CronProps { agentId: "main", loading: false, canManage: true, - triggersEnabled: true, jobsLoadingMore: false, - status: null, + status: { + enabled: true, + triggersEnabled: true, + jobs: Math.max(overrides.jobsTotal ?? 0, overrides.jobs?.length ?? 0), + }, failingCount: null, agentScoped: false, scopedTotal: null, diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 1ce5428867eb..46a71c4379e1 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -54,7 +54,7 @@ describe("cron view list pane", () => { agentScoped: true, scopedTotal: 3, scopedNextWakeAtMs: Date.now() + 60_000, - status: { enabled: true, jobs: 99, nextWakeAtMs: null }, + status: { enabled: true, triggersEnabled: true, jobs: 99, nextWakeAtMs: null }, }); const values = [...container.querySelectorAll(".cron-stat__value")].map((entry) => entry.textContent?.trim(), @@ -68,7 +68,7 @@ describe("cron view list pane", () => { const container = renderView({ agentScoped: true, scopedNextWakeAtMs: Date.now() + 60_000, - status: { enabled: false, jobs: 3, nextWakeAtMs: null }, + status: { enabled: false, triggersEnabled: true, jobs: 3, nextWakeAtMs: null }, }); const values = [...container.querySelectorAll(".cron-stat__value")].map((entry) => entry.textContent?.trim(), @@ -303,7 +303,7 @@ describe("cron view list pane", () => { it("shows a scheduler banner only while the scheduler is off", () => { const off = renderView({ - status: { enabled: false, jobs: 2 }, + status: { enabled: false, triggersEnabled: true, jobs: 2 }, jobs: [createJob("job-1")], jobsTotal: 2, }); @@ -313,7 +313,7 @@ describe("cron view list pane", () => { const footer = getElement(off, ".cron-table__footer", HTMLDivElement); expect(footer.textContent).toContain("1 of 2"); - const on = renderView({ status: { enabled: true, jobs: 2 } }); + const on = renderView({ status: { enabled: true, triggersEnabled: true, jobs: 2 } }); expect(on.querySelector('[data-test-id="cron-scheduler-banner"]')).toBeNull(); }); @@ -704,15 +704,23 @@ describe("cron view editor", () => { ); }); + it("waits for scheduler status before presenting trigger capability", () => { + const pending = renderView({ createOpen: true, status: null }); + + expect(findToggleByLabel(pending, "Condition trigger")).toBeNull(); + expect(pending.textContent).not.toContain("disabled by cron.triggers.enabled"); + }); + it("hides trigger authoring when the operator disabled triggers but keeps clear available", () => { const onFormChange = vi.fn(); - const disabled = renderView({ createOpen: true, triggersEnabled: false, onFormChange }); + const status = { enabled: true, triggersEnabled: false, jobs: 0 }; + const disabled = renderView({ createOpen: true, status, onFormChange }); expect(disabled.querySelector("#cron-trigger-script")).toBeNull(); expect(disabled.textContent).toContain("disabled by cron.triggers.enabled"); const configured = renderView({ createOpen: true, - triggersEnabled: false, + status, onFormChange, form: { ...DEFAULT_CRON_FORM, diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index 165bee9b9c04..972b76f661ac 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -72,7 +72,6 @@ type CronProps = { loading: boolean; /** Canonical gateway capability for every mutation-capable cron control. */ canManage: boolean; - triggersEnabled: boolean; jobsLoadingMore: boolean; status: CronStatus | null; failingCount: number | null; @@ -1736,7 +1735,10 @@ function renderAdvanced( function renderTriggerRows(props: CronProps) { const scriptPayload = props.form.payloadKind === "script"; - if (!props.triggersEnabled || scriptPayload) { + if (!scriptPayload && props.status === null) { + return nothing; + } + if (props.status?.triggersEnabled !== true || scriptPayload) { return renderSettingsRow({ title: t("cron.form.conditionTrigger"), description: scriptPayload