| import { getImportExportSpec } from "#/manifests/automation-interface"; |
| import type { |
| Automation, |
| AutomationExportFile, |
| AutomationSpec, |
| AutomationTrigger, |
| } from "#/types/automation"; |
|
|
| const SUPPORTED_TRIGGER_TYPES = new Set(["cron", "schedule", "event"]); |
|
|
| type UnknownRecord = Record<string, unknown>; |
|
|
| export class AutomationFileValidationError extends Error { |
| readonly issues: string[]; |
|
|
| constructor(issues: string[]) { |
| super(issues.join("\n")); |
| this.name = "AutomationFileValidationError"; |
| this.issues = issues; |
| } |
| } |
|
|
| function isRecord(value: unknown): value is UnknownRecord { |
| return typeof value === "object" && value !== null && !Array.isArray(value); |
| } |
|
|
| function validateRequiredString( |
| record: UnknownRecord, |
| field: string, |
| path: string, |
| issues: string[], |
| ): string | undefined { |
| const value = record[field]; |
| if (typeof value !== "string" || value.trim().length === 0) { |
| issues.push(`${path}: expected a non-empty string`); |
| return undefined; |
| } |
| return value; |
| } |
|
|
| function validateOptionalString( |
| record: UnknownRecord, |
| field: string, |
| path: string, |
| issues: string[], |
| ): string | undefined { |
| const value = record[field]; |
| if (value === undefined) return undefined; |
| if (typeof value !== "string") { |
| issues.push(`${path}: expected a string`); |
| return undefined; |
| } |
| return value; |
| } |
|
|
| function validateTrigger( |
| value: unknown, |
| issues: string[], |
| ): AutomationTrigger | undefined { |
| if (!isRecord(value)) { |
| issues.push("spec.trigger: expected an object"); |
| return undefined; |
| } |
|
|
| const type = validateRequiredString( |
| value, |
| "type", |
| "spec.trigger.type", |
| issues, |
| ); |
| if (type && !SUPPORTED_TRIGGER_TYPES.has(type)) { |
| issues.push( |
| 'spec.trigger.type: expected one of "cron", "schedule", or "event"', |
| ); |
| } |
|
|
| const schedule = validateOptionalString( |
| value, |
| "schedule", |
| "spec.trigger.schedule", |
| issues, |
| ); |
| const scheduleHuman = validateOptionalString( |
| value, |
| "schedule_human", |
| "spec.trigger.schedule_human", |
| issues, |
| ); |
| const timezone = validateOptionalString( |
| value, |
| "timezone", |
| "spec.trigger.timezone", |
| issues, |
| ); |
| const source = validateOptionalString( |
| value, |
| "source", |
| "spec.trigger.source", |
| issues, |
| ); |
| const filter = validateOptionalString( |
| value, |
| "filter", |
| "spec.trigger.filter", |
| issues, |
| ); |
|
|
| let on: string | string[] | undefined; |
| if (value.on !== undefined) { |
| if ( |
| typeof value.on === "string" || |
| (Array.isArray(value.on) && |
| value.on.length > 0 && |
| value.on.every( |
| (entry) => typeof entry === "string" && entry.trim().length > 0, |
| )) |
| ) { |
| on = value.on as string | string[]; |
| } else { |
| issues.push( |
| "spec.trigger.on: expected a string or a non-empty array of strings", |
| ); |
| } |
| } |
|
|
| if ((type === "cron" || type === "schedule") && !schedule?.trim()) { |
| issues.push("spec.trigger.schedule: required for a scheduled trigger"); |
| } |
| if (type === "event") { |
| if (!source?.trim()) { |
| issues.push("spec.trigger.source: required for an event trigger"); |
| } |
| if ( |
| on === undefined || |
| (typeof on === "string" && on.trim().length === 0) |
| ) { |
| issues.push("spec.trigger.on: required for an event trigger"); |
| } |
| } |
|
|
| if (!type || !SUPPORTED_TRIGGER_TYPES.has(type)) return undefined; |
|
|
| return { |
| type, |
| ...(schedule !== undefined && { schedule }), |
| ...(scheduleHuman !== undefined && { schedule_human: scheduleHuman }), |
| ...(timezone !== undefined && { timezone }), |
| ...(source !== undefined && { source }), |
| ...(on !== undefined && { on }), |
| ...(filter !== undefined && { filter }), |
| }; |
| } |
|
|
| export function serializeAutomation(a: Automation): AutomationExportFile { |
| const timezone = a.timezone ?? a.trigger.timezone; |
| const spec: AutomationSpec = { |
| name: a.name, |
| trigger: { ...a.trigger }, |
| enabled: a.enabled, |
| prompt: a.prompt, |
| ...(a.repository !== undefined && { repository: a.repository }), |
| ...(a.model !== undefined && { model: a.model }), |
| ...(a.timeout != null && { timeout: a.timeout }), |
| ...(a.branch !== undefined && { branch: a.branch }), |
| ...(a.plugins !== undefined && { plugins: [...a.plugins] }), |
| ...(a.notification !== undefined && { notification: a.notification }), |
| ...(timezone !== undefined && { timezone }), |
| }; |
|
|
| const envelope = getImportExportSpec(); |
| return { version: envelope.fileVersion, kind: envelope.fileKind, spec }; |
| } |
|
|
| export function getAutomationExportFilename( |
| automation: Pick<Automation, "id" | "name">, |
| ): string { |
| const slug = automation.name |
| .trim() |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, "-") |
| .replace(/^-+|-+$/g, ""); |
| return `${slug || automation.id}${getImportExportSpec().filenameSuffix}`; |
| } |
|
|
| export function parseAutomationFile(json: unknown): AutomationSpec { |
| const issues: string[] = []; |
| if (!isRecord(json)) { |
| throw new AutomationFileValidationError(["file: expected a JSON object"]); |
| } |
|
|
| const envelope = getImportExportSpec(); |
| if (json.version !== envelope.fileVersion) { |
| issues.push(`version: expected ${envelope.fileVersion}`); |
| } |
| if (json.kind !== envelope.fileKind) { |
| issues.push(`kind: expected "${envelope.fileKind}"`); |
| } |
| if (!isRecord(json.spec)) { |
| issues.push("spec: expected an object"); |
| throw new AutomationFileValidationError(issues); |
| } |
|
|
| const name = validateRequiredString(json.spec, "name", "spec.name", issues); |
| const prompt = validateRequiredString( |
| json.spec, |
| "prompt", |
| "spec.prompt", |
| issues, |
| ); |
| const trigger = validateTrigger(json.spec.trigger, issues); |
|
|
| const enabled = json.spec.enabled; |
| if (typeof enabled !== "boolean") { |
| issues.push("spec.enabled: expected a boolean"); |
| } |
|
|
| const repository = validateOptionalString( |
| json.spec, |
| "repository", |
| "spec.repository", |
| issues, |
| ); |
| const branch = validateOptionalString( |
| json.spec, |
| "branch", |
| "spec.branch", |
| issues, |
| ); |
| const notification = validateOptionalString( |
| json.spec, |
| "notification", |
| "spec.notification", |
| issues, |
| ); |
| const timezone = validateOptionalString( |
| json.spec, |
| "timezone", |
| "spec.timezone", |
| issues, |
| ); |
|
|
| let model: string | null | undefined; |
| if (json.spec.model === null || json.spec.model === undefined) { |
| model = json.spec.model; |
| } else if ( |
| typeof json.spec.model === "string" && |
| json.spec.model.trim().length > 0 |
| ) { |
| model = json.spec.model; |
| } else { |
| issues.push("spec.model: expected a non-empty string or null"); |
| } |
|
|
| let timeout: number | null | undefined; |
| if (json.spec.timeout === null || json.spec.timeout === undefined) { |
| timeout = json.spec.timeout; |
| } else if ( |
| typeof json.spec.timeout === "number" && |
| Number.isInteger(json.spec.timeout) && |
| json.spec.timeout > 0 |
| ) { |
| timeout = json.spec.timeout; |
| } else { |
| issues.push("spec.timeout: expected a positive integer"); |
| } |
|
|
| let plugins: string[] | undefined; |
| if (json.spec.plugins !== undefined) { |
| if ( |
| Array.isArray(json.spec.plugins) && |
| json.spec.plugins.every( |
| (plugin) => typeof plugin === "string" && plugin.trim().length > 0, |
| ) |
| ) { |
| plugins = [...json.spec.plugins] as string[]; |
| } else { |
| issues.push("spec.plugins: expected an array of non-empty strings"); |
| } |
| } |
|
|
| if (issues.length > 0 || !name || !prompt || !trigger) { |
| throw new AutomationFileValidationError(issues); |
| } |
|
|
| return { |
| name, |
| prompt, |
| trigger, |
| enabled: enabled as boolean, |
| ...(repository !== undefined && { repository }), |
| ...(model !== undefined && { model }), |
| ...(timeout !== undefined && { timeout }), |
| ...(branch !== undefined && { branch }), |
| ...(plugins !== undefined && { plugins }), |
| ...(notification !== undefined && { notification }), |
| ...(timezone !== undefined && { timezone }), |
| }; |
| } |
|
|