mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
feat(automations): added UI and improved mentions
- Added support for @-mentions in agent tasks, allowing users to reference documents, folders, and connectors directly in their queries. - Updated `run_agent_task` to resolve mentions and include them in the context passed to the agent. - Introduced new parameters in `AgentTaskActionParams` for handling mentioned document and connector IDs. - Refactored the automation edit and new components to utilize the new `AutomationBuilderForm` for a more streamlined user experience. - Removed deprecated JSON forms to simplify the automation creation process.
This commit is contained in:
parent
c601a9b102
commit
d013617bf6
25 changed files with 2490 additions and 281 deletions
456
surfsense_web/lib/automations/builder-schema.ts
Normal file
456
surfsense_web/lib/automations/builder-schema.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* The form builder's own data model plus the mappers that bridge it to the
|
||||
* backend contract (``automation.types.ts``).
|
||||
*
|
||||
* The builder deliberately exposes a *subset* of the full automation
|
||||
* definition: a name, one or more natural-language agent tasks, a single
|
||||
* schedule, and a few execution knobs. Anything richer (goal, per-step
|
||||
* ``when`` predicates, ``inputs`` schema, ``on_failure`` steps, multiple or
|
||||
* non-schedule triggers, custom metadata) is not representable here, so on
|
||||
* edit we detect it and bounce the user to raw-JSON mode rather than silently
|
||||
* dropping their data. ``goal`` is the one exception: it is carried through
|
||||
* invisibly so the common drafter-produced automation stays form-editable.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
type Automation,
|
||||
type AutomationCreateRequest,
|
||||
type AutomationDefinition,
|
||||
type AutomationUpdateRequest,
|
||||
execution as executionContract,
|
||||
type TriggerCreateRequest,
|
||||
} from "@/contracts/types/automation.types";
|
||||
import { DEFAULT_SCHEDULE, fromCron, type ScheduleModel, toCron } from "./schedule-builder";
|
||||
|
||||
const EXECUTION_DEFAULTS = executionContract.parse({});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const builderTaskSchema = z.object({
|
||||
/** Client-side identity for stable React keys across reorder; not persisted. */
|
||||
id: z.string(),
|
||||
query: z.string().trim().min(1, "Describe what the agent should do"),
|
||||
/**
|
||||
* Files / folders / connectors @-mentioned in the query. Mirrors the chat
|
||||
* composer's mention list and is forwarded to the run as step params so the
|
||||
* agent scopes retrieval to them. The query text already carries ``@Title``
|
||||
* for each; this is the structured side-channel of IDs.
|
||||
*/
|
||||
mentions: z.array(z.custom<MentionedDocumentInfo>()),
|
||||
maxRetries: z.number().int().min(0).max(10).nullable(),
|
||||
timeoutSeconds: z.number().int().positive().max(86_400).nullable(),
|
||||
});
|
||||
export type BuilderTask = z.infer<typeof builderTaskSchema>;
|
||||
|
||||
export const builderScheduleSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("preset"),
|
||||
model: z.custom<ScheduleModel>(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("cron"),
|
||||
cron: z.string().trim().min(1, "Enter a schedule expression"),
|
||||
}),
|
||||
]);
|
||||
export type BuilderSchedule = z.infer<typeof builderScheduleSchema>;
|
||||
|
||||
export const builderExecutionSchema = z.object({
|
||||
timeoutSeconds: z.number().int().positive().max(86_400),
|
||||
maxRetries: z.number().int().min(0).max(10),
|
||||
retryBackoff: z.enum(["exponential", "linear", "none"]),
|
||||
concurrency: z.enum(["drop_if_running", "queue", "always"]),
|
||||
});
|
||||
export type BuilderExecution = z.infer<typeof builderExecutionSchema>;
|
||||
|
||||
export const builderFormSchema = z.object({
|
||||
name: z.string().trim().min(1, "Give your automation a name").max(200),
|
||||
description: z.string().trim().max(2000).nullable(),
|
||||
tasks: z.array(builderTaskSchema).min(1, "Add at least one task"),
|
||||
unattended: z.boolean(),
|
||||
schedule: builderScheduleSchema.nullable(),
|
||||
timezone: z.string().min(1),
|
||||
execution: builderExecutionSchema,
|
||||
tags: z.array(z.string()),
|
||||
/** Carried through from an edited definition so we don't drop it. */
|
||||
goal: z.string().nullable(),
|
||||
});
|
||||
export type BuilderForm = z.infer<typeof builderFormSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults / construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDefaultTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimezones(): string[] {
|
||||
try {
|
||||
const supported = (
|
||||
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
|
||||
).supportedValuesOf?.("timeZone");
|
||||
if (supported && supported.length > 0) return supported;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return ["UTC", getDefaultTimezone()];
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
return `task_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyTask(): BuilderTask {
|
||||
return { id: newId(), query: "", mentions: [], maxRetries: null, timeoutSeconds: null };
|
||||
}
|
||||
|
||||
export function createEmptyForm(): BuilderForm {
|
||||
return {
|
||||
name: "",
|
||||
description: null,
|
||||
tasks: [emptyTask()],
|
||||
unattended: true,
|
||||
schedule: { mode: "preset", model: { ...DEFAULT_SCHEDULE } },
|
||||
timezone: getDefaultTimezone(),
|
||||
execution: {
|
||||
timeoutSeconds: EXECUTION_DEFAULTS.timeout_seconds,
|
||||
maxRetries: EXECUTION_DEFAULTS.max_retries,
|
||||
retryBackoff: EXECUTION_DEFAULTS.retry_backoff,
|
||||
concurrency: EXECUTION_DEFAULTS.concurrency,
|
||||
},
|
||||
tags: [],
|
||||
goal: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The cron string a schedule resolves to, regardless of preset/raw mode. */
|
||||
export function scheduleToCron(schedule: BuilderSchedule): string {
|
||||
return schedule.mode === "preset" ? toCron(schedule.model) : schedule.cron.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form -> contract payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Project a task's @-mentions into the ``agent_task`` param fields the backend
|
||||
* understands (the same names the chat ``new_chat`` request uses, minus
|
||||
* SurfSense docs). Returns an empty object when there are no mentions so the
|
||||
* params stay clean. ``mentioned_documents`` carries full chip metadata so the
|
||||
* run can resolve titles/paths and the form can round-trip the chips back.
|
||||
*/
|
||||
function mentionParams(mentions: MentionedDocumentInfo[]): Record<string, unknown> {
|
||||
if (mentions.length === 0) return {};
|
||||
const documentIds: number[] = [];
|
||||
const folderIds: number[] = [];
|
||||
const connectorIds: number[] = [];
|
||||
const connectors: MentionedDocumentInfo[] = [];
|
||||
for (const mention of mentions) {
|
||||
if (mention.kind === "folder") {
|
||||
folderIds.push(mention.id);
|
||||
} else if (mention.kind === "connector") {
|
||||
connectorIds.push(mention.id);
|
||||
connectors.push(mention);
|
||||
} else {
|
||||
documentIds.push(mention.id);
|
||||
}
|
||||
}
|
||||
const out: Record<string, unknown> = { mentioned_documents: mentions };
|
||||
if (documentIds.length > 0) out.mentioned_document_ids = documentIds;
|
||||
if (folderIds.length > 0) out.mentioned_folder_ids = folderIds;
|
||||
if (connectorIds.length > 0) {
|
||||
out.mentioned_connector_ids = connectorIds;
|
||||
out.mentioned_connectors = connectors;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPlan(form: BuilderForm) {
|
||||
return form.tasks.map((task, index) => {
|
||||
const step: Record<string, unknown> = {
|
||||
step_id: `step_${index + 1}`,
|
||||
action: "agent_task",
|
||||
params: {
|
||||
query: task.query.trim(),
|
||||
auto_approve_all: form.unattended,
|
||||
...mentionParams(task.mentions),
|
||||
},
|
||||
};
|
||||
if (task.maxRetries !== null) step.max_retries = task.maxRetries;
|
||||
if (task.timeoutSeconds !== null) step.timeout_seconds = task.timeoutSeconds;
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDefinition(form: BuilderForm): AutomationDefinition {
|
||||
return {
|
||||
schema_version: "1.0",
|
||||
name: form.name.trim(),
|
||||
goal: form.goal,
|
||||
// Triggers are attached at the top level of the create payload, not in
|
||||
// the definition; the in-definition list stays empty.
|
||||
triggers: [],
|
||||
plan: buildPlan(form),
|
||||
execution: {
|
||||
timeout_seconds: form.execution.timeoutSeconds,
|
||||
max_retries: form.execution.maxRetries,
|
||||
retry_backoff: form.execution.retryBackoff,
|
||||
concurrency: form.execution.concurrency,
|
||||
on_failure: [],
|
||||
},
|
||||
metadata: { tags: form.tags },
|
||||
} as unknown as AutomationDefinition;
|
||||
}
|
||||
|
||||
/** The desired schedule trigger for this form, or ``null`` if none. */
|
||||
export function buildScheduleTrigger(form: BuilderForm): TriggerCreateRequest | null {
|
||||
if (!form.schedule) return null;
|
||||
return {
|
||||
type: "schedule",
|
||||
params: { cron: scheduleToCron(form.schedule), timezone: form.timezone },
|
||||
static_inputs: {},
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreatePayload(
|
||||
form: BuilderForm,
|
||||
searchSpaceId: number
|
||||
): AutomationCreateRequest {
|
||||
const trigger = buildScheduleTrigger(form);
|
||||
return {
|
||||
search_space_id: searchSpaceId,
|
||||
name: form.name.trim(),
|
||||
description: form.description?.trim() ? form.description.trim() : null,
|
||||
definition: buildDefinition(form),
|
||||
triggers: trigger ? [trigger] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdatePayload(form: BuilderForm): AutomationUpdateRequest {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description?.trim() ? form.description.trim() : null,
|
||||
definition: buildDefinition(form),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract -> form (edit hydration with safe fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type HydrateResult =
|
||||
| { formable: true; form: BuilderForm }
|
||||
| { formable: false; reason: string };
|
||||
|
||||
/** A trigger as seen by the hydrator: both ``Trigger`` and ``TriggerCreateRequest`` fit. */
|
||||
export interface HydratableTrigger {
|
||||
type: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const BACKOFF_VALUES = ["exponential", "linear", "none"] as const;
|
||||
const CONCURRENCY_VALUES = ["drop_if_running", "queue", "always"] as const;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** Best-effort projection of a stored ``mentioned_documents`` entry into a chip. */
|
||||
function coerceMention(raw: unknown): MentionedDocumentInfo | null {
|
||||
const o = asRecord(raw);
|
||||
if (typeof o.id !== "number" || typeof o.title !== "string") return null;
|
||||
if (o.kind === "folder") {
|
||||
return { id: o.id, title: o.title, kind: "folder" };
|
||||
}
|
||||
if (o.kind === "connector") {
|
||||
if (typeof o.connector_type !== "string" || typeof o.account_name !== "string") return null;
|
||||
return {
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
kind: "connector",
|
||||
connector_type: o.connector_type,
|
||||
account_name: o.account_name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
kind: "doc",
|
||||
document_type: typeof o.document_type === "string" ? o.document_type : "UNKNOWN",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a task's mention chips from step params. Returns ``null`` when the
|
||||
* step carries mention IDs that aren't backed by usable ``mentioned_documents``
|
||||
* metadata (e.g. hand-edited JSON), so the caller can fall back to JSON mode
|
||||
* rather than silently dropping those IDs on the next save.
|
||||
*/
|
||||
function mentionsFromParams(params: Record<string, unknown>): MentionedDocumentInfo[] | null {
|
||||
const rawList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
|
||||
const mentions: MentionedDocumentInfo[] = [];
|
||||
for (const raw of rawList) {
|
||||
const mention = coerceMention(raw);
|
||||
if (mention) mentions.push(mention);
|
||||
}
|
||||
|
||||
const haveByKind = {
|
||||
doc: new Set(mentions.filter((m) => m.kind === "doc").map((m) => m.id)),
|
||||
folder: new Set(mentions.filter((m) => m.kind === "folder").map((m) => m.id)),
|
||||
connector: new Set(mentions.filter((m) => m.kind === "connector").map((m) => m.id)),
|
||||
};
|
||||
const idChecks: Array<[unknown, Set<number>]> = [
|
||||
[params.mentioned_document_ids, haveByKind.doc],
|
||||
[params.mentioned_folder_ids, haveByKind.folder],
|
||||
[params.mentioned_connector_ids, haveByKind.connector],
|
||||
];
|
||||
for (const [arr, have] of idChecks) {
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const id of arr) {
|
||||
if (typeof id === "number" && !have.has(id)) return null;
|
||||
}
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core projection of a definition + triggers into the builder form. Returns
|
||||
* ``formable: false`` whenever something can't be represented, so the caller
|
||||
* can drop into raw-JSON mode without losing data. Shared by the edit
|
||||
* hydrator and the JSON-mode round-trip.
|
||||
*
|
||||
* The definition is read defensively (``unknown``) so a partially edited JSON
|
||||
* tree can still round-trip into the form; completeness is enforced by the
|
||||
* form's own validation at submit time, not here.
|
||||
*/
|
||||
export function hydrateForm(
|
||||
name: string,
|
||||
description: string | null,
|
||||
def: unknown,
|
||||
triggers: HydratableTrigger[]
|
||||
): HydrateResult {
|
||||
const d = asRecord(def);
|
||||
|
||||
if (d.inputs) {
|
||||
return { formable: false, reason: "uses an inputs schema" };
|
||||
}
|
||||
|
||||
const exec = asRecord(d.execution);
|
||||
const onFailure = Array.isArray(exec.on_failure) ? exec.on_failure : [];
|
||||
if (onFailure.length > 0) {
|
||||
return { formable: false, reason: "has on-failure steps" };
|
||||
}
|
||||
|
||||
const metadata = asRecord(d.metadata);
|
||||
const extraMetadataKeys = Object.keys(metadata).filter((key) => key !== "tags");
|
||||
if (extraMetadataKeys.length > 0) {
|
||||
return { formable: false, reason: "has custom metadata" };
|
||||
}
|
||||
|
||||
const plan = Array.isArray(d.plan) ? d.plan : [];
|
||||
const tasks: BuilderTask[] = [];
|
||||
let unattended = true;
|
||||
for (const rawStep of plan) {
|
||||
const step = asRecord(rawStep);
|
||||
if (step.action !== "agent_task") {
|
||||
return { formable: false, reason: `uses the "${String(step.action)}" action` };
|
||||
}
|
||||
if (step.when) {
|
||||
return { formable: false, reason: "uses conditional steps" };
|
||||
}
|
||||
const params = asRecord(step.params);
|
||||
const query = typeof params.query === "string" ? params.query : "";
|
||||
// auto_approve_all is a single global toggle in the form; if any step is
|
||||
// explicitly false we surface the toggle as off.
|
||||
if (params.auto_approve_all === false) unattended = false;
|
||||
const mentions = mentionsFromParams(params);
|
||||
if (mentions === null) {
|
||||
return { formable: false, reason: "references mentions without metadata" };
|
||||
}
|
||||
tasks.push({
|
||||
id: newId(),
|
||||
query,
|
||||
mentions,
|
||||
maxRetries: typeof step.max_retries === "number" ? step.max_retries : null,
|
||||
timeoutSeconds: typeof step.timeout_seconds === "number" ? step.timeout_seconds : null,
|
||||
});
|
||||
}
|
||||
if (tasks.length === 0) {
|
||||
return { formable: false, reason: "has no steps" };
|
||||
}
|
||||
|
||||
if (triggers.length > 1) {
|
||||
return { formable: false, reason: "has multiple triggers" };
|
||||
}
|
||||
const trigger = triggers[0];
|
||||
let schedule: BuilderSchedule | null = null;
|
||||
let timezone = getDefaultTimezone();
|
||||
if (trigger) {
|
||||
if (trigger.type !== "schedule") {
|
||||
return { formable: false, reason: `has a "${trigger.type}" trigger` };
|
||||
}
|
||||
const cron = typeof trigger.params?.cron === "string" ? trigger.params.cron : "";
|
||||
timezone = typeof trigger.params?.timezone === "string" ? trigger.params.timezone : timezone;
|
||||
const model = fromCron(cron);
|
||||
schedule = model ? { mode: "preset", model } : { mode: "cron", cron };
|
||||
}
|
||||
|
||||
const retryBackoff = BACKOFF_VALUES.includes(exec.retry_backoff as never)
|
||||
? (exec.retry_backoff as BuilderExecution["retryBackoff"])
|
||||
: EXECUTION_DEFAULTS.retry_backoff;
|
||||
const concurrency = CONCURRENCY_VALUES.includes(exec.concurrency as never)
|
||||
? (exec.concurrency as BuilderExecution["concurrency"])
|
||||
: EXECUTION_DEFAULTS.concurrency;
|
||||
const tags = Array.isArray(metadata.tags)
|
||||
? metadata.tags.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
|
||||
return {
|
||||
formable: true,
|
||||
form: {
|
||||
name,
|
||||
description: description ?? null,
|
||||
tasks,
|
||||
unattended,
|
||||
schedule,
|
||||
timezone,
|
||||
execution: {
|
||||
timeoutSeconds:
|
||||
typeof exec.timeout_seconds === "number"
|
||||
? exec.timeout_seconds
|
||||
: EXECUTION_DEFAULTS.timeout_seconds,
|
||||
maxRetries:
|
||||
typeof exec.max_retries === "number" ? exec.max_retries : EXECUTION_DEFAULTS.max_retries,
|
||||
retryBackoff,
|
||||
concurrency,
|
||||
},
|
||||
tags,
|
||||
goal: typeof d.goal === "string" ? d.goal : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an existing automation into the builder form for editing.
|
||||
*/
|
||||
export function formFromAutomation(automation: Automation): HydrateResult {
|
||||
return hydrateForm(
|
||||
automation.name,
|
||||
automation.description ?? null,
|
||||
automation.definition,
|
||||
automation.triggers ?? []
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
|
||||
* create form. ``search_space_id`` is omitted on purpose — the form
|
||||
* injects it from the route so users never have to know their id.
|
||||
*
|
||||
* The shape matches the Pydantic ``AutomationCreate`` model less the
|
||||
* search_space_id field; Zod validates the merged payload before submit.
|
||||
*/
|
||||
export const DEFAULT_AUTOMATION_TEMPLATE = {
|
||||
name: "My automation",
|
||||
description: null,
|
||||
definition: {
|
||||
name: "My automation",
|
||||
goal: null,
|
||||
plan: [
|
||||
{
|
||||
step_id: "step_1",
|
||||
action: "agent_task",
|
||||
params: {
|
||||
query: "Summarize new docs added to folder 12 since the last run.",
|
||||
},
|
||||
},
|
||||
],
|
||||
execution: {
|
||||
timeout_seconds: 600,
|
||||
max_retries: 2,
|
||||
retry_backoff: "exponential",
|
||||
concurrency: "drop_if_running",
|
||||
on_failure: [],
|
||||
},
|
||||
metadata: { tags: [] },
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
type: "schedule",
|
||||
params: {
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone: "UTC",
|
||||
},
|
||||
static_inputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
132
surfsense_web/lib/automations/schedule-builder.ts
Normal file
132
surfsense_web/lib/automations/schedule-builder.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Bidirectional bridge between a friendly schedule model and the 5-field cron
|
||||
* expression the backend ``schedule`` trigger expects (see
|
||||
* ``app/automations/triggers/schedule/params.py``).
|
||||
*
|
||||
* The form builder never asks users to type cron. They pick a frequency + time
|
||||
* (+ days), which ``toCron`` compiles. On edit we ``fromCron`` an existing
|
||||
* expression back into the model; anything we don't recognize returns ``null``
|
||||
* so the caller can fall back to a raw-cron escape hatch instead of silently
|
||||
* losing the user's schedule.
|
||||
*
|
||||
* The recognized patterns are intentionally the same family that
|
||||
* ``describe-cron.ts`` humanizes, keeping the picker and the label in sync.
|
||||
*/
|
||||
|
||||
export type ScheduleFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "monthly";
|
||||
|
||||
export interface ScheduleModel {
|
||||
frequency: ScheduleFrequency;
|
||||
/** 0-23. Ignored for ``hourly``. */
|
||||
hour: number;
|
||||
/** 0-59. */
|
||||
minute: number;
|
||||
/** 0 (Sun) - 6 (Sat). Used by ``weekly``. */
|
||||
daysOfWeek: number[];
|
||||
/** 1-31. Used by ``monthly``. */
|
||||
dayOfMonth: number;
|
||||
}
|
||||
|
||||
/** Sunday-first, matching cron's 0-6 day-of-week numbering. */
|
||||
export const WEEKDAY_OPTIONS: ReadonlyArray<{ value: number; short: string; long: string }> = [
|
||||
{ value: 1, short: "Mon", long: "Monday" },
|
||||
{ value: 2, short: "Tue", long: "Tuesday" },
|
||||
{ value: 3, short: "Wed", long: "Wednesday" },
|
||||
{ value: 4, short: "Thu", long: "Thursday" },
|
||||
{ value: 5, short: "Fri", long: "Friday" },
|
||||
{ value: 6, short: "Sat", long: "Saturday" },
|
||||
{ value: 0, short: "Sun", long: "Sunday" },
|
||||
];
|
||||
|
||||
export const FREQUENCY_OPTIONS: ReadonlyArray<{ value: ScheduleFrequency; label: string }> = [
|
||||
{ value: "hourly", label: "Every hour" },
|
||||
{ value: "daily", label: "Every day" },
|
||||
{ value: "weekdays", label: "Every weekday (Mon\u2013Fri)" },
|
||||
{ value: "weekly", label: "Specific days of the week" },
|
||||
{ value: "monthly", label: "Once a month" },
|
||||
];
|
||||
|
||||
export const DEFAULT_SCHEDULE: ScheduleModel = {
|
||||
frequency: "weekdays",
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
daysOfWeek: [1],
|
||||
dayOfMonth: 1,
|
||||
};
|
||||
|
||||
function isInt(value: string): boolean {
|
||||
return /^\d+$/.test(value);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Compile a schedule model into a 5-field cron expression. */
|
||||
export function toCron(model: ScheduleModel): string {
|
||||
const minute = clamp(model.minute, 0, 59);
|
||||
const hour = clamp(model.hour, 0, 23);
|
||||
|
||||
switch (model.frequency) {
|
||||
case "hourly":
|
||||
return `${minute} * * * *`;
|
||||
case "daily":
|
||||
return `${minute} ${hour} * * *`;
|
||||
case "weekdays":
|
||||
return `${minute} ${hour} * * 1-5`;
|
||||
case "weekly": {
|
||||
const days = [...new Set(model.daysOfWeek)].sort((a, b) => a - b);
|
||||
// Guard against an empty selection producing an invalid cron.
|
||||
const dow = days.length > 0 ? days.join(",") : "1";
|
||||
return `${minute} ${hour} * * ${dow}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `${minute} ${hour} ${clamp(model.dayOfMonth, 1, 31)} * *`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 5-field cron expression back into a schedule model. Returns ``null``
|
||||
* for anything outside the recognized pattern family so callers can fall back
|
||||
* to the raw-cron field.
|
||||
*/
|
||||
export function fromCron(cron: string): ScheduleModel | null {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minute, hour, dom, month, dow] = parts;
|
||||
|
||||
// Hourly: "M * * * *"
|
||||
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && isInt(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE, frequency: "hourly", minute: Number(minute) };
|
||||
}
|
||||
|
||||
// Everything below requires concrete minute + hour.
|
||||
if (!isInt(minute) || !isInt(hour)) return null;
|
||||
|
||||
const base = { hour: Number(hour), minute: Number(minute) };
|
||||
|
||||
// Daily: "M H * * *"
|
||||
if (month === "*" && dom === "*" && dow === "*") {
|
||||
return { ...DEFAULT_SCHEDULE, ...base, frequency: "daily" };
|
||||
}
|
||||
|
||||
// Weekdays: "M H * * 1-5"
|
||||
if (month === "*" && dom === "*" && dow === "1-5") {
|
||||
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekdays" };
|
||||
}
|
||||
|
||||
// Weekly: "M H * * 1,3,5"
|
||||
if (month === "*" && dom === "*" && /^[0-6](,[0-6])*$/.test(dow)) {
|
||||
const daysOfWeek = [...new Set(dow.split(",").map(Number))].sort((a, b) => a - b);
|
||||
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekly", daysOfWeek };
|
||||
}
|
||||
|
||||
// Monthly: "M H D * *"
|
||||
if (month === "*" && dow === "*" && isInt(dom)) {
|
||||
return { ...DEFAULT_SCHEDULE, ...base, frequency: "monthly", dayOfMonth: Number(dom) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue