@m0xoo/openboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +99 -0
  2. package/bin/openboard.js +93 -0
  3. package/package.json +33 -0
  4. package/packages/client/dist/assets/index-CAahrBYB.css +1 -0
  5. package/packages/client/dist/assets/index-CvvU24UX.js +145 -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 +132 -0
  11. package/packages/server/dist/agents/dummy.agent.js +49 -0
  12. package/packages/server/dist/agents/opencode.agent.js +214 -0
  13. package/packages/server/dist/agents/opencode.events.js +398 -0
  14. package/packages/server/dist/db/database.js +159 -0
  15. package/packages/server/dist/index.js +66 -0
  16. package/packages/server/dist/repositories/board.repository.js +56 -0
  17. package/packages/server/dist/repositories/column-config.repository.js +30 -0
  18. package/packages/server/dist/repositories/column.repository.js +36 -0
  19. package/packages/server/dist/repositories/comment.repository.js +35 -0
  20. package/packages/server/dist/repositories/ticket.repository.js +158 -0
  21. package/packages/server/dist/routes/boards.router.js +33 -0
  22. package/packages/server/dist/routes/column-config.router.js +42 -0
  23. package/packages/server/dist/routes/columns.router.js +45 -0
  24. package/packages/server/dist/routes/tickets.router.js +88 -0
  25. package/packages/server/dist/sse.js +43 -0
  26. package/packages/server/dist/types.js +2 -0
