diff --git a/packages/opencode/src/automation/fixtures.ts b/packages/opencode/src/automation/fixtures.ts index 09c53fd9c..1bf86cd82 100644 --- a/packages/opencode/src/automation/fixtures.ts +++ b/packages/opencode/src/automation/fixtures.ts @@ -8,7 +8,7 @@ export const automationDefinitionFixture = Automation.Definition.parse({ revision: 2, paused: false, context: "fresh", - where: { projectID: "project-fixture" }, + where: { projectID: "project-fixture", worktree: "daily-brief" }, createdAt: 1_800_000_000_000, updatedAt: 1_800_000_030_000, timezone: "UTC", diff --git a/packages/opencode/src/automation/index.ts b/packages/opencode/src/automation/index.ts index 8068127b7..f9e6292df 100644 --- a/packages/opencode/src/automation/index.ts +++ b/packages/opencode/src/automation/index.ts @@ -282,6 +282,36 @@ export namespace Automation { details.push({ field, message }) } + export function normalizeWorktreePlacement(input: string) { + const slug = input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + return slug.length > 0 && slug.length <= 40 ? slug : undefined + } + + function normalizeWhere(where: T): T { + if (!where.worktree) return where + const worktree = normalizeWorktreePlacement(where.worktree) + if (!worktree) return where + return { ...where, worktree } + } + + function normalizeDefinitionInput(input: T): T { + return { ...input, where: normalizeWhere(input.where) } as T + } + + function normalizeUpdateInput(input: UpdateInput): UpdateInput { + if (!input.where) return input + return { ...input, where: normalizeWhere(input.where) } + } + + export function getWriterKey(definition: Definition) { + return definition.where.worktree ?? definition.where.projectID + } + function rejectUnknownFields( input: Record, allowed: Set, @@ -404,7 +434,15 @@ export namespace Automation { if (input.where.projectID !== projectID) { addDetail(details, "where.projectID", "Automation must target the current project.") } - if (input.where.worktree) addDetail(details, "where.worktree", "unsupported_where_worktree") + if (input.where.worktree && !normalizeWorktreePlacement(input.where.worktree)) { + addDetail(details, "where.worktree", "invalid_worktree_placement") + } + if (input.where.worktree && input.context === "continue") { + addDetail(details, "context", "unsupported_continue_with_worktree") + } + if (input.where.worktree && Instance.project.vcs !== "git") { + addDetail(details, "where.worktree", "unsupported_where_worktree_not_git") + } details.push(...validateScheduleFields(input)) return details } @@ -433,6 +471,7 @@ export namespace Automation { } export function create(input: CreateInput, options?: { now?: number; sourceSessionID?: SessionID }): Definition { + input = normalizeDefinitionInput(input) const now = options?.now ?? Date.now() const details = validateCreateInput(input, Instance.project.id, now) if (details.length) throw new ValidationError(details) @@ -601,6 +640,7 @@ export namespace Automation { export function update(id: string, patch: UpdateInput, options?: { now?: number }): Definition { const previous = get(id) + patch = normalizeUpdateInput(patch) const now = options?.now ?? Date.now() const updateDetails = validateUpdateInput(previous, patch, now) if (updateDetails.length) throw new ValidationError(updateDetails) @@ -873,7 +913,7 @@ export namespace Automation { const writerKeys = new Map( definitions.map((row) => { const item = Definition.parse(row.data) - return [item.id, item.where.worktree ?? item.where.projectID] + return [item.id, getWriterKey(item)] }), ) return rows.some((row) => { @@ -1017,7 +1057,7 @@ export namespace Automation { let current = initial try { const definition = get(initial.automationID) - writerKey = definition.where.worktree ?? definition.where.projectID + writerKey = getWriterKey(definition) for (const run of await reconcileInterruptedRuns()) await publishRunUpdated(run) if (data.activeWriters.has(writerKey) || hasDurableActiveWriter(initial, writerKey)) { const stopped = reviseRun(initial, { diff --git a/packages/opencode/src/automation/runner.ts b/packages/opencode/src/automation/runner.ts index 1c86f7ee8..1a681f8cb 100644 --- a/packages/opencode/src/automation/runner.ts +++ b/packages/opencode/src/automation/runner.ts @@ -1,15 +1,72 @@ import { Effect } from "effect" import { Automation } from "." +import { AutomationRunTable } from "./automation.sql" +import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionPrompt } from "@/session/prompt" +import { Database, and, eq, sql } from "@/storage/db" import { AutomationRunContext, type AutomationRunBlocker } from "./run-context" +import { Worktree } from "@/worktree" + +function isAutomationOwnedSession(sessionID: string) { + return Boolean( + Database.use((db) => + db + .select({ id: AutomationRunTable.id }) + .from(AutomationRunTable) + .where( + and( + eq(AutomationRunTable.project_id, Instance.project.id), + eq(AutomationRunTable.owner_directory, Instance.directory), + sql`json_extract(${AutomationRunTable.data}, '$.sessionID') = ${sessionID}`, + ), + ) + .limit(1) + .get(), + ), + ) +} + +async function releaseAutomationWorktreeBindings(directory: string) { + for (let attempt = 0; attempt < 20; attempt++) { + const binding = await Session.findActiveWorktreeBinding(directory) + if (!binding) return + if (!isAutomationOwnedSession(binding.id)) return + await Session.updateExecutionContext({ sessionID: binding.id, activeWorktree: null }) + } +} + +async function prepareWorktreePlacement(definition: Automation.Definition) { + const placement = definition.where.worktree + if (!placement) return undefined + const existing = await Worktree.lookupBySlug(placement) + if (existing) { + await releaseAutomationWorktreeBindings(existing.directory) + await Worktree.reset({ directory: existing.directory }) + return (await Worktree.lookupBySlug(placement)) ?? existing + } + return Worktree.createReady({ name: placement, exactName: true }) +} export const sessionPromptExecutor: Automation.RunExecutor = async ({ definition, run, attendance, signal }) => { + signal.throwIfAborted() + const worktree = await prepareWorktreePlacement(definition) signal.throwIfAborted() const sessionID = definition.context === "continue" && definition.automationSessionID ? definition.automationSessionID : (await Session.create({ title: `Automation: ${definition.title}` })).id + if (worktree) { + await Session.updateExecutionContext({ + sessionID, + activeWorktree: { + directory: worktree.directory, + name: worktree.name, + branch: worktree.branch, + source: worktree.source, + }, + }) + } const cancelPrompt = () => { void SessionPrompt.cancel(sessionID, { source: "automation.cancel" }).catch(() => undefined) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 84553d87c..0372f4dce 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -162,6 +162,7 @@ export namespace Worktree { readonly makeWorktreeInfo: (name?: string) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect + readonly createReady: (input?: CreateInput) => Effect.Effect readonly list: () => Effect.Effect readonly lookupByDirectory: (directory: string) => Effect.Effect readonly lookupBySlug: (slug: string) => Effect.Effect @@ -348,9 +349,10 @@ export namespace Worktree { const MAX_NAME_ATTEMPTS = 26 const BRANCH_PREFIX = "pawwork/" - const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { + const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string, exactName?: boolean) { const ctx = yield* InstanceState.context - for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { + const attempts = exactName ? 1 : MAX_NAME_ATTEMPTS + for (const attempt of Array.from({ length: attempts }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() const branch = `${BRANCH_PREFIX}${name}` const directory = pathSvc.join(root, name) @@ -366,17 +368,21 @@ export namespace Worktree { throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) - const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) { + const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string, exactName?: boolean) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } + const base = name ? slugify(name) : "" + if (exactName && name !== undefined && !base) { + throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) + } + const root = pathSvc.join(ctx.worktree, ".worktrees", "pawwork") yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) - const base = name ? slugify(name) : "" - return yield* candidate(root, base || undefined) + return yield* candidate(root, base || undefined, exactName) }) const setup = Effect.fnUntraced(function* (info: Info) { @@ -409,15 +415,14 @@ export namespace Worktree { workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) - return + throw new CreateFailedError({ message }) } - const booted = yield* Effect.promise(() => + yield* Effect.promise(() => Instance.provide({ directory: info.directory, fn: () => undefined, }) - .then(() => true) .catch((error) => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) @@ -427,10 +432,9 @@ export namespace Worktree { workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) - return false + throw new CreateFailedError({ message }) }), ) - if (!booted) return GlobalBus.emit("event", { directory: info.directory, @@ -442,7 +446,10 @@ export namespace Worktree { }, }) - yield* runStartScripts(info.directory, { projectID, extra }) + yield* runStartScripts(info.directory, { projectID, extra }).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))), + Effect.forkIn(scope), + ) }) const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { @@ -459,6 +466,13 @@ export namespace Worktree { return info }) + const createReady = Effect.fn("Worktree.createReady")(function* (input?: CreateInput & { exactName?: boolean }) { + const info = yield* makeWorktreeInfo(input?.name, input?.exactName) + yield* setup(info) + yield* boot(info, input?.startCommand) + return info + }) + const canonical = Effect.fnUntraced(function* (input: string) { const abs = pathSvc.resolve(input) const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) @@ -673,6 +687,13 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } + const bound = yield* Effect.promise(() => Session.findActiveWorktreeBinding(directory)) + if (bound) { + throw new ResetFailedError({ + message: `Worktree is in use by session "${bound.title}". Call ExitWorktree from that session first.`, + }) + } + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.code !== 0) { throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) @@ -739,6 +760,12 @@ export namespace Worktree { throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) } + const registered = yield* lookupByDirectory(directory) + const branch = entry.branch?.replace(/^refs\/heads\//, "") + if (registered && branch && registered.branch !== branch) { + yield* upsertRegistry(Info.parse({ ...registered, branch })) + } + yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe( Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))), Effect.forkIn(scope), @@ -751,6 +778,7 @@ export namespace Worktree { makeWorktreeInfo, createFromInfo, create, + createReady, list, lookupByDirectory, lookupBySlug, @@ -782,6 +810,10 @@ export namespace Worktree { return runPromise((svc) => svc.create(input)) } + export async function createReady(input?: CreateInput & { exactName?: boolean }) { + return runPromise((svc) => svc.createReady(input)) + } + export async function list() { return runPromise((svc) => svc.list()) } diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 6605bda11..6717a3db3 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -196,3 +196,42 @@ describe("Worktree.remove", () => { expect(ref.exitCode).not.toBe(0) }) }) + +describe("Worktree.reset", () => { + test("refuses to reset a worktree bound to an active session", async () => { + await using tmp = await tmpdir({ git: true }) + const root = tmp.path + + const { info, session } = await Instance.provide({ + directory: root, + fn: async () => { + const info = await Worktree.createReady({ name: "reset-bound-session" }) + const session = await Session.create({ title: "Bound reset session" }) + await Session.updateExecutionContext({ + sessionID: session.id, + activeWorktree: info, + }) + return { info, session } + }, + }) + + await Bun.write(path.join(info.directory, "unsaved.txt"), "do not delete\n") + + await expect( + Instance.provide({ + directory: root, + fn: () => Worktree.reset({ directory: info.directory }), + }), + ).rejects.toThrow("WorktreeResetFailedError") + expect(await Bun.file(path.join(info.directory, "unsaved.txt")).text()).toBe("do not delete\n") + + await Instance.provide({ + directory: root, + fn: async () => { + await Session.updateExecutionContext({ sessionID: session.id, activeWorktree: null }) + await Session.remove(session.id) + await Worktree.remove({ directory: info.directory }) + }, + }) + }) +}) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 178ebaea0..05e3fc872 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ const wintest = process.platform !== "win32" ? test : test.skip import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { ProjectTable } from "../../src/project/project.sql" import { Database, eq } from "../../src/storage/db" import { Worktree } from "../../src/worktree" @@ -145,6 +146,42 @@ describe("Worktree", () => { await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) }) + test("createReady with exact name rejects occupied managed placement", async () => { + await using tmp = await tmpdir({ git: true }) + const occupied = path.join(tmp.path, ".worktrees", "pawwork", "daily-brief") + await fs.mkdir(occupied, { recursive: true }) + + await expect( + withInstance(tmp.path, () => Worktree.createReady({ name: "daily-brief", exactName: true })), + ).rejects.toThrow("WorktreeNameGenerationFailedError") + + const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() + expect(list).not.toContain("daily-brief-") + }) + + test("createReady with exact name rejects names with an empty slug", async () => { + await using tmp = await tmpdir({ git: true }) + + await expect(withInstance(tmp.path, () => Worktree.createReady({ name: "!!!", exactName: true }))).rejects.toThrow( + "WorktreeNameGenerationFailedError", + ) + + const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() + expect(normalize(list)).not.toContain(normalize(path.join(".worktrees", "pawwork"))) + }) + + test("createReady rejects when worktree bootstrap fails", async () => { + await using tmp = await tmpdir({ git: true }) + + await withInstance(tmp.path, async () => { + await Bun.write(path.join(tmp.path, "opencode.json"), "{ invalid") + await $`git add opencode.json`.cwd(tmp.path).quiet() + await $`git commit -m invalid-config`.cwd(tmp.path).quiet() + + await expect(Worktree.createReady({ name: "bad-config" })).rejects.toThrow("WorktreeCreateFailedError") + }) + }) + test("refuses to create when .gitignore has local changes", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\n") @@ -192,6 +229,57 @@ describe("Worktree", () => { }) }) + describe("reset", () => { + test("starts project start command without waiting for it to exit", async () => { + await using tmp = await tmpdir({ git: true }) + + await withInstance(tmp.path, async () => { + const info = await Worktree.createReady({ name: "reset-start-command" }) + await Project.update({ + projectID: Instance.project.id, + commands: { + start: + "bun -e \"await Bun.write('.reset-start-began', 'ready'); while (!(await Bun.file('.reset-start-release').exists())) await Bun.sleep(20)\"", + }, + }) + + const reset = Worktree.reset({ directory: info.directory }) + const result = await Promise.race([ + reset.then(() => "done" as const), + Bun.sleep(2_000).then(() => "timeout" as const), + ]) + if (result === "timeout") { + await Bun.write(path.join(info.directory, ".reset-start-release"), "done") + await reset.catch(() => undefined) + throw new Error("Worktree.reset waited for the project start command to exit") + } + + const deadline = Date.now() + 1_000 + while (!(await Bun.file(path.join(info.directory, ".reset-start-began")).exists()) && Date.now() < deadline) { + await Bun.sleep(20) + } + expect(await Bun.file(path.join(info.directory, ".reset-start-began")).text()).toBe("ready") + await Bun.write(path.join(info.directory, ".reset-start-release"), "done") + await Worktree.remove({ directory: info.directory }) + }) + }) + + test("refreshes registry branch metadata from the attached worktree", async () => { + await using tmp = await tmpdir({ git: true }) + + await withInstance(tmp.path, async () => { + const info = await Worktree.createReady({ name: "reset-branch-metadata" }) + await $`git checkout -b manual-reset-branch`.cwd(info.directory).quiet() + + await Worktree.reset({ directory: info.directory }) + + const refreshed = await Worktree.lookupBySlug("reset-branch-metadata") + expect(refreshed?.branch).toBe("manual-reset-branch") + await Worktree.remove({ directory: info.directory }) + }) + }) + }) + describe("registry source", () => { test("created worktrees are slug-addressable, existing worktrees are path-addressable only", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/server/automation-event-fixtures.test.ts b/packages/opencode/test/server/automation-event-fixtures.test.ts index 56281fc7f..9fd519745 100644 --- a/packages/opencode/test/server/automation-event-fixtures.test.ts +++ b/packages/opencode/test/server/automation-event-fixtures.test.ts @@ -5,6 +5,7 @@ import { Automation } from "../../src/automation" describe("automation event fixtures", () => { test("match the frozen automation event schemas", () => { expect(() => Automation.Event.DefinitionUpdated.properties.parse(automationEventFixtures[0].properties)).not.toThrow() + expect(automationEventFixtures[0].properties.where.worktree).toBe("daily-brief") expect(() => Automation.Event.DefinitionDeleted.properties.parse(automationEventFixtures[1].properties)).not.toThrow() expect(() => Automation.Event.RunUpdated.properties.parse(automationEventFixtures[2].properties)).not.toThrow() }) diff --git a/packages/opencode/test/server/automation-routes.test.ts b/packages/opencode/test/server/automation-routes.test.ts index b6a1b2729..4dfd2e851 100644 --- a/packages/opencode/test/server/automation-routes.test.ts +++ b/packages/opencode/test/server/automation-routes.test.ts @@ -19,9 +19,12 @@ afterEach(async () => { await Instance.disposeAll() }) -async function withAutomationApp(fn: (input: { app: Hono; projectID: ProjectID }) => Promise) { - await using tmp = await tmpdir({ git: true }) - return Instance.provide({ +async function withAutomationApp( + fn: (input: { app: Hono; projectID: ProjectID }) => Promise, + options: { git?: boolean } = { git: true }, +) { + await using tmp = await tmpdir({ git: options.git ?? true }) + return await Instance.provide({ directory: tmp.path, fn: async () => { const app = new Hono().route("/automation", AutomationRoutes()) @@ -420,22 +423,77 @@ describe("automation routes", () => { }) }) - test("rejects worktree placement until the PR5 location slice", async () => { + test("accepts fresh worktree placement for git projects", async () => { + await withAutomationApp(async ({ app, projectID }) => { + const body = await json(app, "/automation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(recurringInput(projectID, { where: { projectID, worktree: "daily-brief" } })), + }) + + expect(body.where).toEqual({ projectID, worktree: "daily-brief" }) + }) + }) + + test("normalizes worktree placement before echoing the definition", async () => { + await withAutomationApp(async ({ app, projectID }) => { + const body = await json(app, "/automation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(recurringInput(projectID, { where: { projectID, worktree: "Daily Brief!" } })), + }) + + expect(body.where).toEqual({ projectID, worktree: "daily-brief" }) + }) + }) + + test("rejects worktree placement that cannot be normalized to a slug", async () => { await withAutomationApp(async ({ app, projectID }) => { const response = await app.request("/automation", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(recurringInput(projectID, { where: { projectID, worktree: "/repo/.worktrees/run" } })), + body: JSON.stringify(recurringInput(projectID, { where: { projectID, worktree: "!!!" } })), }) const body = await response.json() expect(response.status).toBe(422) - expect(body.details).toEqual([ - { field: "where.worktree", message: "unsupported_where_worktree" }, - ]) + expect(body.details).toEqual([{ field: "where.worktree", message: "invalid_worktree_placement" }]) }) }) + test("rejects continue worktree placement", async () => { + await withAutomationApp(async ({ app, projectID }) => { + const response = await app.request("/automation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + recurringInput(projectID, { context: "continue", where: { projectID, worktree: "daily-brief" } }), + ), + }) + const body = await response.json() + + expect(response.status).toBe(422) + expect(body.details).toEqual([{ field: "context", message: "unsupported_continue_with_worktree" }]) + }) + }) + + test("rejects worktree placement for non-git projects", async () => { + await withAutomationApp( + async ({ app, projectID }) => { + const response = await app.request("/automation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(recurringInput(projectID, { where: { projectID, worktree: "daily-brief" } })), + }) + const body = await response.json() + + expect(response.status).toBe(422) + expect(body.details).toEqual([{ field: "where.worktree", message: "unsupported_where_worktree_not_git" }]) + }, + { git: false }, + ) + }) + test("rejects invalid semantic fields with the automation validation error shape", async () => { await withAutomationApp(async ({ app, projectID }) => { const cases = [ diff --git a/packages/opencode/test/server/automation-runner.test.ts b/packages/opencode/test/server/automation-runner.test.ts index da703e9e1..941a293fa 100644 --- a/packages/opencode/test/server/automation-runner.test.ts +++ b/packages/opencode/test/server/automation-runner.test.ts @@ -1,4 +1,6 @@ import path from "path" +import fs from "fs/promises" +import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Automation } from "../../src/automation" @@ -7,11 +9,14 @@ import { AutomationRunTable } from "../../src/automation/automation.sql" import { Bus } from "../../src/bus" import { Database, eq } from "../../src/storage/db" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { ProjectID } from "../../src/project/schema" import { Session } from "../../src/session" +import { SessionTable } from "../../src/session/session.sql" import { SessionID } from "../../src/session/schema" import { AutomationRunContext, AutomationStepCapError } from "../../src/automation/run-context" import { Flock } from "../../src/util/flock" +import { Worktree } from "../../src/worktree" import { tmpdir } from "../fixture/fixture" afterEach(async () => { @@ -50,6 +55,39 @@ async function waitForRun(automationID: string, state: Automation.Run["state"]) throw new Error(`Timed out waiting for ${state}`) } +async function waitForRunCount(automationID: string, count: number) { + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + const items = Automation.runs({ automationID, limit: 100 }).items + if (items.length >= count) return items + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${count} automation runs`) +} + +async function waitForSucceededRunCount(automationID: string, count: number) { + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + const items = Automation.runs({ automationID, limit: 100 }).items + const succeeded = items.filter((run) => run.state === "succeeded") + if (succeeded.length >= count) return succeeded + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${count} succeeded automation runs`) +} + +async function waitForTerminalRun(automationID: string) { + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + const run = Automation.runs({ automationID }).items.find((item) => + item.state === "succeeded" || item.state === "failed" || item.state === "stopped" + ) + if (run) return run + await Bun.sleep(10) + } + throw new Error("Timed out waiting for terminal automation run") +} + function defer() { let resolve!: (value: T | PromiseLike) => void const promise = new Promise((done) => { @@ -58,6 +96,16 @@ function defer() { return { promise, resolve } } +function automationSessionsForTitle(title: string) { + return Database.use((db) => + db + .select() + .from(SessionTable) + .where(eq(SessionTable.title, `Automation: ${title}`)) + .all(), + ) +} + function hangingChat(ready: () => void) { const encoder = new TextEncoder() let timer: ReturnType | undefined @@ -489,6 +537,426 @@ describe("automation runNow execution", () => { } }) + test("executes fresh worktree runs from the managed worktree directory", async () => { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + return Response.json({ + id: "chatcmpl-1", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + build: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const definition = Automation.create( + input(Instance.project.id, { + title: "Worktree prompt", + where: { projectID: Instance.project.id, worktree: "daily-brief" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + + const succeeded = await waitForRun(definition.id, "succeeded") + if (!succeeded.sessionID) throw new Error("expected run session") + const session = await Session.get(succeeded.sessionID) + expect(session.executionContext.ownerDirectory).toBe(tmp.path) + expect(session.executionContext.activeWorktree).toMatchObject({ + name: "daily-brief", + source: "created", + }) + expect(session.executionContext.activeDirectory).toContain(path.join(".worktrees", "pawwork", "daily-brief")) + }, + }) + } finally { + void server.stop(true) + } + }) + + test("does not wait for a long-lived worktree start command before prompting", async () => { + let providerCalls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + providerCalls++ + return Response.json({ + id: "chatcmpl-1", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + build: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Project.update({ + projectID: Instance.project.id, + commands: { + start: + "bun -e \"await Bun.write('.automation-start-began', 'ready'); while (!(await Bun.file('.automation-start-release').exists())) await Bun.sleep(20)\"", + }, + }) + const definition = Automation.create( + input(Instance.project.id, { + title: "Worktree long start", + where: { projectID: Instance.project.id, worktree: "long-start" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + const result = await Promise.race([ + waitForRun(definition.id, "succeeded").then((run) => ({ state: "succeeded" as const, run })), + Bun.sleep(2_000).then(() => ({ state: "timeout" as const })), + ]) + if (result.state === "timeout") { + const worktree = await Worktree.lookupBySlug("long-start") + if (worktree) await Bun.write(path.join(worktree.directory, ".automation-start-release"), "done") + throw new Error("Automation waited for the worktree start command to exit before prompting") + } + + const succeeded = result.run + if (!succeeded.sessionID) throw new Error("expected run session") + expect(providerCalls).toBe(1) + const worktree = await Worktree.lookupBySlug("long-start") + if (!worktree) throw new Error("expected worktree placement") + const deadline = Date.now() + 1_000 + while ( + !(await Bun.file(path.join(worktree.directory, ".automation-start-began")).exists()) && + Date.now() < deadline + ) { + await Bun.sleep(20) + } + expect(await Bun.file(path.join(worktree.directory, ".automation-start-began")).text()).toBe("ready") + await Bun.write(path.join(worktree.directory, ".automation-start-release"), "done") + }, + }) + } finally { + void server.stop(true) + } + }) + + test("can run the same worktree placement more than once", async () => { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + return Response.json({ + id: "chatcmpl-1", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + build: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const definition = Automation.create( + input(Instance.project.id, { + title: "Reusable worktree prompt", + where: { projectID: Instance.project.id, worktree: "daily-brief" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + await waitForRun(definition.id, "succeeded") + const worktree = await Worktree.lookupBySlug("daily-brief") + if (!worktree) throw new Error("expected worktree placement") + await $`git checkout -b manual-automation-branch`.cwd(worktree.directory).quiet() + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + + await waitForRunCount(definition.id, 2) + await waitForSucceededRunCount(definition.id, 2) + const latest = Automation.runs({ automationID: definition.id }).items[0] + if (!latest?.sessionID) throw new Error("expected latest run session") + const session = await Session.get(latest.sessionID) + expect(session.executionContext.activeWorktree?.branch).toBe("manual-automation-branch") + }, + }) + } finally { + void server.stop(true) + } + }) + + test("does not release user sessions whose title looks like automation", async () => { + let providerCalls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + providerCalls++ + return Response.json({ + id: "chatcmpl-1", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + build: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const worktree = await Worktree.createReady({ name: "daily-brief" }) + await Bun.write(path.join(worktree.directory, "user-draft.txt"), "keep me\n") + const userSession = await Session.create({ title: "Automation: User renamed" }) + await Session.updateExecutionContext({ + sessionID: userSession.id, + activeWorktree: { + directory: worktree.directory, + name: worktree.name, + branch: worktree.branch, + source: worktree.source, + }, + }) + const definition = Automation.create( + input(Instance.project.id, { + title: "Respect user binding", + where: { projectID: Instance.project.id, worktree: "daily-brief" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + + const terminal = await waitForTerminalRun(definition.id) + if (terminal.state !== "stopped") throw new Error(`expected stopped run, got ${terminal.state}`) + expect(terminal.stopReason).toBe("cancelled") + expect(providerCalls).toBe(0) + expect(await Bun.file(path.join(worktree.directory, "user-draft.txt")).text()).toBe("keep me\n") + const updatedUserSession = await Session.get(userSession.id) + expect(updatedUserSession.executionContext.activeWorktree?.name).toBe("daily-brief") + }, + }) + } finally { + void server.stop(true) + } + }) + + test("does not fall back to a random worktree when the placement slug is occupied outside the registry", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const occupied = path.join(tmp.path, ".worktrees", "pawwork", "daily-brief") + await fs.mkdir(occupied, { recursive: true }) + await Bun.write(path.join(occupied, "blocker.txt"), "occupied\n") + const definition = Automation.create( + input(Instance.project.id, { + title: "Exact worktree placement", + where: { projectID: Instance.project.id, worktree: "daily-brief" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + + const stopped = await waitForRun(definition.id, "stopped") + if (stopped.state !== "stopped") throw new Error("expected stopped run") + expect(stopped.stopReason).toBe("cancelled") + const entries = new Bun.Glob("daily-brief-*").scan({ + cwd: path.join(tmp.path, ".worktrees", "pawwork"), + onlyFiles: false, + }) + expect(await Array.fromAsync(entries)).toEqual([]) + expect(automationSessionsForTitle("Exact worktree placement")).toEqual([]) + }, + }) + }) + + test("does not prompt or keep a session when worktree bootstrap fails", async () => { + let providerCalls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + providerCalls++ + return Response.json({ + id: "chatcmpl-1", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + build: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write(path.join(tmp.path, "opencode.json"), "{ invalid") + await $`git add opencode.json`.cwd(tmp.path).quiet() + await $`git commit -m invalid-config`.cwd(tmp.path).quiet() + + const definition = Automation.create( + input(Instance.project.id, { + title: "Bootstrap failure", + where: { projectID: Instance.project.id, worktree: "bad-config" }, + }), + ) + + await Automation.runNowExecuting(definition.id, { executor: sessionPromptExecutor }) + + const stopped = await waitForRun(definition.id, "stopped") + if (stopped.state !== "stopped") throw new Error("expected stopped run") + expect(stopped.stopReason).toBe("cancelled") + expect(providerCalls).toBe(0) + expect(automationSessionsForTitle("Bootstrap failure")).toEqual([]) + }, + }) + } finally { + void server.stop(true) + } + }) + test("deleting after run start but before prompt runner is busy does not call the provider", async () => { let providerCalls = 0 const server = Bun.serve({ diff --git a/packages/opencode/test/tool/automate.test.ts b/packages/opencode/test/tool/automate.test.ts index 3fa094301..751105b9a 100644 --- a/packages/opencode/test/tool/automate.test.ts +++ b/packages/opencode/test/tool/automate.test.ts @@ -138,7 +138,7 @@ describe("automate tool", () => { test.each([ ["wrong project", () => ({ projectID: "other-project" }), "where.projectID"], - ["worktree placement", (projectID: string) => ({ projectID, worktree: "feature" }), "where.worktree"], + ["invalid worktree placement", (projectID: string) => ({ projectID, worktree: "!!!" }), "where.worktree"], ])("reports execute-time automation validation as model-readable input errors: %s", async (_name, where, field) => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/packages/sdk/js/src/v2/event-types.test-d.ts b/packages/sdk/js/src/v2/event-types.test-d.ts index 810ddc208..0e9fb962a 100644 --- a/packages/sdk/js/src/v2/event-types.test-d.ts +++ b/packages/sdk/js/src/v2/event-types.test-d.ts @@ -31,7 +31,7 @@ const _automationDefinitionUpdated: EventAutomationDefinitionUpdated = { revision: 2, paused: false, context: "fresh", - where: { projectID: "project-fixture" }, + where: { projectID: "project-fixture", worktree: "daily-brief" }, createdAt: 1800000000000, updatedAt: 1800000030000, timezone: "UTC",