@openboard/start 1.0.2

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.
Files changed (30) hide show
  1. package/README.md +110 -0
  2. package/bin/openboard.js +116 -0
  3. package/package.json +35 -0
  4. package/packages/client/dist/assets/index-CIdk7SfN.css +1 -0
  5. package/packages/client/dist/assets/index-HJtFAWwr.js +203 -0
  6. package/packages/client/dist/index.html +15 -0
  7. package/packages/server/dist/agents/agent-queue.js +175 -0
  8. package/packages/server/dist/agents/agent-runner.js +18 -0
  9. package/packages/server/dist/agents/agent.interface.js +1 -0
  10. package/packages/server/dist/agents/codereview.agent.js +113 -0
  11. package/packages/server/dist/agents/dummy.agent.js +49 -0
  12. package/packages/server/dist/agents/opencode.agent.js +184 -0
  13. package/packages/server/dist/agents/opencode.events.js +348 -0
  14. package/packages/server/dist/db/database.js +176 -0
  15. package/packages/server/dist/gh-worker.js +82 -0
  16. package/packages/server/dist/index.js +81 -0
  17. package/packages/server/dist/repositories/board.repository.js +72 -0
  18. package/packages/server/dist/repositories/column-config.repository.js +30 -0
  19. package/packages/server/dist/repositories/column.repository.js +36 -0
  20. package/packages/server/dist/repositories/comment.repository.js +35 -0
  21. package/packages/server/dist/repositories/ticket.repository.js +171 -0
  22. package/packages/server/dist/routes/boards.router.js +33 -0
  23. package/packages/server/dist/routes/column-config.router.js +44 -0
  24. package/packages/server/dist/routes/columns.router.js +45 -0
  25. package/packages/server/dist/routes/system.router.js +173 -0
  26. package/packages/server/dist/routes/tickets.router.js +88 -0
  27. package/packages/server/dist/sse.js +43 -0
  28. package/packages/server/dist/types.js +2 -0
  29. package/packages/server/dist/utils/opencode.js +11 -0
  30. package/packages/server/dist/utils/os.js +73 -0
