From 84957ca01c227b1950de1de9026dc7ada0d1f131 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 16:58:52 +0800 Subject: [PATCH 01/17] feat(automation): wire global-sync data layer for automations panel Add directory-scoped automation and run state to the child store, fed by SSE events (definition.updated/deleted, run.updated) and the bootstrap automation.list snapshot. Writes are gated on the monotonic revision with tombstone fencing, so a route response that lands after a newer event is ignored and reconnect re-fetches converge regardless of arrival order. Expose a lazy automation.runs loader for the detail view. Part of #950 PR6 (frontend automations panel), data-layer slice. --- packages/app/src/context/global-sync.tsx | 18 ++ .../global-sync/automation-store.test.ts | 177 ++++++++++++++++++ .../context/global-sync/automation-store.ts | 105 +++++++++++ .../src/context/global-sync/bootstrap.test.ts | 11 ++ .../app/src/context/global-sync/bootstrap.ts | 7 + .../src/context/global-sync/child-store.ts | 3 + .../src/context/global-sync/event-reducer.ts | 16 ++ packages/app/src/context/global-sync/types.ts | 11 ++ 8 files changed, 348 insertions(+) create mode 100644 packages/app/src/context/global-sync/automation-store.test.ts create mode 100644 packages/app/src/context/global-sync/automation-store.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 8b529a476..22b561617 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -31,6 +31,7 @@ import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" +import { mergeAutomationRuns } from "./global-sync/automation-store" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { createTodoHydrateCoordinator } from "./global-sync/todo-hydrate-coordinator" @@ -321,6 +322,20 @@ function createGlobalSync() { return promise } + async function loadAutomationRuns(directory: string, automationID: string, options?: { cursor?: string }) { + if (!directory || !automationID) return + children.pin(directory) + try { + const [store, setStore] = children.peek(directory, { bootstrap: false }) + const sdk = sdkFor(directory) + const res = await sdk.automation.runs({ automationID, ...(options?.cursor ? { cursor: options.cursor } : {}) }) + mergeAutomationRuns(store, setStore, res.data?.items ?? []) + return res.data?.nextCursor ?? null + } finally { + children.unpin(directory) + } + } + async function bootstrapInstance(directory: string) { if (!directory) return const pending = booting.get(directory) @@ -563,6 +578,9 @@ function createGlobalSync() { bootstrap, updateConfig, project: projectApi, + automation: { + loadRuns: loadAutomationRuns, + }, todo: { set: setSessionTodo, accept: acceptSessionTodo, diff --git a/packages/app/src/context/global-sync/automation-store.test.ts b/packages/app/src/context/global-sync/automation-store.test.ts new file mode 100644 index 000000000..68be357e7 --- /dev/null +++ b/packages/app/src/context/global-sync/automation-store.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test" +import type { AutomationDefinition, AutomationRun } from "@opencode-ai/sdk/v2/client" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { + applyAutomationDefinition, + applyAutomationRun, + applyAutomationTombstone, + canAcceptAutomationDefinition, + canAcceptAutomationRun, + canAcceptAutomationTombstone, + mergeAutomationList, +} from "./automation-store" + +const definition = (input: { id: string; revision: number; title?: string; paused?: boolean }): AutomationDefinition => + ({ + kind: "recurring", + id: input.id, + title: input.title ?? input.id, + prompt: "do the thing", + revision: input.revision, + paused: input.paused ?? false, + context: "fresh", + where: { projectID: "prj_1" }, + createdAt: 1, + updatedAt: 1, + timezone: "UTC", + normalizationWarnings: [], + model: { providerID: "anthropic", modelID: "claude" }, + rhythm: { kind: "cron", expression: "0 9 * * *" }, + stop: { kind: "never" }, + nextFireAt: 1, + nextFires: [1], + failureStreak: 0, + }) as AutomationDefinition + +const run = (input: { id: string; automationID: string; revision: number; state?: AutomationRun["state"] }): AutomationRun => + ({ + id: input.id, + automationID: input.automationID, + revision: input.revision, + definitionRevision: 1, + triggeredAt: 1, + cost: null, + state: input.state ?? "scheduled", + sessionID: null, + startedAt: null, + completedAt: null, + result: null, + error: null, + }) as AutomationRun + +const baseState = (input: Partial = {}) => + ({ + automation: {}, + automation_run: {}, + automation_tombstone: {}, + ...input, + }) as State + +describe("canAcceptAutomationDefinition", () => { + test("accepts when no current and no tombstone", () => { + expect( + canAcceptAutomationDefinition({ current: undefined, tombstoneRevision: undefined, incoming: definition({ id: "a", revision: 1 }) }), + ).toBe(true) + }) + + test("accepts a strictly higher revision", () => { + expect( + canAcceptAutomationDefinition({ + current: definition({ id: "a", revision: 2 }), + tombstoneRevision: undefined, + incoming: definition({ id: "a", revision: 3 }), + }), + ).toBe(true) + }) + + test("rejects an equal or stale revision", () => { + const current = definition({ id: "a", revision: 3 }) + expect(canAcceptAutomationDefinition({ current, tombstoneRevision: undefined, incoming: definition({ id: "a", revision: 3 }) })).toBe(false) + expect(canAcceptAutomationDefinition({ current, tombstoneRevision: undefined, incoming: definition({ id: "a", revision: 2 }) })).toBe(false) + }) + + test("rejects an update at or below the tombstone revision", () => { + expect( + canAcceptAutomationDefinition({ current: undefined, tombstoneRevision: 5, incoming: definition({ id: "a", revision: 5 }) }), + ).toBe(false) + expect( + canAcceptAutomationDefinition({ current: undefined, tombstoneRevision: 5, incoming: definition({ id: "a", revision: 4 }) }), + ).toBe(false) + }) +}) + +describe("canAcceptAutomationTombstone", () => { + test("accepts when newer than both current and prior tombstone", () => { + expect(canAcceptAutomationTombstone({ currentRevision: 2, tombstoneRevision: undefined, incoming: { id: "a", deleted: true, revision: 3 } })).toBe(true) + }) + + test("rejects when not newer than the known max revision", () => { + expect(canAcceptAutomationTombstone({ currentRevision: 3, tombstoneRevision: undefined, incoming: { id: "a", deleted: true, revision: 3 } })).toBe(false) + expect(canAcceptAutomationTombstone({ currentRevision: undefined, tombstoneRevision: 4, incoming: { id: "a", deleted: true, revision: 4 } })).toBe(false) + }) +}) + +describe("canAcceptAutomationRun", () => { + test("accepts first run and strictly higher revisions", () => { + expect(canAcceptAutomationRun({ current: undefined, incoming: run({ id: "r", automationID: "a", revision: 1 }) })).toBe(true) + expect( + canAcceptAutomationRun({ current: run({ id: "r", automationID: "a", revision: 1 }), incoming: run({ id: "r", automationID: "a", revision: 2 }) }), + ).toBe(true) + }) + + test("rejects stale run revisions", () => { + expect( + canAcceptAutomationRun({ current: run({ id: "r", automationID: "a", revision: 2 }), incoming: run({ id: "r", automationID: "a", revision: 2 }) }), + ).toBe(false) + }) +}) + +describe("applyAutomationDefinition", () => { + test("inserts then ignores a stale revision", () => { + const [store, setStore] = createStore(baseState()) + expect(applyAutomationDefinition(store, setStore, definition({ id: "a", revision: 2, title: "v2" }))).toBe(true) + expect(store.automation["a"]?.title).toBe("v2") + expect(applyAutomationDefinition(store, setStore, definition({ id: "a", revision: 1, title: "v1" }))).toBe(false) + expect(store.automation["a"]?.title).toBe("v2") + }) + + test("ignores an update for a tombstoned id", () => { + const [store, setStore] = createStore(baseState({ automation_tombstone: { a: 5 } })) + expect(applyAutomationDefinition(store, setStore, definition({ id: "a", revision: 5 }))).toBe(false) + expect(store.automation["a"]).toBeUndefined() + }) +}) + +describe("applyAutomationTombstone", () => { + test("removes the definition and fences a late stale update (route-after-event race)", () => { + const [store, setStore] = createStore(baseState({ automation: { a: definition({ id: "a", revision: 2 }) } })) + expect(applyAutomationTombstone(store, setStore, { id: "a", deleted: true, revision: 3 })).toBe(true) + expect(store.automation["a"]).toBeUndefined() + expect(store.automation_tombstone["a"]).toBe(3) + // A list/get response computed before the delete must not resurrect it. + expect(applyAutomationDefinition(store, setStore, definition({ id: "a", revision: 2 }))).toBe(false) + expect(store.automation["a"]).toBeUndefined() + }) +}) + +describe("applyAutomationRun", () => { + test("upserts then ignores a stale run", () => { + const [store, setStore] = createStore(baseState()) + expect(applyAutomationRun(store, setStore, run({ id: "r", automationID: "a", revision: 1 }))).toBe(true) + expect(applyAutomationRun(store, setStore, run({ id: "r", automationID: "a", revision: 2, state: "running" }))).toBe(true) + expect(store.automation_run["r"]?.state).toBe("running") + expect(applyAutomationRun(store, setStore, run({ id: "r", automationID: "a", revision: 1 }))).toBe(false) + expect(store.automation_run["r"]?.state).toBe("running") + }) +}) + +describe("mergeAutomationList", () => { + test("drops missing ids, skips tombstoned, keeps locally newer", () => { + const [store, setStore] = createStore( + baseState({ + automation: { gone: definition({ id: "gone", revision: 1 }), live: definition({ id: "live", revision: 4, title: "local-v4" }) }, + automation_tombstone: { deleted: 2 }, + }), + ) + mergeAutomationList(store, setStore, [ + definition({ id: "live", revision: 3, title: "stale-v3" }), + definition({ id: "fresh", revision: 1 }), + definition({ id: "deleted", revision: 2 }), + ]) + expect(store.automation["gone"]).toBeUndefined() + expect(store.automation["deleted"]).toBeUndefined() + expect(store.automation["fresh"]?.id).toBe("fresh") + expect(store.automation["live"]?.title).toBe("local-v4") + }) +}) diff --git a/packages/app/src/context/global-sync/automation-store.ts b/packages/app/src/context/global-sync/automation-store.ts new file mode 100644 index 000000000..72a57b374 --- /dev/null +++ b/packages/app/src/context/global-sync/automation-store.ts @@ -0,0 +1,105 @@ +import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { AutomationDefinition, AutomationDefinitionTombstone, AutomationRun } from "@opencode-ai/sdk/v2/client" +import type { State } from "./types" + +// Definitions and runs carry a monotonic `revision`; deletions carry a tombstone +// revision. The HTTP list/get responses and the SSE events share that sequence, +// so a route response that lands after a newer event must be ignored. These +// helpers gate every write on revision so reconnect re-fetches and live events +// converge regardless of arrival order. + +export function canAcceptAutomationDefinition(input: { + current: AutomationDefinition | undefined + tombstoneRevision: number | undefined + incoming: AutomationDefinition +}): boolean { + if (input.tombstoneRevision !== undefined && input.tombstoneRevision >= input.incoming.revision) return false + return input.current === undefined || input.incoming.revision > input.current.revision +} + +export function canAcceptAutomationTombstone(input: { + currentRevision: number | undefined + tombstoneRevision: number | undefined + incoming: AutomationDefinitionTombstone +}): boolean { + const baseline = Math.max(input.currentRevision ?? -1, input.tombstoneRevision ?? -1) + return input.incoming.revision > baseline +} + +export function canAcceptAutomationRun(input: { + current: AutomationRun | undefined + incoming: AutomationRun +}): boolean { + return input.current === undefined || input.incoming.revision > input.current.revision +} + +export function applyAutomationDefinition( + store: Store, + setStore: SetStoreFunction, + incoming: AutomationDefinition, +): boolean { + const current = store.automation[incoming.id] + const tombstoneRevision = store.automation_tombstone[incoming.id] + if (!canAcceptAutomationDefinition({ current, tombstoneRevision, incoming })) return false + setStore("automation", incoming.id, incoming) + return true +} + +export function applyAutomationTombstone( + store: Store, + setStore: SetStoreFunction, + incoming: AutomationDefinitionTombstone, +): boolean { + const current = store.automation[incoming.id] + const tombstoneRevision = store.automation_tombstone[incoming.id] + if (!canAcceptAutomationTombstone({ currentRevision: current?.revision, tombstoneRevision, incoming })) return false + if (current) { + setStore( + "automation", + produce((draft) => { + delete draft[incoming.id] + }), + ) + } + setStore("automation_tombstone", incoming.id, incoming.revision) + return true +} + +export function applyAutomationRun( + store: Store, + setStore: SetStoreFunction, + incoming: AutomationRun, +): boolean { + const current = store.automation_run[incoming.id] + if (!canAcceptAutomationRun({ current, incoming })) return false + setStore("automation_run", incoming.id, incoming) + return true +} + +// Authoritative merge for the bootstrap `automation.list` snapshot: drop +// definitions the server no longer returns, skip re-adding a locally tombstoned +// id, and keep a locally newer revision when the snapshot is stale. +export function mergeAutomationList( + store: Store, + setStore: SetStoreFunction, + items: AutomationDefinition[], +) { + const next: Record = {} + for (const incoming of items) { + const tombstoneRevision = store.automation_tombstone[incoming.id] + if (tombstoneRevision !== undefined && tombstoneRevision >= incoming.revision) continue + const current = store.automation[incoming.id] + next[incoming.id] = current && current.revision > incoming.revision ? current : incoming + } + setStore("automation", reconcile(next)) +} + +export function mergeAutomationRuns( + store: Store, + setStore: SetStoreFunction, + items: AutomationRun[], +) { + for (const incoming of items) { + applyAutomationRun(store, setStore, incoming) + } +} diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 132f117ed..6f4a044f6 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -40,6 +40,9 @@ function createState(): State { limit: 5, message: {}, part: {}, + automation: {}, + automation_run: {}, + automation_tombstone: {}, } } @@ -150,6 +153,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => { const next = providers[Math.min(providerCalls, providers.length - 1)] @@ -234,6 +238,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as any @@ -326,6 +331,7 @@ describe("bootstrapDirectory", () => { }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as any @@ -377,6 +383,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as any @@ -432,6 +439,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => permission.promise }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: providers }) }, } as any @@ -485,6 +493,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as any @@ -543,6 +552,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as any @@ -610,6 +620,7 @@ describe("bootstrapDirectory", () => { permission: { list: async () => ({ data: [] }) }, externalResult: { list: async () => ({ data: [] }) }, mcp: { status: async () => ({ data: {} }) }, + automation: { list: async () => ({ data: { items: [] } }) }, provider: { list: async () => { calls += 1 diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 39f445593..3fb300b83 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -18,6 +18,7 @@ import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" +import { mergeAutomationList } from "./automation-store" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" @@ -499,6 +500,12 @@ export async function bootstrapDirectory(input: { input.setStore("mcp_ready", true) }), ), + () => + retry(() => + input.sdk.automation.list().then((x) => { + mergeAutomationList(input.store, input.setStore, x.data?.items ?? []) + }), + ), ] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index c467da3dd..cc3caf630 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -200,6 +200,9 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + automation: {}, + automation_run: {}, + automation_tombstone: {}, }) children[directory] = child disposers.set(directory, dispose) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 31fa5db3c..34b077fce 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,6 +1,9 @@ import { Binary } from "@opencode-ai/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { + AutomationDefinition, + AutomationDefinitionTombstone, + AutomationRun, Message, Part, PermissionRequest, @@ -11,6 +14,7 @@ import type { TodoSnapshot, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" +import { applyAutomationDefinition, applyAutomationRun, applyAutomationTombstone } from "./automation-store" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { message as clean } from "@/utils/diffs" @@ -427,5 +431,17 @@ export function applyDirectoryEvent(input: { input.loadLsp() break } + case "automation.definition.updated": { + applyAutomationDefinition(input.store, input.setStore, event.properties as AutomationDefinition) + break + } + case "automation.definition.deleted": { + applyAutomationTombstone(input.store, input.setStore, event.properties as AutomationDefinitionTombstone) + break + } + case "automation.run.updated": { + applyAutomationRun(input.store, input.setStore, event.properties as AutomationRun) + break + } } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 673294f20..b7a968a0f 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,5 +1,7 @@ import type { Agent, + AutomationDefinition, + AutomationRun, Command, Config, LspStatus, @@ -73,6 +75,15 @@ export type State = { part: { [messageID: string]: Part[] } + automation: { + [automationID: string]: AutomationDefinition + } + automation_run: { + [runID: string]: AutomationRun + } + automation_tombstone: { + [automationID: string]: number + } } export type ChildStoreTuple = [Store, SetStoreFunction] From 2ee5d966269ab634e3c1d165c324a74ca0fe8cf1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 17:23:23 +0800 Subject: [PATCH 02/17] feat(automation): add automations shell surface and sidebar entry Generalize the layout shell surface state from a settings-only boolean to a mutually-exclusive activeSurface (none | settings | automations). Settings keeps its full sidebar+main takeover; automations only takes over the main area and leaves the session sidebar live, so runs stay reachable in All chats. Add an Automations entry to the sidebar top cluster (toggles the surface) and an AutomationsSurface that reads the current project's definitions from the child store: empty state, a read-only list with a humanized schedule summary, and a minimal detail view. Includes a schedule-summary formatter with unit tests and an automations-surface snap target (empty / list / detail). Part of #950 PR6 (frontend automations panel). --- .../app/e2e/snap/automations-surface.snap.ts | 55 ++++++++++++ packages/app/src/i18n/en.ts | 13 +++ packages/app/src/i18n/zh.ts | 13 +++ .../pages/automations/automation-detail.tsx | 34 ++++++++ .../src/pages/automations/automation-list.tsx | 39 +++++++++ .../automations/automation-schedule.test.ts | 52 ++++++++++++ .../pages/automations/automation-schedule.ts | 42 ++++++++++ .../pages/automations/automations-surface.tsx | 83 +++++++++++++++++++ packages/app/src/pages/layout.tsx | 30 ++++++- .../src/pages/layout/layout-shell-frame.tsx | 23 +++-- .../app/src/pages/layout/pawwork-sidebar.tsx | 21 +++++ 11 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 packages/app/e2e/snap/automations-surface.snap.ts create mode 100644 packages/app/src/pages/automations/automation-detail.tsx create mode 100644 packages/app/src/pages/automations/automation-list.tsx create mode 100644 packages/app/src/pages/automations/automation-schedule.test.ts create mode 100644 packages/app/src/pages/automations/automation-schedule.ts create mode 100644 packages/app/src/pages/automations/automations-surface.tsx 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..f1c10679c --- /dev/null +++ b/packages/app/e2e/snap/automations-surface.snap.ts @@ -0,0 +1,55 @@ +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() + + 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: "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/i18n/en.ts b/packages/app/src/i18n/en.ts index c2a77eafc..99c23d0aa 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -781,6 +781,19 @@ export const dict = { "sidebar.empty.title": "No projects open", "sidebar.empty.description": "Open a project to get started", "sidebar.pawwork.search": "Search", + "sidebar.pawwork.automations": "Automations", + "automations.title": "Automations", + "automations.empty.title": "No automations yet", + "automations.empty.description": "Ask the assistant in a chat to automate a task for this project.", + "automations.schedule.once": "Once", + "automations.schedule.hourly": "Hourly", + "automations.schedule.daily": "Daily at {{time}}", + "automations.schedule.weekdays": "Weekdays at {{time}}", + "automations.schedule.weekly": "Weekly at {{time}}", + "automations.schedule.custom": "Custom schedule", + "automations.schedule.every": "Every {{duration}}", + "automations.schedule.minutes": "{{count}} min", + "automations.schedule.hours": "{{count}} h", "sidebar.pawwork.empty.description": "Open a project to start using the session-first sidebar.", "sidebar.pawwork.pinned": "Pinned", "sidebar.pawwork.all": "All sessions", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 5e0dee956..08bec0e17 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -691,6 +691,19 @@ export const dict = { "sidebar.empty.title": "还没有打开项目", "sidebar.empty.description": "打开一个项目开始使用", "sidebar.pawwork.search": "搜索", + "sidebar.pawwork.automations": "自动化", + "automations.title": "自动化", + "automations.empty.title": "还没有自动化", + "automations.empty.description": "在对话里让助手把当前项目的某个任务设为自动运行。", + "automations.schedule.once": "单次", + "automations.schedule.hourly": "每小时", + "automations.schedule.daily": "每天 {{time}}", + "automations.schedule.weekdays": "工作日 {{time}}", + "automations.schedule.weekly": "每周 {{time}}", + "automations.schedule.custom": "自定义日程", + "automations.schedule.every": "每 {{duration}}", + "automations.schedule.minutes": "{{count}} 分钟", + "automations.schedule.hours": "{{count}} 小时", "sidebar.pawwork.empty.description": "打开一个项目后,即可使用以会话为中心的侧边栏。", "sidebar.pawwork.pinned": "已置顶", "sidebar.pawwork.all": "全部会话", diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx new file mode 100644 index 000000000..f85fb4f08 --- /dev/null +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -0,0 +1,34 @@ +import 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 AutomationDetail(props: { + automation: AutomationDefinition + onBack: () => void +}): JSX.Element { + const language = useLanguage() + return ( +
+ + +
+

