mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user