mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(dashboard): expose cron job execution fields
This commit is contained in:
parent
50f6855217
commit
c655cdf2c1
9 changed files with 1589 additions and 366 deletions
|
|
@ -471,7 +471,8 @@ export const api = {
|
|||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
|
||||
getModelOptions: (profile?: string) =>
|
||||
fetchJSON<ModelOptionsResponse>(`/api/model/options${profileQuery(profile)}`),
|
||||
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
|
||||
getMoaModels: () => fetchJSON<MoaConfigResponse>("/api/model/moa"),
|
||||
saveMoaModels: (body: MoaConfigResponse) =>
|
||||
|
|
@ -529,7 +530,7 @@ export const api = {
|
|||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||
getCronDeliveryTargets: () =>
|
||||
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
|
||||
createCronJob: (job: CronJobMutation, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -539,7 +540,7 @@ export const api = {
|
|||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
updateCronJob: (
|
||||
id: string,
|
||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
|
||||
updates: CronJobMutation,
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(
|
||||
|
|
@ -1895,6 +1896,27 @@ export interface ModelsAnalyticsResponse {
|
|||
period_days: number;
|
||||
}
|
||||
|
||||
export interface CronJobRepeat {
|
||||
times: number | null;
|
||||
completed?: number;
|
||||
}
|
||||
|
||||
export interface CronJobMutation {
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string;
|
||||
deliver?: string;
|
||||
skills?: string[];
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
base_url?: string | null;
|
||||
script?: string | null;
|
||||
no_agent?: boolean;
|
||||
context_from?: string[] | null;
|
||||
enabled_toolsets?: string[] | null;
|
||||
workdir?: string | null;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
profile?: string | null;
|
||||
|
|
@ -1905,14 +1927,24 @@ export interface CronJob {
|
|||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
skills?: string[] | null;
|
||||
schedule?: { kind?: string; expr?: string; display?: string };
|
||||
schedule?: { kind?: string; expr?: string; run_at?: string; display?: string };
|
||||
schedule_display?: string | null;
|
||||
repeat?: CronJobRepeat | null;
|
||||
enabled: boolean;
|
||||
state?: string | null;
|
||||
deliver?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
base_url?: string | null;
|
||||
no_agent?: boolean | null;
|
||||
context_from?: string[] | string | null;
|
||||
enabled_toolsets?: string[] | null;
|
||||
workdir?: string | null;
|
||||
last_run_at?: string | null;
|
||||
next_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
last_error?: string | null;
|
||||
last_delivery_error?: string | null;
|
||||
}
|
||||
|
||||
export interface CronDeliveryTarget {
|
||||
|
|
@ -2049,6 +2081,7 @@ export interface ModelOptionProvider {
|
|||
is_user_defined?: boolean;
|
||||
source?: string;
|
||||
warning?: string;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
|
|
|
|||
123
web/src/lib/cron-job.test.ts
Normal file
123
web/src/lib/cron-job.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCronJobPayload,
|
||||
cronJobHasExecutionContent,
|
||||
cronJobFormFromJob,
|
||||
splitCronList,
|
||||
type CronJobFormState,
|
||||
} from "./cron-job";
|
||||
import type { CronJob } from "./api";
|
||||
|
||||
function form(overrides: Partial<CronJobFormState> = {}): CronJobFormState {
|
||||
return {
|
||||
name: "",
|
||||
prompt: "prompt",
|
||||
schedule: "every 1h",
|
||||
deliver: "local",
|
||||
skills: [],
|
||||
provider: "",
|
||||
model: "",
|
||||
base_url: "",
|
||||
script: "",
|
||||
no_agent: false,
|
||||
context_from: "",
|
||||
enabled_toolsets: [],
|
||||
workdir: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("splitCronList", () => {
|
||||
it("normalizes comma and newline separated cron list fields", () => {
|
||||
expect(splitCronList(" web, terminal\nfile ,, ")).toEqual([
|
||||
"web",
|
||||
"terminal",
|
||||
"file",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCronJobPayload", () => {
|
||||
it("normalizes list fields and base URLs", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({
|
||||
base_url: "https://example.invalid/v1/",
|
||||
enabled_toolsets: ["web", ""],
|
||||
context_from: "upstream-a\nupstream-b",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
base_url: "https://example.invalid/v1",
|
||||
context_from: ["upstream-a", "upstream-b"],
|
||||
enabled_toolsets: ["web"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps clear operations explicit for update payloads", () => {
|
||||
const payload = buildCronJobPayload(form({ schedule: "every 2h" }));
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
schedule: "every 2h",
|
||||
provider: null,
|
||||
model: null,
|
||||
base_url: null,
|
||||
script: null,
|
||||
no_agent: false,
|
||||
context_from: null,
|
||||
enabled_toolsets: null,
|
||||
workdir: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronJobHasExecutionContent", () => {
|
||||
it("treats a script as execution content for agent-backed cron jobs", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({ prompt: "", skills: [], script: "collect-status.py" }),
|
||||
);
|
||||
|
||||
expect(cronJobHasExecutionContent(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects payloads with no prompt, skills, or script", () => {
|
||||
const payload = buildCronJobPayload(form({ prompt: "", skills: [], script: "" }));
|
||||
|
||||
expect(cronJobHasExecutionContent(payload)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronJobFormFromJob", () => {
|
||||
it("preserves schedule fallback and editable list fields", () => {
|
||||
const job: CronJob = {
|
||||
id: "abc",
|
||||
enabled: true,
|
||||
schedule_display: "every 1h",
|
||||
context_from: ["upstream-a", "upstream-b"],
|
||||
enabled_toolsets: ["web"],
|
||||
};
|
||||
|
||||
expect(cronJobFormFromJob(job)).toMatchObject({
|
||||
schedule: "every 1h",
|
||||
context_from: "upstream-a\nupstream-b",
|
||||
enabled_toolsets: ["web"],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers one-shot run_at over the human display string", () => {
|
||||
const job: CronJob = {
|
||||
id: "once-job",
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "once",
|
||||
run_at: "2026-02-03T14:00:00+08:00",
|
||||
},
|
||||
schedule_display: "once at 2026-02-03 14:00",
|
||||
};
|
||||
|
||||
expect(cronJobFormFromJob(job)).toMatchObject({
|
||||
schedule: "2026-02-03T14:00:00+08:00",
|
||||
});
|
||||
});
|
||||
});
|
||||
99
web/src/lib/cron-job.ts
Normal file
99
web/src/lib/cron-job.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { CronJob, CronJobMutation } from "./api";
|
||||
|
||||
export interface CronJobFormState {
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
deliver: string;
|
||||
skills: string[];
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
script: string;
|
||||
no_agent: boolean;
|
||||
context_from: string;
|
||||
enabled_toolsets: string[];
|
||||
workdir: string;
|
||||
}
|
||||
|
||||
export function splitCronList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value !== "string") return [];
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function optionalText(value: string): string | null {
|
||||
const text = value.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function optionalBaseUrl(value: string): string | null {
|
||||
const text = optionalText(value);
|
||||
return text ? text.replace(/\/+$/, "") : null;
|
||||
}
|
||||
|
||||
function listToText(value: unknown, separator: string): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean).join(separator);
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function buildCronJobPayload(form: CronJobFormState): CronJobMutation {
|
||||
const contextFrom = splitCronList(form.context_from);
|
||||
const enabledToolsets = form.enabled_toolsets.filter(Boolean);
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
prompt: form.prompt.trim(),
|
||||
schedule: form.schedule.trim(),
|
||||
deliver: form.deliver.trim() || "local",
|
||||
skills: form.skills.filter(Boolean),
|
||||
provider: optionalText(form.provider),
|
||||
model: optionalText(form.model),
|
||||
base_url: optionalBaseUrl(form.base_url),
|
||||
script: optionalText(form.script),
|
||||
no_agent: Boolean(form.no_agent),
|
||||
context_from: contextFrom.length > 0 ? contextFrom : null,
|
||||
enabled_toolsets: enabledToolsets.length > 0 ? enabledToolsets : null,
|
||||
workdir: optionalText(form.workdir),
|
||||
};
|
||||
}
|
||||
|
||||
export function cronJobHasExecutionContent(
|
||||
job: Pick<CronJobMutation, "prompt" | "skills" | "script">,
|
||||
): boolean {
|
||||
const prompt = typeof job.prompt === "string" ? job.prompt.trim() : "";
|
||||
const script = typeof job.script === "string" ? job.script.trim() : "";
|
||||
const skills = Array.isArray(job.skills)
|
||||
? job.skills.map((skill) => String(skill).trim()).filter(Boolean)
|
||||
: [];
|
||||
return Boolean(prompt || script || skills.length > 0);
|
||||
}
|
||||
|
||||
export function cronJobFormFromJob(job: CronJob): CronJobFormState {
|
||||
return {
|
||||
name: typeof job.name === "string" ? job.name : "",
|
||||
prompt: typeof job.prompt === "string" ? job.prompt : "",
|
||||
schedule:
|
||||
(typeof job.schedule?.expr === "string" && job.schedule.expr) ||
|
||||
(typeof job.schedule?.run_at === "string" && job.schedule.run_at) ||
|
||||
(typeof job.schedule_display === "string" ? job.schedule_display : ""),
|
||||
deliver: typeof job.deliver === "string" && job.deliver ? job.deliver : "local",
|
||||
skills: Array.isArray(job.skills) ? job.skills.filter(Boolean) : [],
|
||||
provider: typeof job.provider === "string" ? job.provider : "",
|
||||
model: typeof job.model === "string" ? job.model : "",
|
||||
base_url: typeof job.base_url === "string" ? job.base_url : "",
|
||||
script: typeof job.script === "string" ? job.script : "",
|
||||
no_agent: Boolean(job.no_agent),
|
||||
context_from: listToText(job.context_from, "\n"),
|
||||
enabled_toolsets: Array.isArray(job.enabled_toolsets)
|
||||
? job.enabled_toolsets.filter(Boolean)
|
||||
: splitCronList(job.enabled_toolsets),
|
||||
workdir: typeof job.workdir === "string" ? job.workdir : "",
|
||||
};
|
||||
}
|
||||
123
web/src/lib/schedule.test.ts
Normal file
123
web/src/lib/schedule.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildScheduleString,
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
parseScheduleString,
|
||||
} from "./schedule";
|
||||
|
||||
describe("parseScheduleString", () => {
|
||||
it("parses recurring interval strings", () => {
|
||||
expect(parseScheduleString("every 30m")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 30,
|
||||
intervalUnit: "minutes",
|
||||
});
|
||||
expect(parseScheduleString("every 2h")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 2,
|
||||
intervalUnit: "hours",
|
||||
});
|
||||
expect(parseScheduleString("every 1d")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 1,
|
||||
intervalUnit: "days",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses ISO timestamps into once mode", () => {
|
||||
expect(parseScheduleString("2026-02-03T14:00:00")).toMatchObject({
|
||||
mode: "once",
|
||||
onceAt: "2026-02-03T14:00",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00")).toMatchObject({
|
||||
mode: "once",
|
||||
onceAt: "2026-02-03T14:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses daily cron expressions", () => {
|
||||
expect(parseScheduleString("0 9 * * *")).toMatchObject({
|
||||
mode: "daily",
|
||||
timeOfDay: "09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses weekly cron expressions", () => {
|
||||
expect(parseScheduleString("30 14 * * 1,3,5")).toMatchObject({
|
||||
mode: "weekly",
|
||||
timeOfDay: "14:30",
|
||||
weekdays: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes cron Sunday 7 into the builder's Sunday 0", () => {
|
||||
expect(parseScheduleString("30 14 * * 1,7")).toMatchObject({
|
||||
mode: "weekly",
|
||||
timeOfDay: "14:30",
|
||||
weekdays: [1, 0],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses monthly cron expressions", () => {
|
||||
expect(parseScheduleString("0 9 15 * *")).toMatchObject({
|
||||
mode: "monthly",
|
||||
timeOfDay: "09:00",
|
||||
dayOfMonth: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to custom for unsupported schedule strings", () => {
|
||||
expect(parseScheduleString("0 9 * * 1-5")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 * * 1-5",
|
||||
});
|
||||
expect(parseScheduleString("@daily")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "@daily",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00:00Z")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "2026-02-03T14:00:00Z",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00:00+08:00")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "2026-02-03T14:00:00+08:00",
|
||||
});
|
||||
expect(parseScheduleString("0 9 * * 1,8")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 * * 1,8",
|
||||
});
|
||||
expect(parseScheduleString("0 9 1,15 * *")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 1,15 * *",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the default state for empty input", () => {
|
||||
expect(parseScheduleString("")).toEqual(DEFAULT_SCHEDULE_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleString round-trip", () => {
|
||||
it("rebuilds the schedule string from parsed state", () => {
|
||||
const cases: [string, string][] = [
|
||||
["every 30m", "every 30m"],
|
||||
["every 2h", "every 2h"],
|
||||
["every 1d", "every 1d"],
|
||||
["0 9 * * *", "0 9 * * *"],
|
||||
["30 14 * * 1,3,5", "30 14 * * 1,3,5"],
|
||||
["30 14 * * 1,7", "30 14 * * 0,1"],
|
||||
["0 9 15 * *", "0 9 15 * *"],
|
||||
["0 9 1,15 * *", "0 9 1,15 * *"],
|
||||
["2026-02-03T14:00:00", "2026-02-03T14:00:00"],
|
||||
["2026-02-03T14:00", "2026-02-03T14:00:00"],
|
||||
["2026-02-03T14:00:00Z", "2026-02-03T14:00:00Z"],
|
||||
["2026-02-03T14:00:00+08:00", "2026-02-03T14:00:00+08:00"],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
const state = parseScheduleString(input);
|
||||
expect(buildScheduleString(state)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -147,6 +147,134 @@ export function buildScheduleString(state: ScheduleBuilderState): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** Parse schedules emitted by buildScheduleString; unknown strings stay custom. */
|
||||
export function parseScheduleString(
|
||||
schedule: string,
|
||||
): ScheduleBuilderState {
|
||||
const trimmed = schedule.trim();
|
||||
if (!trimmed) return { ...DEFAULT_SCHEDULE_STATE };
|
||||
|
||||
// ISO timestamp (one-shot).
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(trimmed)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "once",
|
||||
onceAt: trimmed.slice(0, 16),
|
||||
};
|
||||
}
|
||||
|
||||
// Recurring interval.
|
||||
const intervalMatch = /^every\s+(\d+)\s*([mhd])$/i.exec(trimmed);
|
||||
if (intervalMatch) {
|
||||
const value = Number.parseInt(intervalMatch[1], 10);
|
||||
const suffix = intervalMatch[2].toLowerCase();
|
||||
const unit: IntervalUnit =
|
||||
suffix === "d" ? "days" : suffix === "h" ? "hours" : "minutes";
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "interval",
|
||||
intervalValue: Number.isFinite(value) && value > 0 ? value : 1,
|
||||
intervalUnit: unit,
|
||||
};
|
||||
}
|
||||
|
||||
// 5-field cron expression.
|
||||
const parsedCron = parseSimpleCronExpression(trimmed);
|
||||
if (parsedCron) {
|
||||
if (parsedCron.mode === "daily") {
|
||||
return { ...DEFAULT_SCHEDULE_STATE, mode: "daily", timeOfDay: parsedCron.time };
|
||||
}
|
||||
if (parsedCron.mode === "weekly") {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "weekly",
|
||||
timeOfDay: parsedCron.time,
|
||||
weekdays: parsedCron.weekdays ?? [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "monthly",
|
||||
timeOfDay: parsedCron.time,
|
||||
dayOfMonth: parsedCron.dayOfMonth ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: preserve the raw string in custom mode.
|
||||
return { ...DEFAULT_SCHEDULE_STATE, mode: "custom", custom: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: recognise the simple, well-shaped 5-field cron patterns
|
||||
* that both the human-readable describer and the schedule builder care
|
||||
* about. Returns a structured result or ``null`` when the expression has
|
||||
* ranges, steps, per-month rules, or other complexity.
|
||||
*/
|
||||
function parseSimpleCronExpression(
|
||||
expr: string,
|
||||
): { mode: "daily" | "weekly" | "monthly"; time: string; weekdays?: Weekday[]; dayOfMonth?: number } | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [minField, hourField, domField, monField, dowField] = parts;
|
||||
|
||||
if (monField !== "*") return null;
|
||||
|
||||
const isLiteralOrList = (f: string) => /^\d+(,\d+)*$|^\*$/.test(f);
|
||||
if (
|
||||
!isLiteralOrList(minField) ||
|
||||
!isLiteralOrList(hourField) ||
|
||||
!isLiteralOrList(domField) ||
|
||||
!isLiteralOrList(dowField)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (minField === "*" || hourField === "*") return null;
|
||||
|
||||
const minutes = minField.split(",").map((n) => parseInt(n, 10));
|
||||
const hours = hourField.split(",").map((n) => parseInt(n, 10));
|
||||
if (minutes.length !== 1 || hours.length !== 1) return null;
|
||||
if (
|
||||
!Number.isFinite(minutes[0]) ||
|
||||
!Number.isFinite(hours[0]) ||
|
||||
hours[0] < 0 ||
|
||||
hours[0] > 23 ||
|
||||
minutes[0] < 0 ||
|
||||
minutes[0] > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
|
||||
|
||||
const domAll = domField === "*";
|
||||
const dowAll = dowField === "*";
|
||||
|
||||
if (domAll && dowAll) {
|
||||
return { mode: "daily", time };
|
||||
}
|
||||
|
||||
if (domAll && !dowAll) {
|
||||
const weekdays: Weekday[] = [];
|
||||
for (const part of dowField.split(",")) {
|
||||
const day = parseInt(part, 10);
|
||||
if (!Number.isFinite(day) || day < 0 || day > 7) return null;
|
||||
const normalized = (day === 7 ? 0 : day) as Weekday;
|
||||
if (!weekdays.includes(normalized)) weekdays.push(normalized);
|
||||
}
|
||||
if (weekdays.length === 0) return null;
|
||||
return { mode: "weekly", time, weekdays };
|
||||
}
|
||||
|
||||
if (!domAll && dowAll) {
|
||||
if (!/^\d+$/.test(domField)) return null;
|
||||
const dom = parseInt(domField, 10);
|
||||
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
|
||||
return { mode: "monthly", time, dayOfMonth: dom };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
|
||||
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":");
|
||||
|
|
@ -273,71 +401,26 @@ function describeCronExpression(
|
|||
expr: string,
|
||||
strings: ScheduleDescribeStrings,
|
||||
): string | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [minField, hourField, domField, monField, dowField] = parts;
|
||||
const parsed = parseSimpleCronExpression(expr);
|
||||
if (!parsed) return null;
|
||||
|
||||
const month = monField === "*";
|
||||
if (!month) return null; // we don't try to humanize per-month rules
|
||||
|
||||
const isLiteralOrList = (f: string) =>
|
||||
/^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
|
||||
if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
|
||||
if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
|
||||
|
||||
// Star minutes/hours would mean "every minute" / "every hour" — we'd
|
||||
// need a step-value handler ("*/15") to describe that cleanly, and
|
||||
// that path is power-user territory. Bail to raw display.
|
||||
if (minField === "*" || hourField === "*") return null;
|
||||
|
||||
const minutes = minField.split(",").map((n) => parseInt(n, 10));
|
||||
const hours = hourField.split(",").map((n) => parseInt(n, 10));
|
||||
if (minutes.length !== 1 || hours.length !== 1) return null;
|
||||
if (
|
||||
!Number.isFinite(minutes[0]) ||
|
||||
!Number.isFinite(hours[0]) ||
|
||||
hours[0] < 0 ||
|
||||
hours[0] > 23 ||
|
||||
minutes[0] < 0 ||
|
||||
minutes[0] > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
|
||||
|
||||
const domAll = domField === "*";
|
||||
const dowAll = dowField === "*";
|
||||
|
||||
if (domAll && dowAll) {
|
||||
return strings.dailyAt.replace("{time}", time);
|
||||
if (parsed.mode === "daily") {
|
||||
return strings.dailyAt.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
if (domAll && !dowAll) {
|
||||
const days = dowField
|
||||
.split(",")
|
||||
.map((n) => parseInt(n, 10))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
|
||||
if (days.length === 0) return null;
|
||||
const labels = days
|
||||
if (parsed.mode === "weekly") {
|
||||
const labels = (parsed.weekdays ?? [])
|
||||
.map((d) => strings.weekdaysShort[d])
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return strings.weeklyAt
|
||||
.replace("{days}", labels)
|
||||
.replace("{time}", time);
|
||||
.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
if (!domAll && dowAll) {
|
||||
const dom = parseInt(domField, 10);
|
||||
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
|
||||
return strings.monthlyAt
|
||||
.replace("{day}", strings.ordinal(dom))
|
||||
.replace("{time}", time);
|
||||
}
|
||||
|
||||
// Both day-of-month AND day-of-week set is unusual and cron's
|
||||
// OR-semantics for that combo are confusing — fall back to raw.
|
||||
return null;
|
||||
return strings.monthlyAt
|
||||
.replace("{day}", strings.ordinal(parsed.dayOfMonth ?? 1))
|
||||
.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue