From 267ffc4754a08181f1a15ff7cfbd0f4d817ca25a Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Thu, 20 Aug 2026 07:20:01 -0700 Subject: [PATCH] feat(ui): manage automation condition triggers (#126534) * feat(ui): manage cron condition triggers * test(models): isolate ambient CLI availability Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .../OpenClawProtocol/GatewayModels.swift | 4 + .../src/cron-validators.test.ts | 2 + packages/gateway-protocol/src/schema/cron.ts | 6 ++ .../service.list-page-sort-guards.test.ts | 9 +- src/cron/service/list-page-types.ts | 4 + src/cron/service/ops-read.ts | 19 ++++ src/gateway/server-methods/cron.ts | 2 + .../server-methods/cron.validation.test.ts | 61 +++++++----- ...st-result.cli-runtime-availability.test.ts | 39 ++++---- ui/src/api/types.ts | 1 + ui/src/e2e/cron-trigger-filter.e2e.test.ts | 96 +++++++++++++++++++ ui/src/i18n/locales/en.ts | 21 +++- ui/src/lib/cron/index.test.ts | 34 ++++++- ui/src/lib/cron/index.ts | 48 +++++++++- ui/src/pages/cron/cron-page.ts | 12 +++ ui/src/pages/cron/view.test-support.ts | 2 + ui/src/pages/cron/view.test.ts | 52 +++++++++- ui/src/pages/cron/view.ts | 92 ++++++++++++++++++ ui/src/styles/cron.css | 40 ++++++++ ui/src/test-helpers/cron.ts | 3 + 20 files changed, 497 insertions(+), 50 deletions(-) create mode 100644 ui/src/e2e/cron-trigger-filter.e2e.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 640f50c7fb86..e8ae05d18f4d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -16877,6 +16877,7 @@ public struct CronListParams: Codable, Sendable { public let enabled: AnyCodable? public let schedulekind: AnyCodable? public let lastrunstatus: AnyCodable? + public let trigger: AnyCodable? public let sortby: AnyCodable? public let sortdir: AnyCodable? public let agentid: String? @@ -16891,6 +16892,7 @@ public struct CronListParams: Codable, Sendable { enabled: AnyCodable? = nil, schedulekind: AnyCodable? = nil, lastrunstatus: AnyCodable? = nil, + trigger: AnyCodable? = nil, sortby: AnyCodable? = nil, sortdir: AnyCodable? = nil, agentid: String? = nil, @@ -16904,6 +16906,7 @@ public struct CronListParams: Codable, Sendable { self.enabled = enabled self.schedulekind = schedulekind self.lastrunstatus = lastrunstatus + self.trigger = trigger self.sortby = sortby self.sortdir = sortdir self.agentid = agentid @@ -16919,6 +16922,7 @@ public struct CronListParams: Codable, Sendable { case enabled case schedulekind = "scheduleKind" case lastrunstatus = "lastRunStatus" + case trigger case sortby = "sortBy" case sortdir = "sortDir" case agentid = "agentId" diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index b63cf5c35b1b..c0dde486d956 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -397,6 +397,7 @@ describe("cron protocol validators", () => { enabled: "all", scheduleKind: "cron", lastRunStatus: "unknown", + trigger: "conditional", sortBy: "nextRunAtMs", sortDir: "asc", agentId: "ops", @@ -409,6 +410,7 @@ describe("cron protocol validators", () => { { agentId: "" }, { scheduleKind: "yearly" }, { lastRunStatus: "pending" }, + { trigger: "configured" }, ]); }); diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 706ed9dacb4a..5f01bb77223e 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -128,6 +128,11 @@ const CronJobsLastRunStatusFilterSchema = Type.Union([ Type.Literal("skipped"), Type.Literal("unknown"), ]); +const CronJobsTriggerFilterSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("conditional"), + Type.Literal("unconditional"), +]); const CronJobsSortBySchema = Type.Union([ Type.Literal("nextRunAtMs"), Type.Literal("updatedAtMs"), @@ -622,6 +627,7 @@ export const CronListParamsSchema = closedObject({ enabled: Type.Optional(CronJobsEnabledFilterSchema), scheduleKind: Type.Optional(CronJobsScheduleKindFilterSchema), lastRunStatus: Type.Optional(CronJobsLastRunStatusFilterSchema), + trigger: Type.Optional(CronJobsTriggerFilterSchema), sortBy: Type.Optional(CronJobsSortBySchema), sortDir: Type.Optional(CronSortDirSchema), agentId: Type.Optional(NonEmptyString), diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts index 352edf496e83..a53d9bc8e3ab 100644 --- a/src/cron/service.list-page-sort-guards.test.ts +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -283,7 +283,7 @@ describe("cron listPage sort guards", () => { expect(page.jobs.map((job) => job.id)).toEqual(["tax-digest"]); }); - it("applies schedule and last-run status filters before paging", async () => { + it("applies schedule, status, and trigger filters before paging", async () => { const nextRunAtMs = Date.parse("2030-02-27T15:30:00.000Z"); const jobs = [ createBaseJob({ @@ -299,6 +299,12 @@ describe("cron listPage sort guards", () => { createBaseJob({ id: "cron-unknown", schedule: { kind: "cron", expr: "0 10 * * *" }, + trigger: { script: "json({ fire: true })" }, + state: { nextRunAtMs }, + }), + createBaseJob({ + id: "cron-unknown-plain", + schedule: { kind: "cron", expr: "0 11 * * *" }, state: { nextRunAtMs }, }), ]; @@ -310,6 +316,7 @@ describe("cron listPage sort guards", () => { const page = await listPage(state, { scheduleKind: "cron", lastRunStatus: "unknown", + trigger: "conditional", limit: 1, }); diff --git a/src/cron/service/list-page-types.ts b/src/cron/service/list-page-types.ts index 0728bcfa2b65..da0295df914a 100644 --- a/src/cron/service/list-page-types.ts +++ b/src/cron/service/list-page-types.ts @@ -10,6 +10,9 @@ export type CronJobsScheduleKindFilter = "all" | "at" | "every" | "cron" | "on-e /** Last-run status filter, including jobs that have not produced a status yet. */ export type CronJobsLastRunStatusFilter = "all" | CronRunStatus | "unknown"; +/** Condition-trigger filter accepted by paginated cron listing. */ +export type CronJobsTriggerFilter = "all" | "conditional" | "unconditional"; + /** Stable sort keys supported by paginated cron listing. */ export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; @@ -25,6 +28,7 @@ export type CronListPageOptions = { enabled?: CronJobsEnabledFilter; scheduleKind?: CronJobsScheduleKindFilter; lastRunStatus?: CronJobsLastRunStatusFilter; + trigger?: CronJobsTriggerFilter; sortBy?: CronJobsSortBy; sortDir?: CronSortDir; agentId?: string; diff --git a/src/cron/service/ops-read.ts b/src/cron/service/ops-read.ts index 348022eb9c78..e8687b792b8f 100644 --- a/src/cron/service/ops-read.ts +++ b/src/cron/service/ops-read.ts @@ -17,6 +17,7 @@ import type { CronJobsEnabledFilter, CronJobsLastRunStatusFilter, CronJobsScheduleKindFilter, + CronJobsTriggerFilter, CronListPageOptions, CronListPageResult, } from "./list-page-types.js"; @@ -306,6 +307,17 @@ function resolveLastRunStatusFilter(opts?: CronListPageOptions): CronJobsLastRun return "all"; } +function resolveTriggerFilter(opts?: CronListPageOptions): CronJobsTriggerFilter { + if ( + opts?.trigger === "all" || + opts?.trigger === "conditional" || + opts?.trigger === "unconditional" + ) { + return opts.trigger; + } + return "all"; +} + /** Lists a filtered, sorted, bounded page of cron jobs for CLI/RPC callers. */ export async function listPage(state: CronServiceState, opts?: CronListPageOptions) { return await locked(state, async () => { @@ -314,6 +326,7 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio const enabledFilter = resolveEnabledFilter(opts); const scheduleKindFilter = resolveScheduleKindFilter(opts); const lastRunStatusFilter = resolveLastRunStatusFilter(opts); + const triggerFilter = resolveTriggerFilter(opts); const sortBy = opts?.sortBy ?? "nextRunAtMs"; const sortDir = opts?.sortDir ?? "asc"; const requestedAgentId = normalizeOptionalAgentId(opts?.agentId); @@ -340,6 +353,12 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio ) { return false; } + if (triggerFilter === "conditional" && !job.trigger) { + return false; + } + if (triggerFilter === "unconditional" && job.trigger) { + return false; + } if (!query) { return true; } diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index b5a67844b719..72694926dd44 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -496,6 +496,7 @@ export const cronHandlers: GatewayRequestHandlers = { enabled?: "all" | "enabled" | "disabled"; scheduleKind?: "all" | "at" | "every" | "cron"; lastRunStatus?: "all" | "ok" | "error" | "skipped" | "unknown"; + trigger?: "all" | "conditional" | "unconditional"; sortBy?: "nextRunAtMs" | "updatedAtMs" | "name"; sortDir?: "asc" | "desc"; agentId?: string; @@ -516,6 +517,7 @@ export const cronHandlers: GatewayRequestHandlers = { enabled: p.enabled, scheduleKind: p.scheduleKind, lastRunStatus: p.lastRunStatus, + trigger: p.trigger, sortBy: p.sortBy, sortDir: p.sortDir, agentId: callerScope?.agentId ?? p.agentId, diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 46065d639900..7d861413b7ff 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -226,27 +226,36 @@ function createCronContext(currentJobs?: CronJob | CronJob[]) { }, ), list: vi.fn(async () => jobs), - listPage: vi.fn(async (opts?: { agentId?: string; limit?: number; offset?: number }) => { - const requestedAgentId = opts?.agentId?.trim().toLowerCase(); - const filteredJobs = requestedAgentId - ? jobs.filter((job) => (job.agentId ?? "main").trim().toLowerCase() === requestedAgentId) - : jobs; - const total = filteredJobs.length; - const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); - const defaultLimit = total === 0 ? 50 : total; - const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit))); - const pageJobs = filteredJobs.slice(offset, offset + limit); - const nextOffset = offset + pageJobs.length; - return { - jobs: pageJobs, - snapshotRevision: `fixture:${filteredJobs.map((job) => job.id).join(",")}`, - total, - offset, - limit, - hasMore: nextOffset < total, - nextOffset: nextOffset < total ? nextOffset : null, - }; - }), + listPage: vi.fn( + async (opts?: { + agentId?: string; + limit?: number; + offset?: number; + trigger?: "all" | "conditional" | "unconditional"; + }) => { + const requestedAgentId = opts?.agentId?.trim().toLowerCase(); + const filteredJobs = requestedAgentId + ? jobs.filter( + (job) => (job.agentId ?? "main").trim().toLowerCase() === requestedAgentId, + ) + : jobs; + const total = filteredJobs.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const defaultLimit = total === 0 ? 50 : total; + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit))); + const pageJobs = filteredJobs.slice(offset, offset + limit); + const nextOffset = offset + pageJobs.length; + return { + jobs: pageJobs, + snapshotRevision: `fixture:${filteredJobs.map((job) => job.id).join(",")}`, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; + }, + ), }, logGateway: { info: vi.fn(), @@ -925,13 +934,17 @@ describe("cron method validation", () => { }); }); - it("keeps unscoped cron.list agentId filtering global for operator callers", async () => { + it("forwards unscoped cron.list filters for operator callers", async () => { const context = createCronContext(createCronJob({ agentId: "worker" })); - const { respond } = await invokeCron("cron.list", { agentId: "worker" }, { context }); + const { respond } = await invokeCron( + "cron.list", + { agentId: "worker", trigger: "conditional" }, + { context }, + ); expect(context.cron.listPage).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "worker" }), + expect.objectContaining({ agentId: "worker", trigger: "conditional" }), ); expect(respond).toHaveBeenCalledWith( true, diff --git a/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts b/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts index 6c657f333609..a670fdc58b7e 100644 --- a/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts +++ b/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts @@ -1,17 +1,14 @@ +import fs from "node:fs"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { listModels, providerCatalogEntry, } from "./models-list-result.openai-routes.test-support.js"; -const mocks = vi.hoisted(() => ({ - readClaudeCliCredentialsCached: vi.fn<() => unknown>(() => null), - readCodexCliCredentialsCached: vi.fn<() => unknown>(() => null), - readMiniMaxCliCredentialsCached: vi.fn<() => unknown>(() => null), -})); - -vi.mock("../../agents/cli-credentials.js", () => mocks); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); const config = { agents: { @@ -40,12 +37,6 @@ async function listClaudeCliModel() { describe("models.list CLI runtime availability", () => { beforeEach(() => { vi.stubEnv("ANTHROPIC_API_KEY", ""); - mocks.readClaudeCliCredentialsCached.mockReset(); - mocks.readClaudeCliCredentialsCached.mockReturnValue(null); - mocks.readCodexCliCredentialsCached.mockReset(); - mocks.readCodexCliCredentialsCached.mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset(); - mocks.readMiniMaxCliCredentialsCached.mockReturnValue(null); }); afterEach(() => { @@ -53,13 +44,21 @@ describe("models.list CLI runtime availability", () => { }); it("marks a Claude CLI runtime model available with ambient CLI OAuth", async () => { - mocks.readClaudeCliCredentialsCached.mockReturnValue({ - type: "oauth", - provider: "anthropic", - access: "test-access", - refresh: "test-refresh", - expires: Date.now() + 3_600_000, - }); + const homeDir = tempDirs.make("models-list-claude-cli-"); + const credentialDir = path.join(homeDir, ".claude"); + fs.mkdirSync(credentialDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(credentialDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "test-access", + refreshToken: "test-refresh", + expiresAt: Date.now() + 3_600_000, + }, + }), + { mode: 0o600 }, + ); + vi.stubEnv("HOME", homeDir); await expect(listClaudeCliModel()).resolves.toEqual({ models: [expect.objectContaining({ id: "claude-opus-5", available: true })], diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ac634d450fff..5714752c8473 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -555,6 +555,7 @@ export type { export type CronRunStatus = NonNullable; export type CronDeliveryStatus = NonNullable; export type CronJobsEnabledFilter = NonNullable; +export type CronJobsTriggerFilter = NonNullable; export type CronJobsSortBy = NonNullable; export type CronRunScope = NonNullable; export type CronRunsStatusValue = NonNullable[number]; diff --git a/ui/src/e2e/cron-trigger-filter.e2e.test.ts b/ui/src/e2e/cron-trigger-filter.e2e.test.ts new file mode 100644 index 000000000000..c83167ce33b0 --- /dev/null +++ b/ui/src/e2e/cron-trigger-filter.e2e.test.ts @@ -0,0 +1,96 @@ +// Control UI browser proof covers condition-trigger visibility and server-backed filtering. +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI cron trigger filter E2E", + startServerBeforeBrowser: true, + unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`, +}); + +const baseJob = { + enabled: true, + createdAtMs: Date.parse("2026-05-29T08:00:00.000Z"), + updatedAtMs: Date.parse("2026-05-29T08:05:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + state: {}, +}; +const conditionalJob = { + ...baseJob, + id: "conditional-job", + configRevision: "conditional-revision", + name: "Conditional job", + payload: { kind: "systemEvent", text: "conditional" }, + trigger: { script: "json({ fire: true })" }, +}; +const plainJob = { + ...baseJob, + id: "plain-job", + configRevision: "plain-revision", + name: "Plain job", + payload: { kind: "systemEvent", text: "plain" }, +}; + +function listResponse(jobs: unknown[]) { + return { + jobs, + snapshotRevision: `trigger-filter:${jobs.length}`, + total: jobs.length, + offset: 0, + limit: 50, + hasMore: false, + nextOffset: null, + }; +} + +suite.define(() => { + it("shows trigger indicators and filters through cron.list before pagination", async () => { + await suite.withPage( + { locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1280 } }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "cron.list": { + cases: [ + { match: { lastRunStatus: "error" }, response: listResponse([]) }, + { match: { trigger: "conditional" }, response: listResponse([conditionalJob]) }, + { response: listResponse([conditionalJob, plainJob]) }, + ], + }, + "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, + "cron.status": { enabled: true, jobs: 2, nextWakeAtMs: null }, + }, + }); + + await page.goto(`${suite.server.baseUrl}cron`); + const conditionalRow = page.locator('[data-test-id="cron-row-conditional-job"]'); + await conditionalRow.waitFor(); + await page.locator('[data-test-id="cron-row-plain-job"]').waitFor(); + expect(await conditionalRow.locator(".cron-trigger-icon").getAttribute("aria-label")).toBe( + "Trigger configured", + ); + + await page.locator(".cron-filter-popover__trigger").click(); + await page.locator('[data-test-id="cron-jobs-trigger-filter"]').selectOption("conditional"); + await expect + .poll(async () => + (await gateway.getRequests("cron.list")).some( + (request) => + request.params && + typeof request.params === "object" && + "trigger" in request.params && + request.params.trigger === "conditional", + ), + ) + .toBe(true); + await expect + .poll(async () => page.locator('[data-test-id="cron-row-plain-job"]').count()) + .toBe(0); + expect(await conditionalRow.count()).toBe(1); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 937892248fba..10092e80e2bd 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -6394,6 +6394,9 @@ export const en: TranslationMap = { schedule: "Schedule", lastRun: "Last run", all: "All", + condition: "Condition", + conditional: "Conditional", + unconditional: "Unconditional", sort: "Sort", nextRun: "Next run", recentlyUpdated: "Recently updated", @@ -6506,7 +6509,20 @@ export const en: TranslationMap = { toHelp: "Optional recipient override (chat id, phone, or user id).", advanced: "Advanced", advancedHelp: - "Optional overrides for delivery guarantees, schedule jitter, and model controls.", + "Optional condition checks, delivery guarantees, schedule jitter, and model controls.", + conditionTrigger: "Condition trigger", + conditionTriggerHelp: + "Run a quiet headless check before the task and call the model only when it matches.", + triggerConfigured: "Trigger configured", + triggerScript: "Trigger script", + triggerScriptHelp: + "Runs unattended with this automation's tool policy. Return json({ fire, message?, state? }); limits: 30 seconds, 5 tool calls, 16 KB state.", + triggerOnce: "Disable after first match", + triggerOnceHelp: "Disable this automation after the first successful fired task.", + triggerDisabled: "Condition triggers are disabled by cron.triggers.enabled.", + triggerDisabledConfigured: + "Condition triggers are disabled. Existing configuration is preserved until you clear it.", + clearTrigger: "Clear trigger", startEnabled: "Start enabled", deleteAfterRun: "Delete after run", deleteAfterRunHelp: "Best for one-shot reminders that should auto-clean up.", @@ -6578,6 +6594,9 @@ export const en: TranslationMap = { everyAmountInvalid: "Interval must be greater than 0.", cronExprRequired: "Cron expression is required.", staggerAmountInvalid: "Stagger must be greater than 0.", + triggerScriptRequired: "Trigger script is required when the condition trigger is enabled.", + triggerScheduleUnsupported: + "Condition triggers require an interval, cron, or stream schedule.", systemTextRequired: "System text is required.", agentMessageRequired: "Agent message is required.", timeoutInvalid: "If set, timeout must be greater than 0 seconds.", diff --git a/ui/src/lib/cron/index.test.ts b/ui/src/lib/cron/index.test.ts index 9c59a8f498fd..75dda6b4afbf 100644 --- a/ui/src/lib/cron/index.test.ts +++ b/ui/src/lib/cron/index.test.ts @@ -1288,7 +1288,7 @@ describe("cron controller", () => { ).toBe("last"); }); - it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => { + it("includes trigger/model/thinking/stagger/bestEffort in cron.update patch", async () => { const { submit } = createCronSubmitHarness("job-2", { method: "cron.update", form: { @@ -1297,6 +1297,9 @@ describe("cron controller", () => { cronExpr: "0 9 * * *", staggerAmount: "30", staggerUnit: "seconds", + triggerEnabled: true, + triggerScript: "json({ fire: true })", + triggerOnce: true, payloadKind: "agentTurn", payloadText: "run it", payloadModel: "opus", @@ -1314,6 +1317,7 @@ describe("cron controller", () => { const patch = requestPatch(call); expectRecordFields(patch, { schedule: { kind: "cron", expr: "0 9 * * *", staggerMs: 30_000 }, + trigger: { script: "json({ fire: true })", once: true }, payload: { kind: "agentTurn", message: "run it", @@ -1327,6 +1331,20 @@ describe("cron controller", () => { }); }); + it("clears an existing condition trigger from cron.update", async () => { + const job = createCronJob({ + id: "job-clear-trigger", + name: "Conditional job", + trigger: { script: "json({ fire: true })", once: true }, + }); + const { state, submit } = createCronEditHarness(job); + state.cronForm.triggerEnabled = false; + + const call = await submit(); + + expect(requestPatch(call).trigger).toBeNull(); + }); + it("sends lightContext=false in cron.update when clearing prior light-context setting", async () => { const job = createCronJob({ id: "job-clear-light", @@ -1564,13 +1582,14 @@ describe("cron controller", () => { expect(requestPatch(call).failureAlert).toBe(false); }); - it("maps cron stagger, model, thinking, and best effort into form", () => { + it("maps cron trigger, stagger, model, thinking, and best effort into form", () => { const state = createState(); const job = createCronJob({ id: "job-10", name: "Advanced job", deleteAfterRun: true, schedule: { kind: "cron", expr: "0 7 * * *", tz: "UTC", staggerMs: 60_000 }, + trigger: { script: "json({ fire: true })", once: true }, wakeMode: "now", payload: { kind: "agentTurn", @@ -1587,6 +1606,9 @@ describe("cron controller", () => { expect(state.cronForm.scheduleExact).toBe(false); expect(state.cronForm.staggerAmount).toBe("1"); expect(state.cronForm.staggerUnit).toBe("minutes"); + expect(state.cronForm.triggerEnabled).toBe(true); + expect(state.cronForm.triggerScript).toBe("json({ fire: true })"); + expect(state.cronForm.triggerOnce).toBe(true); expect(state.cronForm.payloadModel).toBe("opus"); expect(state.cronForm.payloadThinking).toBe("high"); expect(state.cronForm.deliveryBestEffort).toBe(true); @@ -1627,12 +1649,15 @@ describe("cron controller", () => { payloadKind: "agentTurn", payloadText: "", timeoutSeconds: "-1", + triggerEnabled: true, + triggerScript: "", deliveryMode: "webhook", deliveryTo: "ftp://bad", }); expect(errors.name).toBe("cron.errors.nameRequired"); expect(errors.cronExpr).toBe("cron.errors.cronExprRequired"); expect(errors.payloadText).toBe("cron.errors.agentMessageRequired"); + expect(errors.triggerScript).toBe("cron.errors.triggerScriptRequired"); expect(errors.timeoutSeconds).toBe("cron.errors.timeoutInvalid"); expect(errors.deliveryTo).toBe("cron.errors.webhookUrlInvalid"); }); @@ -1892,6 +1917,7 @@ describe("cron controller", () => { includeDeliveryPreviews: false, scheduleKind: "cron", lastRunStatus: "error", + trigger: "conditional", sortBy: "updatedAtMs", sortDir: "desc", }); @@ -1920,6 +1946,7 @@ describe("cron controller", () => { cronJobsEnabledFilter: "enabled", cronJobsScheduleKindFilter: "cron", cronJobsLastStatusFilter: "error", + cronJobsTriggerFilter: "conditional", cronJobsSortBy: "updatedAtMs", cronJobsSortDir: "desc", }); @@ -2037,6 +2064,7 @@ describe("cron controller", () => { const listPayload = requireRecord(payload, "cron.list payload"); expect(listPayload).not.toHaveProperty("scheduleKind"); expect(listPayload).not.toHaveProperty("lastRunStatus"); + expect(listPayload).not.toHaveProperty("trigger"); return emptyCronListResponse(); } return {}; @@ -2053,6 +2081,7 @@ describe("cron controller", () => { expect.not.objectContaining({ scheduleKind: expect.anything(), lastRunStatus: expect.anything(), + trigger: expect.anything(), }), ); }); @@ -2139,6 +2168,7 @@ describe("cron controller", () => { const pendingPayload = requireRecord(payloads[1], "latest pending cron.list payload"); expect(pendingPayload).not.toHaveProperty("scheduleKind"); expect(pendingPayload).not.toHaveProperty("lastRunStatus"); + expect(pendingPayload).not.toHaveProperty("trigger"); expect(request).toHaveBeenCalledTimes(2); expect(state.cronJobsReloadPending).toBe(false); expect(state.cronJobsReloadPendingTableFilters).toBe(false); diff --git a/ui/src/lib/cron/index.ts b/ui/src/lib/cron/index.ts index 8a194a8ab822..b4eed0ac09a1 100644 --- a/ui/src/lib/cron/index.ts +++ b/ui/src/lib/cron/index.ts @@ -6,6 +6,7 @@ import type { CronJob, CronDeliveryStatus, CronJobsEnabledFilter, + CronJobsTriggerFilter, CronJobsListResult, CronJobsSortBy, CronRunResult, @@ -55,6 +56,9 @@ export type CronFormState = { scheduleExact: boolean; staggerAmount: string; staggerUnit: "seconds" | "minutes"; + triggerEnabled: boolean; + triggerScript: string; + triggerOnce: boolean; sessionTarget: "main" | "isolated" | "current" | `session:${string}`; wakeMode: "next-heartbeat" | "now"; // "heartbeat" is system-owned and always payloadLocked; the form only @@ -140,6 +144,9 @@ const DEFAULT_CRON_FORM: CronFormState = { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds", + triggerEnabled: false, + triggerScript: "", + triggerOnce: false, sessionTarget: "isolated", wakeMode: "now", payloadKind: "agentTurn", @@ -172,6 +179,7 @@ export type CronFieldKey = | "everyAmount" | "cronExpr" | "staggerAmount" + | "triggerScript" | "payloadText" | "payloadModel" | "payloadThinking" @@ -203,6 +211,7 @@ export type CronState = { cronJobsEnabledFilter: CronJobsEnabledFilter; cronJobsScheduleKindFilter: CronJobsScheduleKindFilter; cronJobsLastStatusFilter: CronJobsLastStatusFilter; + cronJobsTriggerFilter: CronJobsTriggerFilter; cronJobsSortBy: CronJobsSortBy; cronJobsSortDir: CronSortDir; cronAgentId: string | null; @@ -265,6 +274,7 @@ export function createInitialCronState( cronJobsEnabledFilter: "all", cronJobsScheduleKindFilter: "all", cronJobsLastStatusFilter: "all", + cronJobsTriggerFilter: "all", cronJobsSortBy: "nextRunAtMs", cronJobsSortDir: "asc", cronAgentId: null, @@ -343,6 +353,17 @@ export function validateCronForm(form: CronFormState): CronFieldErrors { } } } + if (form.triggerEnabled) { + if ( + form.scheduleKind !== "every" && + form.scheduleKind !== "cron" && + form.scheduleKind !== "stream" + ) { + errors.triggerScript = "cron.errors.triggerScheduleUnsupported"; + } else if (!form.triggerScript.trim()) { + errors.triggerScript = "cron.errors.triggerScriptRequired"; + } + } if (!form.payloadLocked && !form.payloadText.trim()) { errors.payloadText = form.payloadKind === "systemEvent" @@ -679,6 +700,7 @@ export async function loadCronJobsPage( ? { scheduleKind: state.cronJobsScheduleKindFilter, lastRunStatus: state.cronJobsLastStatusFilter, + trigger: state.cronJobsTriggerFilter, } : {}), sortBy: state.cronJobsSortBy, @@ -726,6 +748,7 @@ export function updateCronJobsFilter( | "cronJobsEnabledFilter" | "cronJobsScheduleKindFilter" | "cronJobsLastStatusFilter" + | "cronJobsTriggerFilter" | "cronJobsSortBy" | "cronJobsSortDir" > @@ -738,12 +761,16 @@ export function updateCronJobsFilter( state.cronJobsScheduleKindFilter = patch.cronJobsScheduleKindFilter ?? state.cronJobsScheduleKindFilter; state.cronJobsLastStatusFilter = patch.cronJobsLastStatusFilter ?? state.cronJobsLastStatusFilter; + state.cronJobsTriggerFilter = patch.cronJobsTriggerFilter ?? state.cronJobsTriggerFilter; state.cronJobsSortBy = patch.cronJobsSortBy ?? state.cronJobsSortBy; state.cronJobsSortDir = patch.cronJobsSortDir ?? state.cronJobsSortDir; } export function getVisibleCronJobs( - state: Pick, + state: Pick< + CronState, + "cronJobs" | "cronJobsScheduleKindFilter" | "cronJobsLastStatusFilter" | "cronJobsTriggerFilter" + >, ): CronJob[] { return state.cronJobs.filter((job) => { const scheduleKind = resolveCronJobScheduleKind(job); @@ -762,6 +789,12 @@ export function getVisibleCronJobs( ) { return false; } + if (state.cronJobsTriggerFilter === "conditional" && !job.trigger) { + return false; + } + if (state.cronJobsTriggerFilter === "unconditional" && job.trigger) { + return false; + } return true; }); } @@ -894,6 +927,9 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds", + triggerEnabled: job.trigger !== undefined, + triggerScript: job.trigger?.script ?? "", + triggerOnce: job.trigger?.once === true, sessionTarget: job.sessionTarget, wakeMode: job.wakeMode, payloadKind: payload?.kind ?? DEFAULT_CRON_FORM.payloadKind, @@ -1207,6 +1243,15 @@ export async function addCronJob(state: CronState): Promise { } as const) : undefined; const failureAlert = buildFailureAlert(form, editingJob?.failureAlert); + const triggerScript = form.triggerScript.trim(); + const trigger = form.triggerEnabled + ? editingJob?.trigger?.script === triggerScript && + (editingJob.trigger.once === true) === form.triggerOnce + ? undefined + : { script: triggerScript, once: form.triggerOnce } + : editingJob?.trigger + ? null + : undefined; const agentId = form.clearAgent ? null : form.agentId.trim(); const sessionKeyRaw = form.sessionKey.trim(); const sessionKey = sessionKeyRaw || (editingJob?.sessionKey ? null : undefined); @@ -1221,6 +1266,7 @@ export async function addCronJob(state: CronState): Promise { : {}), sessionTarget: form.sessionTarget, wakeMode: form.wakeMode, + trigger, delivery, failureAlert, }; diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts index 1c59aec4e205..665cb0aeb41a 100644 --- a/ui/src/pages/cron/cron-page.ts +++ b/ui/src/pages/cron/cron-page.ts @@ -1,4 +1,5 @@ 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"; @@ -10,6 +11,7 @@ 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, @@ -46,6 +48,13 @@ 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; @@ -399,6 +408,7 @@ 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, @@ -412,6 +422,7 @@ class CronPage extends OpenClawLightDomElement { jobsEnabledFilter: this.cron.cronJobsEnabledFilter, jobsScheduleKindFilter: this.cron.cronJobsScheduleKindFilter, jobsLastStatusFilter: this.cron.cronJobsLastStatusFilter, + jobsTriggerFilter: this.cron.cronJobsTriggerFilter, jobsSortBy: this.cron.cronJobsSortBy, jobsSortDir: this.cron.cronJobsSortDir, editingJob: this.cron.cronEditingJob, @@ -475,6 +486,7 @@ class CronPage extends OpenClawLightDomElement { updateCronJobsFilter(cronState, { cronJobsScheduleKindFilter: "all", cronJobsLastStatusFilter: "all", + cronJobsTriggerFilter: "all", cronJobsSortBy: "nextRunAtMs", cronJobsSortDir: "asc", }); diff --git a/ui/src/pages/cron/view.test-support.ts b/ui/src/pages/cron/view.test-support.ts index 22875e5785a8..c3f0111f3e9e 100644 --- a/ui/src/pages/cron/view.test-support.ts +++ b/ui/src/pages/cron/view.test-support.ts @@ -26,6 +26,7 @@ function createCronViewProps(overrides: Partial = {}): CronProps { agentId: "main", loading: false, canManage: true, + triggersEnabled: true, jobsLoadingMore: false, status: null, failingCount: null, @@ -39,6 +40,7 @@ function createCronViewProps(overrides: Partial = {}): CronProps { jobsEnabledFilter: "all", jobsScheduleKindFilter: "all", jobsLastStatusFilter: "all", + jobsTriggerFilter: "all", jobsSortBy: "nextRunAtMs", jobsSortDir: "asc", error: null, diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 118b6078173f..0f6984b24302 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -135,6 +135,17 @@ describe("cron view list pane", () => { lastStatusFilter.dispatchEvent(new Event("change", { bubbles: true })); expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsLastStatusFilter: "unknown" }); + const triggerFilter = getElement( + container, + '[data-test-id="cron-jobs-trigger-filter"]', + HTMLSelectElement, + ); + triggerFilter.value = "conditional"; + triggerFilter.dispatchEvent(new Event("change", { bubbles: true })); + expect(onJobsFiltersChange).toHaveBeenCalledWith({ + cronJobsTriggerFilter: "conditional", + }); + const reset = getElement( container, '[data-test-id="cron-jobs-filters-reset"]', @@ -158,7 +169,10 @@ describe("cron view list pane", () => { it("renders table rows with schedule and status cells and selects on click", () => { const onSelectJob = vi.fn(); - const job = createJob("job-1", { state: { nextRunAtMs: Date.now() + 60_000 } }); + const job = createJob("job-1", { + trigger: { script: "json({ fire: true })" }, + state: { nextRunAtMs: Date.now() + 60_000 }, + }); const paused = createJob("job-2", { name: "Paused task", enabled: false }); const failed = createJob("job-3", { name: "Failing task", @@ -181,6 +195,9 @@ describe("cron view list pane", () => { ); expect(rows[0]?.querySelector(".cron-last-glyph--ok")).toBeNull(); expect(rows[0]?.textContent).toContain("n/a"); + expect(rows[0]?.querySelector(".cron-trigger-icon")?.getAttribute("aria-label")).toBe( + "Trigger configured", + ); (rows[1] as HTMLElement).click(); expect(onSelectJob).toHaveBeenCalledWith(paused); @@ -660,6 +677,39 @@ describe("cron view editor", () => { form: { ...DEFAULT_CRON_FORM, payloadKind: "systemEvent", sessionTarget: "main" }, }); expect(systemEvent.querySelector("#cron-payload-model")).toBeNull(); + + const conditional = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + }); + expect(conditional.querySelector("#cron-trigger-script")).toBeInstanceOf(HTMLTextAreaElement); + expect(conditional.querySelector(".cron-trigger-summary")?.textContent).toContain( + "Trigger configured", + ); + }); + + 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 }); + expect(disabled.querySelector("#cron-trigger-script")).toBeNull(); + expect(disabled.textContent).toContain("disabled by cron.triggers.enabled"); + + const configured = renderView({ + createOpen: true, + triggersEnabled: false, + onFormChange, + form: { + ...DEFAULT_CRON_FORM, + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + }); + getButtonByText(configured, "Clear trigger").click(); + expect(onFormChange).toHaveBeenCalledWith({ triggerEnabled: false }); }); it("renders script payloads as highlighted read-only code without exposing script authoring", () => { diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index d79652d3077b..a4a2d2fe9758 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -17,6 +17,7 @@ import type { CronStatus, CronDeliveryStatus, CronJobsEnabledFilter, + CronJobsTriggerFilter, CronRunsStatusValue, CronJobsSortBy, CronSortDir, @@ -71,6 +72,7 @@ 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; @@ -84,6 +86,7 @@ type CronProps = { jobsEnabledFilter: CronJobsEnabledFilter; jobsScheduleKindFilter: CronJobsScheduleKindFilter; jobsLastStatusFilter: CronJobsLastStatusFilter; + jobsTriggerFilter: CronJobsTriggerFilter; jobsSortBy: CronJobsSortBy; jobsSortDir: CronSortDir; error: string | null; @@ -131,6 +134,7 @@ type CronProps = { cronJobsEnabledFilter?: CronJobsEnabledFilter; cronJobsScheduleKindFilter?: CronJobsScheduleKindFilter; cronJobsLastStatusFilter?: CronJobsLastStatusFilter; + cronJobsTriggerFilter?: CronJobsTriggerFilter; cronJobsSortBy?: CronJobsSortBy; cronJobsSortDir?: CronSortDir; }) => void | Promise; @@ -184,6 +188,7 @@ const CRON_FIELD_LABEL_KEYS: Record = { everyAmount: "cron.form.every", cronExpr: "cron.form.expression", staggerAmount: "cron.form.staggerWindow", + triggerScript: "cron.form.triggerScript", payloadText: "cron.form.assistantTaskPrompt", payloadModel: "cron.form.model", payloadThinking: "cron.form.thinking", @@ -447,6 +452,7 @@ function renderListView(props: CronProps) { const hasAdvancedJobsFilters = props.jobsScheduleKindFilter !== "all" || props.jobsLastStatusFilter !== "all" || + props.jobsTriggerFilter !== "all" || props.jobsSortBy !== "nextRunAtMs" || props.jobsSortDir !== "asc"; const hasAnyJobsFilters = @@ -659,6 +665,16 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) { { value: "unknown", label: t("cron.runs.runStatusUnknown") }, ], })} + ${renderJobsFilter(props, "cronJobsTriggerFilter", { + label: t("cron.jobs.condition"), + value: props.jobsTriggerFilter, + testId: "cron-jobs-trigger-filter", + options: [ + { value: "all", label: t("cron.jobs.all") }, + { value: "conditional", label: t("cron.jobs.conditional") }, + { value: "unconditional", label: t("cron.jobs.unconditional") }, + ], + })} ${renderJobsFilter(props, "cronJobsSortBy", { label: t("cron.jobs.sort"), value: props.jobsSortBy, @@ -763,6 +779,7 @@ function renderJobRow(job: CronJob, props: CronProps) { > ` : nothing} + ${job.trigger ? renderTriggerIndicator() : nothing} ${job.enabled ? nothing : renderDisabledNote(job)} ${formatCronSchedule(job)} @@ -804,6 +821,13 @@ function renderJobRow(job: CronJob, props: CronProps) { `; } +function renderTriggerIndicator() { + const label = t("cron.form.triggerConfigured"); + return html`${icon("gitBranch")}`; +} + /** Auto-disabled is the escalated failure state, not an operator pause: the * recorded fact (state.autoDisabled) must stay visible or the job silently * drops out of every failure surface the moment the problem became permanent. */ @@ -1003,6 +1027,7 @@ function renderDetailHeader(props: CronProps, mode: CronPanelMode, selectedJob?: ? renderEnabledSwitch(props, selectedJob) : nothing} ${subtitle} + ${selectedJob?.trigger ? renderTriggerIndicator() : nothing}
@@ -1570,9 +1595,15 @@ function renderAdvanced(
${t("cron.form.advanced")} + ${props.form.triggerEnabled + ? html` + ${icon("gitBranch")} ${t("cron.form.triggerConfigured")} + ` + : nothing}

${t("cron.form.advancedHelp")}

+ ${renderTriggerRows(props)} ${renderCronInputField(props, "description", { label: t("cron.form.description"), placeholder: t("cron.form.descriptionPlaceholder"), @@ -1696,6 +1727,67 @@ function renderAdvanced( `; } +function renderTriggerRows(props: CronProps) { + if (!props.triggersEnabled) { + return renderSettingsRow({ + title: t("cron.form.conditionTrigger"), + description: props.form.triggerEnabled + ? t("cron.form.triggerDisabledConfigured") + : t("cron.form.triggerDisabled"), + control: props.form.triggerEnabled + ? html`` + : nothing, + }); + } + return html` + ${renderToggleRow(props, "triggerEnabled", { + label: t("cron.form.conditionTrigger"), + help: t("cron.form.conditionTriggerHelp"), + })} + ${props.form.triggerEnabled + ? html` + ${renderFieldRow({ + label: t("cron.form.triggerScript"), + controlId: "cron-trigger-script", + required: true, + help: t("cron.form.triggerScriptHelp"), + error: props.fieldErrors.triggerScript, + errorId: errorIdForField("triggerScript"), + stacked: true, + wide: true, + control: html``, + })} + ${renderToggleRow(props, "triggerOnce", { + label: t("cron.form.triggerOnce"), + help: t("cron.form.triggerOnceHelp"), + })} + ` + : nothing} + `; +} + function renderFailureAlertRows(props: CronProps, channelOptions: readonly ChannelPickerOption[]) { return html` ${renderCronSelectField(props, "failureAlertMode", { diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index c31b261f6aa9..19d4c175f4bd 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -697,6 +697,46 @@ cursor: var(--cursor-action); } +.cron-trigger-icon { + display: inline-flex; + flex: 0 0 auto; + width: 14px; + height: 14px; + color: var(--accent-2); + vertical-align: middle; +} + +.cron-trigger-icon svg, +.cron-trigger-summary svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; +} + +.cron-trigger-summary { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-left: var(--space-2); + color: var(--accent-2); + font-size: var(--control-ui-text-xs); + font-weight: 600; +} + +.cron-trigger-summary svg { + width: 13px; + height: 13px; +} + +.cron-trigger-script { + width: 100%; + min-height: 160px; + resize: vertical; + line-height: 1.5; + white-space: pre; +} + .cron-advanced > .settings-section__desc { margin: var(--space-2) 0 0; } diff --git a/ui/src/test-helpers/cron.ts b/ui/src/test-helpers/cron.ts index 5ea4d6dba3a8..71125b056fbe 100644 --- a/ui/src/test-helpers/cron.ts +++ b/ui/src/test-helpers/cron.ts @@ -17,6 +17,9 @@ export const DEFAULT_CRON_FORM: CronFormState = { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds", + triggerEnabled: false, + triggerScript: "", + triggerOnce: false, sessionTarget: "isolated", wakeMode: "now", payloadKind: "agentTurn",