diff --git a/src/opencode.ts b/src/opencode.ts index 12f4c65..37f8c55 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,5 +1,5 @@ import { spawn, type Subprocess } from "bun"; -import { mkdirSync, writeFileSync, readdirSync } from "fs"; +import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs"; import { join, resolve, isAbsolute } from "path"; import { homedir } from "os"; import { log } from "./logger"; @@ -27,6 +27,70 @@ export interface TakeoverStrategy { resumeOpenCodeSession(session: OpSession): Promise; } +export interface GroupConfig { + workdirTemplate?: string; + initScript?: string; + destroyScript?: string; + envInitScript?: string; +} + +/** Substitute {owner}/{repo}/{issue}/{session} in a workdir template. */ +export function resolveTemplatedWorkdir( + template: string, + issue: { trackerScopeKey: string; trackerScope: Record; trackerIssueId: string | number }, + session: { name: string }, + baseWorkdir?: string, +): string { + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default"; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default"; + // Use replacer functions to avoid `$&`/`$1` interpretation in replacement strings. + let dir = template + .replace(/\{owner\}/g, () => String(owner)) + .replace(/\{repo\}/g, () => String(repo)) + .replace(/\{issue\}/g, () => String(issue.trackerIssueId)) + .replace(/\{session\}/g, () => session.name); + if (dir.startsWith("~")) { + dir = join(homedir(), dir.slice(1)); + } else if (!isAbsolute(dir) && baseWorkdir) { + dir = resolve(baseWorkdir, dir); + } + return dir; +} + +/** Run a lifecycle script via `bash -c` with cwd=workdir. Uses async Bun.spawn + * (not spawnSync) so the event loop is not blocked. A 60s hard timeout kills + * hung scripts. Failures are logged and swallowed — never throws — so a broken + * init/destroy never blocks the opencode task flow. */ +const HOOK_SCRIPT_TIMEOUT_MS = 60_000; +export async function runHookScript(script: string | undefined, workdir: string, label: string, env: Record = {}): Promise { + if (!script || !script.trim()) return; + try { + mkdirSync(workdir, { recursive: true }); + const proc = Bun.spawn({ + cmd: ["bash", "-c", script], + cwd: workdir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...env }, + }); + const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, HOOK_SCRIPT_TIMEOUT_MS); + try { + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text().catch(() => ""); + if (exitCode !== 0) { + log.warn(`engine: ${label} exited ${exitCode}: ${stderr.slice(0, 500)}`); + } else if (stderr) { + log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`); + } + } finally { + clearTimeout(timer); + } + } catch (e) { + log.warn(`engine: ${label} failed: ${(e as Error).message}`); + } +} + /** * Default TakeoverStrategy: deterministic per-issue workdir under * `/--//`, with a best-effort @@ -158,6 +222,8 @@ export class Engine { private observedIssues = new Set(); private observerTimer?: ReturnType; + private groupConfigs = new Map(); + private static MAX_INLINE_SIZE = 4000; private static MAX_NUDGE_ROUNDS = 1; private static MAX_STUCK_NUDGE_ROUNDS = 1; @@ -234,9 +300,44 @@ export class Engine { } private async resolveWorkdir(session: OpSession, issue: Issue): Promise { + const gc = this.groupConfigFor(issue); + if (gc?.workdirTemplate && !session.workdir) { + const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir); + mkdirSync(dir, { recursive: true }); + return dir; + } return this.takeover.acquireWorkdir(session, issue); } + private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record { + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || ""; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || ""; + return { + EWORK_OWNER: String(owner), + EWORK_REPO: String(repo), + EWORK_ISSUE: String(issue.trackerIssueId), + EWORK_SESSION: session.name, + EWORK_WORKDIR: workdir, + }; + } + + private workdirPathFor(session: OpSession, issue: Issue): string { + if (session.workdir) { + let dir = session.workdir; + if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1)); + return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir); + } + const gc = this.groupConfigFor(issue); + if (gc?.workdirTemplate) { + return resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir); + } + const parts = issue.trackerScopeKey.split("/"); + const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default"; + const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default"; + return join(this.cfg.opencode.baseWorkdir, `${owner}--${repo}`, String(issue.trackerIssueId), session.name); + } + private async persistRuntimeState(sessionId: string) { const session = await this.store.getSession(sessionId); if (!session) return; @@ -314,11 +415,15 @@ export class Engine { // ─── Event Dispatch ─── - async handleEvent(event: TrackerEvent) { + async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) { const { ref, issue: issueData } = event; const tracker = this.getTracker(ref.trackerType); const scopeKey = tracker.formatScopeKey(ref.scope); + if (groupConfig) { + this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig); + } + switch (event.type) { case "issue_opened": return this.handleOpened(ref, scopeKey, issueData, tracker, event.model); @@ -329,6 +434,10 @@ export class Engine { } } + private groupConfigFor(issue: Issue): GroupConfig | undefined { + return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`); + } + private async handleOpened( ref: TrackerRef, scopeKey: string, @@ -515,6 +624,7 @@ export class Engine { ) { const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId); if (!issue) return; + if (issue.state === "closed") return; await this.store.updateIssueState(issue.id, "closed"); this.stopObserver(issue.id); @@ -528,19 +638,30 @@ export class Engine { this.stopping.add(k); try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ } } - // Clear runtime state this.clearRuntimeState(k); - // Mark pending/running messages as interrupted const msgs = await this.store.getMessagesForSession(session.id); for (const msg of msgs) { if (msg.status === "pending" || msg.status === "running") { await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed"); } } - // Update session state await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined }); } + const gcKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`; + const gc = this.groupConfigFor(issue); + if (gc?.destroyScript) { + const workdirs = new Set(); + for (const session of sessions) { + const workdir = this.workdirPathFor(session, issue); + if (existsSync(workdir)) workdirs.add(workdir); + } + for (const workdir of workdirs) { + await runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`, this.hookEnvFor(issue, { name: "" } as OpSession, workdir)); + } + } + if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey); + log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`); } @@ -635,6 +756,11 @@ export class Engine { const workdir = await this.resolveWorkdir(session, issue); + const gc = this.groupConfigFor(issue); + if (gc?.initScript) { + await runHookScript(gc.initScript, workdir, `initScript for ${k}`, this.hookEnvFor(issue, session, workdir)); + } + const ref = this.sessionToRef(session, issue); const tracker = this.getTracker(issue.trackerType); diff --git a/src/server.ts b/src/server.ts index a493dd6..0b36041 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import type { Config } from "./config"; import type { Store } from "./op"; import type { Engine } from "./opencode"; +import type { GroupConfig } from "./opencode"; import type { IssueTracker, TrackerEvent } from "./trackers/types"; import { OpencodeReader, OpencodeReaderError } from "./opencode-reader"; import { listDir, readFile, readFileSince, FileApiError } from "./file-api"; @@ -8,6 +9,17 @@ import { log, uptimeSeconds, version } from "./logger"; type TrackerMap = Map; +export function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined { + if (!raw) return undefined; + try { + const decoded = Buffer.from(raw, "base64").toString("utf8"); + const parsed = JSON.parse(decoded); + if (typeof parsed === "object" && parsed !== null) return parsed as GroupConfig; + } catch { + } + return undefined; +} + function json(data: unknown, status = 200) { return new Response(JSON.stringify(data, null, 2), { status, @@ -41,7 +53,9 @@ export function createServer( `webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}` ); - engine.handleEvent(event).catch((err) => { + const groupConfig = parseGroupConfigHeader(req.headers.get("x-ework-group-config")); + + engine.handleEvent(event, groupConfig).catch((err) => { log.error("webhook: handler error:", err); }); diff --git a/tests/workdir.test.ts b/tests/workdir.test.ts new file mode 100644 index 0000000..1fdad4e --- /dev/null +++ b/tests/workdir.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveTemplatedWorkdir, runHookScript } from "../src/opencode"; +import { parseGroupConfigHeader } from "../src/server"; + +function mkIssue(scopeKey: string, issueId: string | number, scope?: Record) { + return { + trackerScopeKey: scopeKey, + trackerScope: scope ?? {}, + trackerIssueId: issueId, + }; +} + +describe("resolveTemplatedWorkdir", () => { + test("substitutes all variables", () => { + const dir = resolveTemplatedWorkdir( + "/data/{owner}/{repo}/i{issue}/{session}", + mkIssue("acme/widget", "42", { owner: "acme", repo: "widget" }), + { name: "sess1" }, + ); + expect(dir).toBe("/data/acme/widget/i42/sess1"); + }); + + test("falls back to scopeKey parts when trackerScope absent", () => { + const dir = resolveTemplatedWorkdir( + "/w/{owner}/{repo}/{issue}", + mkIssue("fallback/repo-x", "7"), + { name: "s" }, + ); + expect(dir).toBe("/w/fallback/repo-x/7"); + }); + + test("uses 'default' when owner/repo unresolvable", () => { + const dir = resolveTemplatedWorkdir( + "/w/{owner}/{repo}/{issue}", + mkIssue("", "1"), + { name: "s" }, + ); + expect(dir).toBe("/w/default/default/1"); + }); + + test("replaces all occurrences of a variable", () => { + const dir = resolveTemplatedWorkdir( + "/w/{issue}/{issue}", + mkIssue("o/r", "9"), + { name: "s" }, + ); + expect(dir).toBe("/w/9/9"); + }); + + test("expands ~ to homedir", () => { + const dir = resolveTemplatedWorkdir("~/work/{issue}", mkIssue("o/r", "3"), { name: "s" }); + expect(dir.startsWith("/")).toBe(true); + expect(dir).not.toContain("~"); + expect(dir.endsWith("/work/3")).toBe(true); + }); + + test("leaves unknown placeholders literally", () => { + const dir = resolveTemplatedWorkdir( + "/w/{unknown}/{issue}", + mkIssue("o/r", "1"), + { name: "s" }, + ); + expect(dir).toBe("/w/{unknown}/1"); + }); +}); + +describe("parseGroupConfigHeader", () => { + test("round-trips a full config", () => { + const cfg = { workdirTemplate: "/w/{owner}/{repo}", initScript: "echo hi", destroyScript: "rm -rf x" }; + const encoded = Buffer.from(JSON.stringify(cfg), "utf8").toString("base64"); + expect(parseGroupConfigHeader(encoded)).toEqual(cfg); + expect(parseGroupConfigHeader(encoded)).toEqual(cfg); + }); + + test("returns undefined for null/empty", () => { + expect(parseGroupConfigHeader(null)).toBeUndefined(); + expect(parseGroupConfigHeader("")).toBeUndefined(); + expect(parseGroupConfigHeader(null)).toBeUndefined(); + }); + + test("returns undefined for malformed base64/json", () => { + expect(parseGroupConfigHeader("not-base64-json!!!")).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("{bad json").toString("base64"))).toBeUndefined(); + }); + + test("returns undefined for non-object json", () => { + expect(parseGroupConfigHeader(Buffer.from('"string"').toString("base64"))).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("42").toString("base64"))).toBeUndefined(); + expect(parseGroupConfigHeader(Buffer.from("null").toString("base64"))).toBeUndefined(); + }); +}); + +describe("runHookScript", () => { + test("no-op on undefined/empty/whitespace", async () => { + await runHookScript(undefined, "/tmp", "init"); + await runHookScript("", "/tmp", "init"); + await runHookScript(" \n ", "/tmp", "init"); + }); + + test("executes script with workdir as cwd", async () => { + const dir = mkdtempSync(join(tmpdir(), "ewhook-")); + try { + await runHookScript('pwd > cwd.txt', dir, "init"); + const cwd = readFileSync(join(dir, "cwd.txt"), "utf8").trim(); + expect(cwd).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("creates workdir if it does not exist", async () => { + const dir = join(mkdtempSync(join(tmpdir(), "ewp-")), "nested", "deep"); + try { + await runHookScript("echo ok > done.txt", dir, "init"); + expect(readFileSync(join(dir, "done.txt"), "utf8").trim()).toBe("ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("script can write env-templated content", async () => { + const dir = mkdtempSync(join(tmpdir(), "ewenv-")); + try { + writeFileSync(join(dir, "existing.txt"), "data"); + await runHookScript("cat existing.txt > out.txt", dir, "init"); + expect(readFileSync(join(dir, "out.txt"), "utf8").trim()).toBe("data"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("never throws on script failure", async () => { + await expect(runHookScript("exit 1", "/tmp", "init")).resolves.toBeUndefined(); + await expect(runHookScript("false", "/tmp", "init")).resolves.toBeUndefined(); + }); + + test("never throws on nonexistent shell command", async () => { + await expect(runHookScript("this-cmd-does-not-exist-xyz", "/tmp", "init")).resolves.toBeUndefined(); + }); +});