Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5cc8036
refactor(automation): add explicit owner scopes
Astro-Han Jun 11, 2026
e6c6338
feat(automation): run schedules from a process owner
Astro-Han Jun 11, 2026
45dd248
feat(automation): move fresh automations in place
Astro-Han Jun 11, 2026
a33b069
test(app): cover native automation moves
Astro-Han Jun 11, 2026
b0938f0
fix(automation): avoid bootstrapping projects during scans
Astro-Han Jun 11, 2026
1c76fc4
fix(automation): route scoped scheduler outcomes
Astro-Han Jun 11, 2026
be76df5
test(automation): cover unknown project moves
Astro-Han Jun 12, 2026
1404c97
refactor(app): share workspace picker popover
Astro-Han Jun 12, 2026
c9cedc1
feat(app): add sidebar workspace picker
Astro-Han Jun 12, 2026
1a37813
refactor(app): keep workspace picker list reactive
Astro-Han Jun 12, 2026
7b5c158
Revert "refactor(app): keep workspace picker list reactive"
Astro-Han Jun 12, 2026
5333482
Revert "feat(app): add sidebar workspace picker"
Astro-Han Jun 12, 2026
23ae891
Revert "refactor(app): share workspace picker popover"
Astro-Han Jun 12, 2026
52ceed9
fix(app): keep automation project picker available
Astro-Han Jun 12, 2026
5efc2f5
test(app): cover read-only continue automation project
Astro-Han Jun 12, 2026
f42b25a
fix(app): unify automation detail hover states
Astro-Han Jun 12, 2026
98a1d1a
fix(app): normalize automation detail hover targets
Astro-Han Jun 12, 2026
8a88e3f
fix(opencode): refresh moved automation outcomes
Astro-Han Jun 12, 2026
90a8fa2
fix(opencode): await automation scheduler initial scan
Astro-Han Jun 12, 2026
b215f7e
refactor(opencode): remove unused directory run stopper
Astro-Han Jun 12, 2026
867d9b6
test(opencode): keep move active-run guard live
Astro-Han Jun 12, 2026
e4a20fe
fix(opencode): clean up listener on scheduler settle failure
Astro-Han Jun 12, 2026
8ec43b3
refactor(opencode): share scheduler outcome refresh
Astro-Han Jun 12, 2026
9ad1760
refactor(opencode): drop unused scheduler stop wrappers
Astro-Han Jun 12, 2026
9022bd8
refactor(opencode): tighten scheduler event handling
Astro-Han Jun 12, 2026
d1b7a28
fix(opencode): preserve moved automation timer scope
Astro-Han Jun 12, 2026
289b60d
fix(opencode): stop scheduler scans cleanly
Astro-Han Jun 12, 2026
2b09a56
refactor(opencode): clarify automation validation options
Astro-Han Jun 12, 2026
c772070
refactor(opencode): keep scheduler stop runs private
Astro-Han Jun 12, 2026
03b61d6
fix(app): confirm automation move target before caching
Astro-Han Jun 12, 2026
5869d64
test(app): cover automation open-project picker
Astro-Han Jun 12, 2026
d2d8d68
test(app): cover automation project footer click
Astro-Han Jun 12, 2026
78e698d
chore: merge dev into process scheduler branch
Astro-Han Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 92 additions & 18 deletions packages/app/e2e/automations/automations-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ async function openAutomations(page: Parameters<typeof openSidebar>[0]) {
return surface
}

async function expectInternalHoverSurface(locator: ReturnType<Page["locator"]>) {
await locator.hover()
await expect
.poll(async () =>
locator.evaluate((element) => {
const style = getComputedStyle(element)
return {
backgroundColor: style.backgroundColor,
cursor: style.cursor,
height: Math.round(element.getBoundingClientRect().height),
textDecorationLine: style.textDecorationLine,
}
}),
)
.toMatchObject({
cursor: "default",
height: 30,
textDecorationLine: "none",
})
await expect
.poll(async () => locator.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)")
}

