| 1 | import { create } from "zustand"; |
| 2 | import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types"; |
| 3 | import type { HeartbeatFrequencyType } from "../custom/features/heartbeat/heartbeat.presentation"; |
| 4 | export const automationEditableFields = ["title", "prompt", "interval", "enabled", "scope", "workspaceRoot", "approvalMode", "newConversationEachRun", "notifyChannels", "timeWindowStart", "timeWindowEnd"] as const; |
| 5 | type Field = typeof automationEditableFields[number]; |
| 6 | export type AutomationDraft = { |
| 7 | baseline: HeartbeatTask | null; draft: HeartbeatTask; conflicts: Field[]; |
| 8 | missing: boolean; busy: boolean; error: boolean; version: number; |
| 9 | frequency: HeartbeatFrequencyType; tab: "configuration" | "history"; |
| 10 | }; |
| 11 | export function frequencyOf(interval: string): HeartbeatFrequencyType { |
| 12 | const cycle = interval.match(/\|(daily|weekly|biweekly|monthly|yearly)/)?.[1]; |
| 13 | return cycle ? cycle as HeartbeatFrequencyType : interval.trim().split(/\s+/).length >= 5 ? "cron" : "interval"; |
| 14 | } |
| 15 | export function automationDraftDirty(entry: AutomationDraft): boolean { |
| 16 | return !entry.baseline || automationEditableFields.some((key) => entry.draft[key] !== entry.baseline?.[key]); |
| 17 | } |
| 18 | function createEntry(task: HeartbeatTask, isNew: boolean): AutomationDraft { |
| 19 | return { baseline: isNew ? null : { ...task }, draft: { ...task }, conflicts: [], missing: false, |
| 20 | busy: false, error: false, version: 0, frequency: frequencyOf(task.interval), tab: "configuration" }; |
| 21 | } |
| 22 | export function reconcileAutomationDraft(entry: AutomationDraft, current?: HeartbeatTask): AutomationDraft { |
| 23 | if (!entry.baseline) return current ? { ...entry, conflicts: [...automationEditableFields] } : entry; |
| 24 | if (!current) return { ...entry, missing: true }; |
| 25 | const draft = { ...entry.draft }; |
| 26 | const conflicts = new Set(entry.conflicts); |
| 27 | for (const key of automationEditableFields) { |
| 28 | const localChanged = draft[key] !== entry.baseline[key]; |
| 29 | const remoteChanged = current[key] !== entry.baseline[key]; |
| 30 | if (localChanged && remoteChanged && draft[key] !== current[key]) conflicts.add(key); |
| 31 | if (!localChanged) Object.assign(draft, { [key]: current[key] }); |
| 32 | if (draft[key] === current[key]) conflicts.delete(key); |
| 33 | } |
| 34 | draft.topicId = current.topicId; draft.lastRunAt = current.lastRunAt; draft.runHistory = current.runHistory; |
| 35 | return { ...entry, baseline: { ...current }, draft, missing: false, conflicts: [...conflicts], |
| 36 | frequency: draft.interval === entry.draft.interval ? entry.frequency : frequencyOf(draft.interval) }; |
| 37 | } |
| 38 | type Store = { |
| 39 | entries: Record<string, AutomationDraft>; |
| 40 | ensure: (task: HeartbeatTask, isNew?: boolean) => void; |
| 41 | edit: (id: string, update: (task: HeartbeatTask) => HeartbeatTask) => void; |
| 42 | ui: (id: string, patch: Partial<Pick<AutomationDraft, "frequency" | "tab">>) => void; |
| 43 | reconcile: (tasks: HeartbeatTask[]) => void; |
| 44 | begin: (id: string) => number | null; |
| 45 | finish: (id: string, version: number, saved?: HeartbeatTask) => void; |
| 46 | settle: (id: string, version: number, success: boolean) => void; |
| 47 | discard: (id: string) => void; |
| 48 | remove: (id: string) => void; |
| 49 | }; |
| 50 | export const useAutomationDraftStore = create<Store>((set, get) => ({ |
| 51 | entries: {}, |
| 52 | ensure: (task, isNew = false) => { if (!get().entries[task.id]) set((s) => ({ entries: { ...s.entries, [task.id]: createEntry(task, isNew) } })); }, |
| 53 | edit: (id, update) => set((s) => { |
| 54 | const entry = s.entries[id]; if (!entry || entry.busy) return s; |
| 55 | return { entries: { ...s.entries, [id]: { ...entry, draft: update(entry.draft), version: entry.version + 1 } } }; |
| 56 | }), |
| 57 | ui: (id, patch) => set((s) => { const entry = s.entries[id]; return !entry || entry.busy ? s : { entries: { ...s.entries, [id]: { ...entry, ...patch } } }; }), |
| 58 | reconcile: (tasks) => set((s) => ({ entries: Object.fromEntries(Object.entries(s.entries).map(([id, entry]) => [id, reconcileAutomationDraft(entry, tasks.find((task) => task.id === id))])) })), |
| 59 | begin: (id) => { |
| 60 | const entry = get().entries[id]; if (!entry || entry.busy) return null; |
| 61 | const version = entry.version + 1; |
| 62 | set((s) => ({ entries: { ...s.entries, [id]: { ...entry, version, busy: true, error: false } } })); return version; |
| 63 | }, |
| 64 | finish: (id, version, saved) => set((s) => { |
| 65 | const entry = s.entries[id]; if (!entry || entry.version !== version) return s; |
| 66 | return { entries: { ...s.entries, [id]: saved ? { ...createEntry(saved, false), version, tab: entry.tab } : { ...entry, busy: false, error: true } } }; |
| 67 | }), |
| 68 | settle: (id, version, success) => set((s) => { const entry = s.entries[id]; return !entry || entry.version !== version ? s : { entries: { ...s.entries, [id]: { ...entry, busy: false, error: !success } } }; }), |
| 69 | discard: (id) => set((s) => { |
| 70 | const entry = s.entries[id]; if (!entry || entry.busy) return s; |
| 71 | const entries = { ...s.entries }; |
| 72 | if (entry.baseline && !entry.missing) entries[id] = createEntry(entry.baseline, false); else delete entries[id]; |
| 73 | return { entries }; |
| 74 | }), |
| 75 | remove: (id) => set((s) => { const entries = { ...s.entries }; delete entries[id]; return { entries }; }), |
| 76 | })); |
| 77 |