ework-daemon 0.1.2 → 0.2.0

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/src/op.ts CHANGED
@@ -1,8 +1,5 @@
1
- import { Database } from "bun:sqlite";
2
- import { mkdirSync, existsSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
5
1
  import { log } from "./logger";
2
+ import { getDB } from "./db";
6
3
  import type { TrackerRef, Issue, IssueState, OpSession, SessionState, Message } from "./trackers/types";
7
4
 
8
5
  // ─── Row Types ───
@@ -17,6 +14,7 @@ interface IssueRow {
17
14
  title: string;
18
15
  created_at: string;
19
16
  updated_at: string;
17
+ owner_daemon_id: number | null;
20
18
  }
21
19
 
22
20
  interface SessionRow {
@@ -32,6 +30,10 @@ interface SessionRow {
32
30
  progress_comment_id: string | null;
33
31
  reaction_comment_id: string | null;
34
32
  current_prompt: string | null;
33
+ last_output_at: string | null;
34
+ nudge_rounds: number;
35
+ stuck_nudge_rounds: number;
36
+ generation: number;
35
37
  }
36
38
 
37
39
  interface MessageRow {
@@ -62,6 +64,7 @@ function rowToIssue(row: IssueRow): Issue {
62
64
  title: row.title,
63
65
  createdAt: new Date(row.created_at),
64
66
  updatedAt: new Date(row.updated_at),
67
+ ownerDaemonId: row.owner_daemon_id ?? null,
65
68
  };
66
69
  }
67
70
 
@@ -79,6 +82,10 @@ function rowToSession(row: SessionRow): OpSession {
79
82
  progressCommentId: row.progress_comment_id ?? undefined,
80
83
  reactionCommentId: row.reaction_comment_id ?? undefined,
81
84
  currentPrompt: row.current_prompt ?? undefined,
85
+ lastOutputAt: row.last_output_at ? new Date(row.last_output_at).getTime() : undefined,
86
+ nudgeRounds: row.nudge_rounds ?? 0,
87
+ stuckNudgeRounds: row.stuck_nudge_rounds ?? 0,
88
+ generation: row.generation ?? 0,
82
89
  };
83
90
  }
84
91
 
@@ -105,233 +112,36 @@ export function sessionToTrackerRef(session: OpSession, issue: Issue): TrackerRe
105
112
  };
106
113
  }
107
114
 
108
- // ─── Store (SQLite DAO) ───
115
+ // ─── Store (async DAO over the global AsyncDatabase from db.ts) ───
109
116
 