@@ -0,0 +1,348 @@
1
+ import { ticketRepository } from '../repositories/ticket.repository.js';
2
+ import { commentRepository } from '../repositories/comment.repository.js';
3
+ import { agentQueue } from './agent-queue.js';
4
+ import { runCmd, getGhToken } from '../utils/os.js';
5
+ import { createBoardScopedClient } from '../utils/opencode.js';
6
+ export async function setupOpencodeEventListener(events, _unused, // kept for signature compatibility if called elsewhere
7
+ sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName, agentType = 'opencode') {
8
+ const opencodeClient = createBoardScopedClient(originalWorkspacePath);
9
+ const processedMessages = new Set();
10
+ const activeParts = new Map();
11
+ let rawSessionCost = 0;
12
+ let sessionCommentId = null;
13
+ let sessionCommentContent = "";
14
+ function updateSessionComment(content, type = 'message', append = true) {
15
+ let prefix = "";
16
+ if (type === 'status')
17
+ prefix = "\n---\n";
18
+ if (type === 'pr')
19
+ prefix = "\n---\n";
20
+ if (append) {
21
+ sessionCommentContent += (sessionCommentContent ? prefix : "") + content;
22
+ }
23
+ else {
24
+ sessionCommentContent = content;
25
+ }
26
+ if (sessionCommentId) {
27
+ commentRepository.update(sessionCommentId, sessionCommentContent);
28
+ }
29
+ else {
30
+ const comment = commentRepository.create({
31
+ ticketId: ticket.id,
32
+ author: 'opencode', // Fixed author for session log
33
+ content: sessionCommentContent
34
+ });
35
+ sessionCommentId = comment.id;
36
+ }
37
+ }
38
+ try {
39
+ for await (const event of events.stream) {
40
+ // FILTER for events belonging solely to THIS session
41
+ const eventSessionId = event.properties?.sessionID || event.properties?.info?.sessionID || event.properties?.part?.sessionID;
42
+ if (eventSessionId && eventSessionId !== sessionID)
43
+ continue;
44
+ // Handle Blocking Permissions
45
+ if (event.type === 'permission.asked') {
46
+ console.log(`[opencode-agent] Permission requested for ticket ${ticket.id}. Waiting for UI approval.`);
47
+ updateSessionComment(`⚠️ **Permission Required**\n\nThe agent needs your permission to continue. Please open the [Agent UI](${agentUrl}) to approve or deny the request.`, 'status');
48
+ ticketRepository.updateAgentSession(ticket.id, {
49
+ column_id: ticket.column_id,
50
+ agent_type: agentType,
51
+ status: 'needs_approval',
52
+ port: 4096,
53
+ url: agentUrl
54
+ });
55
+ }
56
+ // Resume processing when permission is explicitly granted/answered
57
+ if (event.type === 'permission.replied') {
58
+ console.log(`[opencode-agent] Permission answered for ticket ${ticket.id}. Resuming processing.`);
59
+ updateSessionComment(`✅ **Permission Handled**`, 'status');
60
+ ticketRepository.updateAgentSession(ticket.id, {
61
+ column_id: ticket.column_id,
62
+ agent_type: agentType,
63
+ status: 'processing',
64
+ port: 4096,
65
+ url: agentUrl
66
+ });
67
+ }
68
+ // Handle API Quotas & Retries
69
+ if (event.type === 'session.status') {
70
+ const status = event.properties.status;
71
+ if (status.type === 'retry') {
72
+ // Let's only post the retry warning on the first few attempts so it doesn't spam infinitely
73
+ if (status.attempt <= 5) {
74
+ updateSessionComment(`⚠️ **API Retry Attempt ${status.attempt}**\n\n${status.message}`, 'status');
75
+ }
76
+ }
77
+ }
78
+ // Handle Fatal Session Errors
79
+ if (event.type === 'session.error') {
80
+ const err = event.properties.error;
81
+ if (err) {
82
+ updateSessionComment(`❌ **Fatal Error: ${err.name}**\n\n${err.data?.message || JSON.stringify(err.data)}`, 'status');
83
+ // Agent failed, mark ticket as blocked/failed
84
+ ticketRepository.updateAgentSession(ticket.id, {
85
+ column_id: ticket.column_id,
86
+ agent_type: agentType,
87
+ status: 'blocked',
88
+ port: 4096,
89
+ url: agentUrl,
90
+ error_message: err.data?.message || err.name
91
+ });
92
+ }
93
+ }
94
+ // Handle Text Messages (Wait for assistant message to complete)
95
+ if (event.type === 'message.updated') {
96
+ const info = event.properties.info;
97
+ if (info.cost) {
98
+ rawSessionCost += info.cost;
99
+ }
100
+ if (info.role === 'assistant' && info.time?.completed && !processedMessages.has(info.id)) {
101
+ processedMessages.add(info.id);
102
+ try {
103
+ const messagesRes = await opencodeClient.session.messages({ path: { id: sessionID } });
104
+ if (messagesRes.data) {
105
+ const targetMsg = messagesRes.data.find((m) => m.info.id === info.id);
106
+ if (targetMsg && targetMsg.parts) {
107
+ let combinedText = '';
108
+ for (const part of targetMsg.parts) {
109
+ if (part.type === 'text' && part.text?.trim()) {
110
+ combinedText += (combinedText ? '\n\n' : '') + part.text.trim();
111
+ }
112
+ }
113
+ if (combinedText) {
114
+ // With session consolidation, we might just want to refresh or ensure it's there.
115
+ // For now, let's keep it simple: the deltas handle the live updates,
116
+ // and message.updated ensures the final state is correct if deltas were missed.
117
+ // We don't want to double append.
118
+ // Since we are using a single sessionCommentContent, we'll just let deltas do the work.
119
+ }
120
+ }
121
+ }
122
+ }
123
+ catch (fetchErr) {
124
+ console.error(`[opencode-agent] Failed to fetch parts for message ${info.id}`, fetchErr);
125
+ }
126
+ }
127
+ }
128
+ // Handle Tool Completions and Reasoning Real-Time
129
+ // (WE NOW IGNORE THESE AS PER USER REQUEST TO ONLY SHOW IMPORTANT COMMENTS)
130
+ if (event.type === 'message.part.updated') {
131
+ const part = event.properties.part;
132
+ if (part.type === 'step-finish' && part.cost) {
133
+ rawSessionCost += part.cost;
134
+ }
135
+ }
136
+ // Handle Session Idle (Task Completed)
137
+ if (event.type === 'session.idle') {
138
+ console.log(`[opencode-agent] Agent task completed (idle) for ticket ${ticket.id}.`);
139
+ // Check if the current session failed with a fatal error previously
140
+ const currentTicket = ticketRepository.findById(ticket.id);
141
+ const sessionsForColumn = currentTicket?.agent_sessions?.filter((s) => s.column_id === ticket.column_id) || [];
142
+ const latestSession = sessionsForColumn[sessionsForColumn.length - 1];
143
+ const hasError = latestSession?.status === 'blocked';
144
+ if (!hasError) {
145
+ if (agentType === 'code_review') {
146
+ // Mark the ticket as done now that the agent session finished processing
147
+ ticketRepository.updateAgentSession(ticket.id, {
148
+ column_id: ticket.column_id,
149
+ agent_type: agentType,
150
+ status: 'done',
151
+ port: 4096,
152
+ url: agentUrl,
153
+ total_cost: rawSessionCost > 0 ? Number(rawSessionCost.toFixed(4)) : undefined
154
+ });
155
+ try {
156
+ // Find out if the PR was approved or changes requested
157
+ const latestTicket = ticketRepository.findById(ticket.id) || ticket;
158
+ const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
159
+ const prUrl = prUrlSession?.pr_url;
160
+ if (prUrl) {
161
+ const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath, 'opencode-events');
162
+ const comments = JSON.parse(prStatus).comments;
163
+ // Find the latest comment that contains a decision
164
+ let reviewDecision = 'NONE';
165
+ // 1. Check GitHub PR comments First
166
+ if (comments && comments.length > 0) {
167
+ for (const comment of [...comments].reverse()) {
168
+ const bodyLower = comment.body.toLowerCase();
169
+ if (bodyLower.includes('[approved]') || bodyLower.includes('approve the pr') || bodyLower.includes('lgtm') || bodyLower.includes('approved')) {
170
+ reviewDecision = 'APPROVED';
171
+ break;
172
+ }
173
+ if (bodyLower.includes('[changes_requested]') || bodyLower.includes('request changes') || bodyLower.includes('changes requested') || bodyLower.includes('changes are needed')) {
174
+ reviewDecision = 'CHANGES_REQUESTED';
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ // 2. Fallback to Local Chat Logs
180
+ if (reviewDecision === 'NONE') {
181
+ const localDbComments = commentRepository.findByTicketId(ticket.id);
182
+ if (localDbComments && localDbComments.length > 0) {
183
+ for (const dbC of [...localDbComments].reverse()) {
184
+ const bodyLower = dbC.content.toLowerCase();
185
+ if (bodyLower.includes('[approved]') || bodyLower.includes('approve the pr') || bodyLower.includes('lgtm') || bodyLower.includes('approved')) {
186
+ reviewDecision = 'APPROVED';
187
+ break;
188
+ }
189
+ if (bodyLower.includes('[changes_requested]') || bodyLower.includes('request changes') || bodyLower.includes('changes requested') || bodyLower.includes('changes are needed')) {
190
+ reviewDecision = 'CHANGES_REQUESTED';
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ }
196
+ console.log(`[codereview-agent] PR ${prUrl} review decision parsed from comments: ${reviewDecision}`);
197
+ if (reviewDecision === 'APPROVED') {
198
+ updateSessionComment(`✅ **Code Review Approved!**\n\nThe agent approved the PR.`, 'status');
199
+ // Move ticket to the configured destination column (if set)
200
+ if (config.on_finish_column_id) {
201
+ console.log(`[codereview-agent] Moving ticket ${ticket.id} forward to column ${config.on_finish_column_id}`);
202
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
203
+ if (moved) {
204
+ // Trigger via the queue so concurrency/priority rules are respected
205
+ agentQueue.evaluateColumnQueue(config.on_finish_column_id);
206
+ }
207
+ }
208
+ }
209
+ else if (reviewDecision === 'CHANGES_REQUESTED') {
210
+ updateSessionComment(`⚠️ **Changes Requested**\n\nThe agent has requested changes on the PR. Sending ticket back for revision.`, 'status');
211
+ if (config.on_reject_column_id) {
212
+ console.log(`[codereview-agent] Moving ticket ${ticket.id} back to configured reject column ${config.on_reject_column_id}`);
213
+ const moved = ticketRepository.move(ticket.id, config.on_reject_column_id, 0);
214
+ if (moved) {
215
+ // Use force=true so the dev agent will re-run even though it previously finished 'done'
216
+ agentQueue.enqueue(moved.id, true);
217
+ }
218
+ }
219
+ else {
220
+ console.warn(`[codereview-agent] Ticket ${ticket.id} rejected, but no 'on_reject_column_id' is configured!`);
221
+ }
222
+ }
223
+ else {
224
+ updateSessionComment(`ℹ️ **Code Review Finished**\n\nThe review was completed, but no explicit approval or changes were requested.`, 'status');
225
+ }
226
+ }
227
+ }
228
+ catch (err) {
229
+ console.error(`[codereview-agent] Error handling post-review logic`, err);
230
+ }
231
+ }
232
+ else if (agentType === 'opencode') {
233
+ // Commit, push, and create PR before moving ticket
234
+ try {
235
+ console.log(`[opencode-agent] Checking for changes in worktree ${worktreePath}`);
236
+ const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath, 'opencode-events');
237
+ if (statusOut.trim()) {
238
+ console.log(`[opencode-agent] Changes found for ticket ${ticket.id}. Committing and pushing.`);
239
+ await runCmd('git', ['add', '.'], worktreePath, 'opencode-events');
240
+ await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath, 'opencode-events');
241
+ // Inject GH_TOKEN into remote URL for git push authentication
242
+ const token = await getGhToken(worktreePath);
243
+ if (token) {
244
+ try {
245
+ const { stdout: remoteUrlOut } = await runCmd('git', ['config', '--get', 'remote.origin.url'], worktreePath);
246
+ const remoteUrl = remoteUrlOut.trim();
247
+ if (remoteUrl.startsWith('https://github.com/')) {
248
+ const authedUrl = remoteUrl.replace('https://github.com/', `https://x-access-token:${token}@github.com/`);
249
+ await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath, 'opencode-events');
250
+ }
251
+ }
252
+ catch (urlErr) {
253
+ console.warn(`[opencode-agent] Could not set authenticated remote URL:`, urlErr);
254
+ }
255
+ }
256
+ await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath, 'opencode-events');
257
+ // Check if a PR already exists for this ticket (fetch fresh from DB)
258
+ const freshTicket = ticketRepository.findById(ticket.id) || ticket;
259
+ const existingPrUrlSession = [...(freshTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
260
+ let prUrl = existingPrUrlSession?.pr_url;
261
+ if (prUrl) {
262
+ updateSessionComment(`🚀 **Pull Request Updated**\n\nThe agent has updated the existing PR:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
263
+ }
264
+ else {
265
+ const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath, 'opencode-events');
266
+ prUrl = prOut.trim();
267
+ updateSessionComment(`🚀 **Pull Request Created**\n\nThe agent has proposed the following changes in a PR. Check it out:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
268
+ }
269
+ // Add PR URL to the active agent session and mark as done
270
+ ticketRepository.updateAgentSession(ticket.id, {
271
+ column_id: ticket.column_id,
272
+ agent_type: 'opencode',
273
+ status: 'done',
274
+ port: 4096,
275
+ url: agentUrl,
276
+ pr_url: prUrl,
277
+ total_cost: rawSessionCost > 0 ? Number(rawSessionCost.toFixed(4)) : undefined
278
+ });
279
+ // Move ticket to the configured destination column (if set)
280
+ if (config.on_finish_column_id) {
281
+ console.log(`[opencode-agent] Moving ticket ${ticket.id} to column ${config.on_finish_column_id}`);
282
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
283
+ if (moved) {
284
+ // Trigger via the queue so concurrency/priority rules are respected
285
+ agentQueue.evaluateColumnQueue(config.on_finish_column_id);
286
+ }
287
+ }
288
+ }
289
+ else {
290
+ console.log(`[opencode-agent] No changes to push for ticket ${ticket.id}.`);
291
+ updateSessionComment(`ℹ️ **Task Completed (No Changes)**\n\nThe agent finished the task but did not make any code changes.${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'status');
292
+ // Mark as done even if no changes
293
+ ticketRepository.updateAgentSession(ticket.id, {
294
+ column_id: ticket.column_id,
295
+ agent_type: 'opencode',
296
+ status: 'done',
297
+ port: 4096,
298
+ url: agentUrl,
299
+ total_cost: rawSessionCost > 0 ? Number(rawSessionCost.toFixed(4)) : undefined
300
+ });
301
+ // Still move to the next column if no changes (assumed done)
302
+ if (config.on_finish_column_id) {
303
+ console.log(`[opencode-agent] Moving ticket ${ticket.id} to column ${config.on_finish_column_id}`);
304
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
305
+ if (moved) {
306
+ agentQueue.evaluateColumnQueue(config.on_finish_column_id);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ catch (error) {
312
+ console.error(`[opencode-agent] Failed to create PR for ticket ${ticket.id}`, error);
313
+ updateSessionComment(`❌ **Failed to Create PR**\n\nThe agent finished the task, but an error occurred while pushing changes or creating the PR:\n\`\`\`\n${error.message}\n\`\`\``, 'status');
314
+ // Mark as blocked due to PR failure
315
+ ticketRepository.updateAgentSession(ticket.id, {
316
+ column_id: ticket.column_id,
317
+ agent_type: 'opencode',
318
+ status: 'blocked',
319
+ port: 4096,
320
+ url: agentUrl,
321
+ error_message: `PR creation failed: ${error.message}`
322
+ });
323
+ }
324
+ } // End of agentType === 'opencode' block
325
+ }
326
+ // Wait! Since this ticket finished processing, we should re-evaluate the CURRENT column
327
+ // so the next pending ticket can be picked up by an agent.
328
+ agentQueue.evaluateColumnQueue(ticket.column_id);
329
+ // Clean up the server instance tracking after a brief delay
330
+ setTimeout(() => {
331
+ console.log(`[opencode-agent] Cleaning up tracking for ${ticket.id}`);
332
+ delete activeSessions[ticket.id];
333
+ }, 60000); // 1 minute cleanup
334
+ }
335
+ // ── Live Text Streaming via Deltas ──
336
+ if (event.type === 'message.part.delta') {
337
+ const delta = event.properties.delta;
338
+ const messageID = event.properties.messageID;
339
+ if (delta && messageID) {
340
+ updateSessionComment(delta, 'message', true);
341
+ }
342
+ }
343
+ }
344
+ }
345
+ catch (err) {
346
+ console.error(`[opencode-agent] Event stream error for ticket ${ticket.id}:`, err);
347
+ }
348
+ }
@@ -0,0 +1,176 @@
1
+ import initSqlJs from 'sql.js';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { createRequire } from 'module';
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const openboardDir = path.join(os.homedir(), '.openboard');
9
+ if (!fs.existsSync(openboardDir)) {
10
+ fs.mkdirSync(openboardDir, { recursive: true });
11
+ }
12
+ const DB_PATH = path.join(openboardDir, 'openboard.db');
13
+ console.log("[db] DB_PATH", DB_PATH);
14
+ // Migrate old database if it exists
15
+ const oldDbPath = path.join(__dirname, '..', '..', '..', 'openboard.db');
16
+ if (fs.existsSync(oldDbPath) && !fs.existsSync(DB_PATH)) {
17
+ fs.copyFileSync(oldDbPath, DB_PATH);
18
+ }
19
+ // Resolve sql.js WASM dynamically — handles npm workspace node_modules hoisting
20
+ const require = createRequire(import.meta.url);
21
+ const sqlJsDir = path.dirname(require.resolve('sql.js/dist/sql-wasm.wasm'));
22
+ const WASM_PATH = path.join(sqlJsDir, 'sql-wasm.wasm');
23
+ let db;
24
+ let inTransaction = false;
25
+ function makeStatement(sql) {
26
+ return {
27
+ all(...params) {
28
+ const results = db.exec(sql, params.length ? params : undefined);
29
+ if (!results.length || !results[0].values.length)
30
+ return [];
31
+ const { columns, values } = results[0];
32
+ return values.map(row => Object.fromEntries(columns.map((col, i) => [col, row[i]])));
33
+ },
34
+ get(...params) {
35
+ return this.all(...params)[0];
36
+ },
37
+ run(...params) {
38
+ db.run(sql, params.length ? params : undefined);
39
+ // Don't persist mid-transaction — sql.js auto-commits on db.export(),
40
+ // which would invalidate the open transaction and cause COMMIT/ROLLBACK to fail.
41
+ if (!inTransaction)
42
+ persist();
43
+ },
44
+ };
45
+ }
46
+ function persist() {
47
+ const data = db.export();
48
+ fs.writeFileSync(DB_PATH, Buffer.from(data));
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Transaction helper (sql.js has no WAL, runs synchronously anyway)
52
+ // ---------------------------------------------------------------------------
53
+ function transaction(fn) {
54
+ return () => {
55
+ inTransaction = true;
56
+ db.run('BEGIN');
57
+ try {
58
+ fn();
59
+ db.run('COMMIT');
60
+ inTransaction = false;
61
+ persist(); // single persist after the full transaction commits
62
+ }
63
+ catch (e) {
64
+ db.run('ROLLBACK');
65
+ inTransaction = false;
66
+ throw e;
67
+ }
68
+ };
69
+ }
70
+ let dbHandle;
71
+ export async function initDb() {
72
+ if (dbHandle)
73
+ return dbHandle;
74
+ const wasmFile = fs.readFileSync(WASM_PATH);
75
+ const wasmBinary = wasmFile.buffer.slice(wasmFile.byteOffset, wasmFile.byteOffset + wasmFile.byteLength);
76
+ const SQL = await initSqlJs({ wasmBinary });
77
+ if (fs.existsSync(DB_PATH)) {
78
+ const fileBuffer = fs.readFileSync(DB_PATH);
79
+ db = new SQL.Database(new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength));
80
+ }
81
+ else {
82
+ db = new SQL.Database();
83
+ }
84
+ db.run('PRAGMA foreign_keys = ON;');
85
+ runMigrations();
86
+ dbHandle = {
87
+ prepare: makeStatement,
88
+ exec: (sql) => { db.exec(sql); persist(); },
89
+ transaction,
90
+ };
91
+ return dbHandle;
92
+ }
93
+ export function getDb() {
94
+ if (!dbHandle)
95
+ throw new Error('DB not initialized. Call initDb() first.');
96
+ return dbHandle;
97
+ }
98
+ function runMigrations() {
99
+ db.run(`
100
+ CREATE TABLE IF NOT EXISTS boards (
101
+ id TEXT PRIMARY KEY,
102
+ name TEXT NOT NULL,
103
+ path TEXT,
104
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS board_workspaces (
108
+ id TEXT PRIMARY KEY,
109
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
110
+ type TEXT NOT NULL, -- 'folder' or 'git'
111
+ path TEXT NOT NULL
112
+ );
113
+
114
+ CREATE TABLE IF NOT EXISTS columns (
115
+ id TEXT PRIMARY KEY,
116
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
117
+ name TEXT NOT NULL,
118
+ position INTEGER NOT NULL DEFAULT 0,
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS tickets (
123
+ id TEXT PRIMARY KEY,
124
+ column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
125
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
126
+ title TEXT NOT NULL,
127
+ description TEXT NOT NULL DEFAULT '',
128
+ priority TEXT NOT NULL DEFAULT 'medium',
129
+ position INTEGER NOT NULL DEFAULT 0,
130
+ agent_sessions TEXT NOT NULL DEFAULT '[]',
131
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
132
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
133
+ );
134
+
135
+ CREATE TABLE IF NOT EXISTS comments (
136
+ id TEXT PRIMARY KEY,
137
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
138
+ author TEXT NOT NULL DEFAULT 'agent',
139
+ content TEXT NOT NULL,
140
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS column_configs (
144
+ column_id TEXT PRIMARY KEY REFERENCES columns(id) ON DELETE CASCADE,
145
+ agent_type TEXT NOT NULL,
146
+ agent_model TEXT,
147
+ on_finish_column_id TEXT
148
+ );
149
+ `);
150
+ try {
151
+ db.run("ALTER TABLE boards ADD COLUMN path TEXT;");
152
+ console.log("[db] Migration: Added path column to boards table");
153
+ }
154
+ catch (e) { }
155
+ try {
156
+ db.run("ALTER TABLE tickets ADD COLUMN agent_sessions TEXT NOT NULL DEFAULT '[]';");
157
+ console.log("[db] Migration: Added agent_sessions column to tickets table");
158
+ }
159
+ catch (e) { }
160
+ try {
161
+ db.run("ALTER TABLE column_configs ADD COLUMN agent_model TEXT;");
162
+ console.log("[db] Migration: Added agent_model column to column_configs table");
163
+ }
164
+ catch (e) { }
165
+ try {
166
+ db.run("ALTER TABLE column_configs ADD COLUMN max_agents INTEGER DEFAULT 1;");
167
+ console.log("[db] Migration: Added max_agents column to column_configs table");
168
+ }
169
+ catch (e) { }
170
+ try {
171
+ db.run("ALTER TABLE column_configs ADD COLUMN on_reject_column_id TEXT;");
172
+ console.log("[db] Migration: Added on_reject_column_id column to column_configs table");
173
+ }
174
+ catch (e) { }
175
+ persist();
176
+ }
@@ -0,0 +1,82 @@
1
+ import { ticketRepository } from './repositories/ticket.repository.js';
2
+ import { commentRepository } from './repositories/comment.repository.js';
3
+ import { execFile } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { getDb } from './db/database.js';
6
+ const execFileAsync = promisify(execFile);
7
+ export class GhWorker {
8
+ interval = null;
9
+ isPolling = false;
10
+ start() {
11
+ console.log('[gh-worker] Starting GitHub PR status worker...');
12
+ this.poll();
13
+ this.interval = setInterval(() => this.poll(), 10_000); // Every 60 seconds
14
+ }
15
+ stop() {
16
+ if (this.interval) {
17
+ clearInterval(this.interval);
18
+ this.interval = null;
19
+ }
20
+ }
21
+ async poll() {
22
+ if (this.isPolling)
23
+ return;
24
+ this.isPolling = true;
25
+ try {
26
+ // Find all tickets with a PR URL that isn't merged or closed
27
+ const db = getDb();
28
+ const rows = db.prepare('SELECT * FROM tickets').all();
29
+ for (const row of rows) {
30
+ let sessions = [];
31
+ try {
32
+ sessions = JSON.parse(row.agent_sessions);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ if (!sessions || sessions.length === 0)
38
+ continue;
39
+ for (let i = sessions.length - 1; i >= 0; i--) {
40
+ const session = sessions[i];
41
+ if (session.pr_url && (!session.pr_status || session.pr_status === 'OPEN')) {
42
+ try {
43
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', session.pr_url, '--json', 'state'], { shell: true });
44
+ const data = JSON.parse(stdout);
45
+ const newState = data.state; // 'OPEN', 'MERGED', 'CLOSED'
46
+ if (newState && newState !== session.pr_status) {
47
+ console.log(`[gh-worker] PR status changed from ${session.pr_status} to ${newState} for ticket ${row.id}`);
48
+ // Update this specific session
49
+ session.pr_status = newState;
50
+ ticketRepository.updateAgentSession(row.id, session);
51
+ if (newState === 'MERGED') {
52
+ commentRepository.create({
53
+ ticketId: row.id,
54
+ author: 'System',
55
+ content: `🎉 **Pull Request Merged!**\n\nThe PR [${session.pr_url}](${session.pr_url}) has been merged.`
56
+ });
57
+ }
58
+ else if (newState === 'CLOSED') {
59
+ commentRepository.create({
60
+ ticketId: row.id,
61
+ author: 'System',
62
+ content: `🚫 **Pull Request Closed**\n\nThe PR [${session.pr_url}](${session.pr_url}) was closed without merging.`
63
+ });
64
+ }
65
+ }
66
+ }
67
+ catch (e) {
68
+ console.warn(`[gh-worker] Failed to check PR status for ${session.pr_url}: ${e.message}`);
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ catch (err) {
75
+ console.error('[gh-worker] Polling error:', err);
76
+ }
77
+ finally {
78
+ this.isPolling = false;
79
+ }
80
+ }
81
+ }
82
+ export const ghWorker = new GhWorker();