ework-daemon 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/opencode.ts +131 -5
- package/src/server.ts +15 -1
package/package.json
CHANGED
package/src/opencode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
-
import { mkdirSync, writeFileSync, readdirSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
3
3
|
import { join, resolve, isAbsolute } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { log } from "./logger";
|
|
@@ -27,6 +27,70 @@ export interface TakeoverStrategy {
|
|
|
27
27
|
resumeOpenCodeSession(session: OpSession): Promise<string | null>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export interface GroupConfig {
|
|
31
|
+
workdirTemplate?: string;
|
|
32
|
+
initScript?: string;
|
|
33
|
+
destroyScript?: string;
|
|
34
|
+
envInitScript?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Substitute {owner}/{repo}/{issue}/{session} in a workdir template. */
|
|
38
|
+
export function resolveTemplatedWorkdir(
|
|
39
|
+
template: string,
|
|
40
|
+
issue: { trackerScopeKey: string; trackerScope: Record<string, unknown>; trackerIssueId: string | number },
|
|
41
|
+
session: { name: string },
|
|
42
|
+
baseWorkdir?: string,
|
|
43
|
+
): string {
|
|
44
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
45
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default";
|
|
46
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default";
|
|
47
|
+
// Use replacer functions to avoid `$&`/`$1` interpretation in replacement strings.
|
|
48
|
+
let dir = template
|
|
49
|
+
.replace(/\{owner\}/g, () => String(owner))
|
|
50
|
+
.replace(/\{repo\}/g, () => String(repo))
|
|
51
|
+
.replace(/\{issue\}/g, () => String(issue.trackerIssueId))
|
|
52
|
+
.replace(/\{session\}/g, () => session.name);
|
|
53
|
+
if (dir.startsWith("~")) {
|
|
54
|
+
dir = join(homedir(), dir.slice(1));
|
|
55
|
+
} else if (!isAbsolute(dir) && baseWorkdir) {
|
|
56
|
+
dir = resolve(baseWorkdir, dir);
|
|
57
|
+
}
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run a lifecycle script via `bash -c` with cwd=workdir. Uses async Bun.spawn
|
|
62
|
+
* (not spawnSync) so the event loop is not blocked. A 60s hard timeout kills
|
|
63
|
+
* hung scripts. Failures are logged and swallowed — never throws — so a broken
|
|
64
|
+
* init/destroy never blocks the opencode task flow. */
|
|
65
|
+
const HOOK_SCRIPT_TIMEOUT_MS = 60_000;
|
|
66
|
+
export async function runHookScript(script: string | undefined, workdir: string, label: string, env: Record<string, string> = {}): Promise<void> {
|
|
67
|
+
if (!script || !script.trim()) return;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(workdir, { recursive: true });
|
|
70
|
+
const proc = Bun.spawn({
|
|
71
|
+
cmd: ["bash", "-c", script],
|
|
72
|
+
cwd: workdir,
|
|
73
|
+
stdout: "pipe",
|
|
74
|
+
stderr: "pipe",
|
|
75
|
+
env: { ...process.env, ...env },
|
|
76
|
+
});
|
|
77
|
+
const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, HOOK_SCRIPT_TIMEOUT_MS);
|
|
78
|
+
try {
|
|
79
|
+
const exitCode = await proc.exited;
|
|
80
|
+
const stderr = await new Response(proc.stderr).text().catch(() => "");
|
|
81
|
+
if (exitCode !== 0) {
|
|
82
|
+
log.warn(`engine: ${label} exited ${exitCode}: ${stderr.slice(0, 500)}`);
|
|
83
|
+
} else if (stderr) {
|
|
84
|
+
log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`);
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
log.warn(`engine: ${label} failed: ${(e as Error).message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/**
|
|
31
95
|
* Default TakeoverStrategy: deterministic per-issue workdir under
|
|
32
96
|
* `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
|
|
@@ -158,6 +222,8 @@ export class Engine {
|
|
|
158
222
|
private observedIssues = new Set<string>();
|
|
159
223
|
private observerTimer?: ReturnType<typeof setInterval>;
|
|
160
224
|
|
|
225
|
+
private groupConfigs = new Map<string, GroupConfig>();
|
|
226
|
+
|
|
161
227
|
private static MAX_INLINE_SIZE = 4000;
|
|
162
228
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
163
229
|
private static MAX_STUCK_NUDGE_ROUNDS = 1;
|
|
@@ -234,9 +300,44 @@ export class Engine {
|
|
|
234
300
|
}
|
|
235
301
|
|
|
236
302
|
private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
|
|
303
|
+
const gc = this.groupConfigFor(issue);
|
|
304
|
+
if (gc?.workdirTemplate && !session.workdir) {
|
|
305
|
+
const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
|
|
306
|
+
mkdirSync(dir, { recursive: true });
|
|
307
|
+
return dir;
|
|
308
|
+
}
|
|
237
309
|
return this.takeover.acquireWorkdir(session, issue);
|
|
238
310
|
}
|
|
239
311
|
|
|
312
|
+
private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
|
|
313
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
314
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
|
|
315
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
|
|
316
|
+
return {
|
|
317
|
+
EWORK_OWNER: String(owner),
|
|
318
|
+
EWORK_REPO: String(repo),
|
|
319
|
+
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
320
|
+
EWORK_SESSION: session.name,
|
|
321
|
+
EWORK_WORKDIR: workdir,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private workdirPathFor(session: OpSession, issue: Issue): string {
|
|
326
|
+
if (session.workdir) {
|
|
327
|
+
let dir = session.workdir;
|
|
328
|
+
if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
|
|
329
|
+
return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
|
|
330
|
+
}
|
|
331
|
+
const gc = this.groupConfigFor(issue);
|
|
332
|
+
if (gc?.workdirTemplate) {
|
|
333
|
+
return resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
|
|
334
|
+
}
|
|
335
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
336
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default";
|
|
337
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default";
|
|
338
|
+
return join(this.cfg.opencode.baseWorkdir, `${owner}--${repo}`, String(issue.trackerIssueId), session.name);
|
|
339
|
+
}
|
|
340
|
+
|
|
240
341
|
private async persistRuntimeState(sessionId: string) {
|
|
241
342
|
const session = await this.store.getSession(sessionId);
|
|
242
343
|
if (!session) return;
|
|
@@ -314,11 +415,15 @@ export class Engine {
|
|
|
314
415
|
|
|
315
416
|
// ─── Event Dispatch ───
|
|
316
417
|
|
|
317
|
-
async handleEvent(event: TrackerEvent) {
|
|
418
|
+
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
318
419
|
const { ref, issue: issueData } = event;
|
|
319
420
|
const tracker = this.getTracker(ref.trackerType);
|
|
320
421
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
321
422
|
|
|
423
|
+
if (groupConfig) {
|
|
424
|
+
this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig);
|
|
425
|
+
}
|
|
426
|
+
|
|
322
427
|
switch (event.type) {
|
|
323
428
|
case "issue_opened":
|
|
324
429
|
return this.handleOpened(ref, scopeKey, issueData, tracker, event.model);
|
|
@@ -329,6 +434,10 @@ export class Engine {
|
|
|
329
434
|
}
|
|
330
435
|
}
|
|
331
436
|
|
|
437
|
+
private groupConfigFor(issue: Issue): GroupConfig | undefined {
|
|
438
|
+
return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
332
441
|
private async handleOpened(
|
|
333
442
|
ref: TrackerRef,
|
|
334
443
|
scopeKey: string,
|
|
@@ -515,6 +624,7 @@ export class Engine {
|
|
|
515
624
|
) {
|
|
516
625
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
517
626
|
if (!issue) return;
|
|
627
|
+
if (issue.state === "closed") return;
|
|
518
628
|
|
|
519
629
|
await this.store.updateIssueState(issue.id, "closed");
|
|
520
630
|
this.stopObserver(issue.id);
|
|
@@ -528,19 +638,30 @@ export class Engine {
|
|
|
528
638
|
this.stopping.add(k);
|
|
529
639
|
try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
530
640
|
}
|
|
531
|
-
// Clear runtime state
|
|
532
641
|
this.clearRuntimeState(k);
|
|
533
|
-
// Mark pending/running messages as interrupted
|
|
534
642
|
const msgs = await this.store.getMessagesForSession(session.id);
|
|
535
643
|
for (const msg of msgs) {
|
|
536
644
|
if (msg.status === "pending" || msg.status === "running") {
|
|
537
645
|
await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
|
|
538
646
|
}
|
|
539
647
|
}
|
|
540
|
-
// Update session state
|
|
541
648
|
await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
|
|
542
649
|
}
|
|
543
650
|
|
|
651
|
+
const gcKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
652
|
+
const gc = this.groupConfigFor(issue);
|
|
653
|
+
if (gc?.destroyScript) {
|
|
654
|
+
const workdirs = new Set<string>();
|
|
655
|
+
for (const session of sessions) {
|
|
656
|
+
const workdir = this.workdirPathFor(session, issue);
|
|
657
|
+
if (existsSync(workdir)) workdirs.add(workdir);
|
|
658
|
+
}
|
|
659
|
+
for (const workdir of workdirs) {
|
|
660
|
+
await runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`, this.hookEnvFor(issue, { name: "" } as OpSession, workdir));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
|
|
664
|
+
|
|
544
665
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
545
666
|
}
|
|
546
667
|
|
|
@@ -635,6 +756,11 @@ export class Engine {
|
|
|
635
756
|
|
|
636
757
|
const workdir = await this.resolveWorkdir(session, issue);
|
|
637
758
|
|
|
759
|
+
const gc = this.groupConfigFor(issue);
|
|
760
|
+
if (gc?.initScript) {
|
|
761
|
+
await runHookScript(gc.initScript, workdir, `initScript for ${k}`, this.hookEnvFor(issue, session, workdir));
|
|
762
|
+
}
|
|
763
|
+
|
|
638
764
|
const ref = this.sessionToRef(session, issue);
|
|
639
765
|
const tracker = this.getTracker(issue.trackerType);
|
|
640
766
|
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Config } from "./config";
|
|
2
2
|
import type { Store } from "./op";
|
|
3
3
|
import type { Engine } from "./opencode";
|
|
4
|
+
import type { GroupConfig } from "./opencode";
|
|
4
5
|
import type { IssueTracker, TrackerEvent } from "./trackers/types";
|
|
5
6
|
import { OpencodeReader, OpencodeReaderError } from "./opencode-reader";
|
|
6
7
|
import { listDir, readFile, readFileSince, FileApiError } from "./file-api";
|
|
@@ -8,6 +9,17 @@ import { log, uptimeSeconds, version } from "./logger";
|
|
|
8
9
|
|
|
9
10
|
type TrackerMap = Map<string, IssueTracker>;
|
|
10
11
|
|
|
12
|
+
export function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined {
|
|
13
|
+
if (!raw) return undefined;
|
|
14
|
+
try {
|
|
15
|
+
const decoded = Buffer.from(raw, "base64").toString("utf8");
|
|
16
|
+
const parsed = JSON.parse(decoded);
|
|
17
|
+
if (typeof parsed === "object" && parsed !== null) return parsed as GroupConfig;
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
function json(data: unknown, status = 200) {
|
|
12
24
|
return new Response(JSON.stringify(data, null, 2), {
|
|
13
25
|
status,
|
|
@@ -41,7 +53,9 @@ export function createServer(
|
|
|
41
53
|
`webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}`
|
|
42
54
|
);
|
|
43
55
|
|
|
44
|
-
|
|
56
|
+
const groupConfig = parseGroupConfigHeader(req.headers.get("x-ework-group-config"));
|
|
57
|
+
|
|
58
|
+
engine.handleEvent(event, groupConfig).catch((err) => {
|
|
45
59
|
log.error("webhook: handler error:", err);
|
|
46
60
|
});
|
|
47
61
|
|