diff --git a/plugins/opencode/src/index.ts b/plugins/opencode/src/index.ts index 2580c9c..33eaf43 100644 --- a/plugins/opencode/src/index.ts +++ b/plugins/opencode/src/index.ts @@ -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) }, }) diff --git a/plugins/opencode/src/strategy.ts b/plugins/opencode/src/strategy.ts index fcfdb78..df58552 100644 --- a/plugins/opencode/src/strategy.ts +++ b/plugins/opencode/src/strategy.ts @@ -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 } diff --git a/plugins/opencode/src/tools.ts b/plugins/opencode/src/tools.ts index 789cec3..a88ca22 100644 --- a/plugins/opencode/src/tools.ts +++ b/plugins/opencode/src/tools.ts @@ -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(listeners: Listeners, run: () => Promise) { + 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", @@ -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, @@ -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({ @@ -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, }, @@ -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)) }, }) }) diff --git a/plugins/opencode/test/plugin.test.ts b/plugins/opencode/test/plugin.test.ts new file mode 100644 index 0000000..bc48f7f --- /dev/null +++ b/plugins/opencode/test/plugin.test.ts @@ -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 +} + +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() + 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.", + }) +})