@revealui/harnesses 0.1.7 → 0.1.9

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.
@@ -0,0 +1,651 @@
1
+ // src/storage/schema.ts
2
+ var SCHEMA_SQL = `
3
+ CREATE TABLE IF NOT EXISTS agent_sessions (
4
+ id TEXT PRIMARY KEY,
5
+ env TEXT NOT NULL DEFAULT '',
6
+ task TEXT NOT NULL DEFAULT '(starting)',
7
+ files TEXT NOT NULL DEFAULT '',
8
+ pid INTEGER,
9
+ started_at TIMESTAMP NOT NULL DEFAULT NOW(),
10
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
11
+ ended_at TIMESTAMP,
12
+ exit_summary TEXT
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS agent_messages (
16
+ id SERIAL PRIMARY KEY,
17
+ from_agent TEXT NOT NULL,
18
+ to_agent TEXT NOT NULL,
19
+ subject TEXT NOT NULL DEFAULT '',
20
+ body TEXT NOT NULL DEFAULT '',
21
+ read BOOLEAN NOT NULL DEFAULT FALSE,
22
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS idx_messages_to_unread
26
+ ON agent_messages (to_agent, read) WHERE read = FALSE;
27
+
28
+ CREATE TABLE IF NOT EXISTS file_reservations (
29
+ file_path TEXT PRIMARY KEY,
30
+ agent_id TEXT NOT NULL,
31
+ reserved_at TIMESTAMP NOT NULL DEFAULT NOW(),
32
+ expires_at TIMESTAMP NOT NULL,
33
+ reason TEXT NOT NULL DEFAULT ''
34
+ );
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_reservations_agent
37
+ ON file_reservations (agent_id);
38
+
39
+ CREATE TABLE IF NOT EXISTS tasks (
40
+ id TEXT PRIMARY KEY,
41
+ description TEXT NOT NULL DEFAULT '',
42
+ status TEXT NOT NULL DEFAULT 'open',
43
+ owner TEXT,
44
+ claimed_at TIMESTAMP,
45
+ completed_at TIMESTAMP,
46
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_tasks_status
50
+ ON tasks (status);
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_tasks_owner
53
+ ON tasks (owner) WHERE owner IS NOT NULL;
54
+
55
+ CREATE TABLE IF NOT EXISTS events (
56
+ id SERIAL PRIMARY KEY,
57
+ agent_id TEXT NOT NULL,
58
+ event_type TEXT NOT NULL,
59
+ payload JSONB NOT NULL DEFAULT '{}',
60
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_events_agent
64
+ ON events (agent_id, created_at DESC);
65
+
66
+ CREATE TABLE IF NOT EXISTS worktrees (
67
+ agent_id TEXT PRIMARY KEY,
68
+ branch TEXT NOT NULL,
69
+ worktree_path TEXT NOT NULL,
70
+ base_branch TEXT NOT NULL DEFAULT 'test',
71
+ status TEXT NOT NULL DEFAULT 'active',
72
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS agent_memory (
76
+ id SERIAL PRIMARY KEY,
77
+ agent_id TEXT NOT NULL,
78
+ memory_type TEXT NOT NULL,
79
+ content TEXT NOT NULL,
80
+ metadata JSONB NOT NULL DEFAULT '{}',
81
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_memory_agent_type
85
+ ON agent_memory (agent_id, memory_type, created_at DESC);
86
+
87
+ CREATE TABLE IF NOT EXISTS merge_requests (
88
+ id TEXT PRIMARY KEY,
89
+ agent_id TEXT NOT NULL,
90
+ task_id TEXT,
91
+ source_branch TEXT NOT NULL,
92
+ base_branch TEXT NOT NULL DEFAULT 'test',
93
+ status TEXT NOT NULL DEFAULT 'pending',
94
+ pr_number INTEGER,
95
+ pr_url TEXT,
96
+ retry_count INTEGER NOT NULL DEFAULT 0,
97
+ error_message TEXT,
98
+ ci_output TEXT,
99
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
100
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
101
+ );
102
+
103
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_agent
104
+ ON merge_requests (agent_id, status);
105
+
106
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_branch
107
+ ON merge_requests (source_branch);
108
+
109
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_pr
110
+ ON merge_requests (pr_number) WHERE pr_number IS NOT NULL;
111
+ `;
112
+
113
+ // src/storage/daemon-store.ts
114
+ var DaemonStore = class {
115
+ db = null;
116
+ dataDir;
117
+ constructor(config) {
118
+ this.dataDir = config.dataDir;
119
+ }
120
+ /** Initialize PGlite and create tables. */
121
+ async init() {
122
+ const { PGlite: PGliteClass } = await import("@electric-sql/pglite");
123
+ this.db = new PGliteClass(this.dataDir);
124
+ await this.db.exec(SCHEMA_SQL);
125
+ }
126
+ /** Shut down the database. */
127
+ async close() {
128
+ if (this.db) {
129
+ await this.db.close();
130
+ this.db = null;
131
+ }
132
+ }
133
+ getDb() {
134
+ if (!this.db) throw new Error("DaemonStore not initialized - call init() first");
135
+ return this.db;
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Sessions
139
+ // ---------------------------------------------------------------------------
140
+ /** Register or update an agent session. */
141
+ async registerSession(session) {
142
+ const db = this.getDb();
143
+ const result = await db.query(
144
+ `INSERT INTO agent_sessions (id, env, task, pid)
145
+ VALUES ($1, $2, $3, $4)
146
+ ON CONFLICT (id) DO UPDATE SET
147
+ env = EXCLUDED.env,
148
+ task = COALESCE(EXCLUDED.task, agent_sessions.task),
149
+ pid = COALESCE(EXCLUDED.pid, agent_sessions.pid),
150
+ updated_at = NOW(),
151
+ ended_at = NULL
152
+ RETURNING *`,
153
+ [session.id, session.env, session.task ?? "(starting)", session.pid ?? null]
154
+ );
155
+ return result.rows[0];
156
+ }
157
+ /** Update a session's task and/or files. */
158
+ async updateSession(id, updates) {
159
+ const db = this.getDb();
160
+ const sets = ["updated_at = NOW()"];
161
+ const params = [];
162
+ let paramIdx = 1;
163
+ if (updates.task !== void 0) {
164
+ sets.push(`task = $${paramIdx++}`);
165
+ params.push(updates.task);
166
+ }
167
+ if (updates.files !== void 0) {
168
+ sets.push(`files = $${paramIdx++}`);
169
+ params.push(updates.files);
170
+ }
171
+ params.push(id);
172
+ const result = await db.query(
173
+ `UPDATE agent_sessions SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
174
+ params
175
+ );
176
+ return result.rows[0] ?? null;
177
+ }
178
+ /** End a session (mark ended_at, record exit summary). */
179
+ async endSession(id, exitSummary) {
180
+ const db = this.getDb();
181
+ await db.query(
182
+ `UPDATE agent_sessions SET ended_at = NOW(), exit_summary = $2, updated_at = NOW()
183
+ WHERE id = $1`,
184
+ [id, exitSummary ?? null]
185
+ );
186
+ }
187
+ /** Get all active sessions (ended_at IS NULL). */
188
+ async getActiveSessions() {
189
+ const db = this.getDb();
190
+ const result = await db.query(
191
+ "SELECT * FROM agent_sessions WHERE ended_at IS NULL ORDER BY started_at"
192
+ );
193
+ return result.rows;
194
+ }
195
+ /** Get session history for an agent (most recent first). */
196
+ async getSessionHistory(agentId, limit) {
197
+ const db = this.getDb();
198
+ const result = await db.query(
199
+ "SELECT * FROM agent_sessions WHERE id = $1 ORDER BY started_at DESC LIMIT $2",
200
+ [agentId, limit]
201
+ );
202
+ return result.rows;
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Messages
206
+ // ---------------------------------------------------------------------------
207
+ /** Send a message from one agent to another. */
208
+ async sendMessage(msg) {
209
+ const db = this.getDb();
210
+ const result = await db.query(
211
+ `INSERT INTO agent_messages (from_agent, to_agent, subject, body)
212
+ VALUES ($1, $2, $3, $4)
213
+ RETURNING *`,
214
+ [msg.fromAgent, msg.toAgent, msg.subject, msg.body ?? ""]
215
+ );
216
+ return result.rows[0];
217
+ }
218
+ /** Broadcast a message to all active agents (except sender). */
219
+ async broadcastMessage(msg) {
220
+ const active = await this.getActiveSessions();
221
+ let sent = 0;
222
+ for (const session of active) {
223
+ if (session.id === msg.fromAgent) continue;
224
+ await this.sendMessage({
225
+ fromAgent: msg.fromAgent,
226
+ toAgent: session.id,
227
+ subject: msg.subject,
228
+ body: msg.body
229
+ });
230
+ sent++;
231
+ }
232
+ return sent;
233
+ }
234
+ /** Get unread messages for an agent. */
235
+ async getInbox(agentId, unreadOnly) {
236
+ const db = this.getDb();
237
+ const whereClause = unreadOnly ? "WHERE to_agent = $1 AND read = FALSE" : "WHERE to_agent = $1";
238
+ const result = await db.query(
239
+ `SELECT * FROM agent_messages ${whereClause} ORDER BY created_at DESC LIMIT 50`,
240
+ [agentId]
241
+ );
242
+ return result.rows;
243
+ }
244
+ /** Mark messages as read. */
245
+ async markRead(messageIds) {
246
+ if (messageIds.length === 0) return;
247
+ const db = this.getDb();
248
+ const placeholders = messageIds.map((_, i) => `$${i + 1}`).join(", ");
249
+ await db.query(
250
+ `UPDATE agent_messages SET read = TRUE WHERE id IN (${placeholders})`,
251
+ messageIds
252
+ );
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // File Reservations
256
+ // ---------------------------------------------------------------------------
257
+ /** Reserve a file for an agent (CAS: fails if already reserved by another). */
258
+ async reserveFile(reservation) {
259
+ const db = this.getDb();
260
+ await db.query("DELETE FROM file_reservations WHERE expires_at < NOW()");
261
+ const result = await db.query(
262
+ `INSERT INTO file_reservations (file_path, agent_id, expires_at, reason)
263
+ VALUES ($1, $2, NOW() + ($3 || ' seconds')::INTERVAL, $4)
264
+ ON CONFLICT (file_path) DO UPDATE SET
265
+ agent_id = EXCLUDED.agent_id,
266
+ reserved_at = NOW(),
267
+ expires_at = EXCLUDED.expires_at,
268
+ reason = EXCLUDED.reason
269
+ WHERE file_reservations.agent_id = $2 OR file_reservations.expires_at < NOW()
270
+ RETURNING *`,
271
+ [
272
+ reservation.filePath,
273
+ reservation.agentId,
274
+ String(reservation.ttlSeconds),
275
+ reservation.reason ?? ""
276
+ ]
277
+ );
278
+ if (result.rows.length > 0) {
279
+ return { success: true };
280
+ }
281
+ const existing = await db.query(
282
+ "SELECT agent_id FROM file_reservations WHERE file_path = $1",
283
+ [reservation.filePath]
284
+ );
285
+ return { success: false, holder: existing.rows[0]?.agent_id };
286
+ }
287
+ /** Check who holds a file reservation. */
288
+ async checkReservation(filePath) {
289
+ const db = this.getDb();
290
+ await db.query("DELETE FROM file_reservations WHERE expires_at < NOW()");
291
+ const result = await db.query(
292
+ "SELECT * FROM file_reservations WHERE file_path = $1",
293
+ [filePath]
294
+ );
295
+ return result.rows[0] ?? null;
296
+ }
297
+ /** Release all reservations held by an agent. */
298
+ async releaseAllReservations(agentId) {
299
+ const db = this.getDb();
300
+ const result = await db.query("DELETE FROM file_reservations WHERE agent_id = $1", [agentId]);
301
+ return result.affectedRows ?? 0;
302
+ }
303
+ /** Get all reservations for an agent. */
304
+ async getReservations(agentId) {
305
+ const db = this.getDb();
306
+ await db.query("DELETE FROM file_reservations WHERE expires_at < NOW()");
307
+ const result = await db.query(
308
+ "SELECT * FROM file_reservations WHERE agent_id = $1 ORDER BY reserved_at",
309
+ [agentId]
310
+ );
311
+ return result.rows;
312
+ }
313
+ // ---------------------------------------------------------------------------
314
+ // Tasks
315
+ // ---------------------------------------------------------------------------
316
+ /** Create a new task. */
317
+ async createTask(task) {
318
+ const db = this.getDb();
319
+ const result = await db.query(
320
+ `INSERT INTO tasks (id, description) VALUES ($1, $2)
321
+ ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description
322
+ RETURNING *`,
323
+ [task.id, task.description]
324
+ );
325
+ return result.rows[0];
326
+ }
327
+ /** Claim a task atomically (CAS: fails if already claimed by another agent). */
328
+ async claimTask(taskId, agentId) {
329
+ const db = this.getDb();
330
+ const result = await db.query(
331
+ `UPDATE tasks SET status = 'claimed', owner = $2, claimed_at = NOW()
332
+ WHERE id = $1 AND (status = 'open' OR owner = $2)
333
+ RETURNING *`,
334
+ [taskId, agentId]
335
+ );
336
+ if (result.rows.length > 0) {
337
+ return { success: true };
338
+ }
339
+ const existing = await db.query("SELECT owner FROM tasks WHERE id = $1", [taskId]);
340
+ if (existing.rows.length === 0) {
341
+ return { success: false, owner: void 0 };
342
+ }
343
+ return { success: false, owner: existing.rows[0].owner ?? void 0 };
344
+ }
345
+ /** Complete a task (only the owner can complete it). */
346
+ async completeTask(taskId, agentId) {
347
+ const db = this.getDb();
348
+ const result = await db.query(
349
+ `UPDATE tasks SET status = 'completed', completed_at = NOW()
350
+ WHERE id = $1 AND owner = $2
351
+ RETURNING id`,
352
+ [taskId, agentId]
353
+ );
354
+ return (result.rows?.length ?? 0) > 0;
355
+ }
356
+ /** Release a claimed task back to open (only the owner can release). */
357
+ async releaseTask(taskId, agentId) {
358
+ const db = this.getDb();
359
+ const result = await db.query(
360
+ `UPDATE tasks SET status = 'open', owner = NULL, claimed_at = NULL
361
+ WHERE id = $1 AND owner = $2
362
+ RETURNING id`,
363
+ [taskId, agentId]
364
+ );
365
+ return (result.rows?.length ?? 0) > 0;
366
+ }
367
+ /** List tasks, optionally filtered by status and/or owner. */
368
+ async listTasks(filter) {
369
+ const db = this.getDb();
370
+ const conditions = [];
371
+ const params = [];
372
+ let paramIdx = 1;
373
+ if (filter?.status) {
374
+ conditions.push(`status = $${paramIdx++}`);
375
+ params.push(filter.status);
376
+ }
377
+ if (filter?.owner) {
378
+ conditions.push(`owner = $${paramIdx++}`);
379
+ params.push(filter.owner);
380
+ }
381
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
382
+ const result = await db.query(
383
+ `SELECT * FROM tasks ${where} ORDER BY created_at`,
384
+ params
385
+ );
386
+ return result.rows;
387
+ }
388
+ // ---------------------------------------------------------------------------
389
+ // Events
390
+ // ---------------------------------------------------------------------------
391
+ /** Append an event to the audit log. */
392
+ async logEvent(event) {
393
+ const db = this.getDb();
394
+ const result = await db.query(
395
+ `INSERT INTO events (agent_id, event_type, payload)
396
+ VALUES ($1, $2, $3)
397
+ RETURNING *`,
398
+ [event.agentId, event.eventType, JSON.stringify(event.payload ?? {})]
399
+ );
400
+ return result.rows[0];
401
+ }
402
+ /** Get recent events (newest first). */
403
+ async getRecentEvents(limit) {
404
+ const db = this.getDb();
405
+ const result = await db.query(
406
+ "SELECT * FROM events ORDER BY created_at DESC LIMIT $1",
407
+ [limit]
408
+ );
409
+ return result.rows;
410
+ }
411
+ /** Prune events older than a given number of days. */
412
+ async pruneEvents(olderThanDays) {
413
+ const db = this.getDb();
414
+ const result = await db.query(
415
+ `DELETE FROM events WHERE created_at < NOW() - ($1 || ' days')::INTERVAL`,
416
+ [String(olderThanDays)]
417
+ );
418
+ return result.affectedRows ?? 0;
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // Worktrees
422
+ // ---------------------------------------------------------------------------
423
+ /** Register a worktree for an agent. */
424
+ async registerWorktree(wt) {
425
+ const db = this.getDb();
426
+ const result = await db.query(
427
+ `INSERT INTO worktrees (agent_id, branch, worktree_path, base_branch)
428
+ VALUES ($1, $2, $3, $4)
429
+ ON CONFLICT (agent_id) DO UPDATE SET
430
+ branch = EXCLUDED.branch,
431
+ worktree_path = EXCLUDED.worktree_path,
432
+ base_branch = EXCLUDED.base_branch,
433
+ status = 'active'
434
+ RETURNING *`,
435
+ [wt.agentId, wt.branch, wt.worktreePath, wt.baseBranch ?? "test"]
436
+ );
437
+ return result.rows[0];
438
+ }
439
+ /** Get a worktree by agent ID. */
440
+ async getWorktree(agentId) {
441
+ const db = this.getDb();
442
+ const result = await db.query("SELECT * FROM worktrees WHERE agent_id = $1", [
443
+ agentId
444
+ ]);
445
+ return result.rows[0] ?? null;
446
+ }
447
+ /** List all active worktrees. */
448
+ async getActiveWorktrees() {
449
+ const db = this.getDb();
450
+ const result = await db.query(
451
+ "SELECT * FROM worktrees WHERE status = 'active' ORDER BY created_at"
452
+ );
453
+ return result.rows;
454
+ }
455
+ /** Update worktree status (active → merged | abandoned). */
456
+ async updateWorktreeStatus(agentId, status) {
457
+ const db = this.getDb();
458
+ const result = await db.query(
459
+ "UPDATE worktrees SET status = $2 WHERE agent_id = $1 RETURNING agent_id",
460
+ [agentId, status]
461
+ );
462
+ return (result.rows?.length ?? 0) > 0;
463
+ }
464
+ /** Remove a worktree record. */
465
+ async removeWorktree(agentId) {
466
+ const db = this.getDb();
467
+ const result = await db.query("DELETE FROM worktrees WHERE agent_id = $1 RETURNING agent_id", [
468
+ agentId
469
+ ]);
470
+ return (result.rows?.length ?? 0) > 0;
471
+ }
472
+ // ---------------------------------------------------------------------------
473
+ // Agent Memory
474
+ // ---------------------------------------------------------------------------
475
+ /** Store a memory entry. */
476
+ async storeMemory(entry) {
477
+ const db = this.getDb();
478
+ const result = await db.query(
479
+ `INSERT INTO agent_memory (agent_id, memory_type, content, metadata)
480
+ VALUES ($1, $2, $3, $4)
481
+ RETURNING *`,
482
+ [entry.agentId, entry.memoryType, entry.content, JSON.stringify(entry.metadata ?? {})]
483
+ );
484
+ return result.rows[0];
485
+ }
486
+ /** Recall memory entries by agent and type (newest first). */
487
+ async recallMemory(query) {
488
+ const db = this.getDb();
489
+ const conditions = [];
490
+ const params = [];
491
+ let paramIdx = 1;
492
+ if (query.agentId) {
493
+ conditions.push(`agent_id = $${paramIdx++}`);
494
+ params.push(query.agentId);
495
+ }
496
+ if (query.memoryType) {
497
+ conditions.push(`memory_type = $${paramIdx++}`);
498
+ params.push(query.memoryType);
499
+ }
500
+ if (query.keyword) {
501
+ conditions.push(`content ILIKE $${paramIdx++}`);
502
+ params.push(`%${query.keyword}%`);
503
+ }
504
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
505
+ const limit = query.limit ?? 20;
506
+ params.push(limit);
507
+ const result = await db.query(
508
+ `SELECT * FROM agent_memory ${where} ORDER BY created_at DESC LIMIT $${paramIdx}`,
509
+ params
510
+ );
511
+ return result.rows;
512
+ }
513
+ /** Get a summary of recent memory (last N per type for context injection). */
514
+ async summarizeMemory(agentId, perType) {
515
+ const db = this.getDb();
516
+ const result = await db.query(
517
+ `SELECT * FROM (
518
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY memory_type ORDER BY created_at DESC) AS rn
519
+ FROM agent_memory WHERE agent_id = $1
520
+ ) sub WHERE rn <= $2
521
+ ORDER BY memory_type, created_at DESC`,
522
+ [agentId, perType]
523
+ );
524
+ return result.rows;
525
+ }
526
+ /** Prune old memory entries (keep last N per agent). */
527
+ async pruneMemory(keepPerAgent) {
528
+ const db = this.getDb();
529
+ const result = await db.query(
530
+ `DELETE FROM agent_memory WHERE id IN (
531
+ SELECT id FROM (
532
+ SELECT id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY created_at DESC) AS rn
533
+ FROM agent_memory
534
+ ) sub WHERE rn > $1
535
+ )`,
536
+ [keepPerAgent]
537
+ );
538
+ return result.affectedRows ?? 0;
539
+ }
540
+ // ---------------------------------------------------------------------------
541
+ // Merge Requests
542
+ // ---------------------------------------------------------------------------
543
+ /** Create a merge request for an agent's branch. */
544
+ async createMergeRequest(mr) {
545
+ const db = this.getDb();
546
+ const result = await db.query(
547
+ `INSERT INTO merge_requests (id, agent_id, task_id, source_branch, base_branch)
548
+ VALUES ($1, $2, $3, $4, $5)
549
+ ON CONFLICT (id) DO UPDATE SET
550
+ status = 'pending',
551
+ retry_count = merge_requests.retry_count,
552
+ updated_at = NOW()
553
+ RETURNING *`,
554
+ [mr.id, mr.agentId, mr.taskId ?? null, mr.sourceBranch, mr.baseBranch ?? "test"]
555
+ );
556
+ return result.rows[0];
557
+ }
558
+ /** Get a merge request by ID. */
559
+ async getMergeRequest(id) {
560
+ const db = this.getDb();
561
+ const result = await db.query("SELECT * FROM merge_requests WHERE id = $1", [id]);
562
+ return result.rows[0] ?? null;
563
+ }
564
+ /** Get a merge request by source branch. */
565
+ async getMergeRequestByBranch(branch) {
566
+ const db = this.getDb();
567
+ const result = await db.query(
568
+ "SELECT * FROM merge_requests WHERE source_branch = $1 AND status NOT IN ('merged', 'escalated') ORDER BY created_at DESC LIMIT 1",
569
+ [branch]
570
+ );
571
+ return result.rows[0] ?? null;
572
+ }
573
+ /** Get a merge request by PR number. */
574
+ async getMergeRequestByPr(prNumber) {
575
+ const db = this.getDb();
576
+ const result = await db.query(
577
+ "SELECT * FROM merge_requests WHERE pr_number = $1 ORDER BY created_at DESC LIMIT 1",
578
+ [prNumber]
579
+ );
580
+ return result.rows[0] ?? null;
581
+ }
582
+ /** Update a merge request's fields. */
583
+ async updateMergeRequest(id, updates) {
584
+ const db = this.getDb();
585
+ const sets = ["updated_at = NOW()"];
586
+ const params = [];
587
+ let paramIdx = 1;
588
+ if (updates.status !== void 0) {
589
+ sets.push(`status = $${paramIdx++}`);
590
+ params.push(updates.status);
591
+ }
592
+ if (updates.prNumber !== void 0) {
593
+ sets.push(`pr_number = $${paramIdx++}`);
594
+ params.push(updates.prNumber);
595
+ }
596
+ if (updates.prUrl !== void 0) {
597
+ sets.push(`pr_url = $${paramIdx++}`);
598
+ params.push(updates.prUrl);
599
+ }
600
+ if (updates.errorMessage !== void 0) {
601
+ sets.push(`error_message = $${paramIdx++}`);
602
+ params.push(updates.errorMessage);
603
+ }
604
+ if (updates.ciOutput !== void 0) {
605
+ sets.push(`ci_output = $${paramIdx++}`);
606
+ params.push(updates.ciOutput);
607
+ }
608
+ params.push(id);
609
+ const result = await db.query(
610
+ `UPDATE merge_requests SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
611
+ params
612
+ );
613
+ return result.rows[0] ?? null;
614
+ }
615
+ /** Increment the retry count for a merge request. */
616
+ async incrementMergeRetry(id) {
617
+ const db = this.getDb();
618
+ const result = await db.query(
619
+ `UPDATE merge_requests SET retry_count = retry_count + 1, updated_at = NOW()
620
+ WHERE id = $1 RETURNING retry_count`,
621
+ [id]
622
+ );
623
+ return result.rows[0]?.retry_count ?? 0;
624
+ }
625
+ /** List merge requests, optionally filtered by status and/or agent. */
626
+ async listMergeRequests(filter) {
627
+ const db = this.getDb();
628
+ const conditions = [];
629
+ const params = [];
630
+ let paramIdx = 1;
631
+ if (filter?.status) {
632
+ conditions.push(`status = $${paramIdx++}`);
633
+ params.push(filter.status);
634
+ }
635
+ if (filter?.agentId) {
636
+ conditions.push(`agent_id = $${paramIdx++}`);
637
+ params.push(filter.agentId);
638
+ }
639
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
640
+ const result = await db.query(
641
+ `SELECT * FROM merge_requests ${where} ORDER BY created_at DESC`,
642
+ params
643
+ );
644
+ return result.rows;
645
+ }
646
+ };
647
+
648
+ export {
649
+ SCHEMA_SQL,
650
+ DaemonStore
651
+ };