Web UI: add full cron edit parity, all-jobs run history, and compact filters (openclaw#24155) thanks @Takhoffman

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Tak Hoffman
2026-02-22 23:05:42 -06:00
committed by GitHub
parent 610863e733
commit 77c3b142a9
23 changed files with 3769 additions and 344 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows.
- Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc.
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.

View File

@@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
@@ -85,6 +85,9 @@ Cron jobs panel notes:
- Channel/target fields appear when announce is selected.
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
- For main-session jobs, webhook and none delivery modes are available.
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options,
agent model/thinking overrides, and best-effort delivery toggles.
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.

View File

@@ -19,6 +19,35 @@ export type CronRunLogEntry = {
nextRunAtMs?: number;
} & CronRunTelemetry;
export type CronRunLogSortDir = "asc" | "desc";
export type CronRunLogStatusFilter = "all" | "ok" | "error" | "skipped";
export type ReadCronRunLogPageOptions = {
limit?: number;
offset?: number;
jobId?: string;
status?: CronRunLogStatusFilter;
statuses?: CronRunStatus[];
deliveryStatus?: CronDeliveryStatus;
deliveryStatuses?: CronDeliveryStatus[];
query?: string;
sortDir?: CronRunLogSortDir;
};
export type CronRunLogPageResult = {
entries: CronRunLogEntry[];
total: number;
offset: number;
limit: number;
hasMore: boolean;
nextOffset: number | null;
};
type ReadCronRunLogAllPageOptions = Omit<ReadCronRunLogPageOptions, "jobId"> & {
storePath: string;
jobNameById?: Record<string, string>;
};
function assertSafeCronRunLogJobId(jobId: string): string {
const trimmed = jobId.trim();
if (!trimmed) {
@@ -98,14 +127,78 @@ export async function readCronRunLogEntries(
opts?: { limit?: number; jobId?: string },
): Promise<CronRunLogEntry[]> {
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
const page = await readCronRunLogEntriesPage(filePath, {
jobId: opts?.jobId,
limit,
offset: 0,
status: "all",
sortDir: "desc",
});
return page.entries.toReversed();
}
function normalizeRunStatusFilter(status?: string): CronRunLogStatusFilter {
if (status === "ok" || status === "error" || status === "skipped" || status === "all") {
return status;
}
return "all";
}
function normalizeRunStatuses(opts?: {
statuses?: CronRunStatus[];
status?: CronRunLogStatusFilter;
}): CronRunStatus[] | null {
if (Array.isArray(opts?.statuses) && opts.statuses.length > 0) {
const filtered = opts.statuses.filter(
(status): status is CronRunStatus =>
status === "ok" || status === "error" || status === "skipped",
);
if (filtered.length > 0) {
return Array.from(new Set(filtered));
}
}
const status = normalizeRunStatusFilter(opts?.status);
if (status === "all") {
return null;
}
return [status];
}
function normalizeDeliveryStatuses(opts?: {
deliveryStatuses?: CronDeliveryStatus[];
deliveryStatus?: CronDeliveryStatus;
}): CronDeliveryStatus[] | null {
if (Array.isArray(opts?.deliveryStatuses) && opts.deliveryStatuses.length > 0) {
const filtered = opts.deliveryStatuses.filter(
(status): status is CronDeliveryStatus =>
status === "delivered" ||
status === "not-delivered" ||
status === "unknown" ||
status === "not-requested",
);
if (filtered.length > 0) {
return Array.from(new Set(filtered));
}
}
if (
opts?.deliveryStatus === "delivered" ||
opts?.deliveryStatus === "not-delivered" ||
opts?.deliveryStatus === "unknown" ||
opts?.deliveryStatus === "not-requested"
) {
return [opts.deliveryStatus];
}
return null;
}
function parseAllRunLogEntries(raw: string, opts?: { jobId?: string }): CronRunLogEntry[] {
const jobId = opts?.jobId?.trim() || undefined;
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
if (!raw.trim()) {
return [];
}
const parsed: CronRunLogEntry[] = [];
const lines = raw.split("\n");
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim();
if (!line) {
continue;
@@ -182,5 +275,125 @@ export async function readCronRunLogEntries(
// ignore invalid lines
}
}
return parsed.toReversed();
return parsed;
}
export async function readCronRunLogEntriesPage(
filePath: string,
opts?: ReadCronRunLogPageOptions,
): Promise<CronRunLogPageResult> {
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? 50)));
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
const statuses = normalizeRunStatuses(opts);
const deliveryStatuses = normalizeDeliveryStatuses(opts);
const query = opts?.query?.trim().toLowerCase() ?? "";
const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc";
const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId });
const filtered = all.filter((entry) => {
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
return false;
}
if (deliveryStatuses) {
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
if (!deliveryStatuses.includes(deliveryStatus)) {
return false;
}
}
if (!query) {
return true;
}
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" ").toLowerCase();
return haystack.includes(query);
});
const sorted =
sortDir === "asc"
? filtered.toSorted((a, b) => a.ts - b.ts)
: filtered.toSorted((a, b) => b.ts - a.ts);
const total = sorted.length;
const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0)));
const entries = sorted.slice(offset, offset + limit);
const nextOffset = offset + entries.length;
return {
entries,
total,
offset,
limit,
hasMore: nextOffset < total,
nextOffset: nextOffset < total ? nextOffset : null,
};
}
export async function readCronRunLogEntriesPageAll(
opts: ReadCronRunLogAllPageOptions,
): Promise<CronRunLogPageResult> {
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 50)));
const statuses = normalizeRunStatuses(opts);
const deliveryStatuses = normalizeDeliveryStatuses(opts);
const query = opts.query?.trim().toLowerCase() ?? "";
const sortDir: CronRunLogSortDir = opts.sortDir === "asc" ? "asc" : "desc";
const runsDir = path.resolve(path.dirname(path.resolve(opts.storePath)), "runs");
const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []);
const jsonlFiles = files
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map((entry) => path.join(runsDir, entry.name));
if (jsonlFiles.length === 0) {
return {
entries: [],
total: 0,
offset: 0,
limit,
hasMore: false,
nextOffset: null,
};
}
const chunks = await Promise.all(
jsonlFiles.map(async (filePath) => {
const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
return parseAllRunLogEntries(raw);
}),
);
const all = chunks.flat();
const filtered = all.filter((entry) => {
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
return false;
}
if (deliveryStatuses) {
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
if (!deliveryStatuses.includes(deliveryStatus)) {
return false;
}
}
if (!query) {
return true;
}
const jobName = opts.jobNameById?.[entry.jobId] ?? "";
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
const sorted =
sortDir === "asc"
? filtered.toSorted((a, b) => a.ts - b.ts)
: filtered.toSorted((a, b) => b.ts - a.ts);
const total = sorted.length;
const offset = Math.max(0, Math.min(total, Math.floor(opts.offset ?? 0)));
const entries = sorted.slice(offset, offset + limit);
if (opts.jobNameById) {
for (const entry of entries) {
const jobName = opts.jobNameById[entry.jobId];
if (jobName) {
(entry as CronRunLogEntry & { jobName?: string }).jobName = jobName;
}
}
}
const nextOffset = offset + entries.length;
return {
entries,
total,
offset,
limit,
hasMore: nextOffset < total,
nextOffset: nextOffset < total ? nextOffset : null,
};
}

View File

@@ -26,6 +26,10 @@ export class CronService {
return await ops.list(this.state, opts);
}
async listPage(opts?: ops.CronListPageOptions) {
return await ops.listPage(this.state, opts);
}
async add(input: CronJobCreate) {
return await ops.add(this.state, input);
}

View File