110
117
  export class Store {
111
- private db: Database;
112
-
113
- private issueStmts!: {
114
- getById: ReturnType<Database["prepare"]>;
115
- getByTrackerRef: ReturnType<Database["prepare"]>;
116
- insert: ReturnType<Database["prepare"]>;
117
- updateState: ReturnType<Database["prepare"]>;
118
- updateTitle: ReturnType<Database["prepare"]>;
119
- listActive: ReturnType<Database["prepare"]>;
120
- listAll: ReturnType<Database["prepare"]>;
121
- };
122
-
123
- private sessionStmts!: {
124
- getById: ReturnType<Database["prepare"]>;
125
- getByName: ReturnType<Database["prepare"]>;
126
- getByIssue: ReturnType<Database["prepare"]>;
127
- insert: ReturnType<Database["prepare"]>;
128
- update: ReturnType<Database["prepare"]>;
129
- listAll: ReturnType<Database["prepare"]>;
130
- listNonIdle: ReturnType<Database["prepare"]>;
131
- };
132
-
133
- private msgStmts!: {
134
- getById: ReturnType<Database["prepare"]>;
135
- insert: ReturnType<Database["prepare"]>;
136
- getNextPending: ReturnType<Database["prepare"]>;
137
- updateStatus: ReturnType<Database["prepare"]>;
138
- updateStatusSelect: ReturnType<Database["prepare"]>;
139
- getBySession: ReturnType<Database["prepare"]>;
140
- getPendingOrRunning: ReturnType<Database["prepare"]>;
141
- findByCommentId: ReturnType<Database["prepare"]>;
142
- getRecentBySession: ReturnType<Database["prepare"]>;
143
- };
144
-
145
- constructor(dbPath?: string) {
146
- const resolved = dbPath ?? join(
147
- process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
148
- "ework-daemon",
149
- "ework-daemon.db"
150
- );
151
- const dir = join(resolved, "..");
152
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
153
-
154
- this.db = new Database(resolved, { create: true });
155
- this.db.exec("PRAGMA journal_mode = WAL");
156
- this.db.exec("PRAGMA foreign_keys = ON");
157
-
158
- this.initSchema();
159
- this.migrateOldSchema();
160
- this.prepareStatements();
161
- }
162
-
163
- private initSchema() {
164
- // Drop old tables with incompatible schemas
165
- const oldMessages = this.db.prepare(
166
- "SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'"
167
- ).get() as { sql: string } | null;
168
- if (oldMessages && oldMessages.sql.includes('op_id')) {
169
- log.info("store: dropping old messages table (incompatible schema)");
170
- this.db.exec("DROP TABLE messages");
171
- }
172
- const oldSessions = this.db.prepare(
173
- "SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'"
174
- ).get() as { sql: string } | null;
175
- if (oldSessions) {
176
- log.info("store: dropping old sessions table");
177
- this.db.exec("DROP TABLE IF EXISTS sessions");
178
- }
179
-
180
- this.db.exec(`
181
- CREATE TABLE IF NOT EXISTS issues (
182
- id TEXT PRIMARY KEY,
183
- tracker_type TEXT NOT NULL,
184
- tracker_scope_key TEXT NOT NULL,
185
- tracker_scope TEXT NOT NULL,
186
- tracker_issue_id TEXT NOT NULL,
187
- state TEXT NOT NULL DEFAULT 'created',
188
- title TEXT NOT NULL DEFAULT '',
189
- created_at TEXT NOT NULL,
190
- updated_at TEXT NOT NULL,
191
- UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)
192
- )
193
- `);
194
-
195
- this.db.exec(`
196
- CREATE TABLE IF NOT EXISTS op_sessions (
197
- id TEXT PRIMARY KEY,
198
- issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
199
- name TEXT NOT NULL,
200
- state TEXT NOT NULL DEFAULT 'idle',
201
- opencode_session_id TEXT,
202
- opencode_pid INTEGER,
203
- workdir TEXT,
204
- created_at TEXT NOT NULL,
205
- started_at INTEGER,
206
- progress_comment_id TEXT,
207
- reaction_comment_id TEXT,
208
- current_prompt TEXT,
209
- UNIQUE(issue_id, name)
210
- )
211
- `);
212
-
213
- this.db.exec(`
214
- CREATE TABLE IF NOT EXISTS messages (
215
- id TEXT PRIMARY KEY,
216
- session_id TEXT NOT NULL REFERENCES op_sessions(id) ON DELETE CASCADE,
217
- content TEXT NOT NULL,
218
- source_comment_id TEXT,
219
- reaction_comment_id TEXT,
220
- status TEXT NOT NULL DEFAULT 'pending',
221
- attempts INTEGER NOT NULL DEFAULT 0,
222
- error TEXT,
223
- created_at TEXT NOT NULL,
224
- updated_at TEXT NOT NULL
225
- )
226
- `);
227
-
228
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_issue ON op_sessions(issue_id)");
229
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)");
230
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status)");
231
- }
232
-
233
- private migrateOldSchema() {
234
- const hasOldOps = this.db.prepare(
235
- "SELECT name FROM sqlite_master WHERE type='table' AND name='ops'"
236
- ).get();
237
- if (!hasOldOps) return;
238
-
239
- log.info("store: migrating old ops table to issues + op_sessions");
240
-
241
- this.db.exec(`
242
- INSERT OR IGNORE INTO issues (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at)
243
- SELECT
244
- lower(hex(randomblob(4))),
245
- tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id,
246
- CASE WHEN status = 'closed' THEN 'closed' ELSE 'active' END,
247
- '',
248
- created_at, last_activity_at
249
- FROM ops
250
- WHERE tracker_type IS NOT NULL
251
- `);
252
-
253
- this.db.exec(`DROP TABLE IF EXISTS ops`);
254
- this.db.exec(`DROP TABLE IF EXISTS sessions`);
255
- }
256
-
257
- private prepareStatements() {
258
- this.issueStmts = {
259
- getById: this.db.prepare("SELECT * FROM issues WHERE id = ?"),
260
- getByTrackerRef: this.db.prepare(
261
- "SELECT * FROM issues WHERE tracker_type = ? AND tracker_scope_key = ? AND tracker_issue_id = ?"
262
- ),
263
- insert: this.db.prepare(
264
- "INSERT OR IGNORE INTO issues (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
265
- ),
266
- updateState: this.db.prepare("UPDATE issues SET state = ?, updated_at = ? WHERE id = ?"),
267
- updateTitle: this.db.prepare("UPDATE issues SET title = ?, updated_at = ? WHERE id = ?"),
268
- listActive: this.db.prepare("SELECT * FROM issues WHERE state != 'closed'"),
269
- listAll: this.db.prepare("SELECT * FROM issues"),
270
- };
271
-
272
- this.sessionStmts = {
273
- getById: this.db.prepare("SELECT * FROM op_sessions WHERE id = ?"),
274
- getByName: this.db.prepare(
275
- "SELECT * FROM op_sessions WHERE issue_id = ? AND name = ?"
276
- ),
277
- getByIssue: this.db.prepare(
278
- "SELECT * FROM op_sessions WHERE issue_id = ? ORDER BY created_at"
279
- ),
280
- insert: this.db.prepare(
281
- "INSERT OR IGNORE INTO op_sessions (id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
282
- ),
283
- update: this.db.prepare(
284
- `UPDATE op_sessions SET state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
285
- started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ? WHERE id = ?`
286
- ),
287
- listAll: this.db.prepare("SELECT * FROM op_sessions"),
288
- listNonIdle: this.db.prepare("SELECT * FROM op_sessions WHERE state != 'idle'"),
289
- };
290
-
291
- this.msgStmts = {
292
- getById: this.db.prepare("SELECT * FROM messages WHERE id = ?"),
293
- insert: this.db.prepare(
294
- "INSERT OR IGNORE INTO messages (id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
295
- ),
296
- getNextPending: this.db.prepare(
297
- "SELECT * FROM messages WHERE session_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT 1"
298
- ),
299
- updateStatus: this.db.prepare(
300
- "UPDATE messages SET status = ?, attempts = ?, error = ?, updated_at = ? WHERE id = ?"
301
- ),
302
- updateStatusSelect: this.db.prepare("SELECT * FROM messages WHERE id = ?"),
303
- getBySession: this.db.prepare(
304
- "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC"
305
- ),
306
- getPendingOrRunning: this.db.prepare(
307
- "SELECT * FROM messages WHERE status IN ('pending', 'running') ORDER BY created_at ASC"
308
- ),
309
- findByCommentId: this.db.prepare(
310
- "SELECT * FROM messages WHERE source_comment_id = ?"
311
- ),
312
- getRecentBySession: this.db.prepare(
313
- "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?"
314
- ),
315
- };
316
- }
118
+ // No constructor work: the DB is opened globally by initDB() at boot.
119
+ // Tests rely on tests/setup.ts to call initDB() in beforeAll.
317
120
 
