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
136 changes: 131 additions & 5 deletions src/opencode.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -27,6 +27,70 @@ export interface TakeoverStrategy {
resumeOpenCodeSession(session: OpSession): Promise<string | null>;
}

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<string, unknown>; 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<string, string> = {}): Promise<void> {
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
* `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
Expand Down Expand Up @@ -158,6 +222,8 @@ export class Engine {
private observedIssues = new Set<string>();
private observerTimer?: ReturnType<typeof setInterval>;

private groupConfigs = new Map<string, GroupConfig>();

private static MAX_INLINE_SIZE = 4000;
private static MAX_NUDGE_ROUNDS = 1;
private static MAX_STUCK_NUDGE_ROUNDS = 1;
Expand Down Expand Up @@ -234,9 +300,44 @@ export class Engine {
}

private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
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<string, string> {
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;
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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<string>();
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}`);
}

Expand Down Expand Up @@ -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);

Expand Down
16 changes: 15 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
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";
import { log, uptimeSeconds, version } from "./logger";

type TrackerMap = Map<string, IssueTracker>;

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,
Expand Down Expand Up @@ -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);
});

Expand Down
143 changes: 143 additions & 0 deletions tests/workdir.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
Loading