ework-daemon 0.1.3 → 0.2.1

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.1.3",
3
+ "version": "0.2.1",
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",
@@ -50,6 +50,7 @@
50
50
  "typecheck": "tsc --noEmit",
51
51
  "test": "bun test",
52
52
  "test:gitea": "bun run scripts/test-gitea.ts",
53
+ "test:mysql": "bash scripts/test-mysql.sh",
53
54
  "build": "bun build src/cli.ts --compile --outfile ework-daemon",
54
55
  "install-global": "bun run build && cp ework-daemon ~/.local/bin/"
55
56
  },
@@ -60,6 +61,7 @@
60
61
  "typescript": "^5"
61
62
  },
62
63
  "dependencies": {
64
+ "mysql2": "^3.23.1",
63
65
  "zod": "^4.4.3"
64
66
  }
65
67
  }
package/src/config.ts CHANGED
@@ -29,8 +29,21 @@ export const configSchema = z.object({
29
29
  binary: z.string().default("opencode"),
30
30
  baseWorkdir: z.string(),
31
31
  }),
32
+ work: z.object({
33
+ capacity: z.coerce.number().int().positive().default(4),
34
+ heartbeatMs: z.coerce.number().int().positive().default(10_000),
35
+ leaseTtlMs: z.coerce.number().int().positive().default(60_000),
36
+ }),
32
37
  db: z.object({
38
+ driver: z.enum(["sqlite", "mysql"]).default("sqlite"),
39
+ host: z.string().default("127.0.0.1"),
40
+ port: z.coerce.number().default(3306),
41
+ user: z.string().default("ework-daemon"),
42
+ password: z.string().default(""),
43
+ name: z.string().default("ework-daemon"),
33
44
  path: z.string(),
45
+ prefix: z.string().default(""),
46
+ skipCreate: z.boolean().default(false),
34
47
  }),
