diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 24fd4f7ccd..906b6a0f29 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | -| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ⛔ Scheduled · Skills · Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | +| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ⛔ Skills · Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/studio/knip.ts b/studio/knip.ts index a2f0289e80..485fceaacb 100644 --- a/studio/knip.ts +++ b/studio/knip.ts @@ -29,11 +29,7 @@ const config: KnipConfig = { // (these leave the list as their first reached consumers land later in // the stacked series: lucide-react/cmdk/tooltip with the workspace shell, // select/switch/tabs with the surface pages) - "@radix-ui/react-label", - "@radix-ui/react-scroll-area", - "@radix-ui/react-select", "@radix-ui/react-switch", - "@radix-ui/react-tabs", "@radix-ui/react-avatar", "@radix-ui/react-checkbox", "@radix-ui/react-popover", diff --git a/studio/src/app/workspace/schedules/[scheduleId]/page.tsx b/studio/src/app/workspace/schedules/[scheduleId]/page.tsx new file mode 100644 index 0000000000..567530d0b7 --- /dev/null +++ b/studio/src/app/workspace/schedules/[scheduleId]/page.tsx @@ -0,0 +1,570 @@ +"use client"; + +import { Ellipsis, Loader2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + directed, + SortableHead, + useTableSort, +} from "@/components/sortable-head"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAgentCron } from "@/features/agent"; +import { TranscriptDialog } from "@/features/agent/components/transcript-dialog"; +import { + describeCron, + formatRelativeTime, + formatUntilTime, +} from "@/lib/formatters"; +import { listScheduleFires } from "@/lib/harness/client"; +import type { ScheduleFireRow, ScheduleRow } from "@/lib/protocol"; +import { pageTitleClass } from "@/lib/typography"; +import { EditScheduleDialog } from "../_components/edit-schedule-dialog"; +import { ScheduleStatusBadge } from "../_components/schedule-badges"; +import { permissionModeLabel } from "../_components/schedule-form"; + +/** + * The route segment is the schedule name. `useParams` hands back the encoded + * segment, so matching tolerates both forms rather than assuming one. + */ +function segmentMatches(raw: string): (row: ScheduleRow) => boolean { + let decoded = raw; + try { + decoded = decodeURIComponent(raw); + } catch { + // Malformed escape: the raw form is the only candidate. + } + return (row) => row.name === decoded || row.name === raw; +} + +function formatInstant(ms: number | null): string { + if (ms === null) return "—"; + return new Date(ms).toLocaleString(); +} + +function formatDurationMs(ms: number): string { + if (ms < 0) return "—"; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${Math.round(seconds - minutes * 60)}s`; +} + +/** + * A terminal fire record carries no end timestamp, so the best available end + * is the last progress heartbeat, then the deadline; an in-flight fire is + * still accruing and reads against now. Null = never started. + */ +function fireDurationMs(fire: ScheduleFireRow): number | null { + if (fire.startedAt === null) return null; + const end = fire.inFlight + ? Date.now() + : (fire.progressAt ?? fire.deadline ?? fire.startedAt); + return end - fire.startedAt; +} + +function fireDuration(fire: ScheduleFireRow): string { + const ms = fireDurationMs(fire); + return ms === null ? "—" : formatDurationMs(ms); +} + +/** The text the Outcome column effectively shows, for sorting. */ +function fireOutcomeKey(fire: ScheduleFireRow): string { + if (fire.inFlight) return fire.startedAt === null ? "claimed" : "in flight"; + return fire.err ? `error: ${fire.err}` : (fire.stop ?? ""); +} + +export default function ScheduleDetailPage() { + const params = useParams<{ scheduleId: string }>(); + const router = useRouter(); + const cron = useAgentCron(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [editing, setEditing] = useState(false); + const [firing, setFiring] = useState(false); + const [fires, setFires] = useState(null); + const [firesError, setFiresError] = useState(null); + const [transcript, setTranscript] = useState<{ + sessionId: string; + label: string; + } | null>(null); + + const fireSort = useTableSort<"fired" | "duration" | "outcome">( + "fired", + "desc", + ); + const sortedFires = useMemo(() => { + if (!fires) return []; + const primary = (a: ScheduleFireRow, b: ScheduleFireRow) => { + switch (fireSort.key) { + case "duration": + return (fireDurationMs(a) ?? -1) - (fireDurationMs(b) ?? -1); + case "outcome": + return fireOutcomeKey(a).localeCompare(fireOutcomeKey(b)); + default: + return (a.firedAt ?? 0) - (b.firedAt ?? 0); + } + }; + // Newest-first is the direction-independent tiebreak. + return [...fires].sort( + (a, b) => + directed(fireSort.dir, primary(a, b)) || + (b.firedAt ?? 0) - (a.firedAt ?? 0), + ); + }, [fires, fireSort.key, fireSort.dir]); + + const row = cron.rows.find(segmentMatches(params.scheduleId)); + const name = row?.name; + + const loadFires = useCallback( + async (signal?: AbortSignal) => { + if (!name) return; + try { + const next = await listScheduleFires(name, signal); + if (signal?.aborted) return; + setFires(next); + setFiresError(null); + } catch (caught) { + if (signal?.aborted) return; + setFires([]); + setFiresError( + caught instanceof Error ? caught.message : String(caught), + ); + } + }, + [name], + ); + + useEffect(() => { + if (!cron.harnessLive || !name) return; + const controller = new AbortController(); + void loadFires(controller.signal); + return () => controller.abort(); + }, [cron.harnessLive, name, loadFires]); + + // A live fire changes shape without user input (claimed → running → + // terminal), so the page re-reads while one is in flight. + const anyLive = + row?.fireStage !== undefined && row.fireStage !== "idle" + ? true + : (fires?.some((fire) => fire.inFlight) ?? false); + useEffect(() => { + if (!cron.harnessLive || !anyLive) return; + const timer = setInterval(() => { + void loadFires(); + void cron.refresh(); + }, 5000); + return () => clearInterval(timer); + }, [cron.harnessLive, anyLive, loadFires, cron.refresh]); + + const back = () => router.push("/workspace/schedules"); + + if (!row) { + // The registry may still be loading; only treat as missing once settled. + if (cron.isLoading) { + return ( +
+ Loading… +
+ ); + } + return ( +
+
+

Scheduled task not found

+

+ {cron.notWired ?? + "This scheduled task no longer exists — it may have been deleted."} +

+ +
+
+ ); + } + + const fireNow = async () => { + setFiring(true); + try { + // FireNow is synchronous on the daemon: this await lasts the whole run. + await cron.runJob(row.name); + } catch { + // The refusal already landed in cron.error, rendered verbatim below. + } finally { + setFiring(false); + await loadFires(); + } + }; + + const act = async (action: () => Promise) => { + try { + await action(); + } catch { + // Refusal surfaces via cron.error. + } + await loadFires(); + }; + + return ( +
+
+ + + {/* Header: title + one compact badge row, matching the skill detail page. + All actions live in the ⋯ menu on the right. */} +
+
+

+ {row.name} +

+ + + + + + void fireNow()} + > + Run now + + + void act(() => + row.enabled + ? cron.pauseJob(row.name) + : cron.resumeJob(row.name), + ) + } + > + {row.enabled ? "Pause" : "Resume"} + + setEditing(true)}> + Edit + + setConfirmDelete(true)} + > + Delete + + + +
+
+ + {permissionModeLabel(row.mode)} + {row.mutating && mutating} + {firing && ( + + + Running… + + )} +
+
+ + {cron.error && ( +

+ {cron.error} +

+ )} + +
+ {/* The prompt is the schedule's instruction — it leads, unlabelled. */} +

