ework-daemon 0.4.4 → 0.4.6

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.4",
3
+ "version": "0.4.6",
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/db.ts CHANGED
@@ -323,6 +323,111 @@ async function runMigrations(db: AsyncDatabase): Promise<void> {
323
323
  await db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
324
324
  };
325
325
 
326
+ // ── uid: surrogate AUTO_INCREMENT PK on issues/op_sessions/messages ──
327
+ // SQLite cannot ADD PRIMARY KEY via ALTER — must rebuild. Runs before
328
+ // owner_daemon_id etc. so rebuild only copies base columns; ephemeral
329
+ // coordination data (owner_daemon_id, nudge state) is re-added below.
330
+ const tMessages = `${prefix}messages`;
331
+ if (sqlite) {
332
+ const UID_REBUILDS: { table: string; createSql: string; dataCols: string }[] = [
333
+ {
334
+ table: tIssues,
335
+ createSql: `CREATE TABLE ${tIssues} (
336
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
337
+ id TEXT NOT NULL UNIQUE,
338
+ tracker_type TEXT NOT NULL,
339
+ tracker_scope_key TEXT NOT NULL,
340
+ tracker_scope TEXT NOT NULL,
341
+ tracker_issue_id TEXT NOT NULL,
342
+ state TEXT NOT NULL DEFAULT 'created',
343
+ title TEXT NOT NULL DEFAULT '',
344
+ created_at TEXT NOT NULL,
345
+ updated_at TEXT NOT NULL,
346
+ UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)
347
+ )`,
348
+ dataCols:
349
+ "id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at",
350
+ },
351
+ {
352
+ table: tSessions,
353
+ createSql: `CREATE TABLE ${tSessions} (
354
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
355
+ id TEXT NOT NULL UNIQUE,
356
+ issue_id TEXT NOT NULL REFERENCES ${tIssues}(id) ON DELETE CASCADE,
357
+ name TEXT NOT NULL,
358
+ state TEXT NOT NULL DEFAULT 'idle',
359
+ opencode_session_id TEXT,
360
+ opencode_pid INTEGER,
361
+ workdir TEXT,
362
+ created_at TEXT NOT NULL,
363
+ started_at INTEGER,
364
+ progress_comment_id TEXT,
365
+ reaction_comment_id TEXT,
366
+ current_prompt TEXT,
367
+ UNIQUE(issue_id, name)
368
+ )`,
369
+ dataCols:
370
+ "id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at, started_at, progress_comment_id, reaction_comment_id, current_prompt",
371
+ },
372
+ {
373
+ table: tMessages,
374
+ createSql: `CREATE TABLE ${tMessages} (
375
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
376
+ id TEXT NOT NULL UNIQUE,
377
+ session_id TEXT NOT NULL REFERENCES ${tSessions}(id) ON DELETE CASCADE,
378
+ content TEXT NOT NULL,
379
+ source_comment_id TEXT,
380
+ reaction_comment_id TEXT,
381
+ status TEXT NOT NULL DEFAULT 'pending',
382
+ attempts INTEGER NOT NULL DEFAULT 0,
383
+ error TEXT,
384
+ created_at TEXT NOT NULL,
385
+ updated_at TEXT NOT NULL
386
+ )`,
387
+ dataCols:
388
+ "id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at",
389
+ },
390
+ ];
391
+
392
+ for (const { table, createSql, dataCols } of UID_REBUILDS) {
393
+ if (await hasColumn(table, "uid")) continue;
394
+ const exists = await db.get<{ cnt: number }>(
395
+ `SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name='${table}'`
396
+ );
397
+ if (Number(exists?.cnt ?? 0) === 0) continue;
398
+
399
+ await db.exec("PRAGMA foreign_keys = OFF");
400
+ try {
401
+ await db.transaction(async () => {
402
+ await db.exec(`ALTER TABLE ${table} RENAME TO ${table}__old_uid`);
403
+ await db.exec(createSql);
404
+ await db.exec(
405
+ `CREATE INDEX IF NOT EXISTS idx_${table}__id ON ${table}(id)`
406
+ );
407
+ await db.exec(`INSERT INTO ${table} (${dataCols}) SELECT ${dataCols} FROM ${table}__old_uid`);
408
+ await db.exec(`DROP TABLE ${table}__old_uid`);
409
+ });
410
+ } finally {
411
+ await db.exec("PRAGMA foreign_keys = ON");
412
+ }
413
+ }
414
+ } else {
415
+ const uidMySqlTables = [tIssues, tSessions, tMessages];
416
+ for (const tbl of uidMySqlTables) {
417
+ if (await hasColumn(tbl, "uid")) continue;
418
+ try {
419
+ await db.exec(
420
+ `ALTER TABLE ${tbl} DROP PRIMARY KEY, ` +
421
+ `ADD COLUMN uid BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ` +
422
+ `ADD UNIQUE INDEX uk_${tbl}_id (id)`
423
+ );
424
+ } catch (e) {
425
+ if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1146) continue;
426
+ throw e;
427
+ }
428
+ }
429
+ }
430
+
326
431
  // issues.owner_daemon_id — points at the leasing daemon (nullable = unclaimed).