@@ -1,4 +1,4 @@
import type { CronJobCreate, CronJobPatch } from "../types.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
import {
applyJobPatch,
computeJobNextRunAtMs,
@@ -22,6 +22,29 @@ import {
wake,
} from "./timer.js";
type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
type CronSortDir = "asc" | "desc";
export type CronListPageOptions = {
includeDisabled?: boolean;
limit?: number;
offset?: number;
query?: string;
enabled?: CronJobsEnabledFilter;
sortBy?: CronJobsSortBy;
sortDir?: CronSortDir;
};
export type CronListPageResult = {
jobs: ReturnType<typeof sortJobs>;
total: number;
offset: number;
limit: number;
hasMore: boolean;
nextOffset: number | null;
};
async function ensureLoadedForRead(state: CronServiceState) {
await ensureLoaded(state, { skipRecompute: true });
if (!state.store) {
@@ -101,6 +124,80 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b
});
}
function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter {
if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") {
return opts.enabled;
}
return opts?.includeDisabled ? "all" : "enabled";
}
function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) {
const dir = sortDir === "desc" ? -1 : 1;
return jobs.toSorted((a, b) => {
let cmp = 0;
if (sortBy === "name") {
cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
} else if (sortBy === "updatedAtMs") {
cmp = a.updatedAtMs - b.updatedAtMs;
} else {
const aNext = a.state.nextRunAtMs;
const bNext = b.state.nextRunAtMs;
if (typeof aNext === "number" && typeof bNext === "number") {
cmp = aNext - bNext;
} else if (typeof aNext === "number") {
cmp = -1;
} else if (typeof bNext === "number") {
cmp = 1;
} else {
cmp = 0;
}
}
if (cmp !== 0) {
return cmp * dir;
}
return a.id.localeCompare(b.id);
});
}
export async function listPage(state: CronServiceState, opts?: CronListPageOptions) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
const query = opts?.query?.trim().toLowerCase() ?? "";
const enabledFilter = resolveEnabledFilter(opts);
const sortBy = opts?.sortBy ?? "nextRunAtMs";
const sortDir = opts?.sortDir ?? "asc";
const source = state.store?.jobs ?? [];
const filtered = source.filter((job) => {
if (enabledFilter === "enabled" && !job.enabled) {
return false;
}
if (enabledFilter === "disabled" && job.enabled) {
return false;
}
if (!query) {
return true;
}
const haystack = [job.name, job.description ?? "", job.agentId ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
const sorted = sortJobs(filtered, sortBy, sortDir);
const total = sorted.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 jobs = sorted.slice(offset, offset + limit);
const nextOffset = offset + jobs.length;
return {
jobs,
total,
offset,
limit,
hasMore: nextOffset < total,
nextOffset: nextOffset < total ? nextOffset : null,
} satisfies CronListPageResult;
});
}
export async function add(state: CronServiceState, input: CronJobCreate) {
return await locked(state, async () => {
warnIfDisabled(state, "add");

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
validateCronAddParams,
validateCronListParams,
validateCronRemoveParams,
validateCronRunParams,
validateCronRunsParams,
@@ -40,6 +41,21 @@ describe("cron protocol validators", () => {
expect(validateCronRunParams({ jobId: "job-2", mode: "due" })).toBe(true);
});
it("accepts list paging/filter/sort params", () => {
expect(
validateCronListParams({
includeDisabled: true,
limit: 50,
offset: 0,
query: "daily",
enabled: "all",
sortBy: "nextRunAtMs",
sortDir: "asc",
}),
).toBe(true);
expect(validateCronListParams({ offset: -1 })).toBe(false);
});
it("enforces runs limit minimum for id and jobId selectors", () => {
expect(validateCronRunsParams({ id: "job-1", limit: 1 })).toBe(true);
expect(validateCronRunsParams({ jobId: "job-2", limit: 1 })).toBe(true);
@@ -53,4 +69,37 @@ describe("cron protocol validators", () => {
expect(validateCronRunsParams({ jobId: "..\\job-2" })).toBe(false);
expect(validateCronRunsParams({ jobId: "nested\\job-2" })).toBe(false);
});
it("accepts runs paging/filter/sort params", () => {
expect(
validateCronRunsParams({
id: "job-1",
limit: 50,
offset: 0,
status: "error",
query: "timeout",
sortDir: "desc",
}),
).toBe(true);
expect(validateCronRunsParams({ id: "job-1", offset: -1 })).toBe(false);
});
it("accepts all-scope runs with multi-select filters", () => {
expect(
validateCronRunsParams({
scope: "all",
limit: 25,
statuses: ["ok", "error"],
deliveryStatuses: ["delivered", "not-requested"],
query: "fail",
sortDir: "desc",
}),
).toBe(true);
expect(
validateCronRunsParams({
scope: "job",
statuses: [],
}),
).toBe(false);
});
});

View File

@@ -26,6 +26,28 @@ const CronRunStatusSchema = Type.Union([
Type.Literal("error"),
Type.Literal("skipped"),
]);
const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]);
const CronJobsEnabledFilterSchema = Type.Union([
Type.Literal("all"),
Type.Literal("enabled"),
Type.Literal("disabled"),
]);
const CronJobsSortBySchema = Type.Union([
Type.Literal("nextRunAtMs"),
Type.Literal("updatedAtMs"),
Type.Literal("name"),
]);
const CronRunsStatusFilterSchema = Type.Union([
Type.Literal("all"),
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]);
const CronRunsStatusValueSchema = Type.Union([
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]);
const CronDeliveryStatusSchema = Type.Union([
Type.Literal("delivered"),
Type.Literal("not-delivered"),
@@ -65,25 +87,6 @@ const CronRunLogJobIdSchema = Type.String({
pattern: "^[^/\\\\]+$",
});
function cronRunsIdOrJobIdParams(extraFields: Record<string, TSchema>) {
return Type.Union([
Type.Object(
{
id: CronRunLogJobIdSchema,
...extraFields,
},
{ additionalProperties: false },
),
Type.Object(
{
jobId: CronRunLogJobIdSchema,
...extraFields,
},
{ additionalProperties: false },
),
]);
}
export const CronScheduleSchema = Type.Union([
Type.Object(
{
@@ -223,6 +226,12 @@ export const CronJobSchema = Type.Object(
export const CronListParamsSchema = Type.Object(
{
includeDisabled: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
offset: Type.Optional(Type.Integer({ minimum: 0 })),
query: Type.Optional(Type.String()),
enabled: Type.Optional(CronJobsEnabledFilterSchema),
sortBy: Type.Optional(CronJobsSortBySchema),
sortDir: Type.Optional(CronSortDirSchema),
},
{ additionalProperties: false },
);
@@ -266,9 +275,24 @@ export const CronRunParamsSchema = cronIdOrJobIdParams({
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
});
export const CronRunsParamsSchema = cronRunsIdOrJobIdParams({
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
});
export const CronRunsParamsSchema = Type.Object(
{
scope: Type.Optional(Type.Union([Type.Literal("job"), Type.Literal("all")])),
id: Type.Optional(CronRunLogJobIdSchema),
jobId: Type.Optional(CronRunLogJobIdSchema),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
offset: Type.Optional(Type.Integer({ minimum: 0 })),
statuses: Type.Optional(Type.Array(CronRunsStatusValueSchema, { minItems: 1, maxItems: 3 })),
status: Type.Optional(CronRunsStatusFilterSchema),
deliveryStatuses: Type.Optional(
Type.Array(CronDeliveryStatusSchema, { minItems: 1, maxItems: 4 }),
),
deliveryStatus: Type.Optional(CronDeliveryStatusSchema),
query: Type.Optional(Type.String()),
sortDir: Type.Optional(CronSortDirSchema),
},
{ additionalProperties: false },
);
export const CronRunLogEntrySchema = Type.Object(
{
@@ -286,6 +310,21 @@ export const CronRunLogEntrySchema = Type.Object(
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
model: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
usage: Type.Optional(
Type.Object(
{
input_tokens: Type.Optional(Type.Number()),
output_tokens: Type.Optional(Type.Number()),
total_tokens: Type.Optional(Type.Number()),
cache_read_tokens: Type.Optional(Type.Number()),
cache_write_tokens: Type.Optional(Type.Number()),
},
{ additionalProperties: false },
),
),
jobName: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);

View File

@@ -1,5 +1,9 @@
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js";
import {
readCronRunLogEntriesPage,
readCronRunLogEntriesPageAll,
resolveCronRunLogPath,
} from "../../cron/run-log.js";
import type { CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js";
import {
@@ -49,11 +53,25 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { includeDisabled?: boolean };
const jobs = await context.cron.list({
const p = params as {
includeDisabled?: boolean;
limit?: number;
offset?: number;
query?: string;
enabled?: "all" | "enabled" | "disabled";
sortBy?: "nextRunAtMs" | "updatedAtMs" | "name";
sortDir?: "asc" | "desc";
};
const page = await context.cron.listPage({
includeDisabled: p.includeDisabled,
limit: p.limit,
offset: p.offset,
query: p.query,
enabled: p.enabled,
sortBy: p.sortBy,
sortDir: p.sortDir,
});
respond(true, { jobs }, undefined);
respond(true, page, undefined);
},
"cron.status": async ({ params, respond, context }) => {
if (!validateCronStatusParams(params)) {
@@ -204,9 +222,23 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { id?: string; jobId?: string; limit?: number };
const p = params as {
scope?: "job" | "all";
id?: string;
jobId?: string;
limit?: number;
offset?: number;
statuses?: Array<"ok" | "error" | "skipped">;
status?: "all" | "ok" | "error" | "skipped";
deliveryStatuses?: Array<"delivered" | "not-delivered" | "unknown" | "not-requested">;
deliveryStatus?: "delivered" | "not-delivered" | "unknown" | "not-requested";
query?: string;
sortDir?: "asc" | "desc";
};
const explicitScope = p.scope;
const jobId = p.id ?? p.jobId;
if (!jobId) {
const scope: "job" | "all" = explicitScope ?? (jobId ? "job" : "all");
if (scope === "job" && !jobId) {
respond(
false,
undefined,
@@ -214,11 +246,33 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
if (scope === "all") {
const jobs = await context.cron.list({ includeDisabled: true });
const jobNameById = Object.fromEntries(
jobs
.filter((job) => typeof job.id === "string" && typeof job.name === "string")
.map((job) => [job.id, job.name]),
);
const page = await readCronRunLogEntriesPageAll({
storePath: context.cronStorePath,
limit: p.limit,
offset: p.offset,
statuses: p.statuses,
status: p.status,
deliveryStatuses: p.deliveryStatuses,
deliveryStatus: p.deliveryStatus,
query: p.query,
sortDir: p.sortDir,
jobNameById,
});
respond(true, page, undefined);
return;
}
let logPath: string;
try {
logPath = resolveCronRunLogPath({
storePath: context.cronStorePath,
jobId,
jobId: jobId as string,
});
} catch {
respond(
@@ -228,10 +282,17 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const entries = await readCronRunLogEntries(logPath, {
const page = await readCronRunLogEntriesPage(logPath, {
limit: p.limit,
jobId,
offset: p.offset,
jobId: jobId as string,
statuses: p.statuses,
status: p.status,
deliveryStatuses: p.deliveryStatuses,
deliveryStatus: p.deliveryStatus,
query: p.query,
sortDir: p.sortDir,
});
respond(true, { entries }, undefined);
respond(true, page, undefined);
},
};

View File

@@ -424,6 +424,17 @@ describe("gateway server cron", () => {
expect((entries as Array<{ deliveryStatus?: unknown }>).at(-1)?.deliveryStatus).toBe(
"not-requested",
);
const allRunsRes = await rpcReq(ws, "cron.runs", {
scope: "all",
limit: 50,
statuses: ["ok"],
});
expect(allRunsRes.ok).toBe(true);
const allEntries = (allRunsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(allEntries)).toBe(true);
expect(
(allEntries as Array<{ jobId?: unknown }>).some((entry) => entry.jobId === jobId),
).toBe(true);
const statusRes = await rpcReq(ws, "cron.status", {});
expect(statusRes.ok).toBe(true);

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { formatNextRun } from "../ui/src/ui/presenter.ts";
describe("formatNextRun", () => {
it("returns n/a for nullish values", () => {
expect(formatNextRun(null)).toBe("n/a");
expect(formatNextRun(undefined)).toBe("n/a");
});
it("includes weekday and relative time", () => {
const ts = Date.UTC(2026, 1, 23, 15, 0, 0);
const out = formatNextRun(ts);
expect(out).toMatch(/^[A-Za-z]{3}, /);
expect(out).toContain("(");
expect(out).toContain(")");
});
});

View File

@@ -526,6 +526,441 @@
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* ===========================================
Cron Form
=========================================== */
.cron-summary-strip {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px 18px;
padding: 14px 16px;
}
.cron-summary-strip__left {
display: grid;
gap: 8px 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
flex: 1 1 auto;
min-width: 0;
}
.cron-summary-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
padding: 10px 12px;
min-height: 62px;
display: grid;
gap: 6px;
}
.cron-summary-item--wide {
grid-column: span 1;
}
.cron-summary-label {
color: var(--muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cron-summary-value {
color: var(--text-strong);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
display: flex;
align-items: center;
gap: 8px;
}
.cron-summary-strip__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
}
.cron-workspace {
margin-top: 16px;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
gap: 16px;
align-items: start;
}
.cron-workspace-main {
display: grid;
gap: 16px;
}
.cron-workspace-form {
position: sticky;
top: 74px;
}
.cron-form {
margin-top: 16px;
display: grid;
gap: 14px;
}
.cron-form-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px;
background: var(--bg-elevated);
display: grid;
gap: 12px;
}
.cron-form-section__title {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.cron-form-section__sub {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.cron-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.cron-help {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin-top: 2px;
}
.cron-error {
color: var(--danger-color);
}
.cron-required-legend {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.cron-required-marker {
color: var(--danger-color);
font-weight: 700;
margin-left: 3px;
}
.cron-required-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.field input[aria-invalid="true"],
.field textarea[aria-invalid="true"],
.field select[aria-invalid="true"] {
border-color: var(--danger);
box-shadow:
inset 0 1px 0 var(--card-highlight),
0 0 0 1px rgba(239, 68, 68, 0.2);
}
.cron-form-status {
margin-top: 4px;
border: 1px solid var(--danger-subtle);
background: var(--danger-subtle);
border-radius: var(--radius-md);
padding: 10px 12px;
}
.cron-form-status__title {
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.cron-form-status__list {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
}
.cron-form-status__link {
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 12px;
line-height: 1.4;
padding: 0;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
}
.cron-form-status__link:hover {
color: var(--text-strong);
}
.cron-span-2 {
grid-column: 1 / -1;
}
.cron-checkbox {
align-items: center;
grid-template-columns: 16px minmax(0, 1fr);
column-gap: 10px;
}
.cron-checkbox input[type="checkbox"] {
margin: 2px 0 0;
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.cron-checkbox .field-checkbox__label {
color: var(--text-strong);
font-size: 13px;
font-weight: 500;
}
.cron-checkbox .cron-help {
grid-column: 2;
}
.cron-checkbox-inline {
align-content: start;
align-items: start;
padding-top: 28px;
}
.cron-advanced {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
background: var(--bg-elevated);
display: grid;
gap: 10px;
}
.cron-advanced__summary {
cursor: pointer;
color: var(--muted);
font-size: 13px;
font-weight: 500;
}
.cron-stagger-group {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;
gap: 14px 16px;
align-items: start;
}
.cron-form-actions {
margin-top: 14px;
justify-content: flex-start;
align-items: center;
gap: 10px 14px;
flex-wrap: wrap;
}
.cron-submit-reason {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.cron-filter-search {
flex: 1 1 320px;
min-width: 280px;
}
.cron-workspace .filters .field {
min-width: 160px;
}
.cron-run-filters {
margin-top: 12px;
display: grid;
gap: 12px;
}
.cron-run-filters__row {
display: grid;
gap: 12px;
}
.cron-run-filters__row--primary {
grid-template-columns: minmax(160px, 220px) minmax(240px, 1fr) minmax(160px, 220px);
}
.cron-run-filters__row--secondary {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.cron-run-filter-search {
min-width: 0;
}
.cron-filter-dropdown {
min-width: 0;
}
.cron-filter-dropdown__details {
position: relative;
}
.cron-filter-dropdown__details > summary {
list-style: none;
}
.cron-filter-dropdown__details > summary::-webkit-details-marker {
display: none;
}
.cron-filter-dropdown__trigger {
width: 100%;
justify-content: space-between;
text-align: left;
}
.cron-filter-dropdown__panel {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
width: min(360px, calc(100vw - 48px));
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
padding: 10px;
display: grid;
gap: 10px;
box-shadow: var(--shadow-card);
}
.cron-filter-dropdown__list {
display: grid;
gap: 6px;
}
.cron-filter-dropdown__option {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
align-items: center;
color: var(--text);
font-size: 13px;
}
.cron-filter-dropdown__option input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
}
.cron-run-entry {
align-items: start;
}
.cron-run-entry__meta {
text-align: right;
min-width: 220px;
}
.cron-run-entry__summary {
white-space: pre-wrap;
line-height: 1.45;
}
@media (max-width: 1100px) {
.cron-summary-strip {
flex-direction: column;
}
.cron-summary-strip__left {
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.cron-summary-strip__actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.cron-workspace {
grid-template-columns: 1fr;
}
.cron-workspace-form {
position: static;
order: -1;
}
.cron-form-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.cron-span-2 {
grid-column: auto;
}
.cron-checkbox-inline {
padding-top: 0;
}
.cron-stagger-group {
grid-template-columns: 1fr;
gap: 12px;
}
.cron-filter-search {
min-width: 0;
flex: 1 1 100%;
}
.cron-run-filters__row--primary,
.cron-run-filters__row--secondary {
grid-template-columns: 1fr;
}
.cron-filter-dropdown__panel {
width: 100%;
max-width: none;
position: static;
margin-top: 8px;
}
.cron-run-entry__meta {
min-width: 0;
text-align: left;
}
}
:root[data-theme="light"] .field input,
:root[data-theme="light"] .field textarea,
:root[data-theme="light"] .field select {

View File

@@ -14,19 +14,27 @@ export const DEFAULT_CRON_FORM: CronFormState = {
name: "",
description: "",
agentId: "",
clearAgent: false,
enabled: true,
deleteAfterRun: true,
scheduleKind: "every",
scheduleAt: "",
everyAmount: "30",
everyUnit: "minutes",
cronExpr: "0 7 * * *",
cronTz: "",
scheduleExact: false,
staggerAmount: "",
staggerUnit: "seconds",
sessionTarget: "isolated",
wakeMode: "now",
payloadKind: "agentTurn",
payloadText: "",
payloadModel: "",
payloadThinking: "",
deliveryMode: "announce",
deliveryChannel: "last",
deliveryTo: "",
deliveryBestEffort: false,
timeoutSeconds: "",
};

View File

@@ -21,11 +21,21 @@ import {
} from "./controllers/config.ts";
import {
loadCronRuns,
loadMoreCronJobs,
loadMoreCronRuns,
reloadCronJobs,
toggleCronJob,
runCronJob,
removeCronJob,
addCronJob,
startCronEdit,
startCronClone,
cancelCronEdit,
validateCronForm,
hasCronFormErrors,
normalizeCronFormState,
updateCronJobsFilter,
updateCronRunsFilter,
} from "./controllers/cron.ts";
import { loadDebug, callDebugMethod } from "./controllers/debug.ts";
import {
@@ -71,6 +81,43 @@ import { renderSkills } from "./views/skills.ts";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"];
const CRON_TIMEZONE_SUGGESTIONS = [
"UTC",
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"Europe/London",
"Europe/Berlin",
"Asia/Tokyo",
];
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
function normalizeSuggestionValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function uniquePreserveOrder(values: string[]): string[] {
const seen = new Set<string>();
const output: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized) {
continue;
}
const key = normalized.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
@@ -106,6 +153,56 @@ export function renderApp(state: AppViewState) {
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
const cronAgentSuggestions = Array.from(
new Set(
[
...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []),
...state.cronJobs
.map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : ""))
.filter(Boolean),
].filter(Boolean),
),
).toSorted((a, b) => a.localeCompare(b));
const cronModelSuggestions = Array.from(
new Set(
[
...state.cronModelSuggestions,
...state.cronJobs
.map((job) => {
if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") {
return "";
}
return job.payload.model.trim();
})
.filter(Boolean),
].filter(Boolean),
),
).toSorted((a, b) => a.localeCompare(b));
const selectedDeliveryChannel =
state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim()
? state.cronForm.deliveryChannel.trim()
: "last";
const jobToSuggestions = state.cronJobs
.map((job) => normalizeSuggestionValue(job.delivery?.to))
.filter(Boolean);
const accountToSuggestions = (
selectedDeliveryChannel === "last"
? Object.values(state.channelsSnapshot?.channelAccounts ?? {}).flat()
: (state.channelsSnapshot?.channelAccounts?.[selectedDeliveryChannel] ?? [])
)
.flatMap((account) => [
normalizeSuggestionValue(account.accountId),
normalizeSuggestionValue(account.name),
])
.filter(Boolean);
const rawDeliveryToSuggestions = uniquePreserveOrder([
...jobToSuggestions,
...accountToSuggestions,
]);
const deliveryToSuggestions =
state.cronForm.deliveryMode === "webhook"
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
: rawDeliveryToSuggestions;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
@@ -327,11 +424,21 @@ export function renderApp(state: AppViewState) {
? renderCron({
basePath: state.basePath,
loading: state.cronLoading,
jobsLoadingMore: state.cronJobsLoadingMore,
status: state.cronStatus,
jobs: state.cronJobs,
jobsTotal: state.cronJobsTotal,
jobsHasMore: state.cronJobsHasMore,
jobsQuery: state.cronJobsQuery,
jobsEnabledFilter: state.cronJobsEnabledFilter,
jobsSortBy: state.cronJobsSortBy,
jobsSortDir: state.cronJobsSortDir,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
fieldErrors: state.cronFieldErrors,
canSubmit: !hasCronFormErrors(state.cronFieldErrors),
editingJobId: state.cronEditingJobId,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: (state.channelsSnapshot?.channelOrder ?? []),
@@ -339,14 +446,50 @@ export function renderApp(state: AppViewState) {
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) =>
(state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch })),
runsTotal: state.cronRunsTotal,
runsHasMore: state.cronRunsHasMore,
runsLoadingMore: state.cronRunsLoadingMore,
runsScope: state.cronRunsScope,
runsStatuses: state.cronRunsStatuses,
runsDeliveryStatuses: state.cronRunsDeliveryStatuses,
runsStatusFilter: state.cronRunsStatusFilter,
runsQuery: state.cronRunsQuery,
runsSortDir: state.cronRunsSortDir,
agentSuggestions: cronAgentSuggestions,
modelSuggestions: cronModelSuggestions,
thinkingSuggestions: CRON_THINKING_SUGGESTIONS,
timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS,
deliveryToSuggestions,
onFormChange: (patch) => {
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
state.cronFieldErrors = validateCronForm(state.cronForm);
},
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onEdit: (job) => startCronEdit(state, job),
onClone: (job) => startCronClone(state, job),
onCancelEdit: () => cancelCronEdit(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
onLoadRuns: async (jobId) => {
updateCronRunsFilter(state, { cronRunsScope: "job" });
await loadCronRuns(state, jobId);
},
onLoadMoreJobs: () => loadMoreCronJobs(state),
onJobsFiltersChange: async (patch) => {
updateCronJobsFilter(state, patch);
await reloadCronJobs(state);
},
onLoadMoreRuns: () => loadMoreCronRuns(state),
onRunsFiltersChange: async (patch) => {
updateCronRunsFilter(state, patch);
if (state.cronRunsScope === "all") {
await loadCronRuns(state, null);
return;
}
await loadCronRuns(state, state.cronRunsJobId);
},
})
: nothing
}

View File

@@ -12,7 +12,12 @@ import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts";
import {
loadCronJobs,
loadCronModelSuggestions,
loadCronRuns,
loadCronStatus,
} from "./controllers/cron.ts";
import { loadDebug } from "./controllers/debug.ts";
import { loadDevices } from "./controllers/devices.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
@@ -421,9 +426,18 @@ export async function loadChannelsTab(host: SettingsHost) {
}
export async function loadCron(host: SettingsHost) {
const cronHost = host as unknown as OpenClawApp;
await Promise.all([
loadChannels(host as unknown as OpenClawApp, false),
loadCronStatus(host as unknown as OpenClawApp),
loadCronJobs(host as unknown as OpenClawApp),
loadCronStatus(cronHost),
loadCronJobs(cronHost),
loadCronModelSuggestions(cronHost),
]);
if (cronHost.cronRunsScope === "all") {
await loadCronRuns(cronHost, null);
return;
}
if (cronHost.cronRunsJobId) {
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
}
}

View File

@@ -1,5 +1,6 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type { CronFieldErrors } from "./controllers/cron.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -17,6 +18,13 @@ import type {
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronJobsEnabledFilter,
CronJobsSortBy,
CronDeliveryStatus,
CronRunScope,
CronSortDir,
CronRunsStatusValue,
CronRunsStatusFilter,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
@@ -187,12 +195,35 @@ export type AppViewState = {
usageLogFilterHasTools: boolean;
usageLogFilterQuery: string;
cronLoading: boolean;
cronJobsLoadingMore: boolean;
cronJobs: CronJob[];
cronJobsTotal: number;
cronJobsHasMore: boolean;
cronJobsNextOffset: number | null;
cronJobsLimit: number;
cronJobsQuery: string;
cronJobsEnabledFilter: CronJobsEnabledFilter;
cronJobsSortBy: CronJobsSortBy;
cronJobsSortDir: CronSortDir;
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronFieldErrors: CronFieldErrors;
cronEditingJobId: string | null;
cronRunsJobId: string | null;
cronRunsLoadingMore: boolean;
cronRuns: CronRunLogEntry[];
cronRunsTotal: number;
cronRunsHasMore: boolean;
cronRunsNextOffset: number | null;
cronRunsLimit: number;
cronRunsScope: CronRunScope;
cronRunsStatuses: CronRunsStatusValue[];
cronRunsDeliveryStatuses: CronDeliveryStatus[];
cronRunsStatusFilter: CronRunsStatusFilter;
cronRunsQuery: string;
cronRunsSortDir: CronSortDir;
cronModelSuggestions: string[];
cronBusy: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;

View File

@@ -53,6 +53,7 @@ import {
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { CronFieldErrors } from "./controllers/cron.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -297,12 +298,35 @@ export class OpenClawApp extends LitElement {
usageQueryDebounceTimer: number | null = null;
@state() cronLoading = false;
@state() cronJobsLoadingMore = false;
@state() cronJobs: CronJob[] = [];
@state() cronJobsTotal = 0;
@state() cronJobsHasMore = false;
@state() cronJobsNextOffset: number | null = null;
@state() cronJobsLimit = 50;
@state() cronJobsQuery = "";
@state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all";
@state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs";
@state() cronJobsSortDir: import("./types.js").CronSortDir = "asc";
@state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronFieldErrors: CronFieldErrors = {};
@state() cronEditingJobId: string | null = null;
@state() cronRunsJobId: string | null = null;
@state() cronRunsLoadingMore = false;
@state() cronRuns: CronRunLogEntry[] = [];
@state() cronRunsTotal = 0;
@state() cronRunsHasMore = false;
@state() cronRunsNextOffset: number | null = null;
@state() cronRunsLimit = 50;
@state() cronRunsScope: import("./types.js").CronRunScope = "all";
@state() cronRunsStatuses: import("./types.js").CronRunsStatusValue[] = [];
@state() cronRunsDeliveryStatuses: import("./types.js").CronDeliveryStatus[] = [];
@state() cronRunsStatusFilter: import("./types.js").CronRunsStatusFilter = "all";
@state() cronRunsQuery = "";
@state() cronRunsSortDir: import("./types.js").CronSortDir = "desc";
@state() cronModelSuggestions: string[] = [];
@state() cronBusy = false;
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;

View File

@@ -1,18 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
import { addCronJob, normalizeCronFormState, type CronState } from "./cron.ts";
import {
addCronJob,
cancelCronEdit,
loadCronJobsPage,
loadCronRuns,
loadMoreCronRuns,
normalizeCronFormState,
startCronEdit,
startCronClone,
validateCronForm,
type CronState,
} from "./cron.ts";
function createState(overrides: Partial<CronState> = {}): CronState {
return {
client: null,
connected: true,
cronLoading: false,
cronJobsLoadingMore: false,
cronJobs: [],
cronJobsTotal: 0,
cronJobsHasMore: false,
cronJobsNextOffset: null,
cronJobsLimit: 50,
cronJobsQuery: "",
cronJobsEnabledFilter: "all",
cronJobsSortBy: "nextRunAtMs",
cronJobsSortDir: "asc",
cronStatus: null,
cronError: null,
cronForm: { ...DEFAULT_CRON_FORM },
cronFieldErrors: {},
cronEditingJobId: null,
cronRunsJobId: null,
cronRunsLoadingMore: false,
cronRuns: [],
cronRunsTotal: 0,
cronRunsHasMore: false,
cronRunsNextOffset: null,
cronRunsLimit: 50,
cronRunsScope: "all",
cronRunsStatuses: [],
cronRunsDeliveryStatuses: [],
cronRunsStatusFilter: "all",
cronRunsQuery: "",
cronRunsSortDir: "desc",
cronBusy: false,
...overrides,
};
@@ -127,4 +160,378 @@ describe("cron controller", () => {
expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined();
expect(state.cronForm.deliveryMode).toBe("none");
});
it("submits cron.update when editing an existing job", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.update") {
return { id: "job-1" };
}
if (method === "cron.list") {
return { jobs: [{ id: "job-1" }] };
}
if (method === "cron.status") {
return { enabled: true, jobs: 1, nextWakeAtMs: null };
}
return {};
});
const state = createState({
client: {
request,
} as unknown as CronState["client"],
cronEditingJobId: "job-1",
cronForm: {
...DEFAULT_CRON_FORM,
name: "edited job",
description: "",
clearAgent: true,
deleteAfterRun: false,
scheduleKind: "cron",
cronExpr: "0 8 * * *",
scheduleExact: true,
payloadKind: "systemEvent",
payloadText: "updated",
deliveryMode: "none",
},
});
await addCronJob(state);
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
expect(updateCall).toBeDefined();
expect(updateCall?.[1]).toMatchObject({
id: "job-1",
patch: {
name: "edited job",
description: "",
agentId: null,
deleteAfterRun: false,
schedule: { kind: "cron", expr: "0 8 * * *", staggerMs: 0 },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(state.cronEditingJobId).toBeNull();
});
it("maps a cron job into editable form fields", () => {
const state = createState();
const job = {
id: "job-9",
name: "Weekly report",
description: "desc",
enabled: false,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "every" as const, everyMs: 7_200_000 },
sessionTarget: "isolated" as const,
wakeMode: "next-heartbeat" as const,
payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 },
delivery: { mode: "announce" as const, channel: "telegram", to: "123" },
state: {},
};
startCronEdit(state, job);
expect(state.cronEditingJobId).toBe("job-9");
expect(state.cronRunsJobId).toBe("job-9");
expect(state.cronForm.name).toBe("Weekly report");
expect(state.cronForm.enabled).toBe(false);
expect(state.cronForm.scheduleKind).toBe("every");
expect(state.cronForm.everyAmount).toBe("2");
expect(state.cronForm.everyUnit).toBe("hours");
expect(state.cronForm.payloadKind).toBe("agentTurn");
expect(state.cronForm.payloadText).toBe("ship it");
expect(state.cronForm.timeoutSeconds).toBe("45");
expect(state.cronForm.deliveryMode).toBe("announce");
expect(state.cronForm.deliveryChannel).toBe("telegram");
expect(state.cronForm.deliveryTo).toBe("123");
});
it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.update") {
return { id: "job-2" };
}
if (method === "cron.list") {
return { jobs: [{ id: "job-2" }] };
}
if (method === "cron.status") {
return { enabled: true, jobs: 1, nextWakeAtMs: null };
}
return {};
});
const state = createState({
client: { request } as unknown as CronState["client"],
cronEditingJobId: "job-2",
cronForm: {
...DEFAULT_CRON_FORM,
name: "advanced edit",
scheduleKind: "cron",
cronExpr: "0 9 * * *",
staggerAmount: "30",
staggerUnit: "seconds",
payloadKind: "agentTurn",
payloadText: "run it",
payloadModel: "opus",
payloadThinking: "low",
deliveryMode: "announce",
deliveryBestEffort: true,
},
});
await addCronJob(state);
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
expect(updateCall).toBeDefined();
expect(updateCall?.[1]).toMatchObject({
id: "job-2",
patch: {
schedule: { kind: "cron", expr: "0 9 * * *", staggerMs: 30_000 },
payload: {
kind: "agentTurn",
message: "run it",
model: "opus",
thinking: "low",
},
delivery: { mode: "announce", bestEffort: true },
},
});
});
it("maps cron stagger, model, thinking, and best effort into form", () => {
const state = createState();
const job = {
id: "job-10",
name: "Advanced job",
enabled: true,
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "UTC", staggerMs: 60_000 },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: {
kind: "agentTurn" as const,
message: "hi",
model: "opus",
thinking: "high",
},
delivery: { mode: "announce" as const, bestEffort: true },
state: {},
};
startCronEdit(state, job);
expect(state.cronForm.deleteAfterRun).toBe(true);
expect(state.cronForm.scheduleKind).toBe("cron");
expect(state.cronForm.scheduleExact).toBe(false);
expect(state.cronForm.staggerAmount).toBe("1");
expect(state.cronForm.staggerUnit).toBe("minutes");
expect(state.cronForm.payloadModel).toBe("opus");
expect(state.cronForm.payloadThinking).toBe("high");
expect(state.cronForm.deliveryBestEffort).toBe(true);
});
it("validates key cron form errors", () => {
const errors = validateCronForm({
...DEFAULT_CRON_FORM,
name: "",
scheduleKind: "cron",
cronExpr: "",
payloadKind: "agentTurn",
payloadText: "",
timeoutSeconds: "0",
deliveryMode: "webhook",
deliveryTo: "ftp://bad",
});
expect(errors.name).toBeDefined();
expect(errors.cronExpr).toBeDefined();
expect(errors.payloadText).toBeDefined();
expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds.");
expect(errors.deliveryTo).toBeDefined();
});
it("blocks add/update submit when validation errors exist", async () => {
const request = vi.fn(async () => ({}));
const state = createState({
client: { request } as unknown as CronState["client"],
cronForm: {
...DEFAULT_CRON_FORM,
name: "",
payloadText: "",
},
});
await addCronJob(state);
expect(request).not.toHaveBeenCalled();
expect(state.cronFieldErrors.name).toBeDefined();
expect(state.cronFieldErrors.payloadText).toBeDefined();
});
it("canceling edit resets form to defaults and clears edit mode", () => {
const state = createState();
const job = {
id: "job-cancel",
name: "Editable",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron" as const, expr: "0 6 * * *" },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: { kind: "agentTurn" as const, message: "run" },
delivery: { mode: "announce" as const, to: "123" },
state: {},
};
startCronEdit(state, job);
state.cronForm.name = "changed";
state.cronFieldErrors = { name: "Name is required." };
cancelCronEdit(state);
expect(state.cronEditingJobId).toBeNull();
expect(state.cronForm).toEqual({ ...DEFAULT_CRON_FORM });
expect(state.cronFieldErrors).toEqual(validateCronForm(DEFAULT_CRON_FORM));
});
it("cloning a job switches to create mode and applies copy naming", () => {
const state = createState({
cronJobs: [
{
id: "job-1",
name: "Daily ping",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "ping" },
state: {},
},
],
cronEditingJobId: "job-1",
});
const sourceJob = state.cronJobs[0];
expect(sourceJob).toBeDefined();
if (!sourceJob) {
return;
}
startCronClone(state, sourceJob);
expect(state.cronEditingJobId).toBeNull();
expect(state.cronRunsJobId).toBe("job-1");
expect(state.cronForm.name).toBe("Daily ping copy");
expect(state.cronForm.payloadText).toBe("ping");
});
it("submits cron.add after cloning", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.add") {
return { id: "job-new" };
}
if (method === "cron.list") {
return { jobs: [] };
}
if (method === "cron.status") {
return { enabled: true, jobs: 0, nextWakeAtMs: null };
}
return {};
});
const sourceJob = {
id: "job-1",
name: "Daily ping",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron" as const, expr: "0 9 * * *" },
sessionTarget: "main" as const,
wakeMode: "next-heartbeat" as const,
payload: { kind: "systemEvent" as const, text: "ping" },
state: {},
};
const state = createState({
client: { request } as unknown as CronState["client"],
cronJobs: [sourceJob],
cronEditingJobId: "job-1",
});
startCronClone(state, sourceJob);
await addCronJob(state);
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
expect(addCall).toBeDefined();
expect(updateCall).toBeUndefined();
expect((addCall?.[1] as { name?: string } | undefined)?.name).toBe("Daily ping copy");
});
it("loads paged jobs with query/filter/sort params", async () => {
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "cron.list") {
expect(payload).toMatchObject({
limit: 50,
offset: 0,
query: "daily",
enabled: "enabled",
sortBy: "updatedAtMs",
sortDir: "desc",
});
return {
jobs: [{ id: "job-1", name: "Daily", enabled: true }],
total: 1,
hasMore: false,
nextOffset: null,
};
}
return {};
});
const state = createState({
client: { request } as unknown as CronState["client"],
cronJobsQuery: "daily",
cronJobsEnabledFilter: "enabled",
cronJobsSortBy: "updatedAtMs",
cronJobsSortDir: "desc",
});
await loadCronJobsPage(state);
expect(state.cronJobs).toHaveLength(1);
expect(state.cronJobsTotal).toBe(1);
expect(state.cronJobsHasMore).toBe(false);
});
it("loads and appends paged run history", async () => {
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method !== "cron.runs") {
return {};
}
const offset = (payload as { offset?: number } | undefined)?.offset ?? 0;
if (offset === 0) {
return {
entries: [{ ts: 2, jobId: "job-1", status: "ok", summary: "newest" }],
total: 2,
hasMore: true,
nextOffset: 1,
};
}
return {
entries: [{ ts: 1, jobId: "job-1", status: "ok", summary: "older" }],
total: 2,
hasMore: false,
nextOffset: null,
};
});
const state = createState({
client: { request } as unknown as CronState["client"],
});
await loadCronRuns(state, "job-1");
expect(state.cronRuns).toHaveLength(1);
expect(state.cronRunsHasMore).toBe(true);
await loadMoreCronRuns(state);
expect(state.cronRuns).toHaveLength(2);
expect(state.cronRuns[0]?.summary).toBe("newest");
expect(state.cronRuns[1]?.summary).toBe("older");
});
});

View File

@@ -1,21 +1,78 @@
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
import { toNumber } from "../format.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
import type {
CronJob,
CronDeliveryStatus,
CronJobsEnabledFilter,
CronJobsListResult,
CronJobsSortBy,
CronRunScope,
CronRunLogEntry,
CronRunsResult,
CronRunsStatusFilter,
CronRunsStatusValue,
CronSortDir,
CronStatus,
} from "../types.ts";
import { CRON_CHANNEL_LAST } from "../ui-types.ts";
import type { CronFormState } from "../ui-types.ts";
export type CronFieldKey =
| "name"
| "scheduleAt"
| "everyAmount"
| "cronExpr"
| "staggerAmount"
| "payloadText"
| "payloadModel"
| "payloadThinking"
| "timeoutSeconds"
| "deliveryTo";
export type CronFieldErrors = Partial<Record<CronFieldKey, string>>;
export type CronState = {
client: GatewayBrowserClient | null;
connected: boolean;
cronLoading: boolean;
cronJobsLoadingMore: boolean;
cronJobs: CronJob[];
cronJobsTotal: number;
cronJobsHasMore: boolean;
cronJobsNextOffset: number | null;
cronJobsLimit: number;
cronJobsQuery: string;
cronJobsEnabledFilter: CronJobsEnabledFilter;
cronJobsSortBy: CronJobsSortBy;
cronJobsSortDir: CronSortDir;
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronFieldErrors: CronFieldErrors;
cronEditingJobId: string | null;
cronRunsJobId: string | null;
cronRunsLoadingMore: boolean;
cronRuns: CronRunLogEntry[];
cronRunsTotal: number;
cronRunsHasMore: boolean;
cronRunsNextOffset: number | null;
cronRunsLimit: number;
cronRunsScope: CronRunScope;
cronRunsStatuses: CronRunsStatusValue[];
cronRunsDeliveryStatuses: CronDeliveryStatus[];
cronRunsStatusFilter: CronRunsStatusFilter;
cronRunsQuery: string;
cronRunsSortDir: CronSortDir;
cronBusy: boolean;
};
export type CronModelSuggestionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
cronModelSuggestions: string[];
};
export function supportsAnnounceDelivery(
form: Pick<CronFormState, "sessionTarget" | "payloadKind">,
) {
@@ -35,6 +92,65 @@ export function normalizeCronFormState(form: CronFormState): CronFormState {
};
}
export function validateCronForm(form: CronFormState): CronFieldErrors {
const errors: CronFieldErrors = {};
if (!form.name.trim()) {
errors.name = "Name is required.";
}
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) {
errors.scheduleAt = "Enter a valid date/time.";
}
} else if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) {
errors.everyAmount = "Interval must be greater than 0.";
}
} else {
if (!form.cronExpr.trim()) {
errors.cronExpr = "Cron expression is required.";
}
if (!form.scheduleExact) {
const staggerAmount = form.staggerAmount.trim();
if (staggerAmount) {
const stagger = toNumber(staggerAmount, 0);
if (stagger <= 0) {
errors.staggerAmount = "Stagger must be greater than 0.";
}
}
}
}
if (!form.payloadText.trim()) {
errors.payloadText =
form.payloadKind === "systemEvent"
? "System text is required."
: "Agent message is required.";
}
if (form.payloadKind === "agentTurn") {
const timeoutRaw = form.timeoutSeconds.trim();
if (timeoutRaw) {
const timeout = toNumber(timeoutRaw, 0);
if (timeout <= 0) {
errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds.";
}
}
}
if (form.deliveryMode === "webhook") {
const target = form.deliveryTo.trim();
if (!target) {
errors.deliveryTo = "Webhook URL is required.";
} else if (!/^https?:\/\//i.test(target)) {
errors.deliveryTo = "Webhook URL must start with http:// or https://.";
}
}
return errors;
}
export function hasCronFormErrors(errors: CronFieldErrors): boolean {
return Object.keys(errors).length > 0;
}
export async function loadCronStatus(state: CronState) {
if (!state.client || !state.connected) {
return;
@@ -47,27 +163,267 @@ export async function loadCronStatus(state: CronState) {
}
}
export async function loadCronJobs(state: CronState) {
export async function loadCronModelSuggestions(state: CronModelSuggestionsState) {
if (!state.client || !state.connected) {
return;
}
if (state.cronLoading) {
try {
const res = await state.client.request("models.list", {});
const models = (res as { models?: unknown[] } | null)?.models;
if (!Array.isArray(models)) {
state.cronModelSuggestions = [];
return;
}
const ids = models
.map((entry) => {
if (!entry || typeof entry !== "object") {
return "";
}
const id = (entry as { id?: unknown }).id;
return typeof id === "string" ? id.trim() : "";
})
.filter(Boolean);
state.cronModelSuggestions = Array.from(new Set(ids)).toSorted((a, b) => a.localeCompare(b));
} catch {
state.cronModelSuggestions = [];
}
}
export async function loadCronJobs(state: CronState) {
return await loadCronJobsPage(state, { append: false });
}
function normalizeCronPageMeta(params: {
totalRaw: unknown;
limitRaw: unknown;
offsetRaw: unknown;
nextOffsetRaw: unknown;
hasMoreRaw: unknown;
pageCount: number;
}) {
const total =
typeof params.totalRaw === "number" && Number.isFinite(params.totalRaw)
? Math.max(0, Math.floor(params.totalRaw))
: params.pageCount;
const limit =
typeof params.limitRaw === "number" && Number.isFinite(params.limitRaw)
? Math.max(1, Math.floor(params.limitRaw))
: Math.max(1, params.pageCount);
const offset =
typeof params.offsetRaw === "number" && Number.isFinite(params.offsetRaw)
? Math.max(0, Math.floor(params.offsetRaw))
: 0;
const hasMore =
typeof params.hasMoreRaw === "boolean"
? params.hasMoreRaw
: offset + params.pageCount < Math.max(total, offset + params.pageCount);
const nextOffset =
typeof params.nextOffsetRaw === "number" && Number.isFinite(params.nextOffsetRaw)
? Math.max(0, Math.floor(params.nextOffsetRaw))
: hasMore
? offset + params.pageCount
: null;
return { total, limit, offset, hasMore, nextOffset };
}
export async function loadCronJobsPage(state: CronState, opts?: { append?: boolean }) {
if (!state.client || !state.connected) {
return;
}
state.cronLoading = true;
if (state.cronLoading || state.cronJobsLoadingMore) {
return;
}
const append = opts?.append === true;
if (append) {
if (!state.cronJobsHasMore) {
return;
}
state.cronJobsLoadingMore = true;
} else {
state.cronLoading = true;
}
state.cronError = null;
try {
const res = await state.client.request<{ jobs?: Array<CronJob> }>("cron.list", {
includeDisabled: true,
const offset = append ? Math.max(0, state.cronJobsNextOffset ?? state.cronJobs.length) : 0;
const res = await state.client.request<CronJobsListResult>("cron.list", {
includeDisabled: state.cronJobsEnabledFilter === "all",
limit: state.cronJobsLimit,
offset,
query: state.cronJobsQuery.trim() || undefined,
enabled: state.cronJobsEnabledFilter,
sortBy: state.cronJobsSortBy,
sortDir: state.cronJobsSortDir,
});
state.cronJobs = Array.isArray(res.jobs) ? res.jobs : [];
const jobs = Array.isArray(res.jobs) ? res.jobs : [];
state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs;
const meta = normalizeCronPageMeta({
totalRaw: res.total,
limitRaw: res.limit,
offsetRaw: res.offset,
nextOffsetRaw: res.nextOffset,
hasMoreRaw: res.hasMore,
pageCount: jobs.length,
});
state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length);
state.cronJobsHasMore = meta.hasMore;
state.cronJobsNextOffset = meta.nextOffset;
if (
state.cronEditingJobId &&
!state.cronJobs.some((job) => job.id === state.cronEditingJobId)
) {
clearCronEditState(state);
}
} catch (err) {
state.cronError = String(err);
} finally {
state.cronLoading = false;
if (append) {
state.cronJobsLoadingMore = false;
} else {
state.cronLoading = false;
}
}
}
export async function loadMoreCronJobs(state: CronState) {
await loadCronJobsPage(state, { append: true });
}
export async function reloadCronJobs(state: CronState) {
await loadCronJobsPage(state, { append: false });
}
export function updateCronJobsFilter(
state: CronState,
patch: Partial<
Pick<
CronState,
"cronJobsQuery" | "cronJobsEnabledFilter" | "cronJobsSortBy" | "cronJobsSortDir"
>
>,
) {
if (typeof patch.cronJobsQuery === "string") {
state.cronJobsQuery = patch.cronJobsQuery;
}
if (patch.cronJobsEnabledFilter) {
state.cronJobsEnabledFilter = patch.cronJobsEnabledFilter;
}
if (patch.cronJobsSortBy) {
state.cronJobsSortBy = patch.cronJobsSortBy;
}
if (patch.cronJobsSortDir) {
state.cronJobsSortDir = patch.cronJobsSortDir;
}
}
function clearCronEditState(state: CronState) {
state.cronEditingJobId = null;
}
function resetCronFormToDefaults(state: CronState) {
state.cronForm = { ...DEFAULT_CRON_FORM };
state.cronFieldErrors = validateCronForm(state.cronForm);
}
function formatDateTimeLocal(input: string): string {
const ms = Date.parse(input);
if (!Number.isFinite(ms)) {
return "";
}
const date = new Date(ms);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:${minute}`;
}
function parseEverySchedule(everyMs: number): Pick<CronFormState, "everyAmount" | "everyUnit"> {
if (everyMs % 86_400_000 === 0) {
return { everyAmount: String(Math.max(1, everyMs / 86_400_000)), everyUnit: "days" };
}
if (everyMs % 3_600_000 === 0) {
return { everyAmount: String(Math.max(1, everyMs / 3_600_000)), everyUnit: "hours" };
}
const minutes = Math.max(1, Math.ceil(everyMs / 60_000));
return { everyAmount: String(minutes), everyUnit: "minutes" };
}
function parseStaggerSchedule(
staggerMs?: number,
): Pick<CronFormState, "scheduleExact" | "staggerAmount" | "staggerUnit"> {
if (staggerMs === 0) {
return { scheduleExact: true, staggerAmount: "", staggerUnit: "seconds" };
}
if (typeof staggerMs !== "number" || !Number.isFinite(staggerMs) || staggerMs < 0) {
return { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds" };
}
if (staggerMs % 60_000 === 0) {
return {
scheduleExact: false,
staggerAmount: String(Math.max(1, staggerMs / 60_000)),
staggerUnit: "minutes",
};
}
return {
scheduleExact: false,
staggerAmount: String(Math.max(1, Math.ceil(staggerMs / 1_000))),
staggerUnit: "seconds",
};
}
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
const next: CronFormState = {
...prev,
name: job.name,
description: job.description ?? "",
agentId: job.agentId ?? "",
clearAgent: false,
enabled: job.enabled,
deleteAfterRun: job.deleteAfterRun ?? false,
scheduleKind: job.schedule.kind,
scheduleAt: "",
everyAmount: prev.everyAmount,
everyUnit: prev.everyUnit,
cronExpr: prev.cronExpr,
cronTz: "",
scheduleExact: false,
staggerAmount: "",
staggerUnit: "seconds",
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payloadKind: job.payload.kind,
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
deliveryMode: job.delivery?.mode ?? "none",
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
deliveryTo: job.delivery?.to ?? "",
deliveryBestEffort: job.delivery?.bestEffort ?? false,
timeoutSeconds:
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
? String(job.payload.timeoutSeconds)
: "",
};
if (job.schedule.kind === "at") {
next.scheduleAt = formatDateTimeLocal(job.schedule.at);
} else if (job.schedule.kind === "every") {
const parsed = parseEverySchedule(job.schedule.everyMs);
next.everyAmount = parsed.everyAmount;
next.everyUnit = parsed.everyUnit;
} else {
next.cronExpr = job.schedule.expr;
next.cronTz = job.schedule.tz ?? "";
const staggerFields = parseStaggerSchedule(job.schedule.staggerMs);
next.scheduleExact = staggerFields.scheduleExact;
next.staggerAmount = staggerFields.staggerAmount;
next.staggerUnit = staggerFields.staggerUnit;
}
return normalizeCronFormState(next);
}
export function buildCronSchedule(form: CronFormState) {
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
@@ -89,7 +445,19 @@ export function buildCronSchedule(form: CronFormState) {
if (!expr) {
throw new Error("Cron expression required.");
}
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
if (form.scheduleExact) {
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 };
}
const staggerAmount = form.staggerAmount.trim();
if (!staggerAmount) {
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
}
const staggerValue = toNumber(staggerAmount, 0);
if (staggerValue <= 0) {
throw new Error("Invalid stagger amount.");
}
const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000;
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs };
}
export function buildCronPayload(form: CronFormState) {
@@ -107,8 +475,18 @@ export function buildCronPayload(form: CronFormState) {
const payload: {
kind: "agentTurn";
message: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
} = { kind: "agentTurn", message };
const model = form.payloadModel.trim();
if (model) {
payload.model = model;
}
const thinking = form.payloadThinking.trim();
if (thinking) {
payload.thinking = thinking;
}
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
if (timeoutSeconds > 0) {
payload.timeoutSeconds = timeoutSeconds;
@@ -127,6 +505,11 @@ export async function addCronJob(state: CronState) {
if (form !== state.cronForm) {
state.cronForm = form;
}
const fieldErrors = validateCronForm(form);
state.cronFieldErrors = fieldErrors;
if (hasCronFormErrors(fieldErrors)) {
return;
}
const schedule = buildCronSchedule(form);
const payload = buildCronPayload(form);
@@ -140,14 +523,16 @@ export async function addCronJob(state: CronState) {
? form.deliveryChannel.trim() || "last"
: undefined,
to: form.deliveryTo.trim() || undefined,
bestEffort: form.deliveryBestEffort,
}
: undefined;
const agentId = form.agentId.trim();
const agentId = form.clearAgent ? null : form.agentId.trim();
const job = {
name: form.name.trim(),
description: form.description.trim() || undefined,
agentId: agentId || undefined,
description: form.description.trim(),
agentId: agentId === null ? null : agentId || undefined,
enabled: form.enabled,
deleteAfterRun: form.deleteAfterRun,
schedule,
sessionTarget: form.sessionTarget,
wakeMode: form.wakeMode,
@@ -157,13 +542,16 @@ export async function addCronJob(state: CronState) {
if (!job.name) {
throw new Error("Name required.");
}
await state.client.request("cron.add", job);
state.cronForm = {
...state.cronForm,
name: "",
description: "",
payloadText: "",
};
if (state.cronEditingJobId) {
await state.client.request("cron.update", {
id: state.cronEditingJobId,
patch: job,
});
clearCronEditState(state);
} else {
await state.client.request("cron.add", job);
resetCronFormToDefaults(state);
}
await loadCronJobs(state);
await loadCronStatus(state);
} catch (err) {
@@ -198,7 +586,11 @@ export async function runCronJob(state: CronState, job: CronJob) {
state.cronError = null;
try {
await state.client.request("cron.run", { id: job.id, mode: "force" });
await loadCronRuns(state, job.id);
if (state.cronRunsScope === "all") {
await loadCronRuns(state, null);
} else {
await loadCronRuns(state, job.id);
}
} catch (err) {
state.cronError = String(err);
} finally {
@@ -214,9 +606,15 @@ export async function removeCronJob(state: CronState, job: CronJob) {
state.cronError = null;
try {
await state.client.request("cron.remove", { id: job.id });
if (state.cronEditingJobId === job.id) {
clearCronEditState(state);
}
if (state.cronRunsJobId === job.id) {
state.cronRunsJobId = null;
state.cronRuns = [];
state.cronRunsTotal = 0;
state.cronRunsHasMore = false;
state.cronRunsNextOffset = null;
}
await loadCronJobs(state);
await loadCronStatus(state);
@@ -227,18 +625,152 @@ export async function removeCronJob(state: CronState, job: CronJob) {
}
}
export async function loadCronRuns(state: CronState, jobId: string) {
export async function loadCronRuns(
state: CronState,
jobId: string | null,
opts?: { append?: boolean },
) {
if (!state.client || !state.connected) {
return;
}
const scope = state.cronRunsScope;
const activeJobId = jobId ?? state.cronRunsJobId;
if (scope === "job" && !activeJobId) {
state.cronRuns = [];
state.cronRunsTotal = 0;
state.cronRunsHasMore = false;
state.cronRunsNextOffset = null;
return;
}
const append = opts?.append === true;
if (append && !state.cronRunsHasMore) {
return;
}
try {
const res = await state.client.request<{ entries?: Array<CronRunLogEntry> }>("cron.runs", {
id: jobId,
limit: 50,
if (append) {
state.cronRunsLoadingMore = true;
}
const offset = append ? Math.max(0, state.cronRunsNextOffset ?? state.cronRuns.length) : 0;
const res = await state.client.request<CronRunsResult>("cron.runs", {
scope,
id: scope === "job" ? (activeJobId ?? undefined) : undefined,
limit: state.cronRunsLimit,
offset,
statuses: state.cronRunsStatuses.length > 0 ? state.cronRunsStatuses : undefined,
status: state.cronRunsStatusFilter,
deliveryStatuses:
state.cronRunsDeliveryStatuses.length > 0 ? state.cronRunsDeliveryStatuses : undefined,
query: state.cronRunsQuery.trim() || undefined,
sortDir: state.cronRunsSortDir,
});
state.cronRunsJobId = jobId;
state.cronRuns = Array.isArray(res.entries) ? res.entries : [];
const entries = Array.isArray(res.entries) ? res.entries : [];
state.cronRuns =
append && (scope === "all" || state.cronRunsJobId === activeJobId)
? [...state.cronRuns, ...entries]
: entries;
if (scope === "job") {
state.cronRunsJobId = activeJobId ?? null;
}
const meta = normalizeCronPageMeta({
totalRaw: res.total,
limitRaw: res.limit,
offsetRaw: res.offset,
nextOffsetRaw: res.nextOffset,
hasMoreRaw: res.hasMore,
pageCount: entries.length,
});
state.cronRunsTotal = Math.max(meta.total, state.cronRuns.length);
state.cronRunsHasMore = meta.hasMore;
state.cronRunsNextOffset = meta.nextOffset;
} catch (err) {
state.cronError = String(err);
} finally {
if (append) {
state.cronRunsLoadingMore = false;
}
}
}
export async function loadMoreCronRuns(state: CronState) {
if (state.cronRunsScope === "job" && !state.cronRunsJobId) {
return;
}
await loadCronRuns(state, state.cronRunsJobId, { append: true });
}
export function updateCronRunsFilter(
state: CronState,
patch: Partial<
Pick<
CronState,
| "cronRunsScope"
| "cronRunsStatuses"
| "cronRunsDeliveryStatuses"
| "cronRunsStatusFilter"
| "cronRunsQuery"
| "cronRunsSortDir"
>
>,
) {
if (patch.cronRunsScope) {
state.cronRunsScope = patch.cronRunsScope;
}
if (Array.isArray(patch.cronRunsStatuses)) {
state.cronRunsStatuses = patch.cronRunsStatuses;
state.cronRunsStatusFilter =
patch.cronRunsStatuses.length === 1 ? patch.cronRunsStatuses[0] : "all";
}
if (Array.isArray(patch.cronRunsDeliveryStatuses)) {
state.cronRunsDeliveryStatuses = patch.cronRunsDeliveryStatuses;
}
if (patch.cronRunsStatusFilter) {
state.cronRunsStatusFilter = patch.cronRunsStatusFilter;
state.cronRunsStatuses =
patch.cronRunsStatusFilter === "all" ? [] : [patch.cronRunsStatusFilter];
}
if (typeof patch.cronRunsQuery === "string") {
state.cronRunsQuery = patch.cronRunsQuery;
}
if (patch.cronRunsSortDir) {
state.cronRunsSortDir = patch.cronRunsSortDir;
}
}
export function startCronEdit(state: CronState, job: CronJob) {
state.cronEditingJobId = job.id;
state.cronRunsJobId = job.id;
state.cronForm = jobToForm(job, state.cronForm);
state.cronFieldErrors = validateCronForm(state.cronForm);
}
function buildCloneName(name: string, existingNames: Set<string>) {
const base = name.trim() || "Job";
const first = `${base} copy`;
if (!existingNames.has(first.toLowerCase())) {
return first;
}
let index = 2;
while (index < 1000) {
const next = `${base} copy ${index}`;
if (!existingNames.has(next.toLowerCase())) {
return next;
}
index += 1;
}
return `${base} copy ${Date.now()}`;
}
export function startCronClone(state: CronState, job: CronJob) {
clearCronEditState(state);
state.cronRunsJobId = job.id;
const existingNames = new Set(state.cronJobs.map((entry) => entry.name.trim().toLowerCase()));
const cloned = jobToForm(job, state.cronForm);
cloned.name = buildCloneName(job.name, existingNames);
state.cronForm = cloned;
state.cronFieldErrors = validateCronForm(state.cronForm);
}
export function cancelCronEdit(state: CronState) {
clearCronEditState(state);
resetCronFormToDefaults(state);
}

View File

@@ -18,7 +18,8 @@ export function formatNextRun(ms?: number | null) {
if (!ms) {
return "n/a";
}
return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
const weekday = new Date(ms).toLocaleDateString(undefined, { weekday: "short" });
return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
}
export function formatSessionTokens(row: GatewaySessionRow) {

View File

@@ -440,7 +440,7 @@ export type {
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
@@ -450,6 +450,7 @@ export type CronPayload =
| {
kind: "agentTurn";
message: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
};
@@ -493,17 +494,58 @@ export type CronStatus = {
nextWakeAtMs?: number | null;
};
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
export type CronSortDir = "asc" | "desc";
export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped";
export type CronRunsStatusValue = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
export type CronRunScope = "job" | "all";
export type CronRunLogEntry = {
ts: number;
jobId: string;
status: "ok" | "error" | "skipped";
jobName?: string;
status?: CronRunsStatusValue;
durationMs?: number;
error?: string;
summary?: string;
deliveryStatus?: CronDeliveryStatus;
deliveryError?: string;
delivered?: boolean;
runAtMs?: number;
nextRunAtMs?: number;
model?: string;
provider?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
};
sessionId?: string;
sessionKey?: string;
};
export type CronJobsListResult = {
jobs?: CronJob[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type CronRunsResult = {
entries?: CronRunLogEntry[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type SkillsStatusConfigCheck = {
path: string;
satisfied: boolean;

View File

@@ -18,19 +18,27 @@ export type CronFormState = {
name: string;
description: string;
agentId: string;
clearAgent: boolean;
enabled: boolean;
deleteAfterRun: boolean;
scheduleKind: "at" | "every" | "cron";
scheduleAt: string;
everyAmount: string;
everyUnit: "minutes" | "hours" | "days";
cronExpr: string;
cronTz: string;
scheduleExact: boolean;
staggerAmount: string;
staggerUnit: "seconds" | "minutes";
sessionTarget: "main" | "isolated";
wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn";
payloadText: string;
payloadModel: string;
payloadThinking: string;
deliveryMode: "none" | "announce" | "webhook";
deliveryChannel: string;
deliveryTo: string;
deliveryBestEffort: boolean;
timeoutSeconds: string;
};

View File

@@ -22,32 +22,93 @@ function createProps(overrides: Partial<CronProps> = {}): CronProps {
return {
basePath: "",
loading: false,
jobsLoadingMore: false,
status: null,
jobs: [],
jobsTotal: 0,
jobsHasMore: false,
jobsQuery: "",
jobsEnabledFilter: "all",
jobsSortBy: "nextRunAtMs",
jobsSortDir: "asc",
error: null,
busy: false,
form: { ...DEFAULT_CRON_FORM },
fieldErrors: {},
canSubmit: true,
editingJobId: null,
channels: [],
channelLabels: {},
runsJobId: null,
runs: [],
runsTotal: 0,
runsHasMore: false,
runsLoadingMore: false,
runsScope: "all",
runsStatuses: [],
runsDeliveryStatuses: [],
runsStatusFilter: "all",
runsQuery: "",
runsSortDir: "desc",
agentSuggestions: [],
modelSuggestions: [],
thinkingSuggestions: [],
timezoneSuggestions: [],
deliveryToSuggestions: [],
onFormChange: () => undefined,
onRefresh: () => undefined,
onAdd: () => undefined,
onEdit: () => undefined,
onClone: () => undefined,
onCancelEdit: () => undefined,
onToggle: () => undefined,
onRun: () => undefined,
onRemove: () => undefined,
onLoadRuns: () => undefined,
onLoadMoreJobs: () => undefined,
onJobsFiltersChange: () => undefined,
onLoadMoreRuns: () => undefined,
onRunsFiltersChange: () => undefined,
...overrides,
};
}
describe("cron view", () => {
it("prompts to select a job before showing run history", () => {
it("shows all-job history mode by default", () => {
const container = document.createElement("div");
render(renderCron(createProps()), container);
expect(container.textContent).toContain("Select a job to inspect run history.");
expect(container.textContent).toContain("Latest runs across all jobs.");
expect(container.textContent).toContain("Status");
expect(container.textContent).toContain("All statuses");
expect(container.textContent).toContain("Delivery");
expect(container.textContent).toContain("All delivery");
expect(container.textContent).not.toContain("multi-select");
});
it("toggles run status filter via dropdown checkboxes", () => {
const container = document.createElement("div");
const onRunsFiltersChange = vi.fn();
render(
renderCron(
createProps({
onRunsFiltersChange,
}),
),
container,
);
const statusOk = container.querySelector(
'.cron-filter-dropdown[data-filter="status"] input[value="ok"]',
);
expect(statusOk).not.toBeNull();
if (!(statusOk instanceof HTMLInputElement)) {
return;
}
statusOk.checked = true;
statusOk.dispatchEvent(new Event("change", { bubbles: true }));
expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] });
});
it("loads run history when clicking a job row", () => {
@@ -80,6 +141,7 @@ describe("cron view", () => {
createProps({
jobs: [job],
runsJobId: "job-1",
runsScope: "job",
onLoadRuns,
}),
),
@@ -135,6 +197,7 @@ describe("cron view", () => {
createProps({
jobs: [job],
runsJobId: "job-1",
runsScope: "job",
runs: [
{ ts: 1, jobId: "job-1", status: "ok", summary: "older run" },
{ ts: 2, jobId: "job-1", status: "ok", summary: "newer run" },
@@ -159,6 +222,30 @@ describe("cron view", () => {
expect(summaries[1]).toBe("older run");
});
it("labels past nextRunAtMs as due instead of next", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
runsScope: "all",
runs: [
{
ts: Date.now(),
jobId: "job-1",
status: "ok",
summary: "done",
nextRunAtMs: Date.now() - 13 * 60_000,
},
],
}),
),
container,
);
expect(container.textContent).toContain("Due");
expect(container.textContent).not.toContain("Next 13");
});
it("shows webhook delivery option in the form", () => {
const container = document.createElement("div");
render(
@@ -198,7 +285,7 @@ describe("cron view", () => {
expect(options).not.toContain("Announce summary (default)");
expect(options).toContain("Webhook POST");
expect(options).toContain("None (internal)");
expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull();
expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull();
});
it("shows webhook delivery details for jobs", () => {
@@ -222,4 +309,346 @@ describe("cron view", () => {
expect(container.textContent).toContain("webhook");
expect(container.textContent).toContain("https://example.invalid/cron");
});
it("wires the Edit action and shows save/cancel controls when editing", () => {
const container = document.createElement("div");
const onEdit = vi.fn();
const onLoadRuns = vi.fn();
const onCancelEdit = vi.fn();
const job = createJob("job-3");
render(
renderCron(
createProps({
jobs: [job],
editingJobId: "job-3",
onEdit,
onLoadRuns,
onCancelEdit,
}),
),
container,
);
const editButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Edit",
);
expect(editButton).not.toBeUndefined();
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onEdit).toHaveBeenCalledWith(job);
expect(onLoadRuns).toHaveBeenCalledWith("job-3");
expect(container.textContent).toContain("Edit Job");
expect(container.textContent).toContain("Save changes");
const cancelButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Cancel",
);
expect(cancelButton).not.toBeUndefined();
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onCancelEdit).toHaveBeenCalledTimes(1);
});
it("renders advanced controls for cron + agent payload + delivery", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
scheduleKind: "cron",
payloadKind: "agentTurn",
deliveryMode: "announce",
},
}),
),
container,
);
expect(container.textContent).toContain("Advanced");
expect(container.textContent).toContain("Exact timing (no stagger)");
expect(container.textContent).toContain("Stagger window");
expect(container.textContent).toContain("Model");
expect(container.textContent).toContain("Thinking");
expect(container.textContent).toContain("Best effort delivery");
});
it("groups stagger window and unit inside the same stagger row", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
scheduleKind: "cron",
payloadKind: "agentTurn",
},
}),
),
container,
);
const staggerGroup = container.querySelector(".cron-stagger-group");
expect(staggerGroup).not.toBeNull();
expect(staggerGroup?.textContent).toContain("Stagger window");
expect(staggerGroup?.textContent).toContain("Stagger unit");
});
it("explains timeout blank behavior and shows cron jitter hint", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
scheduleKind: "cron",
payloadKind: "agentTurn",
},
}),
),
container,
);
expect(container.textContent).toContain(
"Optional. Leave blank to use the gateway default timeout behavior for this run.",
);
expect(container.textContent).toContain("Need jitter? Use Advanced");
});
it("disables Agent ID when clear-agent is enabled", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
clearAgent: true,
},
}),
),
container,
);
const agentInput = container.querySelector('input[placeholder="main or ops"]');
expect(agentInput).not.toBeNull();
expect(agentInput instanceof HTMLInputElement).toBe(true);
expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true);
});
it("renders sectioned cron form layout", () => {
const container = document.createElement("div");
render(renderCron(createProps()), container);
expect(container.textContent).toContain("Enabled");
expect(container.textContent).toContain("Jobs");
expect(container.textContent).toContain("Next wake");
expect(container.textContent).toContain("Basics");
expect(container.textContent).toContain("Schedule");
expect(container.textContent).toContain("Execution");
expect(container.textContent).toContain("Delivery");
expect(container.textContent).toContain("Advanced");
});
it("renders checkbox fields with input first for alignment", () => {
const container = document.createElement("div");
render(renderCron(createProps()), container);
const checkboxLabel = container.querySelector(".cron-checkbox");
expect(checkboxLabel).not.toBeNull();
const firstElement = checkboxLabel?.firstElementChild;
expect(firstElement?.tagName.toLowerCase()).toBe("input");
});
it("hides cron-only advanced controls for non-cron schedules", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
scheduleKind: "every",
payloadKind: "systemEvent",
deliveryMode: "none",
},
}),
),
container,
);
expect(container.textContent).not.toContain("Exact timing (no stagger)");
expect(container.textContent).not.toContain("Stagger window");
expect(container.textContent).not.toContain("Model");
expect(container.textContent).not.toContain("Best effort delivery");
});
it("renders inline validation errors and disables submit when invalid", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
name: "",
scheduleKind: "cron",
cronExpr: "",
payloadText: "",
},
fieldErrors: {
name: "Name is required.",
cronExpr: "Cron expression is required.",
payloadText: "Agent message is required.",
},
canSubmit: false,
}),
),
container,
);
expect(container.textContent).toContain("Name is required.");
expect(container.textContent).toContain("Cron expression is required.");
expect(container.textContent).toContain("Agent message is required.");
expect(container.textContent).toContain("Can't add job yet");
expect(container.textContent).toContain("Fix 3 fields to continue.");
const saveButton = Array.from(container.querySelectorAll("button")).find((btn) =>
["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""),
);
expect(saveButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
});
it("shows required legend and aria bindings for invalid required fields", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: {
...DEFAULT_CRON_FORM,
scheduleKind: "every",
name: "",
everyAmount: "",
payloadText: "",
},
fieldErrors: {
name: "Name is required.",
everyAmount: "Interval must be greater than 0.",
payloadText: "Agent message is required.",
},
canSubmit: false,
}),
),
container,
);
expect(container.textContent).toContain("* Required");
const nameInput = container.querySelector("#cron-name");
expect(nameInput?.getAttribute("aria-invalid")).toBe("true");
expect(nameInput?.getAttribute("aria-describedby")).toBe("cron-error-name");
expect(container.querySelector("#cron-error-name")?.textContent).toContain("Name is required.");
const everyInput = container.querySelector("#cron-every-amount");
expect(everyInput?.getAttribute("aria-invalid")).toBe("true");
expect(everyInput?.getAttribute("aria-describedby")).toBe("cron-error-everyAmount");
expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain(
"Interval must be greater than 0.",
);
});
it("wires the Clone action from job rows", () => {
const container = document.createElement("div");
const onClone = vi.fn();
const onLoadRuns = vi.fn();
const job = createJob("job-clone");
render(
renderCron(
createProps({
jobs: [job],
onClone,
onLoadRuns,
}),
),
container,
);
const cloneButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Clone",
);
expect(cloneButton).not.toBeUndefined();
cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onClone).toHaveBeenCalledWith(job);
expect(onLoadRuns).toHaveBeenCalledWith("job-clone");
});
it("selects row when clicking Enable/Disable, Run, and Remove actions", () => {
const container = document.createElement("div");
const onToggle = vi.fn();
const onRun = vi.fn();
const onRemove = vi.fn();
const onLoadRuns = vi.fn();
const job = createJob("job-actions");
render(
renderCron(
createProps({
jobs: [job],
onToggle,
onRun,
onRemove,
onLoadRuns,
}),
),
container,
);
const enableButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Disable",
);
expect(enableButton).not.toBeUndefined();
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const runButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Run",
);
expect(runButton).not.toBeUndefined();
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const removeButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Remove",
);
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onToggle).toHaveBeenCalledWith(job, false);
expect(onRun).toHaveBeenCalledWith(job);
expect(onRemove).toHaveBeenCalledWith(job);
expect(onLoadRuns).toHaveBeenCalledTimes(3);
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions");
expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-actions");
expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions");
});
it("renders suggestion datalists for agent/model/thinking/timezone", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", payloadKind: "agentTurn" },
agentSuggestions: ["main"],
modelSuggestions: ["openai/gpt-5.2"],
thinkingSuggestions: ["low"],
timezoneSuggestions: ["UTC"],
deliveryToSuggestions: ["+15551234567"],
}),
),
container,
);
expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull();
expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull();
expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull();
expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull();
expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull();
expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull();
expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull();
expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull();
expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull();
expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull();
});
});

File diff suppressed because it is too large Load Diff