test("automations panel: create manually adds an automation", async ({ page, project }) => {
test.setTimeout(120_000)

Expand Down Expand Up @@ -595,19 +619,15 @@ test("automations panel: detail moves an automation to another open project", as
const detail = surface.locator('[data-component="automation-detail"]')
await expect(detail).toBeVisible()

// The Project row is a picker when another project is open. Moving is a
// create-in-target + delete-from-source pair: the automation appears in
// the target project's list, vanishes from the source, and the detail
// stays open on the recreated definition (new id, same fields).
// The Project row is a picker when another project is open. Moving updates
// the same automation in place: it appears in the target project's list,
// vanishes from the source, and the detail stays open on the same id.
await detail.locator('[data-action="automation-edit-project"]').click()
await page.locator(`[data-project="${otherProjectID}"]`).click()

await expect
.poll(async () =>
((await otherSDK.automation.list()).data?.items ?? []).filter((item) => item.title === "Migrating digest")
.length,
)
.toBe(1)
.poll(async () => ((await otherSDK.automation.list()).data?.items ?? []).find((item) => item.id === created.id))
.toMatchObject({ id: created.id, title: "Migrating digest" })
await expect
.poll(async () =>
((await project.sdk.automation.list()).data?.items ?? []).filter((item) => item.id === created.id).length,
Expand All @@ -620,6 +640,62 @@ test("automations panel: detail moves an automation to another open project", as
}
})

test("automations panel: detail project picker stays available with one open project", async ({ page, project }) => {
test.setTimeout(120_000)

await project.open()

const projectID = (await project.sdk.project.current()).data!.id
await project.sdk.automation.create(
recurring(projectID, "Single project digest", "Summarize the current project.", "0 9 * * *"),
)

const surface = await openAutomations(page)
await surface.locator('[data-action="automation-row"]', { hasText: "Single project digest" }).first().click()

const detail = surface.locator('[data-component="automation-detail"]')
await expect(detail).toBeVisible()

const projectPicker = detail.locator('[data-action="automation-edit-project"]')
await expect(projectPicker).toBeVisible()

await expectInternalHoverSurface(projectPicker)
await expectInternalHoverSurface(detail.locator('[data-action="automation-edit-schedule"]'))
await expectInternalHoverSurface(detail.locator('[data-action="automation-edit-model"]'))

await projectPicker.click()
const menu = page.locator('[role="menu"][aria-label="Workspace"]')
await expect(menu).toBeVisible()
await expect(menu.locator(`[data-project="${projectID}"]`)).toBeVisible()
await menu.locator('[data-action="automation-folder-open-project"]').click()
await expect(menu).toBeHidden()
const directoryDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search folders") })
await expect(directoryDialog).toBeVisible()
await expect(surface).toBeVisible()
expect(new URL(page.url()).pathname).toBe("/automations")
})

test("automations panel: continue automation project stays read-only", async ({ page, project, assistant }) => {
test.setTimeout(120_000)

await project.open()
await assistant.tool("automate", {
title: "Continue project loop",
prompt: "Keep checking the same conversation.",
cron: "0 8 * * *",
continueSession: true,
})
await project.prompt("Loop this conversation every morning.")

const surface = await openAutomations(page)
await surface.locator('[data-action="automation-row"]', { hasText: "Continue project loop" }).first().click()

const detail = surface.locator('[data-component="automation-detail"]')
await expect(detail).toBeVisible()
await expect(detail.locator('[data-action="automation-edit-project"]')).toHaveCount(0)
await expectInternalHoverSurface(detail.locator('[data-action="automation-open-source"]'))
})

test("automations panel: a rhythm the picker cannot express is read-only", async ({ page, project }) => {
test.setTimeout(120_000)

Expand Down Expand Up @@ -670,12 +746,10 @@ test("automations panel: a paused automation arrives paused after a move", async
await detail.locator('[data-action="automation-edit-project"]').click()
await page.locator(`[data-project="${otherProjectID}"]`).click()

// The recreated copy must keep the silence the user set, not wake up live.
// The moved definition must keep the silence the user set, not wake up live.
await expect
.poll(async () => {
const moved = ((await otherSDK.automation.list()).data?.items ?? []).find(
(item) => item.title === "Silenced digest",
)
const moved = ((await otherSDK.automation.list()).data?.items ?? []).find((item) => item.id === created.id)
return moved ? moved.paused : "missing"
})
.toBe(true)
Expand All @@ -687,7 +761,7 @@ test("automations panel: a paused automation arrives paused after a move", async
}
})

test("automations panel: a failed pause during a move rolls back and keeps the source", async ({
test("automations panel: a failed move keeps the source", async ({
page,
project,
backend,
Expand All @@ -713,10 +787,10 @@ test("automations panel: a failed pause during a move rolls back and keeps the s
const detail = surface.locator('[data-component="automation-detail"]')
await expect(detail).toBeVisible()

// Re-applying the pause on the target fails. The move must fail as a whole:
// the fresh copy is deleted, the paused source is untouched, and the user
// is told — never a silenced automation suddenly live in another project.
await page.route("**/automation/*/pause*", (route) => route.fulfill({ status: 500, body: "{}" }))
await page.route(`**/automation/${created.id}`, (route) => {
if (route.request().method() !== "PUT") return route.continue()
return route.fulfill({ status: 500, body: "{}" })
})
await detail.locator('[data-action="automation-edit-project"]').click()
await page.locator(`[data-project="${otherProjectID}"]`).click()

Expand Down
34 changes: 34 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type {
AutomationCreateInput,
AutomationUpdateInput,
Expand Down Expand Up @@ -37,6 +37,7 @@
import { pendingSessionIDsForDirectory, type PendingQuestionIndex } from "./global-sync/pending-question-index"
import {
applyAutomationDefinition,
applyAutomationMoveResult,
applyAutomationRun,
applyAutomationTombstone,
mergeAutomationRuns,
Expand Down Expand Up @@ -445,6 +446,38 @@
}
}

async function moveAutomation(directory: string, automationID: string, targetProject: { id: string; worktree: string }) {
children.pin(directory)
children.pin(targetProject.worktree)
try {
const source = children.peek(directory, { bootstrap: false })
const current = source[0].automation[automationID]
const res = await sdkFor(directory).automation.update({
automationID,
automationUpdateInput: {
where: {
projectID: targetProject.id,
...(current?.where.worktree ? { worktree: current.where.worktree } : {}),
},
},
})
if (res.data) {
const target = children.peek(targetProject.worktree, { bootstrap: false })
applyAutomationMoveResult({
source,
target,
automationID,
targetProjectID: targetProject.id,
incoming: res.data,
})
}
return res.data
} finally {
children.unpin(targetProject.worktree)
children.unpin(directory)
}
}

async function bootstrapInstance(directory: string) {
if (!directory) return
const pending = booting.get(directory)
Expand Down Expand Up @@ -708,6 +741,7 @@
automation: {
create: createAutomation,
update: updateAutomation,
move: moveAutomation,
loadRuns: loadAutomationRuns,
pause: pauseAutomation,
resume: resumeAutomation,
Expand Down
45 changes: 43 additions & 2 deletions packages/app/src/context/global-sync/automation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createStore } from "solid-js/store"
import type { State } from "./types"
import {
applyAutomationDefinition,
applyAutomationMoveResult,
applyAutomationRun,
applyAutomationTombstone,
canAcceptAutomationDefinition,
Expand All @@ -12,7 +13,7 @@ import {
mergeAutomationList,
} from "./automation-store"

const definition = (input: { id: string; revision: number; title?: string; paused?: boolean }): AutomationDefinition =>
const definition = (input: { id: string; revision: number; title?: string; paused?: boolean; projectID?: string }): AutomationDefinition =>
({
kind: "recurring",
id: input.id,
Expand All @@ -21,7 +22,7 @@ const definition = (input: { id: string; revision: number; title?: string; pause
revision: input.revision,
paused: input.paused ?? false,
context: "fresh",
where: { projectID: "prj_1" },
where: { projectID: input.projectID ?? "prj_1" },
createdAt: 1,
updatedAt: 1,
timezone: "UTC",
Expand Down Expand Up @@ -145,6 +146,46 @@ describe("applyAutomationTombstone", () => {
})
})

describe("applyAutomationMoveResult", () => {
test("tombstones the source and writes the target only after the response confirms the target project", () => {
const [source, setSource] = createStore(baseState({ automation: { a: definition({ id: "a", revision: 1, projectID: "source" }) } }))
const [target, setTarget] = createStore(baseState())
const moved = definition({ id: "a", revision: 2, projectID: "target" })

const applied = applyAutomationMoveResult({
source: [source, setSource],
target: [target, setTarget],
automationID: "a",
targetProjectID: "target",
incoming: moved,
})

expect(applied).toBe("target")
expect(source.automation["a"]).toBeUndefined()
expect(source.automation_tombstone["a"]).toBe(2)
expect(target.automation["a"]).toBe(moved)
})

test("falls back to the source update when a stale target does not match the response project", () => {
const [source, setSource] = createStore(baseState({ automation: { a: definition({ id: "a", revision: 1, projectID: "source" }) } }))
const [target, setTarget] = createStore(baseState())
const response = definition({ id: "a", revision: 2, projectID: "actual" })

const applied = applyAutomationMoveResult({
source: [source, setSource],
target: [target, setTarget],
automationID: "a",
targetProjectID: "stale",
incoming: response,
})

expect(applied).toBe("source")
expect(source.automation["a"]).toEqual(response)
expect(source.automation_tombstone["a"]).toBeUndefined()
expect(target.automation["a"]).toBeUndefined()
})
})

describe("applyAutomationRun", () => {
test("upserts then ignores a stale run", () => {
const [store, setStore] = createStore(baseState())
Expand Down
24 changes: 23 additions & 1 deletion packages/app/src/context/global-sync/automation-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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"
import type { ChildStoreTuple, 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,
Expand Down Expand Up @@ -65,6 +65,28 @@ export function applyAutomationTombstone(
return true
}

export function applyAutomationMoveResult(input: {
source: ChildStoreTuple
target: ChildStoreTuple
automationID: string
targetProjectID: string
incoming: AutomationDefinition
}): "source" | "target" {
const [sourceStore, sourceSetStore] = input.source
if (input.incoming.where.projectID !== input.targetProjectID) {
applyAutomationDefinition(sourceStore, sourceSetStore, input.incoming)
return "source"
}
applyAutomationTombstone(sourceStore, sourceSetStore, {
id: input.automationID,
deleted: true,
revision: input.incoming.revision,
})
const [targetStore, targetSetStore] = input.target
applyAutomationDefinition(targetStore, targetSetStore, input.incoming)
return "target"
}

export function applyAutomationRun(
store: Store<State>,
setStore: SetStoreFunction<State>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Popover } from "@kobalte/core/popover"
import { createEffect, createMemo, createSignal, For, on, Show, type JSX } from "solid-js"
import type { AutomationCreateInput, AutomationDefinition } from "@opencode-ai/sdk/v2/client"
Expand Down Expand Up @@ -36,6 +36,7 @@
directory: string
projectID: string
template?: AutomationTemplate
onOpenProject: () => void
onCreated: (definition: AutomationDefinition) => void
}): JSX.Element {
const globalSync = useGlobalSync()
Expand Down Expand Up @@ -216,6 +217,7 @@
<AutomationFolderPicker
projects={projects()}
current={directory()}
onOpenProject={props.onOpenProject}
onSelect={(project) => {
setDirectory(project.worktree)
setProjectID(project.id)
Expand Down
23 changes: 10 additions & 13 deletions packages/app/src/pages/automations/automation-detail-editors.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { createEffect, createMemo, createSignal, Show, type Accessor, type JSX } from "solid-js"
import type { AutomationDefinition, AutomationUpdateInput } from "@opencode-ai/sdk/v2/client"
import { Icon } from "@opencode-ai/ui/icon"
Expand Down Expand Up @@ -109,7 +109,7 @@
}

const ROW_VALUE_CLASS =
"min-w-0 truncate text-right text-body text-fg-base hover:text-fg-strong hover:underline focus-visible:text-fg-strong focus-visible:underline focus:outline-none cursor-default"
"h-[30px] min-w-0 truncate rounded-md px-2 text-right text-body text-fg-base hover:bg-row-hover-overlay hover:text-fg-strong focus-visible:bg-row-hover-overlay focus-visible:text-fg-strong focus:outline-none cursor-default"

function EditorRow(props: { label: string; children: JSX.Element }): JSX.Element {
return (
Expand All @@ -120,18 +120,18 @@
)
}

// The "Project" row: there is no server-side move (each project is its own
// instance and update rejects a foreign projectID), so "move" is the create
// card's folder picker driving a create-in-target + delete-from-source pair
// (see moveToProject in automation-detail). A continue automation stays
// read-only — it loops inside a conversation that only exists in its source
// project — as does everything else when no other project is open.
// The "Project" row moves fresh automations by updating their owner in place.
// A continue automation stays read-only because it loops inside a conversation
// that only exists in its source project. Fresh automations keep the picker
// clickable even with one project, so the user can open another project from
// this surface before moving it.
export function ProjectEditorRow(props: {
directory: Accessor<string>
automation: Accessor<AutomationDefinition>
projectName: Accessor<string>
t: Translate
onMove: (project: AutomationProject) => void
onOpenProject: () => void
}): JSX.Element {
const layout = useLayout()
const projects = createMemo<AutomationProject[]>(() =>
Expand All @@ -140,15 +140,11 @@
.filter((project) => project.id && project.id !== "global" && project.worktree)
.map((project) => ({ id: project.id!, worktree: project.worktree, name: project.name })),
)
const movable = createMemo(
() =>
props.automation().context === "fresh" &&
projects().some((project) => project.id !== props.automation().where.projectID),
)
const editable = createMemo(() => props.automation().context === "fresh" && projects().length > 0)
return (
<EditorRow label={props.t("automations.detail.project")}>
<Show
when={movable()}
when={editable()}
fallback={<span class="min-w-0 truncate text-right text-body text-fg-base">{props.projectName()}</span>}
>
<AutomationFolderPicker
Expand All @@ -157,6 +153,7 @@
projects={projects()}
current={props.directory()}
onSelect={(project) => props.onMove(project)}
onOpenProject={props.onOpenProject}
/>
</Show>
</EditorRow>
Expand Down
Loading
Loading