@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.
- package/README.md +110 -0
- package/bin/openboard.js +116 -0
- package/package.json +35 -0
- package/packages/client/dist/assets/index-CIdk7SfN.css +1 -0
- package/packages/client/dist/assets/index-HJtFAWwr.js +203 -0
- package/packages/client/dist/index.html +15 -0
- package/packages/server/dist/agents/agent-queue.js +175 -0
- package/packages/server/dist/agents/agent-runner.js +18 -0
- package/packages/server/dist/agents/agent.interface.js +1 -0
- package/packages/server/dist/agents/codereview.agent.js +113 -0
- package/packages/server/dist/agents/dummy.agent.js +49 -0
- package/packages/server/dist/agents/opencode.agent.js +184 -0
- package/packages/server/dist/agents/opencode.events.js +348 -0
- package/packages/server/dist/db/database.js +176 -0
- package/packages/server/dist/gh-worker.js +82 -0
- package/packages/server/dist/index.js +81 -0
- package/packages/server/dist/repositories/board.repository.js +72 -0
- package/packages/server/dist/repositories/column-config.repository.js +30 -0
- package/packages/server/dist/repositories/column.repository.js +36 -0
- package/packages/server/dist/repositories/comment.repository.js +35 -0
- package/packages/server/dist/repositories/ticket.repository.js +171 -0
- package/packages/server/dist/routes/boards.router.js +33 -0
- package/packages/server/dist/routes/column-config.router.js +44 -0
- package/packages/server/dist/routes/columns.router.js +45 -0
- package/packages/server/dist/routes/system.router.js +173 -0
- package/packages/server/dist/routes/tickets.router.js +88 -0
- package/packages/server/dist/sse.js +43 -0
- package/packages/server/dist/types.js +2 -0
- package/packages/server/dist/utils/opencode.js +11 -0
- 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();
|