35
48
  completionCheck: z.object({
36
49
  apiKey: z.string(),
@@ -56,9 +69,33 @@ const TEST_DEFAULTS = {
56
69
  bot: { username: "ework-daemon-test", token: "test-bot-token" },
57
70
  daemon: { port: 3111, host: "0.0.0.0" },
58
71
  opencode: { binary: "opencode", baseWorkdir: join(tmpdir(), "ework-daemon-test") },
72
+ work: { capacity: 4, heartbeatMs: 10_000, leaseTtlMs: 60_000 },
59
73
  db: { path: join(process.cwd(), "test", "ework-daemon-test.db") },
60
74
  };
61
75
 
76
+ function readWorkSection() {
77
+ return {
78
+ capacity: process.env.WORK_DAEMON_CAPACITY ? Number(process.env.WORK_DAEMON_CAPACITY) : 4,
79
+ heartbeatMs: process.env.WORK_DAEMON_HEARTBEAT_MS ? Number(process.env.WORK_DAEMON_HEARTBEAT_MS) : 10_000,
80
+ leaseTtlMs: process.env.WORK_DAEMON_LEASE_TTL_MS ? Number(process.env.WORK_DAEMON_LEASE_TTL_MS) : 60_000,
81
+ };
82
+ }
83
+
84
+ function readDbSection(fallbackPath: string) {
85
+ const driver = (process.env.WORK_DB_DRIVER ?? "sqlite").trim().toLowerCase();
86
+ return {
87
+ driver: (driver === "mysql" ? "mysql" : "sqlite") as "sqlite" | "mysql",
88
+ host: process.env.WORK_DB_HOST ?? "127.0.0.1",
89
+ port: process.env.WORK_DB_PORT ? Number(process.env.WORK_DB_PORT) : 3306,
90
+ user: process.env.WORK_DB_USER ?? "ework-daemon",
91
+ password: process.env.WORK_DB_PASSWORD ?? "",
92
+ name: process.env.WORK_DB_NAME ?? "ework-daemon",
93
+ path: process.env.WORK_DB_PATH ?? process.env.DAEMON_DB_PATH ?? fallbackPath,
94
+ prefix: process.env.WORK_DB_PREFIX ?? "",
95
+ skipCreate: process.env.WORK_DB_SKIP_CREATE === "1" || process.env.WORK_DB_SKIP_CREATE === "true",
96
+ };
97
+ }
98
+
62
99
  export function loadConfig(): Config {
63
100
  const env = getEnv();
64
101
 
@@ -84,9 +121,8 @@ export function loadConfig(): Config {
84
121
  binary: process.env.OPENCODE_BINARY ?? TEST_DEFAULTS.opencode.binary,
85
122
  baseWorkdir: process.env.OPENCODE_BASE_WORKDIR ?? TEST_DEFAULTS.opencode.baseWorkdir,
86
123
  },
87
- db: {
88
- path: process.env.DAEMON_DB_PATH ?? TEST_DEFAULTS.db.path,
89
- },
124
+ work: readWorkSection(),
125
+ db: readDbSection(TEST_DEFAULTS.db.path),
90
126
  completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
91
127
  apiKey: process.env.COMPLETION_CHECK_API_KEY,
92
128
  baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
@@ -118,9 +154,8 @@ export function loadConfig(): Config {
118
154
  binary: process.env.OPENCODE_BINARY ?? "opencode",
119
155
  baseWorkdir: process.env.OPENCODE_BASE_WORKDIR,
120
156
  },
121
- db: {
122
- path: process.env.DAEMON_DB_PATH ?? PRODUCTION_DB_DEFAULT,
123
- },
157
+ work: readWorkSection(),
158
+ db: readDbSection(PRODUCTION_DB_DEFAULT),
124
159
  completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
125
160
  apiKey: process.env.COMPLETION_CHECK_API_KEY,
126
161
  baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
package/src/db.ts ADDED
@@ -0,0 +1,375 @@
1
+ // Storage bootstrap. Owns the AsyncDatabase singleton. Two drivers behind one
2
+ // AsyncDatabase surface: SQLite (bun:sqlite, default) and MySQL (mysql2/promise).
3
+ // Driver picked by WORK_DB_DRIVER (sqlite|mysql). Schema runs in connect().
4
+ // Callers MUST `await initDB()` once at boot before issuing queries.
5
+ //
6
+ // Table prefix: every table/index/constraint reference in SQL is written as a
7
+ // {{name}} token; applyPrefix() rewrites {{name}} -> <WORK_DB_PREFIX>+name
8
+ // before execution. Default prefix "" leaves SQL identical (backward-
9
+ // compatible with existing ework-daemon.db files). WORK_DB_PREFIX is ENV-ONLY
10
+ // — the prefix must be available before the DB is open, so it cannot live in
11
+ // the DB itself (chicken-and-egg).
12
+ //
13
+ // Backward compat: WORK_DB_PATH falls back to DAEMON_DB_PATH (the legacy env
14
+ // var) so existing single-machine deployments keep working without changes.
15
+
16
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
17
+ import { createPool, type Pool, type PoolConnection, type ResultSetHeader } from "mysql2/promise";
18
+ import { mkdirSync, readFileSync, existsSync } from "fs";
19
+ import { dirname, join } from "path";
20
+ import { homedir } from "os";
21
+
22
+ // ---- public async interface (driver-agnostic) ----
23
+ export interface DbRunResult {
24
+ /** Rowid of the last inserted row (SQLite lastInsertRowid / MySQL insertId). */
25
+ insertId: number;
26
+ /** Number of rows affected by the statement. */
27
+ changes: number;
28
+ }
29
+
30
+ export interface AsyncDatabase {
31
+ /** SELECT -> all matching rows. Empty array when none. */
32
+ all<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
33
+ /** SELECT -> first matching row or null. */
34
+ get<T = unknown>(sql: string, params?: unknown[]): Promise<T | null>;
35
+ /** INSERT/UPDATE/DELETE -> insertId + affected-row count. */
36
+ run(sql: string, params?: unknown[]): Promise<DbRunResult>;
37
+ /** Execute DDL / raw statement (no params, no rows back). */
38
+ exec(sql: string): Promise<void>;
39
+ /** Run fn inside a transaction: commit on resolve, rollback on throw. */
40
+ transaction<T>(fn: () => Promise<T>): Promise<T>;
41
+ /** Release the connection/pool. Idempotent. */
42
+ close(): Promise<void>;
43
+ /** Driver dialect — lets callers branch on SQLite vs MySQL specifics. */
44
+ readonly dialect: "sqlite" | "mysql";
45
+ }
46
+
47
+ const DEFAULT_DB_PATH = join(
48
+ process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
49
+ "ework-daemon",
50
+ "ework-daemon.db"
51
+ );
52
+
53
+ // WORK_DB_PATH preferred; fall back to legacy DAEMON_DB_PATH for existing deploys.
54
+ const DB_PATH = process.env.WORK_DB_PATH || process.env.DAEMON_DB_PATH || DEFAULT_DB_PATH;
55
+
56
+ // ---- table prefix (env-only; read once at module load) ----
57
+ // Validated as a safe SQL identifier prefix. Empty = no prefix (default,
58
+ // backward-compatible). A non-empty prefix lets multiple ework-daemon
59
+ // instances share one database without colliding on table names.
60
+ const DB_PREFIX = (() => {
61
+ const raw = (process.env.WORK_DB_PREFIX ?? "").trim();
62
+ if (raw && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(raw)) {
63
+ throw new Error(
64
+ `Invalid WORK_DB_PREFIX "${raw}": must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$`
65
+ );
66
+ }
67
+ return raw;
68
+ })();
69
+
70
+ /** Rewrite {{table}} tokens -> <prefix>table. No-op when sql contains no tokens. */
71
+ export function applyPrefix(sql: string): string {
72
+ if (!sql.includes("{{")) return sql;
73
+ return sql.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => DB_PREFIX + name);
74
+ }
75
+
76
+ // ---- driver selection (env-only; read once at module load) ----
77
+ const DB_DRIVER = (process.env.WORK_DB_DRIVER ?? "sqlite").trim().toLowerCase();
78
+ const DB_SKIP_CREATE =
79
+ process.env.WORK_DB_SKIP_CREATE === "1" || process.env.WORK_DB_SKIP_CREATE === "true";
80
+ if (DB_DRIVER !== "sqlite" && DB_DRIVER !== "mysql") {
81
+ throw new Error(`Unsupported WORK_DB_DRIVER "${DB_DRIVER}": must be "sqlite" or "mysql"`);
82
+ }
83
+
84
+ // ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
85
+ class SqliteDriver implements AsyncDatabase {
86
+ readonly dialect = "sqlite" as const;
87
+ private readonly db: Database;
88
+ private inTx = false;
89
+ private constructor(db: Database) {
90
+ this.db = db;
91
+ }
92
+
93
+ static async create(): Promise<SqliteDriver> {
94
+ const dir = dirname(DB_PATH);
95
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
96
+ const db = new Database(DB_PATH, { create: true, readwrite: true });
97
+ db.exec("PRAGMA journal_mode = WAL");
98
+ db.exec("PRAGMA foreign_keys = ON");
99
+ const schemaSql = applyPrefix(readFileSync(join(import.meta.dir, "schema-sqlite.sql"), "utf8"));
100
+ db.exec(schemaSql);
101
+ return new SqliteDriver(db);
102
+ }
103
+
104
+ async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
105
+ return this.db.query(applyPrefix(sql)).all(...(params as SQLQueryBindings[])) as T[];
106
+ }
107
+ async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
108
+ return (this.db.query(applyPrefix(sql)).get(...(params as SQLQueryBindings[])) as T | null) ?? null;
109
+ }
110
+ async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
111
+ const info = this.db.query(applyPrefix(sql)).run(...(params as SQLQueryBindings[])) as unknown as {
112
+ lastInsertRowid: number | bigint;
113
+ changes: number;
114
+ };
115
+ return { insertId: Number(info.lastInsertRowid), changes: info.changes };
116
+ }
117
+ async exec(sql: string): Promise<void> {
118
+ this.db.exec(applyPrefix(sql));
119
+ }
120
+ async transaction<T>(fn: () => Promise<T>): Promise<T> {
121
+ if (this.inTx) {
122
+ // SQLite can't nest BEGIN without SAVEPOINT; current codebase has no
123
+ // nesting, so this safety net just runs the body inline.
124
+ return fn();
125
+ }
126
+ this.db.exec("BEGIN");
127
+ this.inTx = true;
128
+ try {
129
+ const r = await fn();
130
+ this.db.exec("COMMIT");
131
+ return r;
132
+ } catch (e) {
133
+ try {
134
+ this.db.exec("ROLLBACK");
135
+ } catch {
136
+ /* already rolled back */
137
+ }
138
+ throw e;
139
+ } finally {
140
+ this.inTx = false;
141
+ }
142
+ }
143
+ async close(): Promise<void> {
144
+ try {
145
+ this.db.close();
146
+ } catch {
147
+ /* already closed */
148
+ }
149
+ }
150
+ }
151
+
152
+ // ---- MysqlDriver: wraps mysql2/promise behind AsyncDatabase ----
153
+ // MySQL transactions must run on a single connection, so transaction() checks
154
+ // out a connection, pins it as txConn, and routes all/get/run/exec through it
155
+ // until commit/rollback. Outside a transaction, queries hit the pool. SQLite-
156
+ // specific SQL (INSERT OR IGNORE) is translated to MySQL equivalents by
157
+ // translateForMysql() so op.ts stays single-dialect.
158
+ interface MysqlOptions {
159
+ host: string;
160
+ port: number;
161
+ user: string;
162
+ password: string;
163
+ database: string;
164
+ skipCreate: boolean;
165
+ }
166
+
167
+ function translateForMysql(sql: string): string {
168
+ return sql.replace(/INSERT OR IGNORE INTO/g, "INSERT IGNORE INTO");
169
+ }
170
+
171
+ class MysqlDriver implements AsyncDatabase {
172
+ readonly dialect = "mysql" as const;
173
+ private readonly pool: Pool;
174
+ private txConn: PoolConnection | null = null;
175
+ private constructor(pool: Pool) {
176
+ this.pool = pool;
177
+ }
178
+
179
+ private get conn(): Pool | PoolConnection {
180
+ return this.txConn ?? this.pool;
181
+ }
182
+
183
+ static async create(opts: MysqlOptions): Promise<MysqlDriver> {
184
+ const pool = createPool({
185
+ host: opts.host,
186
+ port: opts.port,
187
+ user: opts.user,
188
+ password: opts.password,
189
+ database: opts.database,
190
+ waitForConnections: true,
191
+ connectionLimit: 10,
192
+ charset: "utf8mb4",
193
+ });
194
+ const probe = await pool.getConnection();
195
+ try {
196
+ await probe.ping();
197
+ } finally {
198
+ probe.release();
199
+ }
200
+ if (!opts.skipCreate) {
201
+ const raw = applyPrefix(readFileSync(join(import.meta.dir, "schema-mysql.sql"), "utf8"));
202
+ // MySQL has no CREATE INDEX IF NOT EXISTS, so split into statements and
203
+ // tolerate ER_DUP_KEYNAME (1061) so re-runs stay idempotent. Comment lines
204
+ // are stripped first — they may contain ';' which would corrupt the split.
205
+ const schema = raw
206
+ .split("\n")
207
+ .filter((l) => !l.trimStart().startsWith("--"))
208
+ .join("\n");
209
+ for (const stmt of schema.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) {
210
+ try {
211
+ await pool.query(stmt);
212
+ } catch (e) {
213
+ if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1061) continue;
214
+ throw e;
215
+ }
216
+ }
217
+ }
218
+ return new MysqlDriver(pool);
219
+ }
220
+
221
+ private prepare(sql: string): string {
222
+ return translateForMysql(applyPrefix(sql));
223
+ }
224
+
225
+ async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
226
+ const [rows] = await this.conn.query(this.prepare(sql), params);
227
+ return rows as T[];
228
+ }
229
+ async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
230
+ const [rows] = await this.conn.query(this.prepare(sql), params);
231
+ const arr = rows as T[];
232
+ return arr[0] ?? null;
233
+ }
234
+ async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
235
+ const [result] = await this.conn.query(this.prepare(sql), params);
236
+ const r = result as ResultSetHeader;
237
+ return { insertId: Number(r.insertId), changes: r.affectedRows };
238
+ }
239
+ async exec(sql: string): Promise<void> {
240
+ await this.conn.query(this.prepare(sql));
241
+ }
242
+ async transaction<T>(fn: () => Promise<T>): Promise<T> {
243
+ if (this.txConn) return fn();
244
+ const conn = await this.pool.getConnection();
245
+ this.txConn = conn;
246
+ await conn.beginTransaction();
247
+ try {
248
+ const r = await fn();
249
+ await conn.commit();
250
+ return r;
251
+ } catch (e) {
252
+ try {
253
+ await conn.rollback();
254
+ } catch {
255
+ /* already rolled back */
256
+ }
257
+ throw e;
258
+ } finally {
259
+ this.txConn = null;
260
+ conn.release();
261
+ }
262
+ }
263
+ async close(): Promise<void> {
264
+ try {
265
+ await this.pool.end();
266
+ } catch {
267
+ /* already closed */
268
+ }
269
+ }
270
+ }
271
+
272
+ let _driver: AsyncDatabase | null = null;
273
+
274
+ /** Initialize + connect the database. MUST be awaited once at boot. */
275
+ export async function initDB(): Promise<AsyncDatabase> {
276
+ if (_driver) return _driver;
277
+ if (DB_DRIVER === "mysql") {
278
+ _driver = await MysqlDriver.create({
279
+ host: process.env.WORK_DB_HOST ?? "127.0.0.1",
280
+ port: Number(process.env.WORK_DB_PORT ?? 3306),
281
+ user: process.env.WORK_DB_USER ?? "ework-daemon",
282
+ password: process.env.WORK_DB_PASSWORD ?? "",
283
+ database: process.env.WORK_DB_NAME ?? "ework-daemon",
284
+ skipCreate: DB_SKIP_CREATE,
285
+ });
286
+ } else {
287
+ _driver = await SqliteDriver.create();
288
+ }
289
+ await runMigrations(_driver);
290
+ return _driver;
291
+ }
292
+
293
+ // Idempotent additive migrations for the multi-machine coordination layer
294
+ // (Phase 1). The new daemons table is in the schema files; these ALTERs add
295
+ // nullable columns to existing tables so an upgraded DB matches a fresh one.
296
+ // Checked per-column so re-running on an already-migrated DB is a no-op.
297
+ async function runMigrations(db: AsyncDatabase): Promise<void> {
298
+ const prefix = DB_PREFIX;
299
+ const tIssues = `${prefix}issues`;
300
+ const tSessions = `${prefix}op_sessions`;
301
+
302
+ const sqlite = db.dialect === "sqlite";
303
+
304
+ // Column-existence check branches on driver: SQLite has PRAGMA table_info,
305
+ // MySQL has information_schema.columns. Both return >=1 row if present.
306
+ const hasColumn = async (table: string, col: string): Promise<boolean> => {
307
+ if (sqlite) {
308
+ const rows = await db.all<{ name: string }>(
309
+ `PRAGMA table_info(${table})`
310
+ );
311
+ return rows.some((r) => r.name === col);
312
+ }
313
+ const row = await db.get<{ cnt: number }>(
314
+ `SELECT COUNT(*) AS cnt FROM information_schema.columns
315
+ WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
316
+ [table, col]
317
+ );
318
+ return Number(row?.cnt ?? 0) > 0;
319
+ };
320
+
321
+ const ensureColumn = async (table: string, col: string, ddl: string): Promise<void> => {
322
+ if (await hasColumn(table, col)) return;
323
+ await db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
324
+ };
325
+
326
+ // issues.owner_daemon_id — points at the leasing daemon (nullable = unclaimed).
327
+ await ensureColumn(
328
+ tIssues,
329
+ "owner_daemon_id",
330
+ sqlite
331
+ ? "owner_daemon_id INTEGER REFERENCES {{daemons}}(id)"
332
+ : "owner_daemon_id BIGINT NULL"
333
+ );
334
+
335
+ // op_sessions runtime-state columns (previously in-memory Maps; now persisted
336
+ // so a restarted daemon can recover the nudge/generation state).
337
+ await ensureColumn(tSessions, "last_output_at", "last_output_at VARCHAR(40)");
338
+ await ensureColumn(
339
+ tSessions,
340
+ "nudge_rounds",
341
+ sqlite ? "nudge_rounds INTEGER NOT NULL DEFAULT 0" : "nudge_rounds INT NOT NULL DEFAULT 0"
342
+ );
343
+ await ensureColumn(
344
+ tSessions,
345
+ "stuck_nudge_rounds",
346
+ sqlite ? "stuck_nudge_rounds INTEGER NOT NULL DEFAULT 0" : "stuck_nudge_rounds INT NOT NULL DEFAULT 0"
347
+ );
348
+ await ensureColumn(
349
+ tSessions,
350
+ "generation",
351
+ sqlite ? "generation INTEGER NOT NULL DEFAULT 0" : "generation INT NOT NULL DEFAULT 0"
352
+ );
353
+
354
+ // Index over owner_daemon_id — added after the column exists. SQLite tolerates
355
+ // IF NOT EXISTS; MySQL lacks it, so we tolerate ER_DUP_KEYNAME (1061) on re-runs.
356
+ if (sqlite) {
357
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_issues_owner ON ${tIssues}(owner_daemon_id)`);
358
+ } else {
359
+ try {
360
+ await db.exec(`CREATE INDEX idx_issues_owner ON ${tIssues}(owner_daemon_id)`);
361
+ } catch (e) {
362
+ if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1061) {
363
+ // index already exists — expected on re-runs
364
+ } else {
365
+ throw e;
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ /** Returns the initialized AsyncDatabase. Throws if initDB() wasn't awaited. */
372
+ export function getDB(): AsyncDatabase {
373
+ if (!_driver) throw new Error("getDB() called before initDB(); await initDB() at boot first");
374
+ return _driver;
375
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ import { Engine } from "./opencode";
6
6
  import { log } from "./logger";
7
7
  import { GiteaTracker } from "./trackers/gitea-tracker";
8
8
  import type { IssueTracker } from "./trackers/types";
9
+ import { initDB } from "./db";
10
+ import { hostname } from "os";
9
11
 
10
12
  const config = loadConfig();
11
13
  const isTest = config.env === "test";
@@ -15,7 +17,8 @@ log.info(` gitea: ${config.gitea.url}`);
15
17
  log.info(` listen: ${config.daemon.host}:${config.daemon.port}`);
16
18
  log.info(` opencode: ${config.opencode.binary}`);
17
19
  log.info(` workdir: ${config.opencode.baseWorkdir}`);
18
- log.info(` db: ${config.db.path}`);
20
+ log.info(` db: ${config.db.driver === "mysql" ? `${config.db.user}@${config.db.host}:${config.db.port}/${config.db.name}` : config.db.path}${config.db.prefix ? ` (prefix=${config.db.prefix})` : ""}`);
21
+ log.info(` work: capacity=${config.work.capacity} heartbeat=${config.work.heartbeatMs}ms leaseTtl=${config.work.leaseTtlMs}ms`);
19
22
 
20
23
  const giteaClient = new GiteaClient(config.gitea, config.bot.token);
21
24
  const giteaTracker = new GiteaTracker(
@@ -28,21 +31,46 @@ const giteaTracker = new GiteaTracker(
28
31
  const trackers = new Map<string, IssueTracker>();
29
32
  trackers.set("gitea", giteaTracker);
30
33
 
31
- const store = new Store(config.db.path);
32
- const engine = new Engine(config, store, trackers);
34
+ async function boot() {
35
+ await initDB();
33
36
 
34
- const server = createServer(config, store, engine, trackers);
37
+ const store = new Store();
35
38
 
36
- async function shutdown(signal: string) {
37
- log.info(`\n${signal} received, shutting down...`);
38
- engine.destroy();
39
- store.close();
40
- process.exit(0);
41
- }
39
+ // Multi-machine coordination boot:
40
+ // 1. Release any stale owners (dead-daemon cleanup) so we can adopt orphans.
41
+ // 2. Register this daemon (adopts an orphan slot if available).
42
+ // 3. First-boot migration: claim all pre-existing ownerless issues.
43
+ await store.releaseDeadOwners(config.work.leaseTtlMs);
44
+ const displayName = hostname();
45
+ const internalEndpoint = `${config.daemon.host}:${config.daemon.port}`;
46
+ const daemonId = await store.registerDaemon(displayName, internalEndpoint, config.work.capacity, config.work.leaseTtlMs);
47
+ const claimed = await store.claimAllOwnerless(daemonId);
48
+ log.info(` daemon registered: id=${daemonId} (adopted orphan slot if id was reused)`);
49
+ if (claimed > 0) log.info(` first-boot migration: claimed ${claimed} previously-ownerless issue(s)`);
50
+
51
+ const engine = new Engine(config, store, trackers, { daemonId });
52
+ engine.startHeartbeat(config.work.heartbeatMs);
53
+ const server = createServer(config, store, engine, trackers);
54
+
55
+ async function shutdown(signal: string) {
56
+ log.info(`\n${signal} received, shutting down...`);
57
+ engine.destroy();
58
+ // Best-effort: mark this daemon drained so peers don't wait for lease expiry.
59
+ try { await store.markDaemonStatus(daemonId, "drained"); } catch { /* best-effort */ }
60
+ await store.close();
61
+ process.exit(0);
62
+ }
42
63
 
43
- process.on("SIGTERM", () => shutdown("SIGTERM"));
44
- process.on("SIGINT", () => shutdown("SIGINT"));
64
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
65
+ process.on("SIGINT", () => shutdown("SIGINT"));
66
+
67
+ const activeCount = (await store.listActiveIssues()).length;
68
+ log.info(`\n${isTest ? "🧪" : "✅"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
69
+ log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
70
+ log.info(` Active issues: ${activeCount}`);
71
+ }
45
72
 
46
- log.info(`\n${isTest ? "🧪" : "✅"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
47
- log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
48
- log.info(` Active issues: ${store.listActiveIssues().length}`);
73
+ void boot().catch((err) => {
74
+ log.error("boot failed:", err);
75
+ process.exit(1);
76
+ });