@@ -0,0 +1,398 @@
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 processedTools = new Set();
53
+ const activeParts = new Map();
54
+ try {
55
+ for await (const event of events.stream) {
56
+ // FILTER for events belonging solely to THIS session
57
+ const eventSessionId = event.properties?.sessionID || event.properties?.info?.sessionID || event.properties?.part?.sessionID;
58
+ if (eventSessionId && eventSessionId !== sessionID)
59
+ continue;
60
+ // Handle Blocking Permissions
61
+ if (event.type === 'permission.asked') {
62
+ console.log(`[opencode-agent] Permission requested for ticket ${ticket.id}. Waiting for UI approval.`);
63
+ commentRepository.create({
64
+ ticketId: ticket.id,
65
+ author: 'System',
66
+ content: `⚠️ **Permission Required**\n\nThe agent needs your permission to continue. Please open the [Agent UI](${agentUrl}) to approve or deny the request.`
67
+ });
68
+ ticketRepository.updateAgentSession(ticket.id, {
69
+ column_id: ticket.column_id,
70
+ agent_type: agentType,
71
+ status: 'needs_approval',
72
+ port: 4096,
73
+ url: agentUrl
74
+ });
75
+ }
76
+ // Resume processing when permission is explicitly granted/answered
77
+ if (event.type === 'permission.replied') {
78
+ console.log(`[opencode-agent] Permission answered for ticket ${ticket.id}. Resuming processing.`);
79
+ ticketRepository.updateAgentSession(ticket.id, {
80
+ column_id: ticket.column_id,
81
+ agent_type: agentType,
82
+ status: 'processing',
83
+ port: 4096,
84
+ url: agentUrl
85
+ });
86
+ }
87
+ // Handle API Quotas & Retries
88
+ if (event.type === 'session.status') {
89
+ const status = event.properties.status;
90
+ if (status.type === 'retry') {
91
+ // Let's only post the retry warning on the first few attempts so it doesn't spam infinitely
92
+ if (status.attempt <= 5) {
93
+ commentRepository.create({
94
+ ticketId: ticket.id,
95
+ author: 'System',
96
+ content: `⚠️ **API Retry Attempt ${status.attempt}**\n\n${status.message}`
97
+ });
98
+ }
99
+ }
100
+ }
101
+ // Handle Fatal Session Errors
102
+ if (event.type === 'session.error') {
103
+ const err = event.properties.error;
104
+ if (err) {
105
+ commentRepository.create({
106
+ ticketId: ticket.id,
107
+ author: 'System',
108
+ content: `❌ **Fatal Error: ${err.name}**\n\n${err.data?.message || JSON.stringify(err.data)}`
109
+ });
110
+ // Agent failed, mark ticket as blocked/failed
111
+ ticketRepository.updateAgentSession(ticket.id, {
112
+ column_id: ticket.column_id,
113
+ agent_type: agentType,
114
+ status: 'blocked',
115
+ port: 4096,
116
+ url: agentUrl,
117
+ error_message: err.data?.message || err.name
118
+ });
119
+ }
120
+ }
121
+ // Handle Text Messages (Wait for assistant message to complete)
122
+ if (event.type === 'message.updated') {
123
+ const info = event.properties.info;
124
+ if (info.role === 'assistant' && info.time?.completed && !processedMessages.has(info.id)) {
125
+ processedMessages.add(info.id);
126
+ try {
127
+ const messagesRes = await opencodeClient.session.messages({ path: { id: sessionID } });
128
+ if (messagesRes.data) {
129
+ const targetMsg = messagesRes.data.find((m) => m.info.id === info.id);
130
+ if (targetMsg && targetMsg.parts) {
131
+ for (const part of targetMsg.parts) {
132
+ if (part.type === 'text' && part.text?.trim()) {
133
+ commentRepository.create({
134
+ ticketId: ticket.id,
135
+ author: 'opencode',
136
+ content: part.text.trim()
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ catch (fetchErr) {
144
+ console.error(`[opencode-agent] Failed to fetch parts for message ${info.id}`, fetchErr);
145
+ }
146
+ }
147
+ }
148
+ // Handle Tool Completions and Reasoning Real-Time
149
+ if (event.type === 'message.part.updated') {
150
+ const part = event.properties.part;
151
+ // 1. Handle tool executions
152
+ if (part.type === 'tool' && part.state?.status === 'completed' && !processedTools.has(part.id)) {
153
+ processedTools.add(part.id);
154
+ let content = `Executed \`${part.tool}\``;
155
+ if (part.state.title)
156
+ content += `\n*${part.state.title}*`;
157
+ commentRepository.create({
158
+ ticketId: ticket.id,
159
+ author: 'opencode',
160
+ content: content
161
+ });
162
+ }
163
+ // 2. Handle final reasoning/thoughts block (if not caught by deltas)
164
+ if (part.type === 'reasoning' && !processedTools.has(part.id)) {
165
+ processedTools.add(part.id);
166
+ // If we already tracked this via deltas, don't recreate it
167
+ if (!activeParts.has(part.id) && part.text && part.text.trim()) {
168
+ commentRepository.create({
169
+ ticketId: ticket.id,
170
+ author: 'opencode (thought)',
171
+ content: part.text.trim()
172
+ });
173
+ }
174
+ }
175
+ }
176
+ // Handle Session Idle (Task Completed)
177
+ if (event.type === 'session.idle') {
178
+ console.log(`[opencode-agent] Agent task completed (idle) for ticket ${ticket.id}.`);
179
+ // Check if the session failed with a fatal error previously
180
+ const currentTicket = ticketRepository.findById(ticket.id);
181
+ const hasError = currentTicket?.agent_sessions?.some((s) => s.column_id === ticket.column_id && s.status === 'blocked');
182
+ if (!hasError) {
183
+ // Mark the ticket as done now that the agent session finished processing
184
+ ticketRepository.updateAgentSession(ticket.id, {
185
+ column_id: ticket.column_id,
186
+ agent_type: agentType,
187
+ status: 'done',
188
+ port: 4096,
189
+ url: agentUrl
190
+ });
191
+ if (agentType === 'code_review') {
192
+ try {
193
+ // Find out if the PR was approved or changes requested
194
+ const latestTicket = ticketRepository.findById(ticket.id) || ticket;
195
+ const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
196
+ const prUrl = prUrlSession?.pr_url;
197
+ if (prUrl) {
198
+ const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath);
199
+ const comments = JSON.parse(prStatus).comments;
200
+ // Find the latest comment that contains a decision
201
+ let reviewDecision = 'NONE';
202
+ // 1. Check GitHub PR comments First
203
+ if (comments && comments.length > 0) {
204
+ for (const comment of [...comments].reverse()) {
205
+ const bodyLower = comment.body.toLowerCase();
206
+ if (bodyLower.includes('[approved]') || bodyLower.includes('approve the pr') || bodyLower.includes('lgtm') || bodyLower.includes('approved')) {
207
+ reviewDecision = 'APPROVED';
208
+ break;
209
+ }
210
+ if (bodyLower.includes('[changes_requested]') || bodyLower.includes('request changes') || bodyLower.includes('changes requested') || bodyLower.includes('changes are needed')) {
211
+ reviewDecision = 'CHANGES_REQUESTED';
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ // 2. Fallback to Local Chat Logs
217
+ if (reviewDecision === 'NONE') {
218
+ const localDbComments = commentRepository.findByTicketId(ticket.id);
219
+ if (localDbComments && localDbComments.length > 0) {
220
+ for (const dbC of [...localDbComments].reverse()) {
221
+ const bodyLower = dbC.content.toLowerCase();
222
+ if (bodyLower.includes('[approved]') || bodyLower.includes('approve the pr') || bodyLower.includes('lgtm') || bodyLower.includes('approved')) {
223
+ reviewDecision = 'APPROVED';
224
+ break;
225
+ }
226
+ if (bodyLower.includes('[changes_requested]') || bodyLower.includes('request changes') || bodyLower.includes('changes requested') || bodyLower.includes('changes are needed')) {
227
+ reviewDecision = 'CHANGES_REQUESTED';
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ }
233
+ console.log(`[codereview-agent] PR ${prUrl} review decision parsed from comments: ${reviewDecision}`);
234
+ if (reviewDecision === 'APPROVED') {
235
+ commentRepository.create({
236
+ ticketId: ticket.id,
237
+ author: 'System',
238
+ content: `✅ **Code Review Approved!**\n\nThe agent approved the PR.`
239
+ });
240
+ // Move ticket to the configured destination column (if set)
241
+ if (config.on_finish_column_id) {
242
+ console.log(`[codereview-agent] Moving ticket ${ticket.id} forward to column ${config.on_finish_column_id}`);
243
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
244
+ if (moved) {
245
+ // Trigger via the queue so concurrency/priority rules are respected
246
+ agentQueue.evaluateColumnQueue(config.on_finish_column_id);
247
+ }
248
+ }
249
+ }
250
+ else if (reviewDecision === 'CHANGES_REQUESTED') {
251
+ commentRepository.create({
252
+ ticketId: ticket.id,
253
+ author: 'System',
254
+ content: `⚠️ **Changes Requested**\n\nThe agent has requested changes on the PR. Sending ticket back for revision.`
255
+ });
256
+ if (config.on_reject_column_id) {
257
+ console.log(`[codereview-agent] Moving ticket ${ticket.id} back to configured reject column ${config.on_reject_column_id}`);
258
+ const moved = ticketRepository.move(ticket.id, config.on_reject_column_id, 0);
259
+ if (moved) {
260
+ // Use force=true so the dev agent will re-run even though it previously finished 'done'
261
+ agentQueue.enqueue(moved.id, true);
262
+ }
263
+ }
264
+ else {
265
+ console.warn(`[codereview-agent] Ticket ${ticket.id} rejected, but no 'on_reject_column_id' is configured!`);
266
+ }
267
+ }
268
+ else {
269
+ commentRepository.create({
270
+ ticketId: ticket.id,
271
+ author: 'System',
272
+ content: `ℹ️ **Code Review Finished**\n\nThe review was completed, but no explicit approval or changes were requested.`
273
+ });
274
+ }
275
+ }
276
+ }
277
+ catch (err) {
278
+ console.error(`[codereview-agent] Error handling post-review logic`, err);
279
+ }
280
+ }
281
+ else if (agentType === 'opencode') {
282
+ // Commit, push, and create PR before moving ticket
283
+ try {
284
+ console.log(`[opencode-agent] Checking for changes in worktree ${worktreePath}`);
285
+ const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath);
286
+ if (statusOut.trim()) {
287
+ console.log(`[opencode-agent] Changes found for ticket ${ticket.id}. Committing and pushing.`);
288
+ await runCmd('git', ['add', '.'], worktreePath);
289
+ await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath);
290
+ // Inject GH_TOKEN into remote URL for git push authentication
291
+ const token = await getGhToken(worktreePath);
292
+ if (token) {
293
+ try {
294
+ const { stdout: remoteUrlOut } = await runCmd('git', ['config', '--get', 'remote.origin.url'], worktreePath);
295
+ const remoteUrl = remoteUrlOut.trim();
296
+ if (remoteUrl.startsWith('https://github.com/')) {
297
+ const authedUrl = remoteUrl.replace('https://github.com/', `https://x-access-token:${token}@github.com/`);
298
+ await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath);
299
+ }
300
+ }
301
+ catch (urlErr) {
302
+ console.warn(`[opencode-agent] Could not set authenticated remote URL:`, urlErr);
303
+ }
304
+ }
305
+ await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath);
306
+ // Check if a PR already exists for this ticket (fetch fresh from DB)
307
+ const freshTicket = ticketRepository.findById(ticket.id) || ticket;
308
+ const existingPrUrlSession = [...(freshTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
309
+ let prUrl = existingPrUrlSession?.pr_url;
310
+ if (prUrl) {
311
+ commentRepository.create({
312
+ ticketId: ticket.id,
313
+ author: 'System',
314
+ content: `🚀 **Pull Request Updated**\n\nThe agent has updated the existing PR:\n${prUrl}`
315
+ });
316
+ }
317
+ else {
318
+ const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath);
319
+ prUrl = prOut.trim();
320
+ commentRepository.create({
321
+ ticketId: ticket.id,
322
+ author: 'System',
323
+ content: `🚀 **Pull Request Created**\n\nThe agent has proposed the following changes in a PR. Check it out:\n${prUrl}`
324
+ });
325
+ }
326
+ // Add PR URL to the active agent session so the UI displays the code review button
327
+ ticketRepository.updateAgentSession(ticket.id, {
328
+ column_id: ticket.column_id,
329
+ agent_type: 'opencode',
330
+ status: 'done',
331
+ port: 4096,
332
+ url: agentUrl,
333
+ pr_url: prUrl
334
+ });
335
+ }
336
+ else {
337
+ console.log(`[opencode-agent] No changes to push for ticket ${ticket.id}.`);
338
+ commentRepository.create({
339
+ ticketId: ticket.id,
340
+ author: 'System',
341
+ content: `ℹ️ **Task Completed (No Changes)**\n\nThe agent finished the task but did not make any code changes.`
342
+ });
343
+ }
344
+ }
345
+ catch (error) {
346
+ console.error(`[opencode-agent] Failed to create PR for ticket ${ticket.id}`, error);
347
+ commentRepository.create({
348
+ ticketId: ticket.id,
349
+ author: 'System',
350
+ content: `❌ **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\`\`\``
351
+ });
352
+ }
353
+ // Move ticket to the configured destination column (if set)
354
+ if (config.on_finish_column_id) {
355
+ console.log(`[opencode-agent] Moving ticket ${ticket.id} to column ${config.on_finish_column_id}`);
356
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
357
+ if (moved) {
358
+ // Trigger via the queue so concurrency/priority rules are respected
359
+ agentQueue.evaluateColumnQueue(config.on_finish_column_id);
360
+ }
361
+ }
362
+ } // End of agentType === 'opencode' block
363
+ }
364
+ // Clean up the server instance tracking after a brief delay
365
+ setTimeout(() => {
366
+ console.log(`[opencode-agent] Cleaning up tracking for ${ticket.id}`);
367
+ delete activeSessions[ticket.id];
368
+ }, 60000); // 1 minute cleanup
369
+ }
370
+ // ── Live Text Streaming via Deltas ──
371
+ if (event.type === 'message.part.delta') {
372
+ const partID = event.properties.partID;
373
+ const delta = event.properties.delta;
374
+ if (delta) {
375
+ let tracking = activeParts.get(partID);
376
+ if (!tracking) {
377
+ // create initial comment
378
+ const comment = commentRepository.create({
379
+ ticketId: ticket.id,
380
+ author: 'opencode',
381
+ content: delta
382
+ });
383
+ tracking = { commentId: comment.id, fullText: delta };
384
+ activeParts.set(partID, tracking);
385
+ }
386
+ else {
387
+ // update existing comment
388
+ tracking.fullText += delta;
389
+ commentRepository.update(tracking.commentId, tracking.fullText);
390
+ }
391
+ }
392
+ }
393
+ }
394
+ }
395
+ catch (err) {
396
+ console.error(`[opencode-agent] Event stream error for ticket ${ticket.id}:`, err);
397
+ }
398
+ }
@@ -0,0 +1,159 @@
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
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const DB_PATH = path.join(__dirname, '..', '..', '..', 'openboard.db');
8
+ // Resolve sql.js WASM dynamically — handles npm workspace node_modules hoisting
9
+ const require = createRequire(import.meta.url);
10
+ const sqlJsDir = path.dirname(require.resolve('sql.js/dist/sql-wasm.wasm'));
11
+ const WASM_PATH = path.join(sqlJsDir, 'sql-wasm.wasm');
12
+ let db;
13
+ let inTransaction = false;
14
+ function makeStatement(sql) {
15
+ return {
16
+ all(...params) {
17
+ const results = db.exec(sql, params.length ? params : undefined);
18
+ if (!results.length || !results[0].values.length)
19
+ return [];
20
+ const { columns, values } = results[0];
21
+ return values.map(row => Object.fromEntries(columns.map((col, i) => [col, row[i]])));
22
+ },
23
+ get(...params) {
24
+ return this.all(...params)[0];
25
+ },
26
+ run(...params) {
27
+ db.run(sql, params.length ? params : undefined);
28
+ // Don't persist mid-transaction — sql.js auto-commits on db.export(),
29
+ // which would invalidate the open transaction and cause COMMIT/ROLLBACK to fail.
30
+ if (!inTransaction)
31
+ persist();
32
+ },
33
+ };
34
+ }
35
+ function persist() {
36
+ const data = db.export();
37
+ fs.writeFileSync(DB_PATH, Buffer.from(data));
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Transaction helper (sql.js has no WAL, runs synchronously anyway)
41
+ // ---------------------------------------------------------------------------
42
+ function transaction(fn) {
43
+ return () => {
44
+ inTransaction = true;
45
+ db.run('BEGIN');
46
+ try {
47
+ fn();
48
+ db.run('COMMIT');
49
+ inTransaction = false;
50
+ persist(); // single persist after the full transaction commits
51
+ }
52
+ catch (e) {
53
+ db.run('ROLLBACK');
54
+ inTransaction = false;
55
+ throw e;
56
+ }
57
+ };
58
+ }
59
+ let dbHandle;
60
+ export async function initDb() {
61
+ if (dbHandle)
62
+ return dbHandle;
63
+ const wasmFile = fs.readFileSync(WASM_PATH);
64
+ const wasmBinary = wasmFile.buffer.slice(wasmFile.byteOffset, wasmFile.byteOffset + wasmFile.byteLength);
65
+ const SQL = await initSqlJs({ wasmBinary });
66
+ if (fs.existsSync(DB_PATH)) {
67
+ const fileBuffer = fs.readFileSync(DB_PATH);
68
+ db = new SQL.Database(new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength));
69
+ }
70
+ else {
71
+ db = new SQL.Database();
72
+ }
73
+ db.run('PRAGMA foreign_keys = ON;');
74
+ runMigrations();
75
+ dbHandle = {
76
+ prepare: makeStatement,
77
+ exec: (sql) => { db.exec(sql); persist(); },
78
+ transaction,
79
+ };
80
+ return dbHandle;
81
+ }
82
+ export function getDb() {
83
+ if (!dbHandle)
84
+ throw new Error('DB not initialized. Call initDb() first.');
85
+ return dbHandle;
86
+ }
87
+ function runMigrations() {
88
+ db.run(`
89
+ CREATE TABLE IF NOT EXISTS boards (
90
+ id TEXT PRIMARY KEY,
91
+ name TEXT NOT NULL,
92
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS board_workspaces (
96
+ id TEXT PRIMARY KEY,
97
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
98
+ type TEXT NOT NULL, -- 'folder' or 'git'
99
+ path TEXT NOT NULL
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS columns (
103
+ id TEXT PRIMARY KEY,
104
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
105
+ name TEXT NOT NULL,
106
+ position INTEGER NOT NULL DEFAULT 0,
107
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS tickets (
111
+ id TEXT PRIMARY KEY,
112
+ column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
113
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
114
+ title TEXT NOT NULL,
115
+ description TEXT NOT NULL DEFAULT '',
116
+ priority TEXT NOT NULL DEFAULT 'medium',
117
+ position INTEGER NOT NULL DEFAULT 0,
118
+ agent_sessions TEXT NOT NULL DEFAULT '[]',
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
120
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
121
+ );
122
+
123
+ CREATE TABLE IF NOT EXISTS comments (
124
+ id TEXT PRIMARY KEY,
125
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
126
+ author TEXT NOT NULL DEFAULT 'agent',
127
+ content TEXT NOT NULL,
128
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
129
+ );
130
+
131
+ CREATE TABLE IF NOT EXISTS column_configs (
132
+ column_id TEXT PRIMARY KEY REFERENCES columns(id) ON DELETE CASCADE,
133
+ agent_type TEXT NOT NULL,
134
+ agent_model TEXT,
135
+ on_finish_column_id TEXT
136
+ );
137
+ `);
138
+ try {
139
+ db.run("ALTER TABLE tickets ADD COLUMN agent_sessions TEXT NOT NULL DEFAULT '[]';");
140
+ console.log("[db] Migration: Added agent_sessions column to tickets table");
141
+ }
142
+ catch (e) { }
143
+ try {
144
+ db.run("ALTER TABLE column_configs ADD COLUMN agent_model TEXT;");
145
+ console.log("[db] Migration: Added agent_model column to column_configs table");
146
+ }
147
+ catch (e) { }
148
+ try {
149
+ db.run("ALTER TABLE column_configs ADD COLUMN max_agents INTEGER DEFAULT 1;");
150
+ console.log("[db] Migration: Added max_agents column to column_configs table");
151
+ }
152
+ catch (e) { }
153
+ try {
154
+ db.run("ALTER TABLE column_configs ADD COLUMN on_reject_column_id TEXT;");
155
+ console.log("[db] Migration: Added on_reject_column_id column to column_configs table");
156
+ }
157
+ catch (e) { }
158
+ persist();
159
+ }
@@ -0,0 +1,66 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { initDb } from './db/database.js';
4
+ import { boardsRouter } from './routes/boards.router.js';
5
+ import { columnsRouter } from './routes/columns.router.js';
6
+ import { ticketsRouter } from './routes/tickets.router.js';
7
+ import { columnConfigRouter } from './routes/column-config.router.js';
8
+ import { sseManager } from './sse.js';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const PORT = process.env.PORT ?? 3001;
14
+ async function start() {
15
+ await initDb();
16
+ console.log('[openboard] Database ready');
17
+ const app = express();
18
+ app.use(cors({ origin: process.env.CLIENT_ORIGIN ? process.env.CLIENT_ORIGIN.split(',') : ['http://localhost:5173', 'http://localhost:4173'] }));
19
+ app.use(express.json());
20
+ // SSE subscription endpoint
21
+ // GET /api/events?boardId=<id>
22
+ // Clients subscribe here; the server pushes events whenever data changes.
23
+ app.get('/api/events', (req, res) => {
24
+ const boardId = req.query.boardId || '*';
25
+ res.setHeader('Content-Type', 'text/event-stream');
26
+ res.setHeader('Cache-Control', 'no-cache');
27
+ res.setHeader('Connection', 'keep-alive');
28
+ res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering if present
29
+ res.flushHeaders();
30
+ // Register this connection
31
+ sseManager.subscribe(boardId, res);
32
+ // Send a connected confirmation event
33
+ res.write(`event: connected\ndata: ${JSON.stringify({ boardId })}\n\n`);
34
+ // Heartbeat every 25 s to keep the connection alive through proxies/firewalls
35
+ const heartbeat = setInterval(() => {
36
+ try {
37
+ res.write(': heartbeat\n\n');
38
+ }
39
+ catch {
40
+ clearInterval(heartbeat);
41
+ }
42
+ }, 25_000);
43
+ req.on('close', () => {
44
+ clearInterval(heartbeat);
45
+ sseManager.unsubscribe(boardId, res);
46
+ });
47
+ });
48
+ app.use('/api/boards', boardsRouter);
49
+ app.use('/api/boards/:boardId/columns', columnsRouter);
50
+ app.use('/api/boards/:boardId/tickets', ticketsRouter);
51
+ app.use('/api/boards/:boardId/columns', columnConfigRouter);
52
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
53
+ // Serve the built client
54
+ const clientExtPath = path.join(__dirname, '../../client/dist');
55
+ app.use(express.static(clientExtPath));
56
+ app.get('*', (req, res) => {
57
+ res.sendFile(path.join(clientExtPath, 'index.html'));
58
+ });
59
+ app.listen(PORT, () => {
60
+ console.log(`[openboard] Server running at http://localhost:${PORT}`);
61
+ });
62
+ }
63
+ start().catch(err => {
64
+ console.error('[openboard] Failed to start:', err);
65
+ process.exit(1);
66
+ });