diff --git a/packages/app/e2e/automations/automations-panel.spec.ts b/packages/app/e2e/automations/automations-panel.spec.ts new file mode 100644 index 000000000..14954db3d --- /dev/null +++ b/packages/app/e2e/automations/automations-panel.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" + +const recurring = (projectID: string, title: string, prompt: string, expression: string) => ({ + automationCreateInput: { + kind: "recurring" as const, + title, + prompt, + context: "fresh" as const, + where: { projectID }, + timezone: "UTC", + model: { providerID: "opencode", modelID: "big-pickle" }, + rhythm: { kind: "cron" as const, expression }, + stop: { kind: "never" as const }, + }, +}) + +test("@smoke automations panel: list, detail, pause, delete", async ({ page, project }) => { + test.setTimeout(120_000) + + await project.open() + await openSidebar(page) + + const toggle = page.locator('[data-action="pawwork-automations-open"]') + await toggle.click() + + const surface = page.locator('[data-component="automations-page"]') + await expect(surface).toBeVisible() + await expect(surface.locator('[data-component="automations-empty"]')).toBeVisible() + + // Unlike the Settings takeover, opening Automations keeps the sidebar live: its + // toggle stays mounted and pressed, and the settings nav never replaces it. + await expect(toggle).toHaveAttribute("aria-pressed", "true") + await expect(page.locator('[data-component="settings-nav"]')).toHaveCount(0) + + // Seed through the SDK; the live SSE event populates the list without a reload. + const projectID = (await project.sdk.project.current()).data!.id + await project.sdk.automation.create( + recurring(projectID, "Daily standup digest", "Summarize overnight changes and list open PRs.", "0 9 * * *"), + ) + + const rows = surface.locator('[data-action="automation-row"]') + await expect(rows).toHaveCount(1) + + await rows.first().click() + const detail = surface.locator('[data-component="automation-detail"]') + await expect(detail).toBeVisible() + await expect(detail.getByRole("heading", { name: "Daily standup digest" })).toBeVisible() + + // Pause flips the icon-only action's aria-label to Resume and the status row to Paused. + await detail.locator('[data-action="automation-toggle-active"]').click() + await expect(detail.locator('[data-action="automation-toggle-active"]')).toHaveAttribute("aria-label", "Resume") + await expect(detail.getByText("Paused")).toBeVisible() + + // Delete confirms through a dialog and drops back to the empty list. + await detail.locator('[data-action="automation-delete"]').click() + const dialog = page.locator('[data-component="dialog"]') + await expect(dialog).toBeVisible() + await dialog.locator('[data-action="automation-delete-confirm"]').click() + + await expect(surface.locator('[data-component="automations-empty"]')).toBeVisible() + await expect(rows).toHaveCount(0) +}) + +test("automations panel: escape unwinds detail then closes the surface", async ({ page, project }) => { + test.setTimeout(120_000) + + await project.open() + await openSidebar(page) + + const toggle = page.locator('[data-action="pawwork-automations-open"]') + await toggle.click() + + const surface = page.locator('[data-component="automations-page"]') + await expect(surface).toBeVisible() + + const projectID = (await project.sdk.project.current()).data!.id + await project.sdk.automation.create( + recurring(projectID, "Hourly build watch", "Check CI and flag a red main build.", "0 * * * *"), + ) + + const rows = surface.locator('[data-action="automation-row"]') + await expect(rows).toHaveCount(1) + await rows.first().click() + await expect(surface.locator('[data-component="automation-detail"]')).toBeVisible() + + // First Escape returns to the list, second Escape closes the surface entirely. + await page.keyboard.press("Escape") + await expect(surface.locator('[data-component="automation-detail"]')).toHaveCount(0) + await expect(rows).toHaveCount(1) + + await page.keyboard.press("Escape") + await expect(surface).toHaveCount(0) + await expect(toggle).toHaveAttribute("aria-pressed", "false") +}) diff --git a/packages/app/e2e/snap/automations-surface.snap.ts b/packages/app/e2e/snap/automations-surface.snap.ts new file mode 100644 index 000000000..60cc55d67 --- /dev/null +++ b/packages/app/e2e/snap/automations-surface.snap.ts @@ -0,0 +1,61 @@ +import { test } from "../fixtures" +import { openSidebar } from "../actions" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) + +const recurring = (projectID: string, title: string, prompt: string, expression: string) => ({ + automationCreateInput: { + kind: "recurring" as const, + title, + prompt, + context: "fresh" as const, + where: { projectID }, + timezone: "UTC", + model: { providerID: "opencode", modelID: "big-pickle" }, + rhythm: { kind: "cron" as const, expression }, + stop: { kind: "never" as const }, + }, +}) + +test("automations-surface", async ({ page, project }) => { + test.setTimeout(180_000) + + await project.open() + await openSidebar(page) + + await page.locator('[data-action="pawwork-automations-open"]').click() + const surface = page.locator('[data-component="automations-page"]') + await surface.waitFor({ state: "visible", timeout: 30_000 }) + await surface.locator('[data-component="automations-empty"]').waitFor({ state: "visible", timeout: 30_000 }) + const empty = await page.screenshot() + + // Seed via SDK; the live SSE event populates the list without a reload. + const projectID = (await project.sdk.project.current()).data!.id + await project.sdk.automation.create(recurring(projectID, "Daily standup digest", "Summarize overnight changes and list open PRs.", "0 9 * * *")) + await project.sdk.automation.create(recurring(projectID, "Hourly build watch", "Check CI and flag a red main build.", "0 * * * *")) + + const rows = surface.locator('[data-action="automation-row"]') + await rows.first().waitFor({ state: "visible", timeout: 30_000 }) + await page.waitForFunction(() => document.querySelectorAll('[data-action="automation-row"]').length >= 2) + const list = await page.screenshot() + + // Hover a row to reveal the one-click pause/resume action. + await rows.first().hover() + await surface.locator('[data-action="automation-toggle-active"]').first().waitFor({ state: "visible", timeout: 10_000 }) + const listHover = await page.screenshot() + + await rows.first().click() + await surface.locator('[data-component="automation-detail"]').waitFor({ state: "visible", timeout: 30_000 }) + const detail = await page.screenshot() + + const shots: Shot[] = [ + { name: "empty", buf: empty }, + { name: "list", buf: list }, + { name: "list-hover", buf: listHover }, + { name: "detail", buf: detail }, + ] + const out = snapOutputPath("automations-surface") + await composeGrid(shots, out) + process.stdout.write(`\n[snap] automations-surface grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/components/dialog-delete-automation.tsx b/packages/app/src/components/dialog-delete-automation.tsx new file mode 100644 index 000000000..621c66aa1 --- /dev/null +++ b/packages/app/src/components/dialog-delete-automation.tsx @@ -0,0 +1,42 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createSignal } from "solid-js" +import { useLanguage } from "@/context/language" + +export function DialogDeleteAutomation(props: { + title: string + onConfirm: () => Promise | void +}) { + const language = useLanguage() + const dialog = useDialog() + const [deleting, setDeleting] = createSignal(false) + + const handleDelete = async () => { + if (deleting()) return + setDeleting(true) + try { + await props.onConfirm() + dialog.close() + } finally { + setDeleting(false) + } + } + + return ( + +
+ {language.t("automations.delete.confirm", { title: props.title })} +

{language.t("automations.delete.description")}

