ework-daemon 0.4.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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
- engine.handleEvent(event).catch((err) => {
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
 
@@ -55,7 +69,7 @@ export function createServer(
55
69
  return json({
56
70
  env: cfg.env,
57
71
  daemon: { host: cfg.daemon.host, port: cfg.daemon.port },
58
- db: cfg.db.path,
72
+ db: cfg.db.driver === "mysql" ? `${cfg.db.host}:${cfg.db.port}/${cfg.db.name}` : cfg.db.path,
59
73
  driver: cfg.db.driver,
60
74
  running: status.runningCount,
61
75
  pending: status.pendingCount,