{props.automation.title}

+

{formatScheduleSummary(props.automation, language.t)}

+
+ +

{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..86d64b148 --- /dev/null +++ b/packages/app/src/pages/automations/automation-list.tsx @@ -0,0 +1,39 @@ +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 +}): JSX.Element { + const language = useLanguage() + return ( +
    + + {(automation) => ( +
  • + +
  • + )} +
    +
+ ) +} 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..7b1293c06 --- /dev/null +++ b/packages/app/src/pages/automations/automation-schedule.test.ts @@ -0,0 +1,52 @@ +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", () => { + expect(formatScheduleSummary(recurring({ kind: "cron", expression: "30 8 * * 0" }), t)).toBe( + 'automations.schedule.weekly:{"time":"08:30"}', + ) + }) + + 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.hourly") + }) + + test("interval in minutes and hours", () => { + 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: 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..96aed0d68 --- /dev/null +++ b/packages/app/src/pages/automations/automation-schedule.ts @@ -0,0 +1,42 @@ +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) { + const minutes = Math.round(everyMs / 60000) + if (minutes < 60) return t("automations.schedule.every", { duration: t("automations.schedule.minutes", { count: minutes }) }) + const hours = Math.round(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") + + if (hour === "*") return t("automations.schedule.hourly") + + 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 }) + if (/^[0-6]$/.test(dow)) return t("automations.schedule.weekly", { 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) +} 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..fce255f91 --- /dev/null +++ b/packages/app/src/pages/automations/automations-surface.tsx @@ -0,0 +1,83 @@ +import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +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 +}): 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. + // Mirrors the settings takeover, and bails while a transient overlay is open. + onMount(() => { + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (document.querySelector('[data-component="dialog-overlay"], [data-component="select-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) + }) + + return ( +
+
+ 0} fallback={}> + + + } + > + {(automation) => setSelectedID(undefined)} />} + +
+
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 81625cc82..9b1620a5c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -116,6 +116,7 @@ import { } from "./layout/pawwork-session-window" 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, removePinnedSessionIDs } from "./layout/layout-page-store" import { SettingsContent, SettingsNav, isSettingsTab, type SettingsTab } from "@/pages/settings/settings-shell" import { DialogDeleteSession } from "@/components/dialog-delete-session" @@ -134,7 +135,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() @@ -1254,7 +1259,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) { @@ -1284,11 +1293,11 @@ export default function Layout(props: ParentProps) { } createEffect(() => { - command.setModalOpen(settingsOpen()) + command.setModalOpen(activeSurface() !== "none") }) function closeSettings() { - setSettingsOpen(false) + setActiveSurface("none") } @@ -2133,6 +2142,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")} @@ -2192,6 +2204,16 @@ export default function Layout(props: ParentProps) { nav: () => , content: () => , }} + automations={{ + open: automationsOpen, + title: () => language.t("automations.title"), + content: () => ( + currentProject()?.worktree ?? projectRoot(currentDir())} + onClose={closeSettings} + /> + ), + }} 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-sidebar.tsx b/packages/app/src/pages/layout/pawwork-sidebar.tsx index 8169585f6..e15bb4b71 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,24 @@ export const PawworkSidebar = (props: { {language.t("sidebar.pawwork.search")} +
From 6617f3354540b479d8697d4ef1801c16038c209a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 17:27:57 +0800 Subject: [PATCH 03/17] feat(automation): add pause/resume row action and mutation wiring Expose pause / resume / delete / runNow on the global-sync automation API. Each mutation applies the authoritative response immediately (revision-gated) so the UI updates without waiting for the SSE round-trip, and the matching event then no-ops. Reveal a one-click pause/resume action on row hover (text button, no new icon) with an error toast on failure. Extend the snap target with a hover shot. Part of #950 PR6 (frontend automations panel). --- .../app/e2e/snap/automations-surface.snap.ts | 6 +++ packages/app/src/context/global-sync.tsx | 39 ++++++++++++++++++- packages/app/src/i18n/en.ts | 5 +++ packages/app/src/i18n/zh.ts | 5 +++ .../src/pages/automations/automation-list.tsx | 19 ++++++++- .../pages/automations/automations-surface.tsx | 20 +++++++++- 6 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/snap/automations-surface.snap.ts b/packages/app/e2e/snap/automations-surface.snap.ts index f1c10679c..60cc55d67 100644 --- a/packages/app/e2e/snap/automations-surface.snap.ts +++ b/packages/app/e2e/snap/automations-surface.snap.ts @@ -40,6 +40,11 @@ test("automations-surface", async ({ page, project }) => { 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() @@ -47,6 +52,7 @@ test("automations-surface", async ({ page, project }) => { 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") diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 22b561617..cafc77342 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -31,7 +31,12 @@ import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" -import { mergeAutomationRuns } from "./global-sync/automation-store" +import { + applyAutomationDefinition, + applyAutomationRun, + applyAutomationTombstone, + mergeAutomationRuns, +} from "./global-sync/automation-store" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { createTodoHydrateCoordinator } from "./global-sync/todo-hydrate-coordinator" @@ -336,6 +341,34 @@ function createGlobalSync() { } } + // Mutations apply the authoritative response immediately (revision-gated), so + // the UI reflects the change without waiting for the SSE round-trip; the + // matching event then no-ops as an equal revision. + async function pauseAutomation(directory: string, automationID: string) { + const [store, setStore] = children.peek(directory, { bootstrap: false }) + const res = await sdkFor(directory).automation.pause({ automationID }) + if (res.data) applyAutomationDefinition(store, setStore, res.data) + } + + async function resumeAutomation(directory: string, automationID: string) { + const [store, setStore] = children.peek(directory, { bootstrap: false }) + const res = await sdkFor(directory).automation.resume({ automationID }) + if (res.data) applyAutomationDefinition(store, setStore, res.data) + } + + async function deleteAutomation(directory: string, automationID: string) { + const [store, setStore] = children.peek(directory, { bootstrap: false }) + const res = await sdkFor(directory).automation.delete({ automationID }) + if (res.data) applyAutomationTombstone(store, setStore, res.data) + } + + async function runAutomationNow(directory: string, automationID: string) { + const [store, setStore] = children.peek(directory, { bootstrap: false }) + const res = await sdkFor(directory).automation.runNow({ automationID }) + if (res.data) applyAutomationRun(store, setStore, res.data) + return res.data + } + async function bootstrapInstance(directory: string) { if (!directory) return const pending = booting.get(directory) @@ -580,6 +613,10 @@ function createGlobalSync() { project: projectApi, automation: { loadRuns: loadAutomationRuns, + pause: pauseAutomation, + resume: resumeAutomation, + delete: deleteAutomation, + runNow: runAutomationNow, }, todo: { set: setSessionTodo, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 99c23d0aa..20c0840a1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -794,6 +794,11 @@ export const dict = { "automations.schedule.every": "Every {{duration}}", "automations.schedule.minutes": "{{count}} min", "automations.schedule.hours": "{{count}} h", + "automations.action.pause": "Pause", + "automations.action.resume": "Resume", + "automations.action.runNow": "Run now", + "automations.action.delete": "Delete", + "automations.toast.actionFailed.title": "Couldn't update automation", "sidebar.pawwork.empty.description": "Open a project to start using the session-first sidebar.", "sidebar.pawwork.pinned": "Pinned", "sidebar.pawwork.all": "All sessions", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 08bec0e17..a08fbcffb 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -704,6 +704,11 @@ export const dict = { "automations.schedule.every": "每 {{duration}}", "automations.schedule.minutes": "{{count}} 分钟", "automations.schedule.hours": "{{count}} 小时", + "automations.action.pause": "暂停", + "automations.action.resume": "恢复", + "automations.action.runNow": "立即运行", + "automations.action.delete": "删除", + "automations.toast.actionFailed.title": "更新自动化失败", "sidebar.pawwork.empty.description": "打开一个项目后,即可使用以会话为中心的侧边栏。", "sidebar.pawwork.pinned": "已置顶", "sidebar.pawwork.all": "全部会话", diff --git a/packages/app/src/pages/automations/automation-list.tsx b/packages/app/src/pages/automations/automation-list.tsx index 86d64b148..e32b4fe8c 100644 --- a/packages/app/src/pages/automations/automation-list.tsx +++ b/packages/app/src/pages/automations/automation-list.tsx @@ -7,13 +7,14 @@ 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/automations-surface.tsx b/packages/app/src/pages/automations/automations-surface.tsx index fce255f91..5018f00f3 100644 --- a/packages/app/src/pages/automations/automations-surface.tsx +++ b/packages/app/src/pages/automations/automations-surface.tsx @@ -1,7 +1,10 @@ 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" @@ -60,6 +63,21 @@ export function AutomationsSurface(props: { 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={}> - + } > From 70af3f1c30c753b06f1ba94733a17c3702bb4ff6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 17:43:30 +0800 Subject: [PATCH 04/17] feat(automation): build automation detail view with runs and lifecycle actions Replace the placeholder detail with the full read-only surface: title plus Run now / Pause-Resume / Delete actions, an Instructions column, and a right column with Status (active/paused, next run, last run), Details (project, repeats, model, reasoning), and Previous runs. Run rows lazy-load via automation.loadRuns on mount and open the run's chat session through the new layout openAutomationRun helper. Delete confirms through a dialog and returns to the list. Knobs stay read-only in v1; inline editing is deferred to PR7. --- .../components/dialog-delete-automation.tsx | 42 +++ packages/app/src/i18n/en.ts | 23 ++ packages/app/src/i18n/zh.ts | 23 ++ .../pages/automations/automation-detail.tsx | 251 +++++++++++++++++- .../automations/automation-run-status.tsx | 29 ++ .../pages/automations/automation-schedule.ts | 17 ++ .../pages/automations/automations-surface.tsx | 10 +- packages/app/src/pages/layout.tsx | 9 + 8 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/components/dialog-delete-automation.tsx create mode 100644 packages/app/src/pages/automations/automation-run-status.tsx 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..e0924c6f2 --- /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/i18n/en.ts b/packages/app/src/i18n/en.ts index 20c0840a1..2f981bbaa 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -799,6 +799,29 @@ export const dict = { "automations.action.runNow": "Run now", "automations.action.delete": "Delete", "automations.toast.actionFailed.title": "Couldn't update automation", + "automations.detail.instructions": "Instructions", + "automations.detail.statusHeading": "Status", + "automations.detail.detailsHeading": "Details", + "automations.detail.active": "Active", + "automations.detail.paused": "Paused", + "automations.detail.nextRun": "Next run", + "automations.detail.lastRun": "Last run", + "automations.detail.project": "Project", + "automations.detail.repeats": "Repeats", + "automations.detail.model": "Model", + "automations.detail.reasoning": "Reasoning", + "automations.detail.previousRuns": "Previous runs", + "automations.detail.showMore": "Show more", + "automations.detail.noRuns": "No runs yet", + "automations.run.scheduled": "Scheduled", + "automations.run.running": "Running", + "automations.run.awaiting_input": "Needs input", + "automations.run.succeeded": "Succeeded", + "automations.run.failed": "Failed", + "automations.run.stopped": "Stopped", + "automations.delete.title": "Delete automation", + "automations.delete.confirm": "Delete “{{title}}”?", + "automations.delete.description": "This stops future runs and removes the automation. Past run sessions are kept.", "sidebar.pawwork.empty.description": "Open a project to start using the session-first sidebar.", "sidebar.pawwork.pinned": "Pinned", "sidebar.pawwork.all": "All sessions", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a08fbcffb..0a3be3b42 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -709,6 +709,29 @@ export const dict = { "automations.action.runNow": "立即运行", "automations.action.delete": "删除", "automations.toast.actionFailed.title": "更新自动化失败", + "automations.detail.instructions": "指令", + "automations.detail.statusHeading": "状态", + "automations.detail.detailsHeading": "详情", + "automations.detail.active": "启用中", + "automations.detail.paused": "已暂停", + "automations.detail.nextRun": "下次运行", + "automations.detail.lastRun": "上次运行", + "automations.detail.project": "项目", + "automations.detail.repeats": "重复", + "automations.detail.model": "模型", + "automations.detail.reasoning": "推理", + "automations.detail.previousRuns": "历史运行", + "automations.detail.showMore": "显示更多", + "automations.detail.noRuns": "还没有运行记录", + "automations.run.scheduled": "已排期", + "automations.run.running": "运行中", + "automations.run.awaiting_input": "待输入", + "automations.run.succeeded": "成功", + "automations.run.failed": "失败", + "automations.run.stopped": "已停止", + "automations.delete.title": "删除自动化", + "automations.delete.confirm": "删除“{{title}}”?", + "automations.delete.description": "这会停止后续运行并移除该自动化。过往运行的会话会保留。", "sidebar.pawwork.empty.description": "打开一个项目后,即可使用以会话为中心的侧边栏。", "sidebar.pawwork.pinned": "已置顶", "sidebar.pawwork.all": "全部会话", diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx index f85fb4f08..6ad20823d 100644 --- a/packages/app/src/pages/automations/automation-detail.tsx +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -1,16 +1,200 @@ -import type { JSX } from "solid-js" -import type { AutomationDefinition } from "@opencode-ai/sdk/v2/client" +import { createMemo, createSignal, For, onMount, Show, type Accessor, type JSX } from "solid-js" +import type { AutomationDefinition, AutomationRun } from "@opencode-ai/sdk/v2/client" import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" +import { showToast } from "@opencode-ai/ui/toast" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import { formatScheduleSummary } from "./automation-schedule" +import { formatServerError } from "@/utils/server-errors" +import { getRelativeTime } from "@/utils/time" +import { DialogDeleteAutomation } from "@/components/dialog-delete-automation" +import { formatScheduleSummary, formatTimestamp } from "./automation-schedule" +import { RunStatusIcon, runStatusLabelKey } from "./automation-run-status" + +const INITIAL_RUN_COUNT = 5 + +type Translate = (key: string, vars?: Record) => string + +function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts[parts.length - 1] ?? path +} + +function InfoRow(props: { label: string; value: string }): JSX.Element { + return ( +
    + {props.label} + {props.value} +
    + ) +} + +function DetailGroup(props: { heading: string; children: JSX.Element }): JSX.Element { + return ( +
    +

    {props.heading}

    +
    {props.children}
    +
    + ) +} + +function PreviousRuns(props: { + runs: AutomationRun[] + t: Translate + onOpenRun: (sessionID: string) => void +}): JSX.Element { + const [expanded, setExpanded] = createSignal(false) + const visible = createMemo(() => (expanded() ? props.runs : props.runs.slice(0, INITIAL_RUN_COUNT))) + return ( + + 0} + fallback={{props.t("automations.detail.noRuns")}} + > +
      + + {(run) => { + const label = props.t(runStatusLabelKey(run.state)) + const when = getRelativeTime(new Date(run.triggeredAt).toISOString(), props.t) + return ( +
    • + + + {label} + {when} + + } + > + {(sessionID) => ( + + )} + +
    • + ) + }} +
      +
    + INITIAL_RUN_COUNT}> + + +
    +
    + ) +} export function AutomationDetail(props: { - automation: AutomationDefinition + 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(() => { + 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}

    -

    {formatScheduleSummary(props.automation, language.t)}

    +
    +

    {props.automation().title}

    +
    + + +
    -

    {props.automation.prompt}

    +
    +
    +

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

    +

    {props.automation().prompt}

    +
    + + +
    ) } 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.ts b/packages/app/src/pages/automations/automation-schedule.ts index 96aed0d68..39ba9f853 100644 --- a/packages/app/src/pages/automations/automation-schedule.ts +++ b/packages/app/src/pages/automations/automation-schedule.ts @@ -40,3 +40,20 @@ export function formatScheduleSummary(definition: AutomationDefinition, t: Trans 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 { + 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 index 5018f00f3..434e12f47 100644 --- a/packages/app/src/pages/automations/automations-surface.tsx +++ b/packages/app/src/pages/automations/automations-surface.tsx @@ -26,6 +26,7 @@ function AutomationsEmpty(): JSX.Element { export function AutomationsSurface(props: { directory: Accessor onClose: () => void + onOpenRun: (sessionID: string) => void }): JSX.Element { const globalSync = useGlobalSync() const language = useLanguage() @@ -93,7 +94,14 @@ export function AutomationsSurface(props: { } > - {(automation) => setSelectedID(undefined)} />} + {(automation) => ( + setSelectedID(undefined)} + onOpenRun={props.onOpenRun} + /> + )}
    diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 9b1620a5c..aaeebcd76 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1300,6 +1300,14 @@ export default function Layout(props: ParentProps) { 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) + } + function projectRoot(directory: string) { const key = workspaceKey(directory) @@ -2211,6 +2219,7 @@ export default function Layout(props: ParentProps) { currentProject()?.worktree ?? projectRoot(currentDir())} onClose={closeSettings} + onOpenRun={openAutomationRun} /> ), }} From 1a7a84de48358b95d1726b2f9f32c394da1b6884 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 17:56:04 +0800 Subject: [PATCH 05/17] feat(automation): unhide automate tool and pin its v1 input surface Remove the OPENCODE_ENABLE_AUTOMATE_TOOL env gate so the automate tool is always available now that the Automations panel ships. Narrow the agent-facing tool schema to the v1 surface: drop the context, recurring stop, and where.worktree knobs, and inject the defaults (fresh context, never-stop) in execute before the domain create parser. The frozen domain contract (Automation.create/update validation) is left untouched on purpose: it still accepts the full shape so HTTP routes, the SDK, and the structured error contract keep working. Enforcing the v1 narrowing only at the tool/product layer follows the "differentiation lives in the tool layer" guidance and avoids changing behavior the merged PR1-5 tests depend on. This diverges from the build-plan note that also asked for domain-level rejection. --- packages/opencode/src/tool/automate.ts | 37 +++++++--------- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/tool/automate.test.ts | 44 ++++++-------------- packages/opencode/test/tool/registry.test.ts | 4 +- 4 files changed, 31 insertions(+), 56 deletions(-) diff --git a/packages/opencode/src/tool/automate.ts b/packages/opencode/src/tool/automate.ts index 5705dcccb..309eae804 100644 --- a/packages/opencode/src/tool/automate.ts +++ b/packages/opencode/src/tool/automate.ts @@ -7,7 +7,6 @@ import * as Tool from "./tool" const Where = Schema.Struct({ projectID: Schema.String, - worktree: Schema.optional(Schema.NonEmptyString), }) const Model = Schema.Struct({ @@ -25,12 +24,14 @@ 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)) +// The v1 automate surface deliberately omits the context, stop, and worktree +// knobs the frozen domain contract still supports. execute() pins them to the +// defaults (fresh session, run until paused, project root) before the domain +// create parser, so chat-created automations match the Automations panel. const Common = { title: Title, prompt: Prompt, - context: Schema.Union([Schema.Literal("continue"), Schema.Literal("fresh")]), where: Where, timezone: Timezone, model: Model, @@ -38,19 +39,8 @@ const Common = { } 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 }), @@ -66,7 +56,6 @@ export const AutomateParameters = Schema.Union([ kind: Schema.Literal("recurring"), ...Common, rhythm: Rhythm, - stop: Stop, }), ]) @@ -77,10 +66,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 shape: oneshot { kind, title, prompt, where, timezone, model, variant?, fireAt } or recurring { kind, title, prompt, where, timezone, model, variant?, rhythm }.", + "where is { projectID }. model is required as { providerID, modelID }; variant is optional and must be a valid effort key for that model (omit for models without reasoning).", + "rhythm is { kind: \"interval\", everyMs } or { kind: \"cron\", expression }. Automations always run as a fresh session and repeat on their schedule until the user pauses or deletes them.", + "Example: { kind: \"recurring\", title: \"Daily repo brief\", prompt: \"Summarize repo changes.\", where: { projectID: \"current-project\" }, timezone: \"UTC\", model: { providerID: \"anthropic\", modelID: \"claude-sonnet-4-6\" }, variant: \"high\", rhythm: { kind: \"interval\", everyMs: 3600000 } }.", detail, ].join("\n") } @@ -93,7 +82,7 @@ function readableAutomationError(error: unknown) { 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 definition for later execution. The automation is not executed by this tool; it only stores the definition and echoes the resolved contract. Each run starts a fresh session and repeats on its schedule until the user pauses or deletes it in the Automations panel.", parameters: AutomateParameters, formatValidationError: formatAutomateValidationError, execute: (params, ctx) => @@ -112,7 +101,13 @@ export function createAutomateDefinition(provider: Provider.Interface): Tool.Def } const definition = yield* Effect.try({ try: () => { - const parsed = Automation.CreateInput.parse(input) + // Pin the v1 defaults the tool surface no longer exposes before the + // domain create parser: fresh context always, never-stop for recurring. + const enriched = + input.kind === "recurring" + ? { ...input, context: "fresh" as const, stop: { kind: "never" as const } } + : { ...input, context: "fresh" as const } + const parsed = Automation.CreateInput.parse(enriched) AutomationScheduler.current() return Automation.create(parsed, { sourceSessionID: ctx.sessionID }) }, 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/tool/automate.test.ts b/packages/opencode/test/tool/automate.test.ts index 1a84a0340..ffaa130e4 100644 --- a/packages/opencode/test/tool/automate.test.ts +++ b/packages/opencode/test/tool/automate.test.ts @@ -64,11 +64,9 @@ describe("automate tool", () => { 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 } }], + ["zero interval", { kind: "recurring", rhythm: { kind: "interval", everyMs: 0 } }], + ["interval below floor", { kind: "recurring", rhythm: { kind: "interval", everyMs: 29_999 } }], + ["fractional interval", { kind: "recurring", rhythm: { kind: "interval", everyMs: 1.5 } }], ])("rejects invalid numeric fields before execute reaches the Zod create parser: %s", (_name, override) => { const decode = Schema.decodeUnknownSync(AutomateParameters) const base = { @@ -91,11 +89,9 @@ describe("automate tool", () => { }) 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 cron expression", { rhythm: { kind: "cron", expression: "" } }], ["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) => { const decode = Schema.decodeUnknownSync(AutomateParameters) let error: unknown @@ -162,14 +158,10 @@ describe("automate tool", () => { { kind: "recurring", title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: where(Instance.project.id), + prompt: "Summarize repo changes.", where: where(Instance.project.id), timezone: "UTC", model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, + rhythm: { kind: "interval", everyMs: 60_000 }, }, { sessionID: sourceSessionID, messageID: MessageID.ascending(), @@ -204,14 +196,10 @@ describe("automate tool", () => { { kind: "recurring", title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, + prompt: "Summarize repo changes.", where: { projectID: Instance.project.id }, timezone: "Asia/Shanghai", model: fixtureModel, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, + rhythm: { kind: "interval", everyMs: 60_000 }, }, { sessionID: sourceSessionID, messageID: MessageID.ascending(), @@ -252,15 +240,11 @@ describe("automate tool", () => { { kind: "recurring", title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, + prompt: "Summarize repo changes.", where: { projectID: Instance.project.id }, timezone: "Asia/Shanghai", model: fixtureModel, ...spoofedSource, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, + rhythm: { kind: "interval", everyMs: 60_000 }, }, { sessionID: sourceSessionID, messageID: MessageID.ascending(), @@ -292,15 +276,11 @@ describe("automate tool", () => { { kind: "recurring", title: "Daily repo brief", - prompt: "Summarize repo changes.", - context: "fresh", - where: { projectID: Instance.project.id }, + prompt: "Summarize repo changes.", where: { projectID: Instance.project.id }, timezone: "Asia/Shanghai", model: fixtureModel, ...spoofedSession, - rhythm: { kind: "interval", everyMs: 60_000 }, - stop: { kind: "never" }, - }, + rhythm: { kind: "interval", everyMs: 60_000 }, }, { sessionID: SessionID.descending(), messageID: MessageID.ascending(), 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") }, }) }) From a53dd5cdbd7205dedf39e4331a38a19f253678b1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 18:02:52 +0800 Subject: [PATCH 06/17] feat(automation): subtle toast when a recurring automation keeps failing Detect the rising edge of failureStreak >= 3 in the directory event reducer and fire one subtle toast, no auto-pause. The edge is gated on an accepted (revision-newer) update and a witnessed transition from below the threshold, so SSE replays and the bootstrap snapshot (which never runs this reducer path) stay quiet. The prior streak is snapshotted as a primitive before the store write because the Solid store proxy would otherwise read back the post-write value. --- packages/app/src/context/global-sync.tsx | 10 ++ .../context/global-sync/event-reducer.test.ts | 101 ++++++++++++++++++ .../src/context/global-sync/event-reducer.ts | 24 ++++- packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/zh.ts | 2 + 5 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index cafc77342..bc6290867 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -461,6 +461,16 @@ function createGlobalSync() { todoHydrate, blockerTerminals, vcsCache: children.vcsCache.get(targetDirectory), + onAutomationFailureStreak: (definition) => { + showToast({ + variant: "subtle", + title: language.t("automations.toast.failureStreak.title"), + description: language.t("automations.toast.failureStreak.description", { + title: definition.title, + count: definition.failureStreak, + }), + }) + }, loadLsp: () => { void sdkFor(targetDirectory) .lsp.status() diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 6c1257bf9..8b05810b6 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { + AutomationDefinition, Message, Part, PermissionRequest, @@ -79,6 +80,28 @@ const permissionRequest = (id: string, sessionID: string, title = id) => const emptyAggregate = (sessionID: string): SessionDiffResponse => ({ kind: "empty", sessionID }) +const recurringAutomation = (input: { id: string; revision: number; failureStreak: number }) => + ({ + kind: "recurring", + id: input.id, + title: `Auto ${input.id}`, + prompt: "do things", + revision: input.revision, + paused: false, + context: "fresh", + where: { projectID: "proj" }, + createdAt: 1, + updatedAt: 1, + timezone: "UTC", + normalizationWarnings: [], + model: { providerID: "opencode", modelID: "big-pickle" }, + rhythm: { kind: "cron", expression: "0 9 * * *" }, + stop: { kind: "never" }, + nextFireAt: null, + nextFires: [], + failureStreak: input.failureStreak, + }) as AutomationDefinition + const baseState = (input: Partial = {}) => ({ status: "complete", @@ -105,6 +128,9 @@ const baseState = (input: Partial = {}) => limit: 10, message: {}, part: {}, + automation: {}, + automation_run: {}, + automation_tombstone: {}, ...input, }) as State @@ -708,6 +734,81 @@ describe("applyDirectoryEvent", () => { expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) }) + test("fires one failure-streak alert on the rising edge, then stays quiet", () => { + const [store, setStore] = createStore( + baseState({ automation: { auto_1: recurringAutomation({ id: "auto_1", revision: 1, failureStreak: 2 }) } }), + ) + const alerts: AutomationDefinition[] = [] + + applyDirectoryEvent({ + event: { + type: "automation.definition.updated", + properties: recurringAutomation({ id: "auto_1", revision: 2, failureStreak: 3 }), + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + onAutomationFailureStreak: (definition) => alerts.push(definition), + }) + + applyDirectoryEvent({ + event: { + type: "automation.definition.updated", + properties: recurringAutomation({ id: "auto_1", revision: 3, failureStreak: 4 }), + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + onAutomationFailureStreak: (definition) => alerts.push(definition), + }) + + expect(alerts.map((definition) => definition.id)).toEqual(["auto_1"]) + expect((store.automation.auto_1 as AutomationDefinition).revision).toBe(3) + }) + + test("stays quiet for first-seen and stale failure-streak definitions", () => { + const [store, setStore] = createStore( + baseState({ automation: { auto_stale: recurringAutomation({ id: "auto_stale", revision: 5, failureStreak: 3 }) } }), + ) + const alerts: AutomationDefinition[] = [] + const onAutomationFailureStreak = (definition: AutomationDefinition) => alerts.push(definition) + + // First time we ever see this automation it is already failing: no transition + // was witnessed (covers the bootstrap-then-replay case), so no alert. + applyDirectoryEvent({ + event: { + type: "automation.definition.updated", + properties: recurringAutomation({ id: "auto_new", revision: 1, failureStreak: 3 }), + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + onAutomationFailureStreak, + }) + + // Stale replay below the stored revision is dropped before it can alert. + applyDirectoryEvent({ + event: { + type: "automation.definition.updated", + properties: recurringAutomation({ id: "auto_stale", revision: 4, failureStreak: 9 }), + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + onAutomationFailureStreak, + }) + + expect(alerts).toEqual([]) + }) + test("routes disposal and lsp events to side-effect handlers", () => { const [store, setStore] = createStore(baseState()) const pushes: string[] = [] diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 34b077fce..09f88aa1d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -22,6 +22,12 @@ import type { createBlockerTerminalCache } from "./blocker-terminal-cache" import type { TodoHydrateCoordinator } from "./todo-hydrate-coordinator" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + +// A recurring automation that fails this many times in a row earns one subtle +// toast on the rising edge. The threshold and the rising-edge gate keep SSE +// replays and the bootstrap snapshot (which never runs this reducer path) quiet. +const AUTOMATION_FAILURE_STREAK_ALERT = 3 +type RecurringAutomation = Extract type AcceptSessionTodo = (sessionID: string, snapshot: TodoSnapshot) => boolean type ClearSessionTodoAuthoritative = (sessionID: string) => void type TodoHydrateBoundary = Partial> @@ -181,6 +187,7 @@ export function applyDirectoryEvent(input: { clearSessionTodoAuthoritative?: ClearSessionTodoAuthoritative todoHydrate?: TodoHydrateBoundary blockerTerminals?: ReturnType + onAutomationFailureStreak?: (definition: RecurringAutomation) => void }) { const event = input.event switch (event.type) { @@ -432,7 +439,22 @@ export function applyDirectoryEvent(input: { break } case "automation.definition.updated": { - applyAutomationDefinition(input.store, input.setStore, event.properties as AutomationDefinition) + const definition = event.properties as AutomationDefinition + // Snapshot the prior streak as a primitive before applying: the store proxy + // reflects the current value at its path, so reading it post-write would + // mask the rising edge. + const previous = input.store.automation[definition.id] + const previousStreak = previous?.kind === "recurring" ? previous.failureStreak : undefined + const accepted = applyAutomationDefinition(input.store, input.setStore, definition) + if ( + accepted && + definition.kind === "recurring" && + definition.failureStreak >= AUTOMATION_FAILURE_STREAK_ALERT && + previousStreak !== undefined && + previousStreak < AUTOMATION_FAILURE_STREAK_ALERT + ) { + input.onAutomationFailureStreak?.(definition) + } break } case "automation.definition.deleted": { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 2f981bbaa..c5924b297 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -799,6 +799,8 @@ export const dict = { "automations.action.runNow": "Run now", "automations.action.delete": "Delete", "automations.toast.actionFailed.title": "Couldn't update automation", + "automations.toast.failureStreak.title": "Automation keeps failing", + "automations.toast.failureStreak.description": "“{{title}}” has failed {{count}} times in a row. Open Automations to check it.", "automations.detail.instructions": "Instructions", "automations.detail.statusHeading": "Status", "automations.detail.detailsHeading": "Details", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 0a3be3b42..a510195e4 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -709,6 +709,8 @@ export const dict = { "automations.action.runNow": "立即运行", "automations.action.delete": "删除", "automations.toast.actionFailed.title": "更新自动化失败", + "automations.toast.failureStreak.title": "自动化持续失败", + "automations.toast.failureStreak.description": "“{{title}}”已连续失败 {{count}} 次,打开自动化面板查看。", "automations.detail.instructions": "指令", "automations.detail.statusHeading": "状态", "automations.detail.detailsHeading": "详情", From 90994f336c4c1f3ba5436dd886b64177387c50d6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 18:10:03 +0800 Subject: [PATCH 07/17] test(automation): cover the Automations panel user path end to end Walk the real workflow in Playwright: open the panel from the sidebar, seed an automation over the SDK, open its detail, pause it, and delete it through the confirm dialog. A second spec asserts Escape unwinds detail to the list and then closes the surface, and that opening Automations keeps the sidebar live (unlike the Settings takeover). Add stable data-action hooks on the detail actions and the delete-confirm button so the selectors do not ride on label text. --- .../e2e/automations/automations-panel.spec.ts | 95 +++++++++++++++++++ .../components/dialog-delete-automation.tsx | 2 +- .../pages/automations/automation-detail.tsx | 5 +- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/automations/automations-panel.spec.ts 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..76a462ad9 --- /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 action 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"]')).toHaveText("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/src/components/dialog-delete-automation.tsx b/packages/app/src/components/dialog-delete-automation.tsx index e0924c6f2..621c66aa1 100644 --- a/packages/app/src/components/dialog-delete-automation.tsx +++ b/packages/app/src/components/dialog-delete-automation.tsx @@ -33,7 +33,7 @@ export function DialogDeleteAutomation(props: { - diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx index 6ad20823d..7fe4788fd 100644 --- a/packages/app/src/pages/automations/automation-detail.tsx +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -210,15 +210,16 @@ export function AutomationDetail(props: {

    {props.automation().title}

    - - - + + + + + {props.automationsLabel()} + + +
    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 From 7d51fa9c8209c7f3398c945f33eaeb703bb2e674 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 22:56:47 +0800 Subject: [PATCH 13/17] feat(automation): add play/pause icons, refine detail action row Add hand-authored `play` and `pause` glyphs to the icon registry: pause reuses circle-check's ring with thin bars; play is a rounded landscape triangle stroked at 1.3 to match the family's measured weight (arrow-right shaft 1.31, circle-check ring 1.04). Reorder the detail action buttons to delete / pause / run-now, make pause an icon-only toggle (play when paused), give run-now a leading play icon, and add breathing room (gap-6 outer, gap-8 grid). --- .../pages/automations/automation-detail.tsx | 21 ++++++++++++------- packages/ui/src/components/icon.tsx | 2 ++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx index 7f9085a46..e024698d7 100644 --- a/packages/app/src/pages/automations/automation-detail.tsx +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -190,7 +190,7 @@ export function AutomationDetail(props: { } return ( -
    +
    -
    +

    {t("automations.detail.instructions")} 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": ``, From d4d53c4212c1cc3c61cb6601fc255f569ad1cac3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 23:01:14 +0800 Subject: [PATCH 14/17] test(automation): register automations-panel smoke spec in e2e inventory The e2e-smoke-tagging inventory test asserts the discovered @smoke titles match a hardcoded list; the new automations-panel.spec.ts smoke case was missing, failing unit-opencode. Add its entry in sorted position. --- packages/opencode/test/config/e2e-smoke-tagging.test.ts | 1 + 1 file changed, 1 insertion(+) 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", From fbb1c0d13e398ab7c0073946343972dac549c198 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 2 Jun 2026 23:26:52 +0800 Subject: [PATCH 15/17] fix(automation): name weekday in weekly summary, scope runs list to recent P3: weekly cron summaries showed only the time, so `0 9 * * 1` (Mon) and `0 9 * * 5` (Fri) rendered identically. Name the weekday via localized strings (en/zh) so single-day schedules are distinguishable. P2: the runs list loads only the most recent page and cannot page further, so "Previous runs" over-promised. Rename it to "Recent runs" and note at the mount call that dropping nextCursor is intentional. --- packages/app/src/i18n/en.ts | 11 +++++++++-- packages/app/src/i18n/zh.ts | 11 +++++++++-- .../app/src/pages/automations/automation-detail.tsx | 2 ++ .../pages/automations/automation-schedule.test.ts | 12 ++++++++++-- .../app/src/pages/automations/automation-schedule.ts | 7 ++++++- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b8256cf70..b9d964c19 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -789,7 +789,14 @@ export const dict = { "automations.schedule.hourly": "Hourly", "automations.schedule.daily": "Daily at {{time}}", "automations.schedule.weekdays": "Weekdays at {{time}}", - "automations.schedule.weekly": "Weekly at {{time}}", + "automations.schedule.weekly": "{{day}} at {{time}}", + "automations.schedule.weekday.0": "Sundays", + "automations.schedule.weekday.1": "Mondays", + "automations.schedule.weekday.2": "Tuesdays", + "automations.schedule.weekday.3": "Wednesdays", + "automations.schedule.weekday.4": "Thursdays", + "automations.schedule.weekday.5": "Fridays", + "automations.schedule.weekday.6": "Saturdays", "automations.schedule.custom": "Custom schedule", "automations.schedule.every": "Every {{duration}}", "automations.schedule.seconds": "{{count}} s", @@ -813,7 +820,7 @@ export const dict = { "automations.detail.repeats": "Repeats", "automations.detail.model": "Model", "automations.detail.reasoning": "Reasoning", - "automations.detail.previousRuns": "Previous runs", + "automations.detail.previousRuns": "Recent runs", "automations.detail.showMore": "Show more", "automations.detail.noRuns": "No runs yet", "automations.run.scheduled": "Scheduled", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 0ead7c1b1..6b71e60cb 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -699,7 +699,14 @@ export const dict = { "automations.schedule.hourly": "每小时", "automations.schedule.daily": "每天 {{time}}", "automations.schedule.weekdays": "工作日 {{time}}", - "automations.schedule.weekly": "每周 {{time}}", + "automations.schedule.weekly": "每{{day}} {{time}}", + "automations.schedule.weekday.0": "周日", + "automations.schedule.weekday.1": "周一", + "automations.schedule.weekday.2": "周二", + "automations.schedule.weekday.3": "周三", + "automations.schedule.weekday.4": "周四", + "automations.schedule.weekday.5": "周五", + "automations.schedule.weekday.6": "周六", "automations.schedule.custom": "自定义日程", "automations.schedule.every": "每 {{duration}}", "automations.schedule.seconds": "{{count}} 秒", @@ -723,7 +730,7 @@ export const dict = { "automations.detail.repeats": "重复", "automations.detail.model": "模型", "automations.detail.reasoning": "推理", - "automations.detail.previousRuns": "历史运行", + "automations.detail.previousRuns": "最近运行", "automations.detail.showMore": "显示更多", "automations.detail.noRuns": "还没有运行记录", "automations.run.scheduled": "已排期", diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx index e024698d7..8a9d87f5c 100644 --- a/packages/app/src/pages/automations/automation-detail.tsx +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -111,6 +111,8 @@ export function AutomationDetail(props: { 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) }) diff --git a/packages/app/src/pages/automations/automation-schedule.test.ts b/packages/app/src/pages/automations/automation-schedule.test.ts index 711ccf8de..666937b6c 100644 --- a/packages/app/src/pages/automations/automation-schedule.test.ts +++ b/packages/app/src/pages/automations/automation-schedule.test.ts @@ -30,12 +30,20 @@ describe("formatScheduleSummary", () => { ) }) - test("weekly cron", () => { + test("weekly cron names the weekday", () => { expect(formatScheduleSummary(recurring({ kind: "cron", expression: "30 8 * * 0" }), t)).toBe( - 'automations.schedule.weekly:{"time":"08:30"}', + '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") diff --git a/packages/app/src/pages/automations/automation-schedule.ts b/packages/app/src/pages/automations/automation-schedule.ts index ed5241ab3..c9520ee61 100644 --- a/packages/app/src/pages/automations/automation-schedule.ts +++ b/packages/app/src/pages/automations/automation-schedule.ts @@ -42,7 +42,12 @@ function formatCron(expression: string, t: Translate) { if (dow === "*") return t("automations.schedule.daily", { time }) if (dow === "1-5") return t("automations.schedule.weekdays", { time }) - if (/^[0-6]$/.test(dow)) return t("automations.schedule.weekly", { 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") } From 9a7b7a005b34ea8238e8c7c11435519bce8e024e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 00:34:36 +0800 Subject: [PATCH 16/17] fix(automations): assert toggle action via aria-label for icon-only button The pause/resume action is icon-only now (icon + aria-label, no text node), so the e2e smoke's toHaveText("Resume") resolved to "" and failed e2e-artifacts. Assert the aria-label instead, matching the button's accessible name. --- packages/app/e2e/automations/automations-panel.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/automations/automations-panel.spec.ts b/packages/app/e2e/automations/automations-panel.spec.ts index 76a462ad9..14954db3d 100644 --- a/packages/app/e2e/automations/automations-panel.spec.ts +++ b/packages/app/e2e/automations/automations-panel.spec.ts @@ -47,9 +47,9 @@ test("@smoke automations panel: list, detail, pause, delete", async ({ page, pro await expect(detail).toBeVisible() await expect(detail.getByRole("heading", { name: "Daily standup digest" })).toBeVisible() - // Pause flips the action label to Resume and the status row to Paused. + // 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"]')).toHaveText("Resume") + 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. From a70b220cf27e9df1f1c019b0e402fbb1fb4c0237 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 00:34:36 +0800 Subject: [PATCH 17/17] fix(automate): scope session-model errors and sample now after validation sessionModel() caught every error from MessageV2.stream and returned undefined, silently downgrading a corrupt-store/IO failure to the provider default model. Only swallow NotFoundError (missing session); rethrow everything else. One-shot creation sampled now before the async model resolution/validation, so across a cron boundary that stale instant could compute an already-due fireAt. Sample now after validation, just before building the create input. Adds unit coverage: a non-NotFound stream failure fails the tool without creating an automation, NotFound still falls back to the default model, and a clock crossing a minute boundary during validation pushes the one-shot fireAt to the next match. --- packages/opencode/src/tool/automate.ts | 19 ++-- packages/opencode/test/tool/automate.test.ts | 107 ++++++++++++++++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/tool/automate.ts b/packages/opencode/src/tool/automate.ts index 880b16676..3c05647f3 100644 --- a/packages/opencode/src/tool/automate.ts +++ b/packages/opencode/src/tool/automate.ts @@ -7,6 +7,7 @@ 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 Timezone = Schema.NonEmptyString.check( @@ -65,16 +66,18 @@ function resolveTimezone(explicit: string | undefined): string { // 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 — if the session has no persisted messages yet, stream() throws -// NotFoundError; treat that as "no model to inherit" and let execute() fall -// back to the provider default rather than hard-failing the tool. +// 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 { - return undefined + } catch (error) { + if (NotFoundError.isInstance(error)) return undefined + throw error } return undefined } @@ -89,7 +92,6 @@ export function createAutomateDefinition( formatValidationError: formatAutomateValidationError, execute: (params, ctx) => Effect.gen(function* () { - const now = Date.now() const timezone = resolveTimezone(params.timezone) let model: { providerID: string; modelID: string } @@ -109,6 +111,11 @@ export function createAutomateDefinition( 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 common = { diff --git a/packages/opencode/test/tool/automate.test.ts b/packages/opencode/test/tool/automate.test.ts index e38de6f9d..4126b12e3 100644 --- a/packages/opencode/test/tool/automate.test.ts +++ b/packages/opencode/test/tool/automate.test.ts @@ -1,9 +1,12 @@ -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" @@ -184,6 +187,108 @@ describe("automate tool", () => { }) }) + 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({