+ {row.prompt} +

+ +
+ + {permissionModeLabel(row.mode)} + + {row.mutating ? "Writes allowed" : "Read-only"} + + {row.workspace && ( + + + {row.workspace} + + + )} + {row.owner && {row.owner}} + + + + + {row.cron + ? describeCron(row.cron) + : `Once at ${formatInstant(row.oneShotAt)}`} + + {row.cron && ( + + {row.timezone || "Daemon-local time"} + + )} + + + {row.enabled && row.nextFireAt !== null + ? `in ${formatUntilTime(row.nextFireAt)}` + : "—"} + + + + {row.lastFireAt + ? `${formatRelativeTime(row.lastFireAt)} ago` + : "Never"} + + {!row.cron && ( + + {row.oneShotRetry + ? `Up to ${row.oneShotMaxRetries || "unlimited"} on failure` + : "None"} + + )} + +
+ +
+

Run log

+ {firesError ? ( +

+ {firesError} +

+ ) : fires === null ? ( +
+ + Reading the run log… +
+ ) : fires.length === 0 ? ( +

+ This schedule has not run yet. +

+ ) : ( +
+ + + + + + + Session + + + + {sortedFires.map((fire) => ( + + + {formatInstant(fire.firedAt)} + + + {fireDuration(fire)} + + + {fire.inFlight ? ( + + + ) : ( +
+ + {fire.stop} + + {fire.err && ( + + {fire.err} + + )} +
+ )} +
+ + {fire.sessionId ? ( + + ) : ( + + — + + )} + +
+ ))} +
+
+
+ )} +
+
+ + {editing && ( + + router.push(`/workspace/schedules/${encodeURIComponent(name)}`) + } + onClose={() => setEditing(false)} + /> + )} + + {transcript && ( + setTranscript(null)} + /> + )} + + + + + Delete {row.name}? + + The schedule stops firing. Past run transcripts stay in the + session store. + + + + Cancel + { + void cron.deleteJob(row.name).catch(() => { + // Refusal surfaces via cron.error on the list page. + }); + setConfirmDelete(false); + back(); + }} + > + Delete + + + + +
+
+ ); +} + +/** Eyebrow-labelled group of read-only facts; editing stays in the Edit dialog. */ +function FactGroup({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

+ {label} +

+
{children}
+
+ ); +} + +/** One label-left/value-right row of a fact group. */ +function FactRow({ + label, + children, + suppressHydrationWarning, +}: { + label: string; + children: React.ReactNode; + suppressHydrationWarning?: boolean; +}) { + return ( +
+ {label} + + {children} + +
+ ); +} diff --git a/studio/src/app/workspace/schedules/_components/create-schedule-dialog.tsx b/studio/src/app/workspace/schedules/_components/create-schedule-dialog.tsx new file mode 100644 index 0000000000..293533a38d --- /dev/null +++ b/studio/src/app/workspace/schedules/_components/create-schedule-dialog.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import type { ScheduleSpecDraft } from "@/lib/protocol"; +import { ScheduleDialog } from "./schedule-dialog"; +import { draftFromForm, emptyScheduleForm } from "./schedule-form"; + +/** + * Authors a full ScheduleSpecDraft and hands it to the page's own hook + * instance, so the new row lands in the table the user is looking at. + */ +export function CreateScheduleDialog({ + createFromDraft, +}: { + createFromDraft: (draft: ScheduleSpecDraft) => Promise; +}) { + const [open, setOpen] = useState(false); + return ( + + + New scheduled task + New + + } + title="New scheduled task" + submitLabel="Create task" + submittingLabel="Creating…" + initialValue={emptyScheduleForm} + onSubmit={async (value) => { + await createFromDraft(draftFromForm(value)); + toast.success(`Scheduled task "${value.name.trim()}" created`); + }} + /> + ); +} diff --git a/studio/src/app/workspace/schedules/_components/edit-schedule-dialog.tsx b/studio/src/app/workspace/schedules/_components/edit-schedule-dialog.tsx new file mode 100644 index 0000000000..8880e75e97 --- /dev/null +++ b/studio/src/app/workspace/schedules/_components/edit-schedule-dialog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { + type ScheduleCarriedSpec, + type ScheduleRow, + type ScheduleSpecDraft, + scheduleDraftFromRow, +} from "@/lib/protocol"; +import { ScheduleDialog } from "./schedule-dialog"; +import { draftFromForm, formFromDraft } from "./schedule-form"; + +/** + * Edits an existing schedule. The form is seeded from the stored spec and the + * save carries the row's `carried` fields verbatim: PUT replaces the whole + * spec, so anything not re-sent (a CLI-set model selector, misfire policy, + * fire timeout) would be silently deleted. + * + * A changed name cannot ride the PUT (the daemon force-stamps the path name + * onto the body — there is no rename on the wire), so a new-name save goes + * through the hook's re-create + delete path instead, with a note in the + * dialog that run history stays behind. + */ +export function EditScheduleDialog({ + row, + updateFromDraft, + renameAndUpdateFromDraft, + onRenamed, + onClose, +}: { + row: ScheduleRow; + updateFromDraft: ( + draft: ScheduleSpecDraft, + carried: ScheduleCarriedSpec, + ) => Promise; + renameAndUpdateFromDraft: ( + draft: ScheduleSpecDraft, + previous: ScheduleRow, + ) => Promise; + /** The old detail route 404s after a rename; the caller navigates. */ + onRenamed: (name: string) => void; + onClose: () => void; +}) { + // The stored draft is captured once on mount: it seeds the form and supplies + // the fields the form has no controls for (profile, workspace, limits). + const [storedDraft] = useState(() => scheduleDraftFromRow(row)); + return ( + { + if (!open) onClose(); + }} + title="Edit Schedule" + submitLabel="Save changes" + submittingLabel="Saving…" + initialValue={() => formFromDraft(storedDraft)} + noteForValue={(value) => + value.name.trim() && value.name.trim() !== row.name + ? "Renaming re-creates the schedule under the new name — run history does not carry over." + : null + } + onSubmit={async (value) => { + const draft = draftFromForm(value, storedDraft); + if (draft.name === row.name) { + await updateFromDraft(draft, row.carried); + toast.success(`Scheduled task "${row.name}" updated`); + return; + } + await renameAndUpdateFromDraft(draft, row); + toast.success(`Scheduled task renamed to "${draft.name}"`); + onRenamed(draft.name); + }} + /> + ); +} diff --git a/studio/src/app/workspace/schedules/_components/schedule-badges.tsx b/studio/src/app/workspace/schedules/_components/schedule-badges.tsx new file mode 100644 index 0000000000..19a78b5712 --- /dev/null +++ b/studio/src/app/workspace/schedules/_components/schedule-badges.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import type { ScheduleRow } from "@/lib/protocol"; +import { cn } from "@/lib/utils"; + +export interface ScheduleStatusMeta { + label: string; + variant: "default" | "secondary" | "success" | "warning" | "destructive"; + /** A fire is live right now (claimed or running) — rendered with a pulse. */ + live: boolean; +} + +/** + * fireStage outranks enabled: a paused schedule can still be mid-fire (pause + * stops future fires, not the one already claimed), and the live fire is the + * more urgent fact. + */ +export function scheduleStatusOf(row: ScheduleRow): ScheduleStatusMeta { + if (row.fireStage === "running") + return { label: "Running", variant: "success", live: true }; + if (row.fireStage === "claimed") + return { label: "Claimed", variant: "warning", live: true }; + return row.enabled + ? { label: "Scheduled", variant: "success", live: false } + : { label: "Paused", variant: "secondary", live: false }; +} + +function dotClass(variant: ScheduleStatusMeta["variant"]): string { + switch (variant) { + case "success": + return "bg-success"; + case "warning": + return "bg-warning"; + default: + return "bg-muted-foreground/50"; + } +} + +/** + * Quiet status dot for the card grid: color carries the state, with the + * label kept for screen readers and hover. + */ +export function ScheduleStatusDot({ row }: { row: ScheduleRow }) { + const status = scheduleStatusOf(row); + return ( + + + ); +} + +/** Status as a badge, for the detail header's pill row. */ +export function ScheduleStatusBadge({ row }: { row: ScheduleRow }) { + const status = scheduleStatusOf(row); + return ( + + {status.live && ( + + ); +} diff --git a/studio/src/app/workspace/schedules/_components/schedule-dialog.tsx b/studio/src/app/workspace/schedules/_components/schedule-dialog.tsx new file mode 100644 index 0000000000..dccbd89b4e --- /dev/null +++ b/studio/src/app/workspace/schedules/_components/schedule-dialog.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + ScheduleFormFields, + type ScheduleFormValue, + scheduleFormProblem, +} from "./schedule-form"; + +/** + * The one dialog shell the create and edit flows share: form state, the + * submit gate, and the daemon-refusal rendering. Callers own what a submit + * means (build the draft, call the hook, toast); a thrown refusal renders + * verbatim inside the dialog — the daemon's words are the validation. + */ +export function ScheduleDialog({ + open, + onOpenChange, + trigger, + title, + description, + submitLabel, + submittingLabel, + initialValue, + noteForValue, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Rendered as the DialogTrigger (the create button); edit opens controlled. */ + trigger?: React.ReactNode; + title: string; + description?: string; + submitLabel: string; + submittingLabel: string; + initialValue: () => ScheduleFormValue; + /** Caller-owned advisory line under the form (e.g. the rename warning). */ + noteForValue?: (value: ScheduleFormValue) => string | null; + onSubmit: (value: ScheduleFormValue) => Promise; +}) { + const [value, setValue] = useState(initialValue); + const [refusal, setRefusal] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const problem = scheduleFormProblem(value); + const note = noteForValue?.(value) ?? null; + + function handleOpenChange(next: boolean) { + onOpenChange(next); + if (!next) { + setValue(initialValue()); + setRefusal(null); + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (problem || submitting) return; + setSubmitting(true); + setRefusal(null); + try { + await onSubmit(value); + handleOpenChange(false); + } catch (caught) { + setRefusal(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSubmitting(false); + } + } + + return ( + + {trigger && {trigger}} + +
+ + {title} + {description && ( + {description} + )} + + +
+ setValue((v) => ({ ...v, ...patch }))} + /> + {note &&

{note}

} +
+ + {refusal && ( +

+ {refusal} +

+ )} + + + + + +
+
+
+ ); +} diff --git a/studio/src/app/workspace/schedules/_components/schedule-form.tsx b/studio/src/app/workspace/schedules/_components/schedule-form.tsx new file mode 100644 index 0000000000..24d23773d3 --- /dev/null +++ b/studio/src/app/workspace/schedules/_components/schedule-form.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + builderToCron, + type CronRepeat, + cronToBuilder, +} from "@/lib/cron-builder"; +import { describeCron, ordinal } from "@/lib/formatters"; +import { PERMISSION_MODES, type ScheduleSpecDraft } from "@/lib/protocol"; + +/** + * Form state for authoring a schedule. + * + * Write access is a single opt-in: `allowWrites` couples `mutating: true` with + * a write-capable permission mode, and `writeMode` only exists under that + * opt-in. The default posture (`allowWrites: false`) is always + * `mutating: false` + plan mode — the invalid pairing (mutating in plan mode, + * or writes without the opt-in) cannot be expressed by this state at all. + */ +export interface ScheduleFormValue { + name: string; + prompt: string; + triggerKind: "cron" | "one-shot"; + cron: string; + timezone: string; + /** Numeric text; empty or "0" means unlimited (the wire's meaning of 0). */ + maxFires: string; + /** `datetime-local` value, interpreted in the browser's local time. */ + oneShotAt: string; + oneShotRetry: boolean; + oneShotMaxRetries: string; + allowWrites: boolean; + writeMode: "default" | "accept_edits"; +} + +export function emptyScheduleForm(): ScheduleFormValue { + return { + name: "", + prompt: "", + triggerKind: "cron", + cron: "0 9 * * *", + timezone: browserTimezone(), + maxFires: "", + oneShotAt: "", + oneShotRetry: false, + oneShotMaxRetries: "3", + allowWrites: false, + writeMode: "accept_edits", + }; +} + +function browserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + return ""; + } +} + +function toLocalDateTimeInput(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * Seed the form from a stored draft (the edit path). A spec whose + * mode/mutating pairing this form cannot express (e.g. a CLI-authored + * `mutating: false` + default mode) is coerced to the nearest expressible + * posture — the form's invariant wins over round-tripping an odd pairing. + */ +export function formFromDraft(draft: ScheduleSpecDraft): ScheduleFormValue { + const base = emptyScheduleForm(); + return { + ...base, + name: draft.name, + prompt: draft.prompt, + triggerKind: draft.trigger.kind, + cron: draft.trigger.kind === "cron" ? draft.trigger.cron : base.cron, + timezone: + draft.trigger.kind === "cron" && draft.trigger.timezone + ? draft.trigger.timezone + : base.timezone, + maxFires: draft.maxFires > 0 ? String(draft.maxFires) : "", + oneShotAt: + draft.trigger.kind === "one-shot" + ? toLocalDateTimeInput(draft.trigger.at) + : "", + oneShotRetry: draft.oneShotRetry, + oneShotMaxRetries: + draft.oneShotMaxRetries > 0 ? String(draft.oneShotMaxRetries) : "3", + allowWrites: draft.mutating, + writeMode: + draft.mode === PERMISSION_MODES.PERMISSION_MODE_DEFAULT + ? "default" + : "accept_edits", + }; +} + +/** + * Build the wire draft. `base` is the stored draft on an edit — it supplies + * the spec fields this form has no controls for (profile, workspace, limits) + * so they survive the PUT-replaces-everything contract. + */ +export function draftFromForm( + value: ScheduleFormValue, + base?: ScheduleSpecDraft, +): ScheduleSpecDraft { + const mode = value.allowWrites + ? value.writeMode === "default" + ? PERMISSION_MODES.PERMISSION_MODE_DEFAULT + : PERMISSION_MODES.PERMISSION_MODE_ACCEPT_EDITS + : PERMISSION_MODES.PERMISSION_MODE_PLAN; + return { + name: value.name.trim(), + prompt: value.prompt.trim(), + trigger: + value.triggerKind === "cron" + ? { + kind: "cron", + cron: value.cron.trim(), + timezone: value.timezone.trim(), + } + : { kind: "one-shot", at: new Date(value.oneShotAt).getTime() }, + profile: base?.profile ?? "", + workspace: base?.workspace ?? "", + mode, + mutating: value.allowWrites, + maxFires: Math.max(0, Number(value.maxFires) || 0), + limits: base?.limits ?? { + maxTurns: 0, + maxToolCalls: 0, + maxConsecutiveFailures: 0, + }, + oneShotRetry: value.oneShotRetry, + oneShotMaxRetries: value.oneShotRetry + ? Math.max(0, Number(value.oneShotMaxRetries) || 0) + : 0, + }; +} + +/** + * Client-side gate for the submit button only — shape checks a request could + * never survive. Everything else (frequency floors, cron grammar, name rules) + * is the daemon's call and its refusal is shown verbatim. + */ +export function scheduleFormProblem(value: ScheduleFormValue): string | null { + if (!value.name.trim()) return "Name is required."; + if (!value.prompt.trim()) return "Prompt is required."; + if (value.triggerKind === "cron") { + if (!value.cron.trim()) return "Cron expression is required."; + } else { + if (!value.oneShotAt) return "Run time is required."; + if (Number.isNaN(new Date(value.oneShotAt).getTime())) + return "Run time is not a valid date."; + } + return null; +} + +/** Badge label for a wire permission mode; list and detail share the wording. */ +export function permissionModeLabel(mode: number): string { + switch (mode) { + case PERMISSION_MODES.PERMISSION_MODE_DEFAULT: + return "default"; + case PERMISSION_MODES.PERMISSION_MODE_PLAN: + return "plan"; + case PERMISSION_MODES.PERMISSION_MODE_ACCEPT_EDITS: + return "accept edits"; + default: + return "unspecified"; + } +} + +/** "YYYY-MM-DDTHH:MM" → its date and time halves ("" when absent). */ +function oneShotParts(at: string): { date: string; time: string } { + return { date: at.slice(0, 10), time: at.slice(11, 16) }; +} + +/** Local (not UTC) YYYY-MM-DD / HH:MM for a Date — presets and defaults. */ +function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Compose the datetime-local string, defaulting the missing half sensibly. */ +function joinOneShot(date: string, time: string): string { + if (!date && !time) return ""; + return `${date || localDate(new Date())}T${time || "09:00"}`; +} + +export function ScheduleFormFields({ + value, + onChange, +}: { + value: ScheduleFormValue; + onChange: (patch: Partial) => void; +}) { + return ( +
+
+ + onChange({ name: e.target.value })} + placeholder="daily-standup-summary" + required + /> +
+ +
+ +