Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/opencode/src/automation/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 43 additions & 3 deletions packages/opencode/src/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { projectID: ProjectID; worktree?: string }>(where: T): T {
if (!where.worktree) return where
const worktree = normalizeWorktreePlacement(where.worktree)
if (!worktree) return where
return { ...where, worktree }
}

function normalizeDefinitionInput<T extends CreateInput | Definition>(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<string, unknown>,
allowed: Set<string>,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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, {
Expand Down
57 changes: 57 additions & 0 deletions packages/opencode/src/automation/runner.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
Comment thread
Astro-Han marked this conversation as resolved.

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)
}
Expand Down
54 changes: 43 additions & 11 deletions packages/opencode/src/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export namespace Worktree {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly createReady: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly lookupByDirectory: (directory: string) => Effect.Effect<Info | undefined>
readonly lookupBySlug: (slug: string) => Effect.Effect<Info | undefined>
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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 })
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const canonical = Effect.fnUntraced(function* (input: string) {
const abs = pathSvc.resolve(input)
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
Expand Down Expand Up @@ -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" })
Expand Down Expand Up @@ -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),
Expand All @@ -751,6 +778,7 @@ export namespace Worktree {
makeWorktreeInfo,
createFromInfo,
create,
createReady,
list,
lookupByDirectory,
lookupBySlug,
Expand Down Expand Up @@ -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())
}
Expand Down
39 changes: 39 additions & 0 deletions packages/opencode/test/project/worktree-remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
},
})
})
})
Loading