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
10 changes: 6 additions & 4 deletions plugins/opencode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Plugin } from "@opencode/plugin"
import { makeStrategy } from "./strategy.js"
import { makeStrategy, type Warning } from "./strategy.js"
import { registerTools } from "./tools.js"

export default Plugin.define({
id: "rift.workspaces",
async setup(ctx) {
const listeners = new Set<(warning: Warning) => void>()
const strategy = makeStrategy(ctx.options, {
warning: ({ directory, message }) => {
console.error(`Rift lifecycle hook failed for ${directory}: ${message}`)
warning: (warning) => {
console.error(`Rift lifecycle hook failed for ${warning.directory}: ${warning.message}`)
for (const listener of listeners) listener(warning)
},
})
await ctx.worktree.transform((editor) => editor.add(strategy))
await registerTools(ctx)
await registerTools(ctx, listeners)
},
})
7 changes: 6 additions & 1 deletion plugins/opencode/src/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ interface Options {
database?: string
}

export interface Warning {
directory: string
message: string
}

interface Runtime {
warning?: (input: { directory: string; message: string }) => void
warning?: (input: Warning) => void
rpc?: typeof rpc
}

Expand Down
69 changes: 49 additions & 20 deletions plugins/opencode/src/tools.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import type { Context } from "@opencode/plugin/promise/plugin"
import path from "node:path"
import type { Warning } from "./strategy.js"

type Listeners = Set<(warning: Warning) => void>

function contains(parent: string, child: string) {
const relative = path.relative(parent, child)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

export async function registerTools(ctx: Context) {
// The strategy raises committed hook failures inside the host's worktree call,
// which reports success, so the tool listens for them while that call runs.
async function watch<T>(listeners: Listeners, run: () => Promise<T>) {
const warnings: Warning[] = []
const listener = (warning: Warning) => warnings.push(warning)
listeners.add(listener)
try {
const result = await run()
return { result, warning: (directory: string) => warnings.find((entry) => entry.directory === directory)?.message }
} finally {
listeners.delete(listener)
}
}

function report(directory: string, content: string, warning: string | undefined) {
return warning
? { output: { directory, warning }, content: `${content}\n\nWarning: ${warning}` }
: { output: { directory }, content }
}

export async function registerTools(ctx: Context, listeners: Listeners) {
await ctx.tool.transform((editor) => {
editor.namespace({
name: "rift",
Expand All @@ -26,6 +49,7 @@ export async function registerTools(ctx: Context) {
type: "object",
properties: {
directory: { type: "string" },
warning: { type: "string", description: "Set when a postcreate hook failed after creation." },
},
required: ["directory"],
additionalProperties: false,
Expand All @@ -39,15 +63,18 @@ export async function registerTools(ctx: Context) {
.filter((entry) => contains(entry.directory, session.location.directory))
.toSorted((left, right) => right.directory.length - left.directory.length)[0]
if (!source) throw new Error(`Current session is not inside a known workspace: ${session.location.directory}`)
const created = await ctx.worktree.create({
projectID: session.projectID,
from: source.directory,
name: value.name,
})
return {
output: { directory: created.directory },
content: `Created ${created.directory}. Use opencode.session_move to move a session into it.`,
}
const { result: created, warning } = await watch(listeners, () =>
ctx.worktree.create({
projectID: session.projectID,
from: source.directory,
name: value.name,
}),
)
return report(
created.directory,
`Created ${created.directory}. Use opencode.session_move to move a session into it.`,
warning(created.directory),
)
},
})
editor.add({
Expand Down Expand Up @@ -98,7 +125,10 @@ export async function registerTools(ctx: Context) {
},
output: {
type: "object",
properties: { directory: { type: "string" } },
properties: {
directory: { type: "string" },
warning: { type: "string", description: "Set when a postremove hook failed after removal." },
},
required: ["directory"],
additionalProperties: false,
},
Expand All @@ -108,15 +138,14 @@ export async function registerTools(ctx: Context) {
const session = await ctx.session.get({ sessionID: tool.sessionID })
if (contains(value.directory, session.location.directory))
throw new Error("Move this session out of the Rift before removing it")
await ctx.worktree.remove({
projectID: session.projectID,
directory: value.directory,
force: false,
})
return {
output: { directory: value.directory },
content: `Removed ${value.directory}.`,
}
const { warning } = await watch(listeners, () =>
ctx.worktree.remove({
projectID: session.projectID,
directory: value.directory,
force: false,
}),
)
return report(value.directory, `Removed ${value.directory}.`, warning(value.directory))
},
})
})
Expand Down
112 changes: 112 additions & 0 deletions plugins/opencode/test/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, expect, test } from "bun:test"
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import plugin from "../src/index.js"

interface Tool {
name: string
execute: (input: unknown, context: { sessionID: string }) => Promise<{ output: unknown; content: string }>
}

interface Strategy {
create: (input: { sourceDirectory: string; directory: string }, context: { signal: AbortSignal }) => Promise<{ directory: string }>
remove: (input: { directory: string; force: boolean }, context: { signal: AbortSignal }) => Promise<void>
}

let temp: string

beforeAll(async () => {
temp = await mkdtemp(join(tmpdir(), "opencode-rift-plugin-"))
})

afterAll(async () => {
await rm(temp, { recursive: true, force: true })
})

async function fakeRift(failure?: { command: string; hook: string; path: string }) {
const executable = join(temp, `rift-${Math.random().toString(36).slice(2)}`)
await writeFile(
executable,
`#!/usr/bin/env node
const failure = ${JSON.stringify(failure ?? null)}
let input = ""
process.stdin.on("data", (chunk) => (input += chunk))
process.stdin.on("end", () => {
const request = JSON.parse(input)
if (failure && request.command === failure.command) {
process.stderr.write("ERR_PNPM_FETCH_401 token expired\\n")
const error = { code: "hook_failed", message: failure.hook + " hook failed", path: failure.path, hook: failure.hook, committed: true }
process.stdout.write(JSON.stringify({ status: "error", error }))
return
}
const value = request.command === "create" ? request.into + "/" + request.name : request.command === "list" ? [] : null
process.stdout.write(JSON.stringify({ status: "ok", value }))
})
`,
)
await chmod(executable, 0o755)
return executable
}

async function setup(executable: string) {
const tools = new Map<string, Tool>()
let strategy: Strategy | undefined
const signal = new AbortController().signal
const ctx = {
options: { executable },
session: {
get: async () => ({ projectID: "project", location: { directory: "/project" } }),
},
worktree: {
transform: async (callback: (editor: { add: (definition: Strategy) => void }) => void) => {
callback({ add: (definition) => (strategy = definition) })
},
list: async () => [{ directory: "/project" }],
create: (input: { from: string; name: string }) =>
strategy!.create({ sourceDirectory: input.from, directory: `/worktrees/${input.name}` }, { signal }),
remove: (input: { directory: string; force: boolean }) => strategy!.remove(input, { signal }),
},
tool: {
transform: async (callback: (editor: { namespace: () => void; add: (tool: Tool) => void }) => void) => {
callback({ namespace: () => {}, add: (tool) => tools.set(tool.name, tool) })
},
},
}
await plugin.setup(ctx as never)
return tools
}

test("rift.create reports a failed postcreate hook to the agent", async () => {
const tools = await setup(await fakeRift({ command: "create", hook: "postcreate", path: "/worktrees/task" }))
const result = await tools.get("create")!.execute({ name: "task" }, { sessionID: "session" })
expect(result.output).toEqual({
directory: "/worktrees/task",
warning: "postcreate hook failed: ERR_PNPM_FETCH_401 token expired",
})
expect(result.content).toBe(
"Created /worktrees/task. Use opencode.session_move to move a session into it.\n\n" +
"Warning: postcreate hook failed: ERR_PNPM_FETCH_401 token expired",
)
})

test("rift.remove reports a failed postremove hook to the agent", async () => {
const tools = await setup(await fakeRift({ command: "remove", hook: "postremove", path: "/rifts/.trash/task" }))
const result = await tools.get("remove")!.execute({ directory: "/worktrees/task" }, { sessionID: "session" })
expect(result.output).toEqual({
directory: "/worktrees/task",
warning: "postremove hook failed: ERR_PNPM_FETCH_401 token expired",
})
expect(result.content).toBe(
"Removed /worktrees/task.\n\nWarning: postremove hook failed: ERR_PNPM_FETCH_401 token expired",
)
})

test("successful hooks add no warning", async () => {
const tools = await setup(await fakeRift())
const result = await tools.get("create")!.execute({ name: "task" }, { sessionID: "session" })
expect(result).toEqual({
output: { directory: "/worktrees/task" },
content: "Created /worktrees/task. Use opencode.session_move to move a session into it.",
})
})
Loading