import { normalizePipeline } from './agents.ts' import type { FrameApproval } from './types.ts' /** Scheduled or approval-triggered workflows. Every step names a canvas; * approval workflows use the watched canvas and carry only the signed-off sources. * Design tasks finish before the following step starts. */ export type ScheduleKind = 'daily' | 'weekdays' | 'weekly' export interface Schedule { kind: ScheduleKind /** weekly only: 0 = Sunday … 6 = Saturday */ weekday: number /** wall-clock time in `tz` */ hour: number minute: number /** IANA zone the wall-clock time is read in, e.g. Europe/Berlin */ tz: string } export type PullFilter = 'active' | 'all' export interface PullStep { id: string type: 'pull' provider: 'meta' /** Meta ad account id, e.g. act_1234 — empty until the user picks one */ accountId: string filter: PullFilter canvasId: string } export type AutomationTrigger = { type: 'schedule' } | { type: 'approval'; canvasId: string } export interface SlackStep { id: string type: 'slack' canvasId: string channelId: string message: string } export interface LinearStep { id: string type: 'linear' canvasId: string teamId: string title: string description: string } export interface AgentStep { id: string type: 'agent' /** short label shown on the node, e.g. "Weekly review"; the prompt is the brief */ title: string prompt: string /** the one agent role the card goes to (kept as a list for the card's pipeline shape) */ roles: string[] canvasId: string } export type Step = PullStep | AgentStep | SlackStep | LinearStep /** A durable run keeps the approved sources and the steps as they were at dispatch. */ export interface ApprovedSource { id: string canvasId: string name: string approval: FrameApproval } export interface AutomationRunState { steps: Step[] sources: ApprovedSource[] stepIndex: number summaries: string[] links: string[] taskId?: string /** Set before a side effect; an interrupted call is never silently replayed. */ dispatching?: boolean } export type RunStatus = 'running' | 'ok' | 'failed' export interface AutomationRun { id: string automationId: string startedAt: number endedAt: number | null status: RunStatus /** one line of what happened: "12 creatives pulled · 1 card queued" */ summary: string | null error: string | null /** the first step's canvas — where "Open canvas" leads */ canvasId: string | null /** what went wrong, when it's something the user can fix with one click */ failure: 'reconnect' | null } export interface Automation { id: string name: string enabled: boolean trigger: AutomationTrigger schedule: Schedule steps: Step[] nextRunAt: number | null lastRun: Pick | null createdAt: number updatedAt: number } export const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const export const WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const export const MAX_STEPS = 6 export const MAX_NAME = 80 export const MAX_PROMPT = 4_000 export const DEFAULT_SCHEDULE: Schedule = { kind: 'weekly', weekday: 1, hour: 9, minute: 0, tz: 'UTC' } export function normalizeTrigger(raw: unknown): AutomationTrigger | string { if (raw === undefined) return { type: 'schedule' } if (!raw || typeof raw !== 'object') return 'Choose a schedule or approval trigger' const r = raw as Record if (r.type === 'schedule') return { type: 'schedule' } if (r.type === 'approval' && typeof r.canvasId === 'string') return { type: 'approval', canvasId: r.canvasId.trim() } return 'Choose a schedule or approval trigger' } /* ------------------------------------------------------------------ */ /* Schedule math */ function pad(n: number): string { return String(n).padStart(2, '0') } export function isValidTimeZone(tz: string): boolean { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }) return true } catch { return false } } interface WallClock { year: number month: number day: number hour: number minute: number second: number weekday: number } const partsCache = new Map() function formatter(tz: string): Intl.DateTimeFormat { let f = partsCache.get(tz) if (!f) { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', weekday: 'short', }) partsCache.set(tz, f) } return f } /** The wall clock in `tz` at the instant `at`. */ export function wallClock(at: number, tz: string): WallClock { const parts: Record = {} for (const p of formatter(tz).formatToParts(new Date(at))) parts[p.type] = p.value return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour), minute: Number(parts.minute), second: Number(parts.second), weekday: WEEKDAYS_SHORT.indexOf(parts.weekday as (typeof WEEKDAYS_SHORT)[number]), } } /** The zone's UTC offset (ms) at the instant `at`. */ function offsetAt(at: number, tz: string): number { const w = wallClock(at, tz) const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) return asUtc - Math.floor(at / 1000) * 1000 } /** The instant a wall-clock time in `tz` happens. Around a DST jump a * nonexistent time resolves to the instant an hour later; an ambiguous one * to its first occurrence. */ export function zonedTime(tz: string, year: number, month: number, day: number, hour: number, minute: number): number { const guess = Date.UTC(year, month - 1, day, hour, minute) const offset = offsetAt(guess, tz) const result = guess - offset /* the offset can differ at the resolved instant when a DST change sits between the guess and the answer — one correction settles it */ const settled = offsetAt(result, tz) return settled === offset ? result : guess - settled } /** The next instant strictly after `after` that the schedule fires. */ export function nextRunAt(schedule: Schedule, after: number): number { const tz = isValidTimeZone(schedule.tz) ? schedule.tz : 'UTC' const now = wallClock(after, tz) /* walk day by day from today; a weekly schedule fires within 7 days */ for (let ahead = 0; ahead <= 8; ahead++) { const dayStart = Date.UTC(now.year, now.month - 1, now.day + ahead) const d = new Date(dayStart) const candidate = zonedTime( tz, d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), schedule.hour, schedule.minute, ) if (candidate <= after) continue const weekday = wallClock(candidate, tz).weekday if (schedule.kind === 'weekly' && weekday !== schedule.weekday) continue if (schedule.kind === 'weekdays' && (weekday === 0 || weekday === 6)) continue return candidate } /* unreachable: 8 days always contain every weekday */ return after + 7 * 86_400_000 } /** The clock as the viewer's locale shows it; falls back to 24h where Intl is missing. */ export function clockTime(hour: number, minute: number): string { try { return new Date(2000, 0, 1, hour, minute).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) } catch { return `${pad(hour)}:${pad(minute)}` } } export function describeSchedule(s: Schedule): string { const time = clockTime(s.hour, s.minute) if (s.kind === 'daily') return `Every day at ${time}` if (s.kind === 'weekdays') return `Weekdays at ${time}` return `Every ${WEEKDAYS[s.weekday] ?? 'Monday'} at ${time}` } /** "Mon 09:00" — the short form for list rows */ export function shortSchedule(s: Schedule): string { const time = clockTime(s.hour, s.minute) if (s.kind === 'daily') return `Daily ${time}` if (s.kind === 'weekdays') return `Weekdays ${time}` return `${WEEKDAYS_SHORT[s.weekday] ?? 'Mon'} ${time}` } /* ------------------------------------------------------------------ */ /* Validation */ function asInt(v: unknown, min: number, max: number, fallback: number): number { const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN if (!Number.isInteger(n) || n < min || n > max) return fallback return n } export function normalizeSchedule(raw: unknown): Schedule { const r = (raw ?? {}) as Partial> const kind: ScheduleKind = r.kind === 'daily' || r.kind === 'weekdays' ? r.kind : 'weekly' const tz = typeof r.tz === 'string' && isValidTimeZone(r.tz) ? r.tz : DEFAULT_SCHEDULE.tz return { kind, weekday: asInt(r.weekday, 0, 6, DEFAULT_SCHEDULE.weekday), hour: asInt(r.hour, 0, 23, DEFAULT_SCHEDULE.hour), minute: asInt(r.minute, 0, 59, DEFAULT_SCHEDULE.minute), tz, } } function stepId(raw: unknown, index: number): string { return typeof raw === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(raw) ? raw : `s${index + 1}` } /** Shape-check the steps. Canvas ids are only validated for shape here — the * server checks the owner can actually reach each one. */ export function normalizeSteps(raw: unknown): Step[] | string { if (!Array.isArray(raw)) return 'steps must be a list' if (raw.length > MAX_STEPS) return `at most ${MAX_STEPS} steps` const out: Step[] = [] raw.forEach((item, i) => { const s = (item ?? {}) as Record const canvasId = typeof s.canvasId === 'string' ? s.canvasId.trim() : '' const field = (key: string, max = MAX_PROMPT) => (typeof s[key] === 'string' ? s[key].trim().slice(0, max) : '') if (s.type === 'slack') { out.push({ id: stepId(s.id, i), type: 'slack', canvasId, channelId: field('channelId', 100), message: field('message'), }) } else if (s.type === 'linear') { out.push({ id: stepId(s.id, i), type: 'linear', canvasId, teamId: field('teamId', 100), title: field('title', 200), description: field('description'), }) } else if (s.type === 'pull') { out.push({ id: stepId(s.id, i), type: 'pull', provider: 'meta', accountId: typeof s.accountId === 'string' ? s.accountId.trim().slice(0, 40) : '', filter: s.filter === 'all' ? 'all' : 'active', canvasId, }) } else if (s.type === 'agent') { out.push({ id: stepId(s.id, i), type: 'agent', title: typeof s.title === 'string' ? s.title.trim().slice(0, MAX_NAME) : '', prompt: typeof s.prompt === 'string' ? s.prompt.trim().slice(0, MAX_PROMPT) : '', /* one agent per task — the first real role wins */ roles: normalizePipeline(s.roles).slice(0, 1), canvasId, }) } }) if (out.length !== raw.length) return 'each step must be a pull, agent task, Slack message or Linear ticket' if (new Set(out.map((s) => s.id)).size !== out.length) return 'step ids must be unique' return out } /** Why a saved automation cannot run yet — shown inline, and the reason the * scheduler skips it. Undefined = ready. */ export function incompleteReason(steps: Step[], trigger?: AutomationTrigger): string | undefined { if (trigger?.type === 'approval' && !trigger.canvasId) return 'Pick a canvas to watch for approvals' if (steps.length === 0) return 'Add a step' for (const s of steps) { if (!s.canvasId) return 'Pick a canvas for every step' if (s.type === 'pull' && !s.accountId) return 'Pick a Meta ad account' if (s.type === 'agent' && !s.prompt) return 'Write what the agent should do' if (s.type === 'slack' && !/^[CG][A-Z0-9]+$/.test(s.channelId)) return 'Enter a Slack channel ID' if (s.type === 'slack' && !s.message) return 'Write a Slack message' if (s.type === 'linear' && !s.teamId) return 'Choose a Linear team' if (s.type === 'linear' && !s.title) return 'Write a Linear ticket title' } return undefined } /* ------------------------------------------------------------------ */ /* Examples */ export type ExampleStep = | Omit | Omit | Omit | Omit export interface AutomationExample { id: string name: string blurb: string schedule: Omit trigger?: 'approval' steps: ExampleStep[] } /** The "start from an example" cards. Canvas (and Meta account) are left for * the user to pick; everything else is filled in. */ export const AUTOMATION_EXAMPLES: AutomationExample[] = [ ...[ { id: 'approved-sizes', name: 'Make different sizes', blurb: 'Adapt approved designs into square, portrait and landscape drafts.', prompt: 'Use draft assets from earlier steps when available, otherwise use the approved sources. For each input, create separate draft frames at 1080×1080, 1080×1920 and 1200×628. Recompose the layout for each size; preserve the brand, message and focal point. Check text fit and keep the original untouched.', }, { id: 'approved-localization', name: 'Localize approved designs', blurb: 'Translate approved designs and adapt their layouts.', prompt: 'Use draft assets from earlier steps when available, otherwise use the approved sources. For each input, create German, French and Spanish draft versions. Adapt idioms, typography and layout for the language and text length. Preserve brand names and the original intent. Name each new frame with its language; keep the original untouched.', }, { id: 'approved-assets', name: 'Create companion assets', blurb: 'Turn an approved direction into a campaign set.', prompt: 'Use draft assets from earlier steps when available, otherwise use the approved sources. Create three companion draft assets for each input: a 1200×628 social banner, a 600×300 email header and a 1440×600 website hero. Carry over the visual direction and adapt copy and composition to each use. Keep the original untouched.', }, ].map((preset): AutomationExample => ({ id: preset.id, name: preset.name, blurb: preset.blurb, trigger: 'approval', schedule: { kind: 'weekly', weekday: 1, hour: 9, minute: 0 }, steps: [{ type: 'agent', title: preset.name, prompt: preset.prompt, roles: ['doop'] }], })), { id: 'weekly-creatives', name: 'Weekly ad creatives', blurb: 'Pull every active Meta ad onto a canvas each Monday.', schedule: { kind: 'weekly', weekday: 1, hour: 9, minute: 0 }, steps: [{ type: 'pull', provider: 'meta', accountId: '', filter: 'active' }], }, { id: 'ad-variants', name: 'Ad variants', blurb: 'Pull this week’s creatives, then have Doop spin up five variants of each.', schedule: { kind: 'weekly', weekday: 1, hour: 9, minute: 0 }, steps: [ { type: 'pull', provider: 'meta', accountId: '', filter: 'active' }, { type: 'agent', title: 'Make variants', prompt: 'For every ad creative pulled in today, make five variants as new frames next to it: a stronger headline, a different visual hierarchy, a bolder colour treatment, a shorter copy version, and one that leads with social proof. Keep the brand intact.', roles: ['doop'], }, ], }, { id: 'friday-ux', name: 'Friday UX review', blurb: 'The UX Lead reviews everything that changed this week.', schedule: { kind: 'weekly', weekday: 5, hour: 16, minute: 0 }, steps: [ { type: 'agent', title: 'Weekly review', prompt: 'Review every frame on this canvas as an interface someone has to use. Fix unclear primary actions, muddled hierarchy and missing states. Leave a short note of what you changed.', roles: ['ux'], }, ], }, { id: 'brand-a11y', name: 'Brand and accessibility check', blurb: 'Bring every frame back on brand and up to WCAG AA.', schedule: { kind: 'weekly', weekday: 3, hour: 10, minute: 0 }, steps: [ { type: 'agent', title: 'Brand pass', prompt: 'Check every frame on this canvas against the brand as established here — palette, type, radii, tone — and against WCAG AA contrast and semantics. Fix what fails without redesigning.', roles: ['brand'], }, ], }, { id: 'copy-pass', name: 'Weekly copy pass', blurb: 'The Copywriter tightens every headline and label.', schedule: { kind: 'weekly', weekday: 2, hour: 9, minute: 30 }, steps: [ { type: 'agent', title: 'Copy pass', prompt: 'Go through every frame on this canvas and tighten the copy: specific headlines, short labels, concrete CTAs, no filler. Keep every line inside its existing box.', roles: ['copy'], }, ], }, { id: 'nightly-polish', name: 'Nightly polish', blurb: 'Visual Polish tidies spacing and alignment every evening.', schedule: { kind: 'weekdays', weekday: 1, hour: 22, minute: 0 }, steps: [ { type: 'agent', title: 'Polish', prompt: 'Polish every frame on this canvas: optical alignment, one spacing scale, even type ramp, nothing overflowing at the frame size. Small surgical changes only.', roles: ['polish'], }, ], }, ] export function exampleById(id: string): AutomationExample | undefined { return AUTOMATION_EXAMPLES.find((e) => e.id === id) }