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.
This commit is contained in:
Peter Steinberger
2026-08-20 21:28:44 -07:00
committed by GitHub
parent aea1ca60e6
commit a4b3f63a87
16 changed files with 267 additions and 33 deletions
+1
View File
@@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) {
}));
const status: CronStatus = {
enabled: true,
triggersEnabled: true,
jobs: jobs.length,
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
};
+26 -3
View File
@@ -21,12 +21,14 @@ function streamJob(overrides: Partial<CronJobCreate> = {}): 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 {
+1
View File
@@ -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,
+1
View File
@@ -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. */
+4
View File
@@ -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,
@@ -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",
+1
View File
@@ -566,6 +566,7 @@ export type CronPayload = ProtocolCronJob["payload"];
export type CronStatus = {
enabled: boolean;
triggersEnabled: boolean;
jobs: number;
nextWakeAtMs?: number | null;
};
+124 -1
View File
@@ -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);
});
},
);
});
});
+1 -1
View File
@@ -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 },
},
});
+5 -5
View File
@@ -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,
+78 -2
View File
@@ -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;
-10
View File
@@ -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,
+1 -1
View File
@@ -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) =>
+5 -2
View File
@@ -26,9 +26,12 @@ function createCronViewProps(overrides: Partial<CronProps> = {}): 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,
+14 -6
View File
@@ -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,
+4 -2
View File
@@ -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