diff options
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 27 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 152 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 286 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 10 |
4 files changed, 435 insertions, 40 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 17013ac..a6e483d 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -1,5 +1,6 @@ import { useAuth } from "../contexts/AuthContext"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; +import { useUserSettings } from "../hooks/useUserSettings"; import { RewriteLink } from "./RewriteLink"; interface NavLink { @@ -7,14 +8,30 @@ interface NavLink { href: string; requiresAuth?: boolean; external?: boolean; + /** + * When true the link is hidden once the user has flipped on the + * document-mode UI — those areas (Exec, Contracts) are subsumed by the + * directive-document interface and surfacing them just creates noise. + */ + hideInDocumentMode?: boolean; } const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, - { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Exec", href: "/exec", requiresAuth: true }, + { + label: "Contracts", + href: "/contracts", + requiresAuth: true, + hideInDocumentMode: true, + }, + { + label: "Exec", + href: "/exec", + requiresAuth: true, + hideInDocumentMode: true, + }, { label: "Daemons", href: "/daemons", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; @@ -22,6 +39,8 @@ const NAV_LINKS: NavLink[] = [ export function NavStrip() { const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth(); const { pendingQuestions } = useSupervisorQuestions(); + const { settings } = useUserSettings(); + const documentMode = settings?.documentModeEnabled ?? false; const directiveQuestionCount = pendingQuestions.filter(q => q.directiveId).length; const handleSignOut = async () => { @@ -41,7 +60,9 @@ export function NavStrip() { NAV// </span> <div className="flex flex-wrap gap-2 items-center flex-1"> - {NAV_LINKS.map((link) => ( + {NAV_LINKS.filter( + (link) => !(documentMode && link.hideInDocumentMode), + ).map((link) => ( <span key={link.label} className="relative inline-flex items-center"> <RewriteLink to={link.href} diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 270f5c3..3dd8522 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -440,6 +440,23 @@ function CountdownKeyBridge({ return null; } +/** + * Render a "Draft saved Ns ago" label that ticks once per second. Returns + * null when the timestamp is older than 60 seconds (clutter-management). + */ +function useDraftFreshnessLabel(draftSavedAt: number | null): string | null { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + if (draftSavedAt == null) return null; + const ageSec = Math.max(0, Math.floor((now - draftSavedAt) / 1000)); + if (ageSec > 60) return null; + if (ageSec < 2) return "Draft saved"; + return `Draft saved ${ageSec}s ago`; +} + // ============================================================================= // Floating formatting toolbar // @@ -669,6 +686,7 @@ interface SaveCountdownBarProps { remainingMs: number; liveStart: boolean; orchestratorRunning: boolean; + draftSavedAt: number | null; onSaveNow: () => void; onCancel: () => void; onToggleLiveStart: (next: boolean) => void; @@ -679,22 +697,14 @@ function SaveCountdownBar({ remainingMs, liveStart, orchestratorRunning, + draftSavedAt, onSaveNow, onCancel, onToggleLiveStart, }: SaveCountdownBarProps) { - // Visibility rules: - // - Always show when actually saving / saved / error (transient feedback). - // - Show when "dirty" if live-start is OFF (user must trigger save). - // - Show when "pending" only inside the last BAR_VISIBLE_MS so the user - // does not feel rushed during the long fresh countdown. - const visible = - state === "saving" || - state === "saved" || - state === "error" || - (state === "dirty" && !liveStart) || - (state === "pending" && remainingMs <= BAR_VISIBLE_MS); - if (!visible) return null; + // The bar is now ALWAYS visible. Users explicitly asked to be able to + // observe save state at all times — and to have a "Save now" button they + // can hit without waiting for the countdown. let label: string; let progressPct = 0; @@ -702,13 +712,19 @@ function SaveCountdownBar({ if (state === "pending") { const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); - label = orchestratorRunning - ? `Replanning in ${seconds}s — Esc/Undo cancels.` - : `Saving goal in ${seconds}s — Esc/Undo cancels.`; - progressPct = Math.max( - 0, - Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100), - ); + // Show ticking countdown in the last 10s, otherwise a quieter label. + if (remainingMs <= BAR_VISIBLE_MS) { + label = orchestratorRunning + ? `Replanning in ${seconds}s — Esc/Undo cancels.` + : `Saving in ${seconds}s — Esc/Undo cancels.`; + progressPct = Math.max( + 0, + Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100), + ); + } else { + label = "Unsaved changes — auto-save soon."; + progressPct = 0; + } } else if (state === "dirty") { label = orchestratorRunning ? "Unsaved changes — saving will replan the directive." @@ -722,12 +738,23 @@ function SaveCountdownBar({ label = "Saved"; progressPct = 100; tone = "border-emerald-700 text-emerald-300"; - } else { + } else if (state === "error") { label = "Save failed — try again."; progressPct = 100; tone = "border-red-700 text-red-300"; + } else { + label = "Up to date."; + progressPct = 0; + tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]"; } + // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so + // the user can see drafts being captured. We only ever surface this when + // a write has happened in the last minute; otherwise we hide it. + const draftLabel = useDraftFreshnessLabel(draftSavedAt); + + const dirtyish = state === "dirty" || state === "pending"; + return ( <div className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`} @@ -742,6 +769,15 @@ function SaveCountdownBar({ <div className="flex items-center gap-3 px-4 py-1.5"> <span className="text-[10px] font-mono flex-1 truncate">{label}</span> + {draftLabel && ( + <span + className="text-[10px] font-mono text-[#556677] shrink-0" + title="Drafts auto-save to this device on every keystroke" + > + {draftLabel} + </span> + )} + {/* Live-start toggle is always shown so users can flip it from the bar. */} <label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0"> <input @@ -753,16 +789,17 @@ function SaveCountdownBar({ <span>Live start</span> </label> - {(state === "dirty" || state === "pending") && ( - <button - type="button" - onClick={onSaveNow} - className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0" - > - Save now - </button> - )} - {(state === "dirty" || state === "pending") && ( + {/* "Save now" is always available when there are unsaved edits, so + users don't have to wait for the auto-save countdown. */} + <button + type="button" + onClick={onSaveNow} + disabled={!dirtyish} + className="text-[10px] font-mono text-emerald-300 hover:text-white disabled:text-[#445566] disabled:cursor-not-allowed border border-emerald-700/60 disabled:border-[#1f2a3a] rounded px-2 py-0.5 shrink-0" + > + Save now + </button> + {dirtyish && ( <button type="button" onClick={onCancel} @@ -831,10 +868,20 @@ export function DocumentEditor({ const [saveState, setSaveState] = useState<SaveState>("idle"); const [remainingMs, setRemainingMs] = useState(countdownMs); const pendingGoalRef = useRef<string>(directive.goal); + const persistedGoalRef = useRef<string>(directive.goal); const timerRef = useRef<number | null>(null); const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); const editorRef = useRef<LexicalEditor | null>(null); + // Timestamp of the most recent localStorage draft write — drives the + // "Draft saved Xs ago" indicator so users can SEE that drafts are working. + const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null); + + // Track the persisted goal in a ref so beforeunload handlers can do their + // own freshness comparison without a stale closure. + useEffect(() => { + persistedGoalRef.current = directive.goal; + }, [directive.goal]); function cancelTimers() { if (timerRef.current != null) { @@ -957,6 +1004,44 @@ export function DocumentEditor({ }; }, []); + // Belt-and-braces draft persistence: even though we write synchronously on + // every keystroke, browsers can swallow the very last edit if the user hits + // a hard close (tab close, browser quit, mobile background) before React + // processes the keystroke. These handlers flush whatever is in pendingGoalRef + // straight to localStorage on every "we're about to be paused" signal. + useEffect(() => { + const flush = () => { + try { + const value = pendingGoalRef.current; + const persisted = persistedGoalRef.current; + const key = DRAFT_KEY(directive.id); + if (value === persisted) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, value); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] flush handler failed to persist draft", err); + } + }; + const onBeforeUnload = () => flush(); + const onPageHide = () => flush(); + const onVisibility = () => { + if (document.visibilityState === "hidden") flush(); + }; + window.addEventListener("beforeunload", onBeforeUnload); + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onVisibility); + // Final flush on React unmount (route navigation within the SPA). + flush(); + }; + }, [directive.id]); + const handleGoalChange = useCallback( (goal: string) => { pendingGoalRef.current = goal; @@ -971,9 +1056,11 @@ export function DocumentEditor({ window.localStorage.removeItem(DRAFT_KEY(directive.id)); } else { window.localStorage.setItem(DRAFT_KEY(directive.id), goal); + setDraftSavedAt(Date.now()); } - } catch { - /* localStorage may be unavailable / full; ignore */ + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] failed to persist draft", err); } // 2. State-machine. @@ -1063,6 +1150,7 @@ export function DocumentEditor({ remainingMs={remainingMs} liveStart={liveStart} orchestratorRunning={orchestratorRunning} + draftSavedAt={draftSavedAt} onSaveNow={() => void fireSave()} onCancel={cancelCountdown} onToggleLiveStart={toggleLiveStart} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index aba3613..9cb984b 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -6,6 +6,17 @@ import { useAuth } from "../contexts/AuthContext"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; import { DocumentEditor } from "../components/directives/DocumentEditor"; import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; +import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu"; +import { + startDirective, + pauseDirective, + updateDirective, + deleteDirective, + completeDirectiveStep, + failDirectiveStep, + skipDirectiveStep, + stopTask, +} from "../lib/api"; import type { DirectiveStatus, DirectiveSummary, @@ -158,6 +169,134 @@ function Caret({ open }: { open: boolean }) { // Sidebar // ============================================================================= +// ============================================================================= +// Task row context menu — sits next to DirectiveContextMenu and offers the +// task-level controls (interrupt for orchestrator/completion, complete/fail/ +// skip for step tasks). +// ============================================================================= + +interface TaskContextMenuProps { + x: number; + y: number; + task: FolderTaskRow; + onClose: () => void; + onInterrupt: () => void; + onComplete?: () => void; + onFail?: () => void; + onSkip?: () => void; +} + +function TaskContextMenu({ + x, + y, + task, + onClose, + onInterrupt, + onComplete, + onFail, + onSkip, +}: TaskContextMenuProps) { + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + const click = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", click); + document.addEventListener("keydown", key); + return () => { + document.removeEventListener("mousedown", click); + document.removeEventListener("keydown", key); + }; + }, [onClose]); + + useEffect(() => { + if (!ref.current) return; + const rect = ref.current.getBoundingClientRect(); + if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`; + if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`; + }, [x, y]); + + const item = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; + const divider = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + // Interrupt is meaningful for live tasks (orchestrator-active or running steps). + const showInterrupt = + task.kind === "orchestrator-active" || + task.kind === "completion" || + task.status === "running"; + // Step lifecycle controls only apply to step tasks. + const isStep = task.kind === "step"; + const showComplete = isStep && task.status !== "done"; + const showFail = isStep && task.status !== "failed"; + const showSkip = isStep && task.status !== "skipped"; + + return ( + <div + ref={ref} + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" + style={{ left: x, top: y }} + > + <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]"> + {task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label} + </div> + {showInterrupt && ( + <button + className={item} + onClick={() => { + onInterrupt(); + onClose(); + }} + > + <span className="text-amber-300">⏹</span> + Interrupt + </button> + )} + {(showComplete || showFail || showSkip) && <div className={divider} />} + {showComplete && ( + <button + className={item} + onClick={() => { + onComplete?.(); + onClose(); + }} + > + <span className="text-emerald-400">✓</span> + Mark complete + </button> + )} + {showFail && ( + <button + className={item} + onClick={() => { + onFail?.(); + onClose(); + }} + > + <span className="text-red-400">✗</span> + Mark failed + </button> + )} + {showSkip && ( + <button + className={item} + onClick={() => { + onSkip?.(); + onClose(); + }} + > + <span className="text-[#7788aa]">⤳</span> + Skip + </button> + )} + </div> + ); +} + function slugify(title: string, fallback: string): string { const slug = title .trim() @@ -196,6 +335,8 @@ function DirectiveFolder({ onSelect, pendingTaskIds, hasPendingForDirective, + onDirectiveContextMenu, + onTaskContextMenu, }: { directive: DirectiveSummary; open: boolean; @@ -206,6 +347,8 @@ function DirectiveFolder({ pendingTaskIds: Set<string>; /** Whether any pending question is associated with this directive. */ hasPendingForDirective: boolean; + onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void; + onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void; }) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; @@ -228,6 +371,7 @@ function DirectiveFolder({ <button type="button" onClick={onToggle} + onContextMenu={(e) => onDirectiveContextMenu(e, directive)} title={directive.title} className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" > @@ -307,6 +451,9 @@ function DirectiveFolder({ taskId: t.taskId, }) } + onContextMenu={(e) => + onTaskContextMenu(e, t, directive.id) + } title={t.label} className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${ isSelected @@ -374,6 +521,8 @@ function StatusDot({ interface FolderTaskRow { taskId: string; + /** Directive step id for step kinds — needed for complete/fail/skip APIs. */ + stepId: string | null; label: string; status: string; kind: "orchestrator-active" | "completion" | "step"; @@ -392,6 +541,7 @@ function collectTasks( if (orchestratorId) { rows.push({ taskId: orchestratorId, + stepId: null, label: "orchestrator", status: "running", kind: "orchestrator-active", @@ -404,6 +554,7 @@ function collectTasks( if (completionId) { rows.push({ taskId: completionId, + stepId: null, label: "completion", status: "running", kind: "completion", @@ -416,6 +567,7 @@ function collectTasks( if (!step.taskId) continue; rows.push({ taskId: step.taskId, + stepId: step.id, label: step.name, status: step.status, kind: "step", @@ -431,9 +583,18 @@ interface SidebarProps { loading: boolean; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; + onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void; + onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void; } -function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) { +function DocumentSidebar({ + directives, + loading, + selection, + onSelect, + onDirectiveContextMenu, + onTaskContextMenu, +}: SidebarProps) { // Pending user questions — drives the "glow" attention ring. We split into // two indices so the directive folder header glows whenever ANY of its // tasks has a pending question, while individual task rows glow only for @@ -530,6 +691,8 @@ function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarPr onSelect={onSelect} pendingTaskIds={tasksWithPending} hasPendingForDirective={directivesWithPending.has(d.id)} + onDirectiveContextMenu={onDirectiveContextMenu} + onTaskContextMenu={onTaskContextMenu} /> )) )} @@ -667,13 +830,25 @@ function EditorShell({ // Page // ============================================================================= +type ContextMenuState = + | { kind: "directive"; x: number; y: number; directive: DirectiveSummary } + | { + kind: "task"; + x: number; + y: number; + task: FolderTaskRow; + directiveId: string; + } + | null; + export default function DocumentDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const selectedTaskId = searchParams.get("task"); - const { directives, loading: listLoading } = useDirectives(); + const { directives, loading: listLoading, refresh: refreshList } = useDirectives(); + const [contextMenu, setContextMenu] = useState<ContextMenuState>(null); useEffect(() => { if (!authLoading && isAuthConfigured && !isAuthenticated) { @@ -697,6 +872,26 @@ export default function DocumentDirectivesPage() { setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams]); + const onDirectiveContextMenu = useCallback( + (e: React.MouseEvent, d: DirectiveSummary) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d }); + }, + [], + ); + + const onTaskContextMenu = useCallback( + (e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId }); + }, + [], + ); + + const closeContextMenu = useCallback(() => setContextMenu(null), []); + if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -726,6 +921,8 @@ export default function DocumentDirectivesPage() { loading={listLoading} selection={selection} onSelect={onSelect} + onDirectiveContextMenu={onDirectiveContextMenu} + onTaskContextMenu={onTaskContextMenu} /> </div> @@ -738,6 +935,91 @@ export default function DocumentDirectivesPage() { onClearTask={onClearTask} /> </main> + + {/* Context menus — rendered at page level so they overlay everything. */} + {contextMenu?.kind === "directive" && ( + <DirectiveContextMenu + x={contextMenu.x} + y={contextMenu.y} + directive={contextMenu.directive} + onClose={closeContextMenu} + onStart={async () => { + await startDirective(contextMenu.directive.id); + await refreshList(); + }} + onPause={async () => { + await pauseDirective(contextMenu.directive.id); + await refreshList(); + }} + onArchive={async () => { + await updateDirective(contextMenu.directive.id, { + status: "archived", + }); + await refreshList(); + }} + onDelete={async () => { + if ( + !window.confirm( + `Delete "${contextMenu.directive.title}"? This cannot be undone.`, + ) + ) { + return; + } + await deleteDirective(contextMenu.directive.id); + await refreshList(); + // If the deleted one was selected, clear selection. + if (selectedId === contextMenu.directive.id) { + navigate("/directives"); + } + }} + onGoToPR={() => { + if (contextMenu.directive.prUrl) { + window.open(contextMenu.directive.prUrl, "_blank", "noreferrer"); + } + }} + /> + )} + {contextMenu?.kind === "task" && ( + <TaskContextMenu + x={contextMenu.x} + y={contextMenu.y} + task={contextMenu.task} + onClose={closeContextMenu} + onInterrupt={async () => { + try { + await stopTask(contextMenu.task.taskId); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[makima] failed to interrupt task", err); + } + await refreshList(); + }} + onComplete={async () => { + if (!contextMenu.task.stepId) return; + await completeDirectiveStep( + contextMenu.directiveId, + contextMenu.task.stepId, + ); + await refreshList(); + }} + onFail={async () => { + if (!contextMenu.task.stepId) return; + await failDirectiveStep( + contextMenu.directiveId, + contextMenu.task.stepId, + ); + await refreshList(); + }} + onSkip={async () => { + if (!contextMenu.task.stepId) return; + await skipDirectiveStep( + contextMenu.directiveId, + contextMenu.task.stepId, + ); + await refreshList(); + }} + /> + )} </div> ); } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index ca07d92..cec9a82 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5625,8 +5625,12 @@ pub async fn check_directive_idle( } /// Update a directive's goal and bump goal_updated_at. -/// Reactivates idle/paused directives and clears any stale orchestrator task -/// so that replanning triggers on the next tick. +/// Reactivates draft/idle/paused directives and clears any stale orchestrator +/// task so that planning/replanning triggers on the next reconciler tick. +/// +/// `draft` is included in the flip set because the document-mode UI treats +/// the first goal save as the implicit "start" — without this, a brand-new +/// directive's goal save would persist but never spawn a planner. pub async fn update_directive_goal( pool: &PgPool, owner_id: Uuid, @@ -5638,7 +5642,7 @@ pub async fn update_directive_goal( UPDATE directives SET goal = $3, goal_updated_at = NOW(), - status = CASE WHEN status IN ('idle', 'paused') THEN 'active' ELSE status END, + status = CASE WHEN status IN ('draft', 'idle', 'paused') THEN 'active' ELSE status END, orchestrator_task_id = NULL, updated_at = NOW(), version = version + 1 |
