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>
This commit is contained in:
ClawSweeper
2026-08-20 07:20:01 -07:00
committed by GitHub
parent fdac5c56c5
commit 267ffc4754
20 changed files with 497 additions and 50 deletions
@@ -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"
@@ -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" },
]);
});
@@ -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),
@@ -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,
});
+4
View File
@@ -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;
+19
View File
@@ -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;
}
+2
View File
@@ -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,
@@ -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,
@@ -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 })],
+1
View File
@@ -555,6 +555,7 @@ export type {
export type CronRunStatus = NonNullable<ProtocolCronRunLogEntry["status"]>;
export type CronDeliveryStatus = NonNullable<ProtocolCronRunLogEntry["deliveryStatus"]>;
export type CronJobsEnabledFilter = NonNullable<CronListParams["enabled"]>;
export type CronJobsTriggerFilter = NonNullable<CronListParams["trigger"]>;
export type CronJobsSortBy = NonNullable<CronListParams["sortBy"]>;
export type CronRunScope = NonNullable<CronRunsParams["scope"]>;
export type CronRunsStatusValue = NonNullable<CronRunsParams["statuses"]>[number];
@@ -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);
},
);
});
});
+20 -1
View File
@@ -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.",
+32 -2
View File
@@ -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);
+47 -1
View File
@@ -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<CronState, "cronJobs" | "cronJobsScheduleKindFilter" | "cronJobsLastStatusFilter">,
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<CronSaveResult> {
} 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<CronSaveResult> {
: {}),
sessionTarget: form.sessionTarget,
wakeMode: form.wakeMode,
trigger,
delivery,
failureAlert,
};
+12
View File
@@ -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",
});
+2
View File
@@ -26,6 +26,7 @@ function createCronViewProps(overrides: Partial<CronProps> = {}): CronProps {
agentId: "main",
loading: false,
canManage: true,
triggersEnabled: true,
jobsLoadingMore: false,
status: null,
failingCount: null,
@@ -39,6 +40,7 @@ function createCronViewProps(overrides: Partial<CronProps> = {}): CronProps {
jobsEnabledFilter: "all",
jobsScheduleKindFilter: "all",
jobsLastStatusFilter: "all",
jobsTriggerFilter: "all",
jobsSortBy: "nextRunAtMs",
jobsSortDir: "asc",
error: null,
+51 -1
View File
@@ -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", () => {
+92
View File
@@ -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<void>;
@@ -184,6 +188,7 @@ const CRON_FIELD_LABEL_KEYS: Record<CronFieldKey, string> = {
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)}
</span>
<span class="cron-table__cell">${formatCronSchedule(job)}</span>
@@ -804,6 +821,13 @@ function renderJobRow(job: CronJob, props: CronProps) {
`;
}
function renderTriggerIndicator() {
const label = t("cron.form.triggerConfigured");
return html`<span class="cron-trigger-icon" role="img" aria-label=${label} title=${label}
>${icon("gitBranch")}</span
>`;
}
/** 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}
<span class="cron-detail-sub">${subtitle}</span>
${selectedJob?.trigger ? renderTriggerIndicator() : nothing}
</div>
</div>
<div class="cron-detail-actions">
@@ -1570,9 +1595,15 @@ function renderAdvanced(
<details class="cron-advanced">
<summary class="settings-section__heading cron-advanced__summary">
${t("cron.form.advanced")}
${props.form.triggerEnabled
? html`<span class="cron-trigger-summary">
${icon("gitBranch")} ${t("cron.form.triggerConfigured")}
</span>`
: nothing}
</summary>
<p class="settings-section__desc">${t("cron.form.advancedHelp")}</p>
<div class="settings-group">
${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`<button
type="button"
class="btn btn--sm"
@click=${() => props.onFormChange({ triggerEnabled: false })}
>
${t("cron.form.clearTrigger")}
</button>`
: 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`<textarea
id="cron-trigger-script"
class="settings-input cron-trigger-script mono"
rows="8"
spellcheck="false"
aria-invalid=${props.fieldErrors.triggerScript ? "true" : "false"}
aria-describedby=${ifDefined(
props.fieldErrors.triggerScript ? errorIdForField("triggerScript") : undefined,
)}
.value=${props.form.triggerScript}
@input=${(event: Event) => {
const target = event.currentTarget;
if (target instanceof HTMLTextAreaElement) {
props.onFormChange({ triggerScript: target.value });
}
}}
></textarea>`,
})}
${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", {
+40
View File
@@ -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;
}
+3
View File
@@ -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",