318
121
  // ─── Issues ───
319
122
 
320
- getIssue(id: string): Issue | undefined {
321
- const row = this.issueStmts.getById.get(id) as IssueRow | null;
123
+ async getIssue(id: string): Promise<Issue | undefined> {
124
+ const row = await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE id = ?", [id]);
322
125
  return row ? rowToIssue(row) : undefined;
323
126
  }
324
127
 
325
- findIssue(trackerType: string, scopeKey: string, issueId: string): Issue | undefined {
326
- const row = this.issueStmts.getByTrackerRef.get(trackerType, scopeKey, issueId) as IssueRow | null;
128
+ async findIssue(trackerType: string, scopeKey: string, issueId: string): Promise<Issue | undefined> {
129
+ const row = await getDB().get<IssueRow>(
130
+ "SELECT * FROM {{issues}} WHERE tracker_type = ? AND tracker_scope_key = ? AND tracker_issue_id = ?",
131
+ [trackerType, scopeKey, issueId]
132
+ );
327
133
  return row ? rowToIssue(row) : undefined;
328
134
  }
329
135
 
330
- findOrCreateIssue(ref: TrackerRef, scopeKey: string, title: string): Issue {
331
- const existing = this.findIssue(ref.trackerType, scopeKey, ref.issueId);
136
+ async findOrCreateIssue(ref: TrackerRef, scopeKey: string, title: string): Promise<Issue> {
137
+ const existing = await this.findIssue(ref.trackerType, scopeKey, ref.issueId);
332
138
  if (existing) {
333
139
  if (title && existing.title !== title) {
334
- this.issueStmts.updateTitle.run(title, new Date().toISOString(), existing.id);
140
+ await getDB().run("UPDATE {{issues}} SET title = ?, updated_at = ? WHERE id = ?", [
141
+ title,
142
+ new Date().toISOString(),
143
+ existing.id,
144
+ ]);
335
145
  existing.title = title;
336
146
  }
337
147
  return existing;
@@ -339,14 +149,13 @@ export class Store {
339
149
 
340
150
  const now = new Date();
341
151
  const id = crypto.randomUUID();
342
- this.issueStmts.insert.run(
343
- id, ref.trackerType, scopeKey,
344
- JSON.stringify(ref.scope), ref.issueId,
345
- "created", title, now.toISOString(), now.toISOString()
152
+ await getDB().run(
153
+ "INSERT OR IGNORE INTO {{issues}} (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
154
+ [id, ref.trackerType, scopeKey, JSON.stringify(ref.scope), ref.issueId, "created", title, now.toISOString(), now.toISOString()]
346
155
  );
347
156
 
348
157
  // Re-read in case INSERT OR IGNORE hit a concurrent insert
349
- const inserted = this.findIssue(ref.trackerType, scopeKey, ref.issueId);
158
+ const inserted = await this.findIssue(ref.trackerType, scopeKey, ref.issueId);
350
159
  if (inserted) return inserted;
351
160
 
352
161
  return {
@@ -356,36 +165,49 @@ export class Store {
356
165
  };
357
166
  }
358
167
 
359
- updateIssueState(id: string, state: IssueState) {
360
- this.issueStmts.updateState.run(state, new Date().toISOString(), id);
168
+ async updateIssueState(id: string, state: IssueState): Promise<void> {
169
+ await getDB().run("UPDATE {{issues}} SET state = ?, updated_at = ? WHERE id = ?", [
170
+ state,
171
+ new Date().toISOString(),
172
+ id,
173
+ ]);
361
174
  }
362
175
 
363
- listActiveIssues(): Issue[] {
364
- return (this.issueStmts.listActive.all() as IssueRow[]).map(rowToIssue);
176
+ async listActiveIssues(): Promise<Issue[]> {
177
+ const rows = await getDB().all<IssueRow>("SELECT * FROM {{issues}} WHERE state != 'closed'");
178
+ return rows.map(rowToIssue);
365
179
  }
366
180
 
367
- listAllIssues(): Issue[] {
368
- return (this.issueStmts.listAll.all() as IssueRow[]).map(rowToIssue);
181
+ async listAllIssues(): Promise<Issue[]> {
182
+ const rows = await getDB().all<IssueRow>("SELECT * FROM {{issues}}");
183
+ return rows.map(rowToIssue);
369
184
  }
370
185
 
371
186
  // ─── OpSessions ───
372
187
 
373
- getSession(id: string): OpSession | undefined {
374
- const row = this.sessionStmts.getById.get(id) as SessionRow | null;
188
+ async getSession(id: string): Promise<OpSession | undefined> {
189
+ const row = await getDB().get<SessionRow>("SELECT * FROM {{op_sessions}} WHERE id = ?", [id]);
375
190
  return row ? rowToSession(row) : undefined;
376
191
  }
377
192
 
378
- getSessionByName(issueId: string, name: string): OpSession | undefined {
379
- const row = this.sessionStmts.getByName.get(issueId, name) as SessionRow | null;
193
+ async getSessionByName(issueId: string, name: string): Promise<OpSession | undefined> {
194
+ const row = await getDB().get<SessionRow>(
195
+ "SELECT * FROM {{op_sessions}} WHERE issue_id = ? AND name = ?",
196
+ [issueId, name]
197
+ );
380
198
  return row ? rowToSession(row) : undefined;
381
199
  }
382
200
 
383
- getSessionsForIssue(issueId: string): OpSession[] {
384
- return (this.sessionStmts.getByIssue.all(issueId) as SessionRow[]).map(rowToSession);
201
+ async getSessionsForIssue(issueId: string): Promise<OpSession[]> {
202
+ const rows = await getDB().all<SessionRow>(
203
+ "SELECT * FROM {{op_sessions}} WHERE issue_id = ? ORDER BY created_at",
204
+ [issueId]
205
+ );
206
+ return rows.map(rowToSession);
385
207
  }
386
208
 
387
- createSession(issueId: string, name: string): OpSession {
388
- const existing = this.getSessionByName(issueId, name);
209
+ async createSession(issueId: string, name: string): Promise<OpSession> {
210
+ const existing = await this.getSessionByName(issueId, name);
389
211
  if (existing) return existing;
390
212
 
391
213
  const session: OpSession = {
@@ -395,50 +217,66 @@ export class Store {
395
217
  state: "idle",
396
218
  createdAt: new Date(),
397
219
  };
398
- this.sessionStmts.insert.run(
399
- session.id, session.issueId, session.name, session.state,
400
- null, null, null, session.createdAt.toISOString()
220
+ await getDB().run(
221
+ "INSERT OR IGNORE INTO {{op_sessions}} (id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
222
+ [session.id, session.issueId, session.name, session.state, null, null, null, session.createdAt.toISOString()]
401
223
  );
402
224
  return session;
403
225
  }
404
226
 
405
- updateSession(id: string, patch: Partial<OpSession>): OpSession | undefined {
406
- const row = this.sessionStmts.getById.get(id) as SessionRow | null;
227
+ async updateSession(id: string, patch: Partial<OpSession>): Promise<OpSession | undefined> {
228
+ const row = await getDB().get<SessionRow>("SELECT * FROM {{op_sessions}} WHERE id = ?", [id]);
407
229
  if (!row) return undefined;
408
230
  const existing = rowToSession(row);
409
231
  const updated = { ...existing, ...patch };
410
232
 
411
- this.sessionStmts.update.run(
412
- updated.state,
413
- updated.opencodeSessionId ?? null,
414
- updated.opencodePid ?? null,
415
- updated.workdir ?? null,
416
- updated.startedAt ?? null,
417
- updated.progressCommentId ?? null,
418
- updated.reactionCommentId ?? null,
419
- updated.currentPrompt ?? null,
420
- id
233
+ await getDB().run(
234
+ `UPDATE {{op_sessions}} SET state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
235
+ started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ?,
236
+ last_output_at = ?, nudge_rounds = ?, stuck_nudge_rounds = ?, generation = ? WHERE id = ?`,
237
+ [
238
+ updated.state,
239
+ updated.opencodeSessionId ?? null,
240
+ updated.opencodePid ?? null,
241
+ updated.workdir ?? null,
242
+ updated.startedAt ?? null,
243
+ updated.progressCommentId ?? null,
244
+ updated.reactionCommentId ?? null,
245
+ updated.currentPrompt ?? null,
246
+ updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null,
247
+ updated.nudgeRounds ?? 0,
248
+ updated.stuckNudgeRounds ?? 0,
249
+ updated.generation ?? 0,
250
+ id,
251
+ ]
421
252
  );
422
253
  return updated;
423
254
  }
424
255
 
425
- listAllSessions(): OpSession[] {
426
- return (this.sessionStmts.listAll.all() as SessionRow[]).map(rowToSession);
256
+ async listAllSessions(): Promise<OpSession[]> {
257
+ const rows = await getDB().all<SessionRow>("SELECT * FROM {{op_sessions}}");
258
+ return rows.map(rowToSession);
427
259
  }
428
260
 
429
- listNonIdleSessions(): OpSession[] {
430
- return (this.sessionStmts.listNonIdle.all() as SessionRow[]).map(rowToSession);
261
+ async listNonIdleSessions(): Promise<OpSession[]> {
262
+ const rows = await getDB().all<SessionRow>("SELECT * FROM {{op_sessions}} WHERE state != 'idle'");
263
+ return rows.map(rowToSession);
431
264
  }
432
265
 
433
266
  // ─── Messages ───
434
267
 
435
- createMessage(sessionId: string, content: string, sourceCommentId?: string, reactionCommentId?: string, model?: string): Message {
268
+ async createMessage(
269
+ sessionId: string,
270
+ content: string,
271
+ sourceCommentId?: string,
272
+ reactionCommentId?: string,
273
+ model?: string,
274
+ ): Promise<Message> {
436
275
  const now = new Date().toISOString();
437
276
  const id = crypto.randomUUID();
438
- this.msgStmts.insert.run(
439
- id, sessionId, content,
440
- sourceCommentId ?? null, reactionCommentId ?? null,
441
- "pending", 0, null, now, now
277
+ await getDB().run(
278
+ "INSERT OR IGNORE INTO {{messages}} (id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
279
+ [id, sessionId, content, sourceCommentId ?? null, reactionCommentId ?? null, "pending", 0, null, now, now]
442
280
  );
443
281
  return {
444
282
  id, sessionId, content, sourceCommentId, reactionCommentId,
@@ -448,40 +286,191 @@ export class Store {
448
286
  };
449
287
  }
450
288
 
451
- getMessage(id: string): Message | undefined {
452
- const row = this.msgStmts.getById.get(id) as MessageRow | null;
289
+ async getMessage(id: string): Promise<Message | undefined> {
290
+ const row = await getDB().get<MessageRow>("SELECT * FROM {{messages}} WHERE id = ?", [id]);
453
291
  return row ? rowToMessage(row) : undefined;
454
292
  }
455
293
 
456
- getNextPendingMessage(sessionId: string): Message | undefined {
457
- const row = this.msgStmts.getNextPending.get(sessionId) as MessageRow | null;
294
+ async getNextPendingMessage(sessionId: string): Promise<Message | undefined> {
295
+ const row = await getDB().get<MessageRow>(
296
+ "SELECT * FROM {{messages}} WHERE session_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
297
+ [sessionId]
298
+ );
458
299
  return row ? rowToMessage(row) : undefined;
459
300
  }
460
301
 
461
- updateMessageStatus(id: string, status: Message["status"], error?: string) {
462
- const row = this.msgStmts.updateStatusSelect.get(id) as MessageRow | null;
302
+ async updateMessageStatus(id: string, status: Message["status"], error?: string): Promise<void> {
303
+ const row = await getDB().get<MessageRow>("SELECT * FROM {{messages}} WHERE id = ?", [id]);
463
304
  const attempts = row ? row.attempts + (status === "failed" ? 1 : 0) : 0;
464
- this.msgStmts.updateStatus.run(status, attempts, error ?? null, new Date().toISOString(), id);
305
+ await getDB().run(
306
+ "UPDATE {{messages}} SET status = ?, attempts = ?, error = ?, updated_at = ? WHERE id = ?",
307
+ [status, attempts, error ?? null, new Date().toISOString(), id]
308
+ );
465
309
  }
466
310
 
467
- getMessagesForSession(sessionId: string): Message[] {
468
- return (this.msgStmts.getBySession.all(sessionId) as MessageRow[]).map(rowToMessage);
311
+ async getMessagesForSession(sessionId: string): Promise<Message[]> {
312
+ const rows = await getDB().all<MessageRow>(
313
+ "SELECT * FROM {{messages}} WHERE session_id = ? ORDER BY created_at DESC",
314
+ [sessionId]
315
+ );
316
+ return rows.map(rowToMessage);
469
317
  }
470
318
 
471
- getRecentMessages(sessionId: string, limit: number): Message[] {
472
- return (this.msgStmts.getRecentBySession.all(sessionId, limit) as MessageRow[]).map(rowToMessage);
319
+ async getRecentMessages(sessionId: string, limit: number): Promise<Message[]> {
320
+ const rows = await getDB().all<MessageRow>(
321
+ "SELECT * FROM {{messages}} WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
322
+ [sessionId, limit]
323
+ );
324
+ return rows.map(rowToMessage);
473
325
  }
474
326
 
475
- getPendingOrRunningMessages(): Message[] {
476
- return (this.msgStmts.getPendingOrRunning.all() as MessageRow[]).map(rowToMessage);
327
+ async getPendingOrRunningMessages(): Promise<Message[]> {
328
+ const rows = await getDB().all<MessageRow>(
329
+ "SELECT * FROM {{messages}} WHERE status IN ('pending', 'running') ORDER BY created_at ASC"
330
+ );
331
+ return rows.map(rowToMessage);
477
332
  }
478
333
 
479
- findMessageByCommentId(commentId: string): Message | undefined {
480
- const row = this.msgStmts.findByCommentId.get(commentId) as MessageRow | null;
334
+ async findMessageByCommentId(commentId: string): Promise<Message | undefined> {
335
+ const row = await getDB().get<MessageRow>(
336
+ "SELECT * FROM {{messages}} WHERE source_comment_id = ?",
337
+ [commentId]
338
+ );
481
339
  return row ? rowToMessage(row) : undefined;
482
340
  }
483
341
 
484
- close() {
485
- this.db.close();
342
+ // ─── Multi-machine coordination (Phase 1) ───
343
+ //
344
+ // daemon_id is a DB-allocated logical slot. A restarted daemon ADOPTS the
345
+ // oldest orphan slot (last_heartbeat older than the lease TTL) instead of
346
+ // inserting a new row — so a daemon that crashes + restarts reclaims its
347
+ // previous id (and thus its owned issues) rather than leaving them stuck
348
+ // until releaseDeadOwners runs.
349
+
350
+ /** Register this daemon, adopting an orphan slot if available. */
351
+ async registerDaemon(
352
+ displayName: string,
353
+ endpoint: string,
354
+ capacity: number,
355
+ leaseTtlMs: number,
356
+ ): Promise<number> {
357
+ const db = getDB();
358
+ const cutoff = new Date(Date.now() - leaseTtlMs).toISOString();
359
+
360
+ const orphan = await db.get<{ id: number }>(
361
+ "SELECT id FROM {{daemons}} WHERE last_heartbeat < ? ORDER BY last_heartbeat LIMIT 1",
362
+ [cutoff]
363
+ );
364
+ if (orphan) {
365
+ const now = new Date().toISOString();
366
+ const res = await db.run(
367
+ "UPDATE {{daemons}} SET display_name = ?, internal_endpoint = ?, last_heartbeat = ?, status = 'active' WHERE id = ? AND last_heartbeat < ?",
368
+ [displayName, endpoint, now, orphan.id, cutoff]
369
+ );
370
+ if (res.changes === 1) return orphan.id;
371
+ }
372
+
373
+ const now = new Date().toISOString();
374
+ const ins = await db.run(
375
+ "INSERT INTO {{daemons}} (display_name, internal_endpoint, capacity, last_heartbeat, registered_at, status) VALUES (?, ?, ?, ?, ?, 'active')",
376
+ [displayName, endpoint, capacity, now, now]
377
+ );
378
+ return ins.insertId;
379
+ }
380
+
381
+ async heartbeat(daemonId: number): Promise<void> {
382
+ await getDB().run(
383
+ "UPDATE {{daemons}} SET last_heartbeat = ? WHERE id = ?",
384
+ [new Date().toISOString(), daemonId]
385
+ );
386
+ }
387
+
388
+ async markDaemonStatus(daemonId: number, status: "active" | "drained" | "dead"): Promise<void> {
389
+ await getDB().run(
390
+ "UPDATE {{daemons}} SET status = ? WHERE id = ?",
391
+ [status, daemonId]
392
+ );
393
+ }
394
+
395
+ /** Clear owner_daemon_id on issues whose daemon has missed the lease. */
396
+ async releaseDeadOwners(leaseTtlMs: number): Promise<number> {
397
+ const db = getDB();
398
+ const cutoff = new Date(Date.now() - leaseTtlMs).toISOString();
399
+ const res = await db.run(
400
+ "UPDATE {{issues}} SET owner_daemon_id = NULL WHERE owner_daemon_id IN (SELECT id FROM {{daemons}} WHERE last_heartbeat < ?)",
401
+ [cutoff]
402
+ );
403
+ return res.changes;
404
+ }
405
+
406
+ /**
407
+ * Atomic claim: affected_rows decides the winner. Returns true iff this
408
+ * daemon won the race. Re-claiming an issue you already own also returns
409
+ * false (the WHERE requires owner IS NULL) — call sites check ownership
410
+ * first when they need to handle the "already mine" case.
411
+ */
412
+ async claimIssue(issueId: string, daemonId: number): Promise<boolean> {
413
+ const res = await getDB().run(
414
+ "UPDATE {{issues}} SET owner_daemon_id = ? WHERE id = ? AND owner_daemon_id IS NULL",
415
+ [daemonId, issueId]
416
+ );
417
+ return res.changes === 1;
418
+ }
419
+
420
+ /** First-boot migration: claim all pre-existing ownerless issues. */
421
+ async claimAllOwnerless(daemonId: number): Promise<number> {
422
+ const res = await getDB().run(
423
+ "UPDATE {{issues}} SET owner_daemon_id = ? WHERE owner_daemon_id IS NULL",
424
+ [daemonId]
425
+ );
426
+ return res.changes;
427
+ }
428
+
429
+ /** Atomic message claim: pending → running. False = lost or already done. */
430
+ async claimMessage(messageId: string): Promise<boolean> {
431
+ const res = await getDB().run(
432
+ "UPDATE {{messages}} SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'",
433
+ [new Date().toISOString(), messageId]
434
+ );
435
+ return res.changes === 1;
436
+ }
437
+
438
+ async listOwnedIssues(daemonId: number): Promise<Issue[]> {
439
+ const rows = await getDB().all<IssueRow>(
440
+ "SELECT * FROM {{issues}} WHERE owner_daemon_id = ?",
441
+ [daemonId]
442
+ );
443
+ return rows.map(rowToIssue);
444
+ }
445
+
446
+ async listOwnedSessions(daemonId: number): Promise<OpSession[]> {
447
+ const rows = await getDB().all<SessionRow>(
448
+ `SELECT s.* FROM {{op_sessions}} s
449
+ INNER JOIN {{issues}} i ON i.id = s.issue_id
450
+ WHERE i.owner_daemon_id = ?
451
+ ORDER BY s.created_at`,
452
+ [daemonId]
453
+ );
454
+ return rows.map(rowToSession);
455
+ }
456
+
457
+ /** Scoped variant of getPendingOrRunningMessages: only this daemon's issues. */
458
+ async getOwnedPendingOrRunningMessages(daemonId: number): Promise<Message[]> {
459
+ const rows = await getDB().all<MessageRow>(
460
+ `SELECT m.* FROM {{messages}} m
461
+ INNER JOIN {{op_sessions}} s ON s.id = m.session_id
462
+ INNER JOIN {{issues}} i ON i.id = s.issue_id
463
+ WHERE i.owner_daemon_id = ? AND m.status IN ('pending', 'running')
464
+ ORDER BY m.created_at ASC`,
465
+ [daemonId]
466
+ );
467
+ return rows.map(rowToMessage);
468
+ }
469
+
470
+ async close(): Promise<void> {
471
+ // The DB singleton is owned by db.ts; callers close it via shutdown of the
472
+ // driver there. This method is retained for API compatibility (tests,
473
+ // index.ts shutdown) and is a no-op now.
474
+ log.debug("store.close() is a no-op; DB lifecycle is managed by db.ts");
486
475
  }
487
476
  }