@openboard/start 1.0.20

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