mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
17
test/ui.presenter-next-run.test.ts
Normal file
17
test/ui.presenter-next-run.test.ts
Normal 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(")");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user