327
432
  await ensureColumn(
328
433
  tIssues,
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,71 @@ 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> = {}, timeoutMs?: number): Promise<void> {
67
+ if (!script || !script.trim()) return;
68
+ const effectiveTimeout = timeoutMs ?? HOOK_SCRIPT_TIMEOUT_MS;
69
+ try {
70
+ mkdirSync(workdir, { recursive: true });
71
+ const proc = Bun.spawn({
72
+ cmd: ["bash", "-c", script],
73
+ cwd: workdir,
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ env: { ...process.env, ...env },
77
+ });
78
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, effectiveTimeout);
79
+ try {
80
+ const exitCode = await proc.exited;
81
+ const stderr = await new Response(proc.stderr).text().catch(() => "");
82
+ if (exitCode !== 0) {
83
+ log.warn(`engine: ${label} exited ${exitCode}: ${stderr.slice(0, 500)}`);
84
+ } else if (stderr) {
85
+ log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`);
86
+ }
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ } catch (e) {
91
+ log.warn(`engine: ${label} failed: ${(e as Error).message}`);
92
+ }
93
+ }
94
+
30
95
  /**
31
96
  * Default TakeoverStrategy: deterministic per-issue workdir under
32
97
  * `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
@@ -158,6 +223,8 @@ export class Engine {
158
223
  private observedIssues = new Set<string>();
159
224
  private observerTimer?: ReturnType<typeof setInterval>;
160
225
 
226
+ private groupConfigs = new Map<string, GroupConfig>();
227
+
161
228
  private static MAX_INLINE_SIZE = 4000;
162
229
  private static MAX_NUDGE_ROUNDS = 1;
163
230
  private static MAX_STUCK_NUDGE_ROUNDS = 1;
@@ -234,9 +301,44 @@ export class Engine {
234
301
  }
235
302
 
236
303
  private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
304
+ const gc = this.groupConfigFor(issue);
305
+ if (gc?.workdirTemplate && !session.workdir) {
306
+ const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
307
+ mkdirSync(dir, { recursive: true });
308
+ return dir;
309
+ }
237
310
  return this.takeover.acquireWorkdir(session, issue);
238
311
  }
239
312
 
313
+ private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
314
+ const parts = issue.trackerScopeKey.split("/");
315
+ const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
316
+ const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
317
+ return {
318
+ EWORK_OWNER: String(owner),
319
+ EWORK_REPO: String(repo),
320
+ EWORK_ISSUE: String(issue.trackerIssueId),
321
+ EWORK_SESSION: session.name,
322
+ EWORK_WORKDIR: workdir,
323
+ };
324
+ }
325
+
326
+ private workdirPathFor(session: OpSession, issue: Issue): string {
327
+ if (session.workdir) {
328
+ let dir = session.workdir;
329
+ if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
330
+ return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
331
+ }
332
+ const gc = this.groupConfigFor(issue);
333
+ if (gc?.workdirTemplate) {
334
+ return resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
335
+ }
336
+ const parts = issue.trackerScopeKey.split("/");
337
+ const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default";
338
+ const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default";
339
+ return join(this.cfg.opencode.baseWorkdir, `${owner}--${repo}`, String(issue.trackerIssueId), session.name);
340
+ }
341
+
240
342
  private async persistRuntimeState(sessionId: string) {
241
343
  const session = await this.store.getSession(sessionId);
242
344
  if (!session) return;
@@ -314,11 +416,15 @@ export class Engine {
314
416
 
315
417
  // ─── Event Dispatch ───
316
418
 
317
- async handleEvent(event: TrackerEvent) {
419
+ async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
318
420
  const { ref, issue: issueData } = event;
319
421
  const tracker = this.getTracker(ref.trackerType);
320
422
  const scopeKey = tracker.formatScopeKey(ref.scope);
321
423
 
424
+ if (groupConfig) {
425
+ this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig);
426
+ }
427
+
322
428
  switch (event.type) {
323
429
  case "issue_opened":
324
430
  return this.handleOpened(ref, scopeKey, issueData, tracker, event.model);
@@ -329,6 +435,10 @@ export class Engine {
329
435
  }
330
436
  }
331
437
 
438
+ private groupConfigFor(issue: Issue): GroupConfig | undefined {
439
+ return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
440
+ }
441
+
332
442
  private async handleOpened(
333
443
  ref: TrackerRef,
334
444
  scopeKey: string,
@@ -515,6 +625,7 @@ export class Engine {
515
625
  ) {
516
626
  const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
517
627
  if (!issue) return;
628
+ if (issue.state === "closed") return;
518
629
 
519
630
  await this.store.updateIssueState(issue.id, "closed");
520
631
  this.stopObserver(issue.id);
@@ -528,19 +639,30 @@ export class Engine {
528
639
  this.stopping.add(k);
529
640
  try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
530
641
  }
531
- // Clear runtime state
532
642
  this.clearRuntimeState(k);
533
- // Mark pending/running messages as interrupted
534
643
  const msgs = await this.store.getMessagesForSession(session.id);
535
644
  for (const msg of msgs) {
536
645
  if (msg.status === "pending" || msg.status === "running") {
537
646
  await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
538
647
  }
539
648
  }
540
- // Update session state
541
649
  await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
542
650
  }
543
651
 
652
+ const gcKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
653
+ const gc = this.groupConfigFor(issue);
654
+ if (gc?.destroyScript) {
655
+ const workdirs = new Set<string>();
656
+ for (const session of sessions) {
657
+ const workdir = this.workdirPathFor(session, issue);
658
+ if (existsSync(workdir)) workdirs.add(workdir);
659
+ }
660
+ for (const workdir of workdirs) {
661
+ await runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`, this.hookEnvFor(issue, { name: "" } as OpSession, workdir));
662
+ }
663
+ }
664
+ if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
665
+
544
666
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
545
667
  }
546
668
 
@@ -635,6 +757,11 @@ export class Engine {
635
757
 
636
758
  const workdir = await this.resolveWorkdir(session, issue);
637
759
 
760
+ const gc = this.groupConfigFor(issue);
761
+ if (gc?.initScript) {
762
+ await runHookScript(gc.initScript, workdir, `initScript for ${k}`, this.hookEnvFor(issue, session, workdir));
763
+ }
764
+
638
765
  const ref = this.sessionToRef(session, issue);
639
766
  const tracker = this.getTracker(issue.trackerType);
640
767
 
@@ -8,7 +8,8 @@
8
8
  -- tables InnoDB + utf8mb4 for FK CASCADE + full Unicode (emoji).
9
9
 
10
10
  CREATE TABLE IF NOT EXISTS {{issues}} (
11
- id VARCHAR(36) PRIMARY KEY,
11
+ uid BIGINT AUTO_INCREMENT PRIMARY KEY,
12
+ id VARCHAR(36) NOT NULL UNIQUE,
12
13
  tracker_type VARCHAR(64) NOT NULL,
13
14
  tracker_scope_key VARCHAR(255) NOT NULL,
14
15
  tracker_scope TEXT NOT NULL,
@@ -21,7 +22,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
21
22
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
22
23
 
23
24
  CREATE TABLE IF NOT EXISTS {{op_sessions}} (
24
- id VARCHAR(36) PRIMARY KEY,
25
+ uid BIGINT AUTO_INCREMENT PRIMARY KEY,
26
+ id VARCHAR(36) NOT NULL UNIQUE,
25
27
  issue_id VARCHAR(36) NOT NULL,
26
28
  name VARCHAR(64) NOT NULL,
27
29
  state VARCHAR(16) NOT NULL DEFAULT 'idle',
@@ -41,7 +43,8 @@ CREATE TABLE IF NOT EXISTS {{op_sessions}} (
41
43
  -- idx_sessions_issue is needed on MySQL.
42
44
 
43
45
  CREATE TABLE IF NOT EXISTS {{messages}} (
44
- id VARCHAR(36) PRIMARY KEY,
46
+ uid BIGINT AUTO_INCREMENT PRIMARY KEY,
47
+ id VARCHAR(36) NOT NULL UNIQUE,
45
48
  session_id VARCHAR(36) NOT NULL,
46
49
  content LONGTEXT NOT NULL,
47
50
  source_comment_id VARCHAR(64),
@@ -4,7 +4,8 @@
4
4
  -- share one DB across multiple daemon instances.
5
5
 
6
6
  CREATE TABLE IF NOT EXISTS {{issues}} (
7
- id TEXT PRIMARY KEY,
7
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ id TEXT NOT NULL UNIQUE,
8
9
  tracker_type TEXT NOT NULL,
9
10
  tracker_scope_key TEXT NOT NULL,
10
11
  tracker_scope TEXT NOT NULL,
@@ -17,7 +18,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
17
18
  );
18
19
 
19
20
  CREATE TABLE IF NOT EXISTS {{op_sessions}} (
20
- id TEXT PRIMARY KEY,
21
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ id TEXT NOT NULL UNIQUE,
21
23
  issue_id TEXT NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE,
22
24
  name TEXT NOT NULL,
23
25
  state TEXT NOT NULL DEFAULT 'idle',
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS {{op_sessions}} (
33
35
  );
34
36
 
35
37
  CREATE TABLE IF NOT EXISTS {{messages}} (
36
- id TEXT PRIMARY KEY,
38
+ uid INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ id TEXT NOT NULL UNIQUE,
37
40
  session_id TEXT NOT NULL REFERENCES {{op_sessions}}(id) ON DELETE CASCADE,
38
41
  content TEXT NOT NULL,
39
42
  source_comment_id TEXT,
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,24 @@ 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
+ if (raw.length > 16_384) return undefined;
15
+ try {
16
+ const decoded = Buffer.from(raw, "base64").toString("utf8");
17
+ const parsed = JSON.parse(decoded);
18
+ if (typeof parsed !== "object" || parsed === null) return undefined;
19
+ const gc = parsed as Record<string, unknown>;
20
+ if (gc.workdirTemplate !== undefined && typeof gc.workdirTemplate !== "string") return undefined;
21
+ if (gc.initScript !== undefined && typeof gc.initScript !== "string") return undefined;
22
+ if (gc.destroyScript !== undefined && typeof gc.destroyScript !== "string") return undefined;
23
+ if (gc.envInitScript !== undefined && typeof gc.envInitScript !== "string") return undefined;
24
+ return gc as GroupConfig;
25
+ } catch {
26
+ }
27
+ return undefined;
28
+ }
29
+
11
30
  function json(data: unknown, status = 200) {
12
31
  return new Response(JSON.stringify(data, null, 2), {
13
32
  status,
@@ -41,7 +60,9 @@ export function createServer(
41
60
  `webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}`
42
61
  );
43
62
 
44
- engine.handleEvent(event).catch((err) => {
63
+ const groupConfig = parseGroupConfigHeader(req.headers.get("x-ework-group-config"));
64
+
65
+ engine.handleEvent(event, groupConfig).catch((err) => {
45
66
  log.error("webhook: handler error:", err);
46
67
  });
47
68