+
+
+ + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index bc90d952b..d2da451da 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -91,7 +91,7 @@ export function SessionHeader() { return ( <> - + {(mount) => ( + } + > + {(sessionID) => ( + + )} + + + ) + }} + + + INITIAL_RUN_COUNT}> + + + + + ) +} + +export function AutomationDetail(props: { + automation: Accessor + directory: Accessor + onBack: () => void + onOpenRun: (sessionID: string) => void +}): JSX.Element { + const globalSync = useGlobalSync() + const language = useLanguage() + const dialog = useDialog() + const t = language.t + const [busy, setBusy] = createSignal(false) + + onMount(() => { + // Load only the most recent page; the "Recent runs" heading scopes the list + // to that page, so the returned nextCursor is intentionally not paged. + void globalSync.automation.loadRuns(props.directory(), props.automation().id) + }) + + const runs = createMemo(() => { + const directory = props.directory() + if (!directory) return [] + const [store] = globalSync.child(directory, { bootstrap: false }) + const id = props.automation().id + return Object.values(store.automation_run) + .filter((run) => run.automationID === id) + .sort((a, b) => b.triggeredAt - a.triggeredAt) + }) + + const lastRunLabel = createMemo(() => { + const run = runs()[0] + return run ? getRelativeTime(new Date(run.triggeredAt).toISOString(), t) : undefined + }) + + const nextRunLabel = createMemo(() => { + const automation = props.automation() + if (automation.kind !== "recurring" || automation.paused || automation.nextFireAt == null) return undefined + return formatTimestamp(automation.nextFireAt, automation.timezone) + }) + + const reasoningLabel = createMemo(() => props.automation().variant) + + const notifyFailure = (error: unknown) => { + showToast({ + variant: "error", + title: t("automations.toast.actionFailed.title"), + description: formatServerError(error, t), + }) + } + + const runNow = async () => { + if (busy()) return + setBusy(true) + try { + await globalSync.automation.runNow(props.directory(), props.automation().id) + } catch (error) { + notifyFailure(error) + } finally { + setBusy(false) + } + } + + const toggleActive = async () => { + if (busy()) return + const automation = props.automation() + setBusy(true) + try { + if (automation.paused) await globalSync.automation.resume(props.directory(), automation.id) + else await globalSync.automation.pause(props.directory(), automation.id) + } catch (error) { + notifyFailure(error) + } finally { + setBusy(false) + } + } + + const confirmDelete = () => { + const automation = props.automation() + dialog.show(() => ( + { + try { + await globalSync.automation.delete(props.directory(), automation.id) + props.onBack() + } catch (error) { + notifyFailure(error) + throw error + } + }} + /> + )) + } + + return ( +
+ + +
+

{props.automation().title}

+
+ +
+
+ +
+
+

+ {t("automations.detail.instructions")} +

+

{props.automation().prompt}

+
+ + +
+
+ ) +} diff --git a/packages/app/src/pages/automations/automation-list.tsx b/packages/app/src/pages/automations/automation-list.tsx new file mode 100644 index 000000000..e32b4fe8c --- /dev/null +++ b/packages/app/src/pages/automations/automation-list.tsx @@ -0,0 +1,54 @@ +import { For, type Accessor, type JSX } from "solid-js" +import type { AutomationDefinition } from "@opencode-ai/sdk/v2/client" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { formatScheduleSummary } from "./automation-schedule" + +export function AutomationList(props: { + automations: Accessor + onSelect: (id: string) => void + onToggleActive: (automation: AutomationDefinition) => void +}): JSX.Element { + const language = useLanguage() + return ( +
    + + {(automation) => ( +
  • + + +
  • + )} +
    +
+ ) +} diff --git a/packages/app/src/pages/automations/automation-run-status.tsx b/packages/app/src/pages/automations/automation-run-status.tsx new file mode 100644 index 000000000..981987bf8 --- /dev/null +++ b/packages/app/src/pages/automations/automation-run-status.tsx @@ -0,0 +1,29 @@ +import type { JSX } from "solid-js" +import type { AutomationRun } from "@opencode-ai/sdk/v2/client" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" + +type RunState = AutomationRun["state"] + +export function runStatusLabelKey(state: RunState): string { + return `automations.run.${state}` +} + +// Run status reuses the sidebar's visual vocabulary: a spinner while running, +// the asking-comment glyph while blocked, and semantic check/cross otherwise. +export function RunStatusIcon(props: { state: RunState; label: string }): JSX.Element { + switch (props.state) { + case "running": + return + case "awaiting_input": + return + case "succeeded": + return + case "failed": + return + case "stopped": + return + case "scheduled": + return + } +} diff --git a/packages/app/src/pages/automations/automation-schedule.test.ts b/packages/app/src/pages/automations/automation-schedule.test.ts new file mode 100644 index 000000000..666937b6c --- /dev/null +++ b/packages/app/src/pages/automations/automation-schedule.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import type { AutomationDefinition, AutomationRhythm } from "@opencode-ai/sdk/v2/client" +import { formatScheduleSummary } from "./automation-schedule" + +const t = (key: string, vars?: Record) => (vars ? `${key}:${JSON.stringify(vars)}` : key) + +const recurring = (rhythm: AutomationRhythm): AutomationDefinition => + ({ kind: "recurring", rhythm }) as AutomationDefinition + +const oneshot = (): AutomationDefinition => ({ kind: "oneshot" }) as AutomationDefinition + +describe("formatScheduleSummary", () => { + test("oneshot", () => { + expect(formatScheduleSummary(oneshot(), t)).toBe("automations.schedule.once") + }) + + test("hourly cron", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "0 * * * *" }), t)).toBe("automations.schedule.hourly") + }) + + test("daily cron with time", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "5 9 * * *" }), t)).toBe( + 'automations.schedule.daily:{"time":"09:05"}', + ) + }) + + test("weekdays cron", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "0 9 * * 1-5" }), t)).toBe( + 'automations.schedule.weekdays:{"time":"09:00"}', + ) + }) + + test("weekly cron names the weekday", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "30 8 * * 0" }), t)).toBe( + 'automations.schedule.weekly:{"day":"automations.schedule.weekday.0","time":"08:30"}', + ) + }) + + test("weekly cron distinguishes weekdays at the same time", () => { + const monday = formatScheduleSummary(recurring({ kind: "cron", expression: "0 9 * * 1" }), t) + const friday = formatScheduleSummary(recurring({ kind: "cron", expression: "0 9 * * 5" }), t) + expect(monday).toBe('automations.schedule.weekly:{"day":"automations.schedule.weekday.1","time":"09:00"}') + expect(friday).toBe('automations.schedule.weekly:{"day":"automations.schedule.weekday.5","time":"09:00"}') + expect(monday).not.toBe(friday) + }) + + test("non-standard cron falls back to custom", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "0 9 1 * *" }), t)).toBe("automations.schedule.custom") + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "*/15 * * * *" }), t)).toBe("automations.schedule.custom") + }) + + test("fixed-minute hourly cron", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "30 * * * *" }), t)).toBe("automations.schedule.hourly") + }) + + test("interval in seconds, minutes and hours", () => { + expect(formatScheduleSummary(recurring({ kind: "interval", everyMs: 30 * 1000 }), t)).toBe( + 'automations.schedule.every:{"duration":"automations.schedule.seconds:{\\"count\\":30}"}', + ) + expect(formatScheduleSummary(recurring({ kind: "interval", everyMs: 30 * 60000 }), t)).toBe( + 'automations.schedule.every:{"duration":"automations.schedule.minutes:{\\"count\\":30}"}', + ) + expect(formatScheduleSummary(recurring({ kind: "interval", everyMs: 90 * 60000 }), t)).toBe( + 'automations.schedule.every:{"duration":"automations.schedule.minutes:{\\"count\\":90}"}', + ) + expect(formatScheduleSummary(recurring({ kind: "interval", everyMs: 2 * 3600000 }), t)).toBe( + 'automations.schedule.every:{"duration":"automations.schedule.hours:{\\"count\\":2}"}', + ) + }) +}) diff --git a/packages/app/src/pages/automations/automation-schedule.ts b/packages/app/src/pages/automations/automation-schedule.ts new file mode 100644 index 000000000..c9520ee61 --- /dev/null +++ b/packages/app/src/pages/automations/automation-schedule.ts @@ -0,0 +1,76 @@ +import type { AutomationDefinition } from "@opencode-ai/sdk/v2/client" + +type Translate = (key: string, vars?: Record) => string + +function pad(value: number) { + return value.toString().padStart(2, "0") +} + +function formatInterval(everyMs: number, t: Translate) { + if (everyMs < 60000) { + const seconds = Math.round(everyMs / 1000) + return t("automations.schedule.every", { duration: t("automations.schedule.seconds", { count: seconds }) }) + } + const minutes = Math.round(everyMs / 60000) + // Only collapse to an hour label when the minutes divide evenly; otherwise the + // exact minute count is shown so a 90-minute cadence isn't rounded to "2 h". + if (minutes % 60 !== 0) return t("automations.schedule.every", { duration: t("automations.schedule.minutes", { count: minutes }) }) + const hours = minutes / 60 + return t("automations.schedule.every", { duration: t("automations.schedule.hours", { count: hours }) }) +} + +// Humanize the cron shapes the create card emits (hourly / daily / weekdays / +// weekly). Anything else is reported as a custom schedule rather than guessed. +function formatCron(expression: string, t: Translate) { + const parts = expression.trim().split(/\s+/) + if (parts.length !== 5) return t("automations.schedule.custom") + const [minute, hour, dom, month, dow] = parts + const everyDay = dom === "*" && month === "*" + if (!everyDay) return t("automations.schedule.custom") + + // "Hourly" only fits a single fixed minute on every hour and day (e.g. + // `0 * * * *`); a stepped/ranged minute like `*/15 * * * *` runs more often. + if (hour === "*") { + if (dow === "*" && /^[0-5]?\d$/.test(minute)) return t("automations.schedule.hourly") + return t("automations.schedule.custom") + } + + const minuteNum = Number(minute) + const hourNum = Number(hour) + if (!Number.isInteger(minuteNum) || !Number.isInteger(hourNum)) return t("automations.schedule.custom") + const time = `${pad(hourNum)}:${pad(minuteNum)}` + + if (dow === "*") return t("automations.schedule.daily", { time }) + if (dow === "1-5") return t("automations.schedule.weekdays", { time }) + // A single weekday (cron 0=Sun..6=Sat) must name the day, otherwise Monday and + // Friday at the same time render identically. + if (/^[0-6]$/.test(dow)) { + const day = t(`automations.schedule.weekday.${dow}`) + return t("automations.schedule.weekly", { day, time }) + } + return t("automations.schedule.custom") +} + +export function formatScheduleSummary(definition: AutomationDefinition, t: Translate): string { + if (definition.kind === "oneshot") return t("automations.schedule.once") + if (definition.rhythm.kind === "interval") return formatInterval(definition.rhythm.everyMs, t) + return formatCron(definition.rhythm.expression, t) +} + +// Absolute short timestamp for future-facing fields (next run) where a "… ago" +// relative phrase would read wrong. Falls back to the host locale if the +// definition timezone is rejected by Intl. +export function formatTimestamp(ms: number, timezone?: string): string { + if (!Number.isFinite(ms)) return "" + const options: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + } + try { + return new Intl.DateTimeFormat(undefined, { ...options, timeZone: timezone }).format(new Date(ms)) + } catch { + return new Intl.DateTimeFormat(undefined, options).format(new Date(ms)) + } +} diff --git a/packages/app/src/pages/automations/automations-surface.tsx b/packages/app/src/pages/automations/automations-surface.tsx new file mode 100644 index 000000000..461b5ae51 --- /dev/null +++ b/packages/app/src/pages/automations/automations-surface.tsx @@ -0,0 +1,116 @@ +import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js" +import type { AutomationDefinition } from "@opencode-ai/sdk/v2/client" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { formatServerError } from "@/utils/server-errors" +import { AutomationList } from "./automation-list" +import { AutomationDetail } from "./automation-detail" + +function AutomationsEmpty(): JSX.Element { + const language = useLanguage() + return ( +
+ + + +
+
{language.t("automations.empty.title")}
+

{language.t("automations.empty.description")}

+
+
+ ) +} + +export function AutomationsSurface(props: { + directory: Accessor + onClose: () => void + onOpenRun: (sessionID: string) => void +}): JSX.Element { + const globalSync = useGlobalSync() + const language = useLanguage() + const [selectedID, setSelectedID] = createSignal() + + // Escape returns to the list when a row is open, otherwise closes the surface. + // The capture listener bails while a transient overlay is open; unlike the + // settings takeover, the sidebar stays live here, so its dropdown/context + // menus must get Escape first instead of being preempted into closing us. + onMount(() => { + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if ( + document.querySelector( + '[data-component="dialog-overlay"], [data-component="select-content"], [data-component="dropdown-menu-content"], [data-component="context-menu-content"]', + ) + ) + return + event.preventDefault() + if (selectedID()) { + setSelectedID(undefined) + return + } + props.onClose() + } + document.addEventListener("keydown", onEscape, true) + onCleanup(() => document.removeEventListener("keydown", onEscape, true)) + }) + + const automations = createMemo(() => { + const directory = props.directory() + if (!directory) return [] + const [store] = globalSync.child(directory, { bootstrap: false }) + return Object.values(store.automation).sort((a, b) => + a.updatedAt !== b.updatedAt ? b.updatedAt - a.updatedAt : a.id < b.id ? 1 : -1, + ) + }) + + const selected = createMemo(() => { + const id = selectedID() + if (!id) return undefined + return automations().find((automation) => automation.id === id) + }) + + const toggleActive = async (automation: AutomationDefinition) => { + const directory = props.directory() + if (!directory) return + try { + if (automation.paused) await globalSync.automation.resume(directory, automation.id) + else await globalSync.automation.pause(directory, automation.id) + } catch (error) { + showToast({ + variant: "error", + title: language.t("automations.toast.actionFailed.title"), + description: formatServerError(error, language.t), + }) + } + } + + return ( +
+
+ 0} fallback={}> + + + } + > + {(automation) => ( + setSelectedID(undefined)} + onOpenRun={props.onOpenRun} + /> + )} + +
+
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c5814390e..d9dc0fd60 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -87,6 +87,7 @@ import { createPawworkSessionController } from "./layout/pawwork-session-control import { createPawworkProjectControls } from "./layout/pawwork-project-controls" import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" +import { AutomationsSurface } from "@/pages/automations/automations-surface" import { createDefaultLayoutPageState, createLayoutPagePersistTarget } from "./layout/layout-page-store" import { SettingsContent, SettingsNav, isSettingsTab, type SettingsTab } from "@/pages/settings/settings-shell" import { DialogDeleteSession } from "@/components/dialog-delete-session" @@ -105,7 +106,11 @@ export default function Layout(props: ParentProps) { let scrollContainerRef: HTMLDivElement | undefined let dialogRun = 0 let dialogDead = false - const [settingsOpen, setSettingsOpen] = createSignal(false) + // One mutually-exclusive shell surface at a time. Settings replaces the + // sidebar + main; automations only takes over main (sidebar stays live). + const [activeSurface, setActiveSurface] = createSignal<"none" | "settings" | "automations">("none") + const settingsOpen = createMemo(() => activeSurface() === "settings") + const automationsOpen = createMemo(() => activeSurface() === "automations") const [settingsTab, setSettingsTab] = createSignal("general") const params = useParams() @@ -370,6 +375,7 @@ export default function Layout(props: ParentProps) { sessions: pawworkSessions, sessionSections: pawworkSessionSections, sessionByID: pawworkSessionByID, + loadSessionByID, navigationSessions: pawworkNavigationSessions, projectKeyForSession, windowLoading: pawworkSessionWindowLoading, @@ -652,7 +658,11 @@ export default function Layout(props: ParentProps) { // as the tab argument — only a known tab string selects a page, anything // else falls back to General. setSettingsTab(typeof tab === "string" && isSettingsTab(tab) ? tab : "general") - setSettingsOpen(true) + setActiveSurface("settings") + } + + function toggleAutomations() { + setActiveSurface((current) => (current === "automations" ? "none" : "automations")) } function openSettings(tab?: SettingsTab) { @@ -682,11 +692,19 @@ export default function Layout(props: ParentProps) { } createEffect(() => { - command.setModalOpen(settingsOpen()) + command.setModalOpen(activeSurface() !== "none") }) function closeSettings() { - setSettingsOpen(false) + setActiveSurface("none") + } + + // Opening a run from the Automations panel leaves the surface and lands on the + // run's chat session, which also lives in the normal All chats list. + async function openAutomationRun(sessionID: string) { + closeSettings() + const session = await loadSessionByID(sessionID) + if (session) navigateToSession(session) } @@ -1527,6 +1545,9 @@ export default function Layout(props: ParentProps) { onNew={() => openPawworkHome(options?.directory)} onSearch={() => command.show()} onOpenProject={chooseProject} + onOpenAutomations={toggleAutomations} + automationsActive={automationsOpen} + automationsLabel={() => language.t("sidebar.pawwork.automations")} onOpenSettings={() => openSettings()} settingsLabel={() => language.t("sidebar.settings")} settingsKeybind={() => command.keybind("settings.open")} @@ -1557,6 +1578,7 @@ export default function Layout(props: ParentProps) { , content: () => , }} + automations={{ + open: automationsOpen, + title: () => language.t("automations.title"), + content: () => ( + currentProject()?.worktree ?? projectRoot(currentDir())} + onClose={closeSettings} + onOpenRun={openAutomationRun} + /> + ), + }} main={() => ( }> {props.children} diff --git a/packages/app/src/pages/layout/layout-shell-frame.tsx b/packages/app/src/pages/layout/layout-shell-frame.tsx index 6db1093f5..a5ec438f4 100644 --- a/packages/app/src/pages/layout/layout-shell-frame.tsx +++ b/packages/app/src/pages/layout/layout-shell-frame.tsx @@ -31,6 +31,11 @@ type LayoutShellFrameProps = { nav: () => JSXElement content: () => JSXElement } + automations: { + open: Accessor + title: Accessor + content: () => JSXElement + } main: () => JSXElement } @@ -43,6 +48,11 @@ export function LayoutShellFrame(props: LayoutShellFrameProps) { }), ) + // Settings replaces both sidebar and main; automations only takes over main + // and keeps the session sidebar interactive. mainSurfaceOpen covers either. + const mainSurfaceOpen = createMemo(() => props.settings.open() || props.automations.open()) + const surfaceTitle = createMemo(() => (props.automations.open() ? props.automations.title() : props.settings.title())) + createEffect(() => { const dialogLeftMargin = props.sidebar.visible() ? sidebarWidth() : 0 document.documentElement.style.setProperty("--dialog-left-margin", `${dialogLeftMargin}px`) @@ -74,7 +84,7 @@ export function LayoutShellFrame(props: LayoutShellFrameProps) { class="flex flex-1 min-h-0 min-w-0 flex-col" > - +
@@ -136,16 +146,19 @@ export function LayoutShellFrame(props: LayoutShellFrameProps) { >
{props.main()}
- {/* Settings takeover keeps the session page mounted so terminal and panel state survive. */} + {/* Surface takeovers keep the session page mounted so terminal and panel state survive. */}
{props.settings.content()}
+ +
{props.automations.content()}
+
diff --git a/packages/app/src/pages/layout/pawwork-session-controller.ts b/packages/app/src/pages/layout/pawwork-session-controller.ts index 102fbedbd..ba0b1024e 100644 --- a/packages/app/src/pages/layout/pawwork-session-controller.ts +++ b/packages/app/src/pages/layout/pawwork-session-controller.ts @@ -318,6 +318,7 @@ export function createPawworkSessionController(input: PawworkSessionControllerIn sessions: pawworkSessions, sessionSections: pawworkSessionSections, sessionByID: pawworkSessionByID, + loadSessionByID, navigationSessions: pawworkNavigationSessions, projectKeyForSession, windowLoading, diff --git a/packages/app/src/pages/layout/pawwork-sidebar.tsx b/packages/app/src/pages/layout/pawwork-sidebar.tsx index 8169585f6..0486d140a 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar.tsx +++ b/packages/app/src/pages/layout/pawwork-sidebar.tsx @@ -146,6 +146,9 @@ export const PawworkSidebar = (props: { onNew: () => void onSearch: () => void onOpenProject: () => void + onOpenAutomations: () => void + automationsActive: Accessor + automationsLabel: Accessor onOpenSettings: () => void settingsLabel: Accessor settingsKeybind: Accessor @@ -486,6 +489,26 @@ export const PawworkSidebar = (props: { {language.t("sidebar.pawwork.search")} + + +
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index aecbe6a58..a56e2b00c 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -10,6 +10,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" +import { useShellSurface } from "@/context/shell-surface" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/blockers/request-tree" @@ -55,10 +56,18 @@ const SessionRow = (props: { titleContent?: JSX.Element }): JSX.Element => { const title = () => sessionTitle(props.session.title) + const shellSurface = useShellSurface() return ( void }): JSX.Element => { const language = useLanguage() + const shellSurface = useShellSurface() const label = language.t("command.session.new") const item = ( { if (!props.onOpenNewSession) return diff --git a/packages/app/src/pages/session/right-panel-tab-strip.tsx b/packages/app/src/pages/session/right-panel-tab-strip.tsx index 4d2b6ae43..a5dc17867 100644 --- a/packages/app/src/pages/session/right-panel-tab-strip.tsx +++ b/packages/app/src/pages/session/right-panel-tab-strip.tsx @@ -10,6 +10,7 @@ import { SessionContextUsage } from "@/components/session-context-usage" import { ShellTab, SortableShellTab } from "@/components/session" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { useShellSurface } from "@/context/shell-surface" import { sortableShellTabIds } from "@/pages/session/helpers" import type { RightPanelShellIconName, RightPanelTab, ShellTabIcon } from "@/pages/session/right-panel-tabs" @@ -66,6 +67,7 @@ export function RightPanelTabStrip(props: { }) { const language = useLanguage() const command = useCommand() + const shellSurface = useShellSurface() // `` keys by reference identity. The parent's `shellTabs()` returns a // fresh array of fresh objects on every recompute (session-side-panel.tsx:137 // builds it via `.map(...)`), so iterating that array directly would cause @@ -86,7 +88,7 @@ export function RightPanelTabStrip(props: { return map }) return ( - + {(mount) => ( {/* Tabs.List portals into 's `pawwork-titlebar-tabs` slot so the diff --git a/packages/opencode/src/automation/derived.ts b/packages/opencode/src/automation/derived.ts index 82b856301..4e348fb9d 100644 --- a/packages/opencode/src/automation/derived.ts +++ b/packages/opencode/src/automation/derived.ts @@ -12,17 +12,17 @@ const PLUS_ONE_DAY = { days: 1 } const PLUS_ONE_HOUR = { hours: 1 } const PLUS_ONE_MINUTE = { minutes: 1 } -function nextCronFires(definition: RecurringDefinition, from: number, count: number): number[] { - if (definition.rhythm.kind !== "cron" || count <= 0) return [] +function collectCronFires(expression: string, timezone: string, from: number, count: number): number[] { + if (count <= 0) return [] let schedule: CronSchedule try { - schedule = parseCronSchedule(definition.rhythm.expression) + schedule = parseCronSchedule(expression) } catch { return [] } const fires: number[] = [] const maxTimestamp = from + CRON_LOOKAHEAD_MINUTES * 60 * 1000 - let cursor = DateTime.fromMillis(from, { zone: definition.timezone }).plus(PLUS_ONE_MINUTE).startOf("minute") + let cursor = DateTime.fromMillis(from, { zone: timezone }).plus(PLUS_ONE_MINUTE).startOf("minute") while (cursor.toMillis() < maxTimestamp && fires.length < count) { if (!schedule.months.has(cursor.month)) { cursor = cursor.plus(PLUS_ONE_MONTH).startOf("month") @@ -47,6 +47,16 @@ function nextCronFires(definition: RecurringDefinition, from: number, count: num return fires } +function nextCronFires(definition: RecurringDefinition, from: number, count: number): number[] { + if (definition.rhythm.kind !== "cron") return [] + return collectCronFires(definition.rhythm.expression, definition.timezone, from, count) +} + +/** First cron fire strictly after `from` in `timezone`, or null if none within the lookahead window. */ +export function nextCronFireAfter(expression: string, timezone: string, from: number): number | null { + return collectCronFires(expression, timezone, from, 1)[0] ?? null +} + function nextIntervalFires(definition: RecurringDefinition, from: number, count: number): number[] { if (definition.rhythm.kind !== "interval" || count <= 0) return [] const fires: number[] = [] diff --git a/packages/opencode/src/tool/automate.ts b/packages/opencode/src/tool/automate.ts index 5705dcccb..3c05647f3 100644 --- a/packages/opencode/src/tool/automate.ts +++ b/packages/opencode/src/tool/automate.ts @@ -1,20 +1,15 @@ import { Effect, Schema } from "effect" import { Automation, ValidationError } from "@/automation" +import { nextCronFireAfter } from "@/automation/derived" import { AutomationScheduler } from "@/automation/scheduler" import { validateModelAndVariantWith } from "@/automation/validation" +import { Instance } from "@/project/instance" import { Provider } from "@/provider/provider" +import { MessageV2 } from "@/session/message-v2" +import type { SessionID } from "@/session/schema" +import { NotFoundError } from "@/storage/db" import * as Tool from "./tool" -const Where = Schema.Struct({ - projectID: Schema.String, - worktree: Schema.optional(Schema.NonEmptyString), -}) - -const Model = Schema.Struct({ - providerID: Schema.NonEmptyString, - modelID: Schema.NonEmptyString, -}) - const Timezone = Schema.NonEmptyString.check( Schema.makeFilter((timezone: string) => (Automation.isValidTimezone(timezone) ? undefined : "invalid_timezone")), ) @@ -25,50 +20,23 @@ const CronExpression = Schema.NonEmptyString.check( ) const Title = Schema.NonEmptyString.check(Schema.isMaxLength(Automation.MAX_TITLE_CHARS)) const Prompt = Schema.NonEmptyString.check(Schema.isMaxLength(Automation.MAX_PROMPT_CHARS)) -const Condition = Schema.NonEmptyString.check(Schema.isMaxLength(Automation.MAX_CONDITION_CHARS)) -const Common = { +// Flat LLM surface: every field is a scalar, no union/anyOf node, so models +// cannot serialize a nested object into a JSON string (the failure mode the +// old `where` and `rhythm` unions triggered in function-calling schemas). +// execute() translates this into the frozen Automation.CreateInput. Only +// title/prompt/cron are required; project, timezone, and model fall back to the +// calling session's context. Interval/sub-minute cadence stays a UI/SDK +// power-user feature and is intentionally off the AI surface. +export const AutomateParameters = Schema.Struct({ title: Title, prompt: Prompt, - context: Schema.Union([Schema.Literal("continue"), Schema.Literal("fresh")]), - where: Where, - timezone: Timezone, - model: Model, + cron: CronExpression, + recurring: Schema.optional(Schema.Boolean), + timezone: Schema.optional(Timezone), + model: Schema.optional(Schema.NonEmptyString), variant: Schema.optional(Schema.NonEmptyString), -} - -const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) -const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) -const IntervalMs = Schema.Int.check(Schema.isGreaterThanOrEqualTo(Automation.MIN_INTERVAL_MS)) - -// Mirrors Automation.Stop. `kind: "condition"` is currently rejected at -// validate time with { field: "stop", message: "unsupported_stop_condition" }; -// kept in the schema so the structured error contract matches HTTP routes. -// The tool description below points the LLM away from condition. -const Stop = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("count"), count: PositiveInt }), - Schema.Struct({ kind: Schema.Literal("condition"), condition: Condition }), - Schema.Struct({ kind: Schema.Literal("never") }), -]) - -const Rhythm = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("interval"), everyMs: IntervalMs }), - Schema.Struct({ kind: Schema.Literal("cron"), expression: CronExpression }), -]) - -export const AutomateParameters = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("oneshot"), - ...Common, - fireAt: NonNegativeInt, - }), - Schema.Struct({ - kind: Schema.Literal("recurring"), - ...Common, - rhythm: Rhythm, - stop: Stop, - }), -]) +}) export function formatAutomateValidationError(error: unknown) { const detail = @@ -77,10 +45,10 @@ export function formatAutomateValidationError(error: unknown) { : String(error) return [ "Invalid automate input.", - "Expected shape: oneshot { kind, title, prompt, context, where, timezone, model, variant?, fireAt } or recurring { kind, title, prompt, context, where, timezone, model, variant?, rhythm, stop }.", - "model is required as { providerID, modelID }; variant is optional and must be a valid effort key for that model (omit for models without reasoning).", - "stop only supports { kind: \"count\", count } or { kind: \"never\" } today; { kind: \"condition\" } is reserved and currently rejected.", - "Example: { kind: \"recurring\", title: \"Daily repo brief\", prompt: \"Summarize repo changes.\", context: \"fresh\", where: { projectID: \"current-project\" }, timezone: \"UTC\", model: { providerID: \"anthropic\", modelID: \"claude-sonnet-4-6\" }, variant: \"high\", rhythm: { kind: \"interval\", everyMs: 3600000 }, stop: { kind: \"never\" } }.", + "Expected: { title, prompt, cron, recurring?, timezone?, model?, variant? }.", + 'cron is a 5-field cron expression (e.g. "0 9 * * *" = 09:00 daily). recurring defaults to true; set it false for a one-shot that fires at the next cron match.', + 'timezone defaults to the host timezone. model, when given, is a "providerID/modelID" string and otherwise defaults to this session\'s model; variant is an optional reasoning-effort key for that model.', + 'Example: { title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *" }.', detail, ].join("\n") } @@ -90,31 +58,97 @@ function readableAutomationError(error: unknown) { return error } -export function createAutomateDefinition(provider: Provider.Interface): Tool.DefWithoutID { +function resolveTimezone(explicit: string | undefined): string { + if (explicit) return explicit + const system = Intl.DateTimeFormat().resolvedOptions().timeZone + return system && Automation.isValidTimezone(system) ? system : "UTC" +} + +// Model the automation inherits when the caller does not name one: the most +// recent user-message model on this session (matches plan.ts), else undefined. +// Best-effort — a missing session makes stream() throw NotFoundError, which we +// treat as "no model to inherit" and let execute() fall back to the provider +// default. Any other failure (corrupt store, IO, parse) propagates instead of +// silently downgrading the inherited model to the provider default. +function sessionModel(sessionID: SessionID) { + try { + for (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + } catch (error) { + if (NotFoundError.isInstance(error)) return undefined + throw error + } + return undefined +} + +export function createAutomateDefinition( + provider: Provider.Interface, +): Tool.DefWithoutID { return { description: - "Create an Automation definition for later execution. The automation is not executed by this tool; it only stores the definition and echoes the resolved contract.", + "Create an Automation that re-runs a prompt on a schedule. Provide a title, the prompt, and a 5-field cron expression; project, timezone, and model default to the current session. Each run starts a fresh session and repeats until the user pauses or deletes it in the Automations panel. This only stores the definition; it does not run the prompt now.", parameters: AutomateParameters, formatValidationError: formatAutomateValidationError, execute: (params, ctx) => Effect.gen(function* () { - const { sourceSessionID: _ignoredSourceSessionID, ...input } = params as typeof params & { sourceSessionID?: unknown } - if (Object.hasOwn(input, "automationSessionID")) { - return yield* Effect.fail( - readableAutomationError( - new ValidationError([{ field: "automationSessionID", message: "unsupported_automation_field" }]), - ), - ) + const timezone = resolveTimezone(params.timezone) + + let model: { providerID: string; modelID: string } + let variant: string | undefined + if (params.model) { + model = Provider.parseModel(params.model) + variant = params.variant + } else { + const fromSession = sessionModel(ctx.sessionID) + const inherited = fromSession ?? (yield* provider.defaultModel()) + model = { providerID: inherited.providerID, modelID: inherited.modelID } + variant = params.variant ?? fromSession?.variant } - const modelDetails = yield* validateModelAndVariantWith(provider, input.model, input.variant) + + const modelDetails = yield* validateModelAndVariantWith(provider, model, variant) if (modelDetails.length) { return yield* Effect.fail(readableAutomationError(new ValidationError(modelDetails))) } + + // Sample now only after model resolution/validation, which may have + // yielded on I/O. Sampling earlier risks a one-shot fireAt computed from + // a stale instant that a crossed cron boundary turns into an already-due + // time. + const now = Date.now() const definition = yield* Effect.try({ try: () => { - const parsed = Automation.CreateInput.parse(input) + const common = { + title: params.title, + prompt: params.prompt, + context: "fresh" as const, + where: { projectID: Instance.project.id }, + timezone, + model, + ...(variant ? { variant } : {}), + } + // recurring defaults to true; a false flag is a one-shot whose fire + // time is the next cron match (5-field cron has no year, so this is + // "next occurrence", not an arbitrary far-future instant). + const createInput = + params.recurring === false + ? (() => { + const fireAt = nextCronFireAfter(params.cron, timezone, now) + if (fireAt === null) + throw new ValidationError([{ field: "cron", message: "cron_has_no_future_fire" }]) + return { kind: "oneshot" as const, ...common, fireAt } + })() + : { + kind: "recurring" as const, + ...common, + rhythm: { kind: "cron" as const, expression: params.cron }, + stop: { kind: "never" as const }, + } + const parsed = Automation.CreateInput.parse(createInput) AutomationScheduler.current() - return Automation.create(parsed, { sourceSessionID: ctx.sessionID }) + // sourceSessionID always comes from ctx, never from input, so a + // model cannot spoof it; automationSessionID is not on the surface. + return Automation.create(parsed, { now, sourceSessionID: ctx.sessionID }) }, catch: readableAutomationError, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a165234ae..71cee7c91 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -301,7 +301,7 @@ export namespace ToolRegistry { tool.patch, ...(lspEnabled ? [tool.lsp] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), - ...(Env.get("OPENCODE_ENABLE_AUTOMATE_TOOL") === "true" ? [tool.automate] : []), + tool.automate, tool.enterWorktree, tool.exitWorktree, ], diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 1f96eb1d3..28cdb75fe 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -13,6 +13,7 @@ const expectedSmokeTests = [ "packages/app/e2e/app/root-redirect.spec.ts:@smoke root route falls back to backend project when local store is empty", "packages/app/e2e/app/session.spec.ts:@smoke session composer matches home structure without docktray or agent control", "packages/app/e2e/app/shell-frame.spec.ts:@smoke shell frame exposes stable desktop hooks", + "packages/app/e2e/automations/automations-panel.spec.ts:@smoke automations panel: list, detail, pause, delete", "packages/app/e2e/files/file-tree.spec.ts:@smoke review tab no longer renders the legacy file-tree sub-panel", "packages/app/e2e/icon-viewbox-fit.spec.ts:@smoke every chrome icon fits inside the 0..20 viewBox", "packages/app/e2e/model-picker-height.spec.ts:@smoke model picker height fits content, no empty bottom space", diff --git a/packages/opencode/test/server/automation-scheduler.test.ts b/packages/opencode/test/server/automation-scheduler.test.ts index 312df8f18..77a16711e 100644 --- a/packages/opencode/test/server/automation-scheduler.test.ts +++ b/packages/opencode/test/server/automation-scheduler.test.ts @@ -305,7 +305,10 @@ describe("automation scheduler", () => { const result = await Effect.runPromise( tool.execute( - recurringInput(projectID, 60_000), + // Flat cron surface: "* * * * *" fires on the next minute boundary, + // which is 60_000 from the fake clock's 0 — the interval-era cadence + // this test asserts. projectID/model default to the instance/session. + { title: "Recurring brief", prompt: "Summarize repo changes.", cron: "* * * * *", timezone: "UTC" }, { sessionID: SessionID.descending(), messageID: MessageID.ascending(), diff --git a/packages/opencode/test/tool/automate.test.ts b/packages/opencode/test/tool/automate.test.ts index 1a84a0340..4126b12e3 100644 --- a/packages/opencode/test/tool/automate.test.ts +++ b/packages/opencode/test/tool/automate.test.ts @@ -1,115 +1,59 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import { Effect, Schema } from "effect" import { AutomateParameters, createAutomateDefinition, formatAutomateValidationError } from "../../src/tool/automate" import { Automation } from "../../src/automation" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { MessageV2 } from "../../src/session/message-v2" import { MessageID, SessionID } from "../../src/session/schema" +import { NotFoundError } from "../../src/storage/db" import { tmpdir } from "../fixture/fixture" import { fakeAutomationProvider } from "../fake/provider" const { providerID: fakeProviderID, modelID: fakeModelID, interface: fakeProviderInterface } = fakeAutomationProvider() -const fixtureModel = { providerID: fakeProviderID, modelID: fakeModelID } + +const ctx = (sessionID: SessionID) => ({ + sessionID, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +}) afterEach(async () => { await Instance.disposeAll() }) describe("automate tool", () => { - test("validation errors name the wrong field and show the expected shape", () => { - const decode = Schema.decodeUnknownSync(AutomateParameters) - let error: unknown - try { - decode({ - kind: "recurring", - title: "Missing prompt", - context: "fresh", - where: { projectID: "project" }, - timezone: "UTC", - model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }) - } catch (caught) { - error = caught - } - - expect(error).toBeDefined() - expect(formatAutomateValidationError(error)).toContain("prompt") - expect(formatAutomateValidationError(error)).toContain("model") - }) - - test("rejects empty strings before execute reaches the Zod create parser", () => { - const decode = Schema.decodeUnknownSync(AutomateParameters) - let error: unknown - try { - decode({ - kind: "recurring", - title: "", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: "project", worktree: "" }, - timezone: "", - model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }) - } catch (caught) { - error = caught - } - - expect(error).toBeDefined() - expect(formatAutomateValidationError(error)).toContain("Invalid automate input") - }) - - test.each([ - ["negative fireAt", { kind: "oneshot", fireAt: -1 }], - ["fractional fireAt", { kind: "oneshot", fireAt: 1.5 }], - ["zero interval", { kind: "recurring", rhythm: { kind: "interval", everyMs: 0 }, stop: { kind: "never" } }], - ["interval below floor", { kind: "recurring", rhythm: { kind: "interval", everyMs: 29_999 }, stop: { kind: "never" } }], - ["fractional interval", { kind: "recurring", rhythm: { kind: "interval", everyMs: 1.5 }, stop: { kind: "never" } }], - ["zero count", { kind: "recurring", rhythm: { kind: "interval", everyMs: 60_000 }, stop: { kind: "count", count: 0 } }], - ["fractional count", { kind: "recurring", rhythm: { kind: "interval", everyMs: 60_000 }, stop: { kind: "count", count: 1.5 } }], - ])("rejects invalid numeric fields before execute reaches the Zod create parser: %s", (_name, override) => { + test("decode rejects a missing required field and shows the flat shape", () => { const decode = Schema.decodeUnknownSync(AutomateParameters) - const base = { - title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: "project" }, - timezone: "UTC", - model: fixtureModel, - } let error: unknown try { - decode({ ...base, ...override }) + decode({ title: "Daily repo brief", cron: "0 9 * * *" }) } catch (caught) { error = caught } expect(error).toBeDefined() expect(formatAutomateValidationError(error)).toContain("Invalid automate input") + expect(formatAutomateValidationError(error)).toContain("prompt") }) test.each([ - ["empty cron expression", { rhythm: { kind: "cron", expression: "" }, stop: { kind: "never" } }], - ["empty stop condition", { rhythm: { kind: "interval", everyMs: 60_000 }, stop: { kind: "condition", condition: "" } }], + ["empty title", { title: "" }], + ["empty prompt", { prompt: "" }], + ["empty cron", { cron: "" }], + ["invalid cron", { cron: "not cron" }], + ["invalid timezone", { timezone: "Not/AZone" }], ["title above replay-safe limit", { title: "x".repeat(161) }], ["prompt above replay-safe limit", { prompt: "x".repeat(20_001) }], - ["condition above replay-safe limit", { rhythm: { kind: "interval", everyMs: 60_000 }, stop: { kind: "condition", condition: "x".repeat(4_001) } }], - ])("rejects empty nested strings before execute reaches the Zod create parser: %s", (_name, override) => { + ])("decode rejects invalid input before execute: %s", (_name, override) => { const decode = Schema.decodeUnknownSync(AutomateParameters) let error: unknown try { - decode({ - kind: "recurring", - title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: "project" }, - timezone: "UTC", - model: fixtureModel, - ...override, - }) + decode({ title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *", ...override }) } catch (caught) { error = caught } @@ -118,206 +62,254 @@ describe("automate tool", () => { expect(formatAutomateValidationError(error)).toContain("Invalid automate input") }) - test.each([ - ["invalid timezone", { timezone: "Not/AZone" }], - ["invalid cron expression", { rhythm: { kind: "cron", expression: "not cron" } }], - ])("rejects semantic validation before execute reaches the Zod create parser: %s", (_name, override) => { - const decode = Schema.decodeUnknownSync(AutomateParameters) - let error: unknown - try { - decode({ - kind: "recurring", - title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: "project" }, - timezone: "UTC", - model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - ...override, - }) - } catch (caught) { - error = caught - } + test("decode strips fields the surface does not expose (spoof + frozen-only knobs)", () => { + const decoded = Schema.decodeUnknownSync(AutomateParameters)({ + title: "Daily repo brief", + prompt: "Summarize repo changes.", + cron: "0 9 * * *", + where: { projectID: "spoofed" }, + automationSessionID: SessionID.descending(), + sourceSessionID: SessionID.descending(), + rhythm: { kind: "interval", everyMs: 60_000 }, + }) - expect(error).toBeDefined() - expect(formatAutomateValidationError(error)).toContain("Invalid automate input") + expect(decoded).toEqual({ title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *" }) }) - test.each([ - ["wrong project", () => ({ projectID: "other-project" }), "where.projectID"], - ["invalid worktree placement", (projectID: string) => ({ projectID, worktree: "!!!" }), "where.worktree"], - ])("reports execute-time automation validation as model-readable input errors: %s", async (_name, where, field) => { + test("creates a recurring cron automation, defaulting project/timezone/model to the session", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const tool = createAutomateDefinition(fakeProviderInterface) const sourceSessionID = SessionID.descending() - let error: unknown - try { - await Effect.runPromise( - tool.execute( - { - kind: "recurring", - title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: where(Instance.project.id), - timezone: "UTC", - model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, - { - sessionID: sourceSessionID, - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ), - ) - } catch (caught) { - error = caught - } + const result = await Effect.runPromise( + tool.execute( + { title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *" }, + ctx(sourceSessionID), + ), + ) - expect(String(error)).toContain("Invalid automate input") - expect(String(error)).toContain(field) - expect(Automation.list()).toHaveLength(0) + expect(result.title).toBe("Automation created") + const definition = result.metadata.automationDefinition + expect(definition).toMatchObject({ + kind: "recurring", + title: "Daily repo brief", + prompt: "Summarize repo changes.", + revision: 1, + paused: false, + where: { projectID: Instance.project.id }, + model: { providerID: fakeProviderID, modelID: fakeModelID }, + sourceSessionID, + }) + expect(definition.kind === "recurring" && definition.rhythm).toEqual({ kind: "cron", expression: "0 9 * * *" }) + expect(Automation.isValidTimezone(definition.timezone)).toBe(true) + expect(Automation.list()).toHaveLength(1) }, }) }) - test("echoes the resolved definition through the automation create path", async () => { + test("recurring:false creates a one-shot fired at the next cron match", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const tool = createAutomateDefinition(fakeProviderInterface) - const sourceSessionID = SessionID.descending() + const before = Date.now() const result = await Effect.runPromise( tool.execute( - { - kind: "recurring", - title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, - timezone: "Asia/Shanghai", - model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, - { - sessionID: sourceSessionID, - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, + { title: "One-off brief", prompt: "Summarize repo changes.", cron: "0 9 * * *", recurring: false }, + ctx(SessionID.descending()), ), ) - expect(result.title).toBe("Automation created") - expect(result.metadata.automationDefinition).toMatchObject({ - title: "Daily repo brief", - prompt: "Summarize repo changes.", - revision: 1, - paused: false, - where: { projectID: Instance.project.id }, - sourceSessionID, - }) - expect(Automation.list()).toHaveLength(1) + const definition = result.metadata.automationDefinition + expect(definition.kind).toBe("oneshot") + expect(definition.kind === "oneshot" && definition.fireAt).toBeGreaterThan(before) }, }) }) - test("binds sourceSessionID to the current tool context even when input tries to spoof it", async () => { + test("honors an explicit flat model override and variant", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const tool = createAutomateDefinition(fakeProviderInterface) - const sourceSessionID = SessionID.descending() - const spoofedSessionID = SessionID.descending() - const spoofedSource = { sourceSessionID: spoofedSessionID } as Record const result = await Effect.runPromise( tool.execute( { - kind: "recurring", title: "Daily repo brief", prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, - timezone: "Asia/Shanghai", - model: fixtureModel, - ...spoofedSource, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, - { - sessionID: sourceSessionID, - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, + cron: "0 9 * * *", + model: `${fakeProviderID}/${fakeModelID}`, + variant: "high", }, + ctx(SessionID.descending()), ), ) - expect(result.metadata.automationDefinition.sourceSessionID).toBe(sourceSessionID) + expect(result.metadata.automationDefinition).toMatchObject({ + model: { providerID: fakeProviderID, modelID: fakeModelID }, + variant: "high", + }) }, }) }) - test("rejects externally supplied automationSessionID through the create path", async () => { + test("surfaces execute-time model validation as a readable input error", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const tool = createAutomateDefinition(fakeProviderInterface) let error: unknown - const spoofedSession = { automationSessionID: SessionID.descending() } as Record try { await Effect.runPromise( tool.execute( { - kind: "recurring", title: "Daily repo brief", prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, - timezone: "Asia/Shanghai", - model: fixtureModel, - ...spoofedSession, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, - { - sessionID: SessionID.descending(), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, + cron: "0 9 * * *", + model: `${fakeProviderID}/does-not-exist`, }, + ctx(SessionID.descending()), ), ) } catch (caught) { error = caught } - expect(error).toBeInstanceOf(Error) - expect(String(error)).toContain("automationSessionID: unsupported_automation_field") + expect(String(error)).toContain("Invalid automate input") + expect(String(error)).toContain("model") + expect(Automation.list()).toHaveLength(0) + }, + }) + }) + + test("a non-NotFound failure reading the session messages fails the tool instead of silently using the default model", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const streamSpy = spyOn(MessageV2, "stream").mockImplementation((() => { + throw new Error("storage corrupt") + }) as typeof MessageV2.stream) + try { + const tool = createAutomateDefinition(fakeProviderInterface) + let error: unknown + try { + await Effect.runPromise( + tool.execute( + { title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *" }, + ctx(SessionID.descending()), + ), + ) + } catch (caught) { + error = caught + } + + expect(String(error)).toContain("storage corrupt") + expect(Automation.list()).toHaveLength(0) + } finally { + streamSpy.mockRestore() + } + }, + }) + }) + + test("a missing session (NotFound) still falls back to the provider default model", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const streamSpy = spyOn(MessageV2, "stream").mockImplementation((() => { + throw new NotFoundError({ message: "Session not found" }) + }) as typeof MessageV2.stream) + try { + const tool = createAutomateDefinition(fakeProviderInterface) + const result = await Effect.runPromise( + tool.execute( + { title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *" }, + ctx(SessionID.descending()), + ), + ) + + expect(result.metadata.automationDefinition.model).toEqual({ + providerID: fakeProviderID, + modelID: fakeModelID, + }) + } finally { + streamSpy.mockRestore() + } + }, + }) + }) + + test("one-shot fireAt is sampled after model validation, so a crossed cron boundary never yields an already-due fire", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const boundary = Date.UTC(2026, 0, 1, 12, 1, 0) + let clock = boundary - 1_000 + const nowSpy = spyOn(Date, "now").mockImplementation(() => clock) + // Validation crosses the minute boundary. If now were sampled before + // validation, the one-shot would fire at 12:01:00 (already due); sampling + // after pushes it to 12:02:00. + const slowProvider: Provider.Interface = { + ...fakeProviderInterface, + getModel: ((pId, mId) => { + clock = boundary + 1_000 + return fakeProviderInterface.getModel(pId, mId) + }) as Provider.Interface["getModel"], + } + try { + const tool = createAutomateDefinition(slowProvider) + const result = await Effect.runPromise( + tool.execute( + { + title: "One-off brief", + prompt: "Summarize repo changes.", + cron: "* * * * *", + recurring: false, + timezone: "UTC", + }, + ctx(SessionID.descending()), + ), + ) + + const definition = result.metadata.automationDefinition + expect(definition.kind).toBe("oneshot") + expect(definition.kind === "oneshot" && definition.fireAt).toBe(Date.UTC(2026, 0, 1, 12, 2, 0)) + } finally { + nowSpy.mockRestore() + } + }, + }) + }) + + test("binds sourceSessionID to the tool context and ignores any spoofed identity fields", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = createAutomateDefinition(fakeProviderInterface) + const sourceSessionID = SessionID.descending() + const spoof = { + sourceSessionID: SessionID.descending(), + automationSessionID: SessionID.descending(), + } as Record + const result = await Effect.runPromise( + tool.execute( + { title: "Daily repo brief", prompt: "Summarize repo changes.", cron: "0 9 * * *", ...spoof }, + ctx(sourceSessionID), + ), + ) + + const definition = result.metadata.automationDefinition + expect(definition.sourceSessionID).toBe(sourceSessionID) + expect(definition.automationSessionID).toBeUndefined() }, }) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0ba7e64c3..2ccc0bbc3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -40,7 +40,7 @@ describe("tool.registry", () => { }) }) - test("keeps automate hidden until the manageability UI slice", async () => { + test("exposes automate now that the Automations panel ships", async () => { await using tmp = await tmpdir() await withMockedConfigInstall(async () => { @@ -48,7 +48,7 @@ describe("tool.registry", () => { directory: tmp.path, fn: async () => { const ids = await ToolRegistry.ids() - expect(ids).not.toContain("automate") + expect(ids).toContain("automate") }, }) }) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 34012e4a3..8a0ad7896 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -63,8 +63,10 @@ export const icons = { "new-session": ``, "open-file": ``, "pencil-line": ``, + "pause": ``, "photo": ``, "pin": ``, + "play": ``, "plugin": ``, "plus": ``, "plus-small": ``,