@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,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OpenBoard</title>
|
|
7
|
+
<meta name="description" content="A local project management board — create boards, columns, and tickets." />
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-HJtFAWwr.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CIdk7SfN.css">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
|
+
import { columnConfigRepository } from '../repositories/column-config.repository.js';
|
|
3
|
+
// Agents are lazy-loaded inside dispatchAgent() to avoid circular ESM imports
|
|
4
|
+
// (agent files import opencode.events.ts which imports agentQueue from here)
|
|
5
|
+
async function resolveAgentClass(agentType) {
|
|
6
|
+
switch (agentType) {
|
|
7
|
+
case 'dummy': return (await import('./dummy.agent.js')).DummyAgent;
|
|
8
|
+
case 'opencode': return (await import('./opencode.agent.js')).OpencodeAgent;
|
|
9
|
+
case 'code_review': return (await import('./codereview.agent.js')).CodeReviewAgent;
|
|
10
|
+
default: return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const PRIORITY_WEIGHTS = {
|
|
14
|
+
urgent: 4,
|
|
15
|
+
high: 3,
|
|
16
|
+
medium: 2,
|
|
17
|
+
low: 1,
|
|
18
|
+
};
|
|
19
|
+
class AgentQueueManager {
|
|
20
|
+
// Ticket IDs currently running (to prevent same ticket starting twice)
|
|
21
|
+
runningTickets = new Set();
|
|
22
|
+
// Map to prevent concurrent evaluation of the same column
|
|
23
|
+
evaluatingColumns = new Set();
|
|
24
|
+
// Tickets explicitly forced to re-run even if their last session is 'done'
|
|
25
|
+
forcedTickets = new Set();
|
|
26
|
+
/**
|
|
27
|
+
* Enqueue a ticket for processing.
|
|
28
|
+
* In the new design, we just evaluate the column it belongs to.
|
|
29
|
+
*/
|
|
30
|
+
async enqueue(ticketId, force = false) {
|
|
31
|
+
const ticket = ticketRepository.findById(ticketId);
|
|
32
|
+
if (ticket) {
|
|
33
|
+
if (force) {
|
|
34
|
+
// Mark this ticket as forced so evaluateColumnQueue bypasses the 'done' skip
|
|
35
|
+
this.forcedTickets.add(ticketId);
|
|
36
|
+
}
|
|
37
|
+
this.evaluateColumnQueue(ticket.column_id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Trigger a global evaluation (e.g. on startup or general pings).
|
|
42
|
+
* Ideally, we use evaluateColumnQueue for specific events.
|
|
43
|
+
*/
|
|
44
|
+
async ping() {
|
|
45
|
+
// Iterate all active columns and evaluate them
|
|
46
|
+
// For simplicity now, we'll just not do a global ping unless needed,
|
|
47
|
+
// as the system is reactive per-column now.
|
|
48
|
+
// But to keep API stable, let's look up all tickets and find unique columns config'd for agents.
|
|
49
|
+
const dbModule = await import('../db/database.js');
|
|
50
|
+
const db = dbModule.getDb();
|
|
51
|
+
const activeCols = db.prepare("SELECT column_id FROM column_configs WHERE agent_type != 'none'").all();
|
|
52
|
+
for (const col of activeCols) {
|
|
53
|
+
this.evaluateColumnQueue(col.column_id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Evaluate a specific column and dispatch agents if slots are available.
|
|
58
|
+
*/
|
|
59
|
+
async evaluateColumnQueue(columnId) {
|
|
60
|
+
if (this.evaluatingColumns.has(columnId))
|
|
61
|
+
return;
|
|
62
|
+
this.evaluatingColumns.add(columnId);
|
|
63
|
+
try {
|
|
64
|
+
const config = columnConfigRepository.findByColumnId(columnId);
|
|
65
|
+
if (!config || config.agent_type === 'none')
|
|
66
|
+
return;
|
|
67
|
+
const maxAgents = config.max_agents ?? 1;
|
|
68
|
+
const tickets = ticketRepository.findByColumnId(columnId);
|
|
69
|
+
// Find how many currently running active sessions this column has
|
|
70
|
+
let activeCount = 0;
|
|
71
|
+
const eligibleTickets = [];
|
|
72
|
+
for (const ticket of tickets) {
|
|
73
|
+
// Skip if already running in memory
|
|
74
|
+
if (this.runningTickets.has(ticket.id)) {
|
|
75
|
+
activeCount++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Look at the LAST session in the ENTIRE history array.
|
|
79
|
+
// - If no sessions: ticket is fresh → eligible
|
|
80
|
+
// - If last session is for a DIFFERENT column: ticket just arrived here → eligible
|
|
81
|
+
// - If last session is for THIS column:
|
|
82
|
+
// processing/needs_approval → already running
|
|
83
|
+
// done → finished here naturally, skip
|
|
84
|
+
// blocked → skip unless forced (retry path)
|
|
85
|
+
const lastSession = ticket.agent_sessions.length > 0
|
|
86
|
+
? ticket.agent_sessions[ticket.agent_sessions.length - 1]
|
|
87
|
+
: null;
|
|
88
|
+
if (!lastSession) {
|
|
89
|
+
// Fresh ticket, no history
|
|
90
|
+
eligibleTickets.push(ticket);
|
|
91
|
+
}
|
|
92
|
+
else if (lastSession.column_id !== columnId) {
|
|
93
|
+
// Ticket just arrived from another column (on_finish or on_reject move)
|
|
94
|
+
eligibleTickets.push(ticket);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Last session is for this column
|
|
98
|
+
if (lastSession.status === 'processing' || lastSession.status === 'needs_approval') {
|
|
99
|
+
activeCount++;
|
|
100
|
+
}
|
|
101
|
+
else if (lastSession.status === 'done') {
|
|
102
|
+
// Finished here naturally; don't re-run unless forced
|
|
103
|
+
if (this.forcedTickets.has(ticket.id)) {
|
|
104
|
+
eligibleTickets.push(ticket);
|
|
105
|
+
}
|
|
106
|
+
// else skip
|
|
107
|
+
}
|
|
108
|
+
else if (lastSession.status === 'blocked') {
|
|
109
|
+
// Blocked; skip unless the user explicitly retried (force)
|
|
110
|
+
if (this.forcedTickets.has(ticket.id)) {
|
|
111
|
+
eligibleTickets.push(ticket);
|
|
112
|
+
}
|
|
113
|
+
// else skip
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (activeCount >= maxAgents) {
|
|
118
|
+
// At concurrency limit.
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Sort eligible: highest priority first, then oldest created_at first
|
|
122
|
+
eligibleTickets.sort((a, b) => {
|
|
123
|
+
const weightA = PRIORITY_WEIGHTS[a.priority] ?? 2;
|
|
124
|
+
const weightB = PRIORITY_WEIGHTS[b.priority] ?? 2;
|
|
125
|
+
if (weightA !== weightB) {
|
|
126
|
+
return weightB - weightA; // Descending priority
|
|
127
|
+
}
|
|
128
|
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); // Ascending date
|
|
129
|
+
});
|
|
130
|
+
// Dispatch agents for top eligible tickets up to the remaining slots
|
|
131
|
+
const slotsAvailable = maxAgents - activeCount;
|
|
132
|
+
const toDispatch = eligibleTickets.slice(0, slotsAvailable);
|
|
133
|
+
for (const ticket of toDispatch) {
|
|
134
|
+
this.forcedTickets.delete(ticket.id); // Clear forced flag before dispatching
|
|
135
|
+
this.runningTickets.add(ticket.id);
|
|
136
|
+
this.dispatchAgent(ticket, config).catch(err => {
|
|
137
|
+
console.error(`[agent-queue] Failed to dispatch agent for ticket ${ticket.id}`, err);
|
|
138
|
+
}).finally(() => {
|
|
139
|
+
this.runningTickets.delete(ticket.id);
|
|
140
|
+
// Re-evaluate when slot frees up
|
|
141
|
+
this.evaluateColumnQueue(columnId);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
this.evaluatingColumns.delete(columnId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async dispatchAgent(ticket, config) {
|
|
150
|
+
console.log(`[agent-queue] Dispatching agent ${config.agent_type} for ticket ${ticket.id} in column ${ticket.column_id} (Priority: ${ticket.priority})`);
|
|
151
|
+
// Agents own setting their own 'processing' status at the start of run()
|
|
152
|
+
const AgentClass = await resolveAgentClass(config.agent_type);
|
|
153
|
+
if (!AgentClass) {
|
|
154
|
+
console.warn(`[agent-queue] Unknown agent type: ${config.agent_type}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const agent = new AgentClass();
|
|
158
|
+
try {
|
|
159
|
+
await agent.run(ticket, config);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.error(`[agent-queue] Error executing agent ${config.agent_type} on ticket ${ticket.id}:`, err);
|
|
163
|
+
// Mark as blocked on execution failure safely
|
|
164
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
165
|
+
column_id: ticket.column_id,
|
|
166
|
+
agent_type: config.agent_type,
|
|
167
|
+
status: 'blocked',
|
|
168
|
+
error_message: err instanceof Error ? err.message : String(err)
|
|
169
|
+
});
|
|
170
|
+
// Optionally, we could set agent_status to 'error' or 'blocked' here if agent.run didn't manage to handle the error properly.
|
|
171
|
+
// Oh wait, we already did via updateAgentSession! Great!
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export const agentQueue = new AgentQueueManager();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { columnConfigRepository } from '../repositories/column-config.repository.js';
|
|
2
|
+
import { agentQueue } from './agent-queue.js';
|
|
3
|
+
/**
|
|
4
|
+
* Checks whether the column the ticket just arrived in has an agent configured,
|
|
5
|
+
* and if so, spawns the agent to run asynchronously (fire-and-forget).
|
|
6
|
+
*/
|
|
7
|
+
export function triggerAgent(ticket, force = false) {
|
|
8
|
+
const config = columnConfigRepository.findByColumnId(ticket.column_id);
|
|
9
|
+
if (!config || config.agent_type === 'none') {
|
|
10
|
+
// Even if the new column doesn't have an agent, this ticket might have moved
|
|
11
|
+
// OUT of a column that DOES have an agent, freeing up a concurrency slot.
|
|
12
|
+
// Therefore we must ping the queue to evaluate.
|
|
13
|
+
agentQueue.ping();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(`[agent-runner] Enqueuing ticket ${ticket.id} for agent ${config.agent_type} (Priority: ${ticket.priority})`);
|
|
17
|
+
agentQueue.enqueue(ticket.id, force);
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
|
+
import { boardRepository } from '../repositories/board.repository.js';
|
|
3
|
+
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
|
+
import { setupOpencodeEventListener } from './opencode.events.js';
|
|
5
|
+
import { createBoardScopedClient } from '../utils/opencode.js';
|
|
6
|
+
import { runCmd, normalizePathForOS } from '../utils/os.js';
|
|
7
|
+
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
8
|
+
const activeSessions = {};
|
|
9
|
+
export class CodeReviewAgent {
|
|
10
|
+
async run(ticket, config) {
|
|
11
|
+
console.log(`[codereview-agent] Starting session for ticket ${ticket.id}...`);
|
|
12
|
+
// Fetch the absolute latest ticket from the DB to ensure we have the latest session history (with PR URLs)
|
|
13
|
+
const latestTicket = ticketRepository.findById(ticket.id) || ticket;
|
|
14
|
+
// Find the PR URL from a previous session
|
|
15
|
+
const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
|
|
16
|
+
const prUrl = prUrlSession?.pr_url;
|
|
17
|
+
if (!prUrl) {
|
|
18
|
+
console.error(`[codereview-agent] No PR URL found for ticket ${ticket.id}`);
|
|
19
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
20
|
+
column_id: ticket.column_id,
|
|
21
|
+
agent_type: 'code_review',
|
|
22
|
+
status: 'blocked',
|
|
23
|
+
error_message: 'No PR found on this ticket to review. Make sure an OpenCode agent runs first.'
|
|
24
|
+
});
|
|
25
|
+
commentRepository.create({
|
|
26
|
+
ticketId: ticket.id,
|
|
27
|
+
author: 'System',
|
|
28
|
+
content: `❌ **Code Review Failed**\n\nCould not find a Pull Request to review. Did the developer agent create one?`
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Create a board-scoped Opencode client for this ticket
|
|
33
|
+
const board = boardRepository.findById(ticket.board_id);
|
|
34
|
+
const opencodeClient = createBoardScopedClient(board?.path);
|
|
35
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
36
|
+
column_id: ticket.column_id,
|
|
37
|
+
agent_type: 'code_review',
|
|
38
|
+
status: 'processing',
|
|
39
|
+
port: Number(opencodePort)
|
|
40
|
+
});
|
|
41
|
+
if (activeSessions[ticket.id]) {
|
|
42
|
+
try {
|
|
43
|
+
await opencodeClient.session.abort({ path: { id: activeSessions[ticket.id] } });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
console.error(`[codereview-agent] Error aborting previous session:`, e);
|
|
47
|
+
}
|
|
48
|
+
delete activeSessions[ticket.id];
|
|
49
|
+
}
|
|
50
|
+
// Resolve workspace path — code review runs in the main workspace (no new worktree needed).
|
|
51
|
+
let workspacePath = normalizePathForOS(board?.path || process.cwd());
|
|
52
|
+
try {
|
|
53
|
+
const session = await opencodeClient.session.create({
|
|
54
|
+
body: { title: `Code Review for Ticket: ${ticket.title}` },
|
|
55
|
+
query: { directory: workspacePath }
|
|
56
|
+
});
|
|
57
|
+
if (!session.data)
|
|
58
|
+
throw new Error("Failed to create OpenCode session");
|
|
59
|
+
const sessionID = session.data.id;
|
|
60
|
+
activeSessions[ticket.id] = sessionID;
|
|
61
|
+
const encodedPath = Buffer.from(workspacePath).toString('base64url');
|
|
62
|
+
const agentUrl = `http://127.0.0.1:${opencodePort}/${encodedPath}/session/${sessionID}`;
|
|
63
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
64
|
+
column_id: ticket.column_id,
|
|
65
|
+
agent_type: 'code_review',
|
|
66
|
+
status: 'processing',
|
|
67
|
+
port: Number(opencodePort),
|
|
68
|
+
url: agentUrl
|
|
69
|
+
});
|
|
70
|
+
const events = await opencodeClient.event.subscribe();
|
|
71
|
+
// Setup the event listener for code_review agent type.
|
|
72
|
+
// worktreePath = workspacePath (main workspace) — used for gh commands inside the event handler.
|
|
73
|
+
setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, workspacePath, // worktreePath — main workspace for gh commands
|
|
74
|
+
workspacePath, // originalWorkspacePath — same here, no separate worktree
|
|
75
|
+
'', // branchName — no branch created for code review
|
|
76
|
+
'code_review');
|
|
77
|
+
// Fetch GH token so the LLM environment can execute `gh` commands
|
|
78
|
+
let ghTokenEnv = '';
|
|
79
|
+
try {
|
|
80
|
+
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath, 'codereview-agent');
|
|
81
|
+
if (ghToken.trim()) {
|
|
82
|
+
ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (authErr) {
|
|
86
|
+
console.warn(`[codereview-agent] Could not fetch GH token:`, authErr);
|
|
87
|
+
}
|
|
88
|
+
const promptRes = await opencodeClient.session.promptAsync({
|
|
89
|
+
path: { id: sessionID },
|
|
90
|
+
body: {
|
|
91
|
+
parts: [{
|
|
92
|
+
type: "text",
|
|
93
|
+
text: `# TASK: Code Review for "${ticket.title}"\n\nThe Pull Request to review is located at: ${prUrl}\n\n## Instructions\n1. Download the diff using \`${ghTokenEnv}gh pr diff ${prUrl}\`.\n2. Analyze the changes for bugs, security issues, best practices, and edge cases.\n3. If the code looks good, leave a comment using \`${ghTokenEnv}gh pr comment ${prUrl} -b "LGTM! [APPROVED]"\`.\n4. If changes are needed, explicitly request changes using \`${ghTokenEnv}gh pr comment ${prUrl} -b "<reason> [CHANGES_REQUESTED]"\`.\n5. Summarize your review directly in this chat.`
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (promptRes.error) {
|
|
98
|
+
throw new Error(`OpenCode session error: ${JSON.stringify(promptRes.error)}`);
|
|
99
|
+
}
|
|
100
|
+
console.log(`[codereview-agent] Code review agent dispatched for ticket ${ticket.id}. Waiting for completion in background.`);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
console.error(`[codereview-agent] Failed to initialize task over SDK: ${e.message}`);
|
|
104
|
+
delete activeSessions[ticket.id];
|
|
105
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
106
|
+
column_id: ticket.column_id,
|
|
107
|
+
agent_type: 'code_review',
|
|
108
|
+
status: 'blocked',
|
|
109
|
+
error_message: e.message
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { commentRepository } from '../repositories/comment.repository.js';
|
|
2
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
const DELAY_MS = 10_000;
|
|
5
|
+
/**
|
|
6
|
+
* DummyAgent — simulates work by waiting 10 seconds, then:
|
|
7
|
+
* 1. Posts a comment "I finished the task" on the ticket.
|
|
8
|
+
* 2. Emits a `comment:added` SSE event.
|
|
9
|
+
* 3. If `on_finish_column_id` is configured, moves the ticket there
|
|
10
|
+
* and emits a `ticket:moved` SSE event.
|
|
11
|
+
*/
|
|
12
|
+
export class DummyAgent {
|
|
13
|
+
async run(ticket, config) {
|
|
14
|
+
console.log(`[dummy-agent] Starting work for ticket ${ticket.id} (will wait 10s)...`);
|
|
15
|
+
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
|
|
16
|
+
// 1 — Post completion comment
|
|
17
|
+
const comment = commentRepository.create({
|
|
18
|
+
ticketId: ticket.id,
|
|
19
|
+
author: 'dummy-agent',
|
|
20
|
+
content: 'I finished the task',
|
|
21
|
+
});
|
|
22
|
+
console.log(`[dummy-agent] Posted comment for ticket ${ticket.id}`);
|
|
23
|
+
sseManager.emit(ticket.board_id, 'comment:added', {
|
|
24
|
+
ticketId: ticket.id,
|
|
25
|
+
comment: comment
|
|
26
|
+
});
|
|
27
|
+
// 2 — Set status to done
|
|
28
|
+
const updated = ticketRepository.updateAgentSession(ticket.id, {
|
|
29
|
+
column_id: ticket.column_id,
|
|
30
|
+
agent_type: 'dummy',
|
|
31
|
+
status: 'done'
|
|
32
|
+
});
|
|
33
|
+
if (updated) {
|
|
34
|
+
sseManager.emit(ticket.board_id, 'ticket:updated', updated);
|
|
35
|
+
}
|
|
36
|
+
// 3 — Move ticket to the configured destination column (if set)
|
|
37
|
+
if (config.on_finish_column_id) {
|
|
38
|
+
console.log(`[dummy-agent] Moving ticket ${ticket.id} to column ${config.on_finish_column_id}`);
|
|
39
|
+
const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
|
|
40
|
+
if (moved) {
|
|
41
|
+
sseManager.emit(ticket.board_id, 'ticket:moved', moved);
|
|
42
|
+
// Also trigger agent for the NEW column if one is configured there
|
|
43
|
+
const { triggerAgent } = await import('./agent-runner.js');
|
|
44
|
+
triggerAgent(moved);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
console.log(`[dummy-agent] Finished work for ticket ${ticket.id}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
|
+
import { boardRepository } from '../repositories/board.repository.js';
|
|
3
|
+
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
|
+
import { setupOpencodeEventListener } from './opencode.events.js';
|
|
5
|
+
import { createBoardScopedClient } from '../utils/opencode.js';
|
|
6
|
+
import { runCmd, normalizePathForOS } from '../utils/os.js';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
10
|
+
// Track active session IDs by ticket ID to prevent/cancel overlaps
|
|
11
|
+
const activeSessions = {};
|
|
12
|
+
export class OpencodeAgent {
|
|
13
|
+
async run(ticket, config) {
|
|
14
|
+
console.log(`[opencode-agent] Starting session for ticket ${ticket.id}...`);
|
|
15
|
+
// Provide immediate visual feedback on the Frontend
|
|
16
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
17
|
+
column_id: ticket.column_id,
|
|
18
|
+
agent_type: 'opencode',
|
|
19
|
+
status: 'processing',
|
|
20
|
+
port: Number(opencodePort)
|
|
21
|
+
});
|
|
22
|
+
// Create a board-scoped Opencode client for this ticket
|
|
23
|
+
const board = boardRepository.findById(ticket.board_id);
|
|
24
|
+
const opencodeClient = createBoardScopedClient(board?.path);
|
|
25
|
+
// Prevent duplicate runs blocking new requests: replace the old session.
|
|
26
|
+
// 1. Find Worktree (Use the board's designated path)
|
|
27
|
+
let originalWorkspacePath = normalizePathForOS(board?.path || process.cwd());
|
|
28
|
+
// 2. Set up Worktree
|
|
29
|
+
// If this ticket already has a PR, we reuse the existing branch and worktree.
|
|
30
|
+
// Worktree paths are derived from branch name so they are stable across re-runs.
|
|
31
|
+
const latestTicket = ticketRepository.findById(ticket.id) || ticket;
|
|
32
|
+
const previousPrSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
|
|
33
|
+
const existingPrUrl = previousPrSession?.pr_url;
|
|
34
|
+
let branchName;
|
|
35
|
+
let worktreePath;
|
|
36
|
+
if (existingPrUrl) {
|
|
37
|
+
// Ticket was already worked on — reuse the existing branch & worktree
|
|
38
|
+
console.log(`[opencode-agent] Found existing PR ${existingPrUrl}. Fetching branch name.`);
|
|
39
|
+
try {
|
|
40
|
+
const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath, 'opencode-agent');
|
|
41
|
+
const prData = JSON.parse(prDataStr);
|
|
42
|
+
if (!prData.headRefName)
|
|
43
|
+
throw new Error('Could not parse headRefName from PR');
|
|
44
|
+
branchName = prData.headRefName;
|
|
45
|
+
}
|
|
46
|
+
catch (ghErr) {
|
|
47
|
+
console.warn(`[opencode-agent] Could not read PR branch name, will create a new branch.`, ghErr.message);
|
|
48
|
+
branchName = `ticket-${ticket.id}-${Date.now()}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Fresh ticket — create a new branch and worktree
|
|
53
|
+
branchName = `ticket-${ticket.id}-${Date.now()}`;
|
|
54
|
+
}
|
|
55
|
+
// Use a local folder within the board path for worktrees
|
|
56
|
+
worktreePath = normalizePathForOS(path.join(originalWorkspacePath, '.openboard-worktrees', branchName));
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(worktreePath)) {
|
|
59
|
+
// Worktree directory already on disk — just reuse it, no git command needed
|
|
60
|
+
console.log(`[opencode-agent] Reusing existing worktree at ${worktreePath} (branch: ${branchName})`);
|
|
61
|
+
}
|
|
62
|
+
else if (existingPrUrl) {
|
|
63
|
+
// Branch exists but worktree was cleaned up — check out the branch into the path
|
|
64
|
+
console.log(`[opencode-agent] Checking out existing branch ${branchName} into new worktree at ${worktreePath}`);
|
|
65
|
+
await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath, 'opencode-agent');
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Completely new — check if the repo is empty (no commits yet)
|
|
69
|
+
let isRepoEmpty = false;
|
|
70
|
+
try {
|
|
71
|
+
await runCmd('git', ['rev-parse', 'HEAD'], originalWorkspacePath, 'opencode-agent');
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
// if rev-parse HEAD fails, it usually means the repo has no commits
|
|
75
|
+
isRepoEmpty = true;
|
|
76
|
+
console.log(`[opencode-agent] Repository appears to be empty. Using --orphan for worktree.`);
|
|
77
|
+
}
|
|
78
|
+
if (isRepoEmpty) {
|
|
79
|
+
console.log(`[opencode-agent] Creating new orphan worktree at ${worktreePath} on branch ${branchName}`);
|
|
80
|
+
await runCmd('git', ['worktree', 'add', '--orphan', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(`[opencode-agent] Creating new worktree at ${worktreePath} on branch ${branchName}`);
|
|
84
|
+
await runCmd('git', ['worktree', 'add', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
console.error(`[opencode-agent] Failed to create git worktree: ${e.message}`);
|
|
90
|
+
delete activeSessions[ticket.id];
|
|
91
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
92
|
+
column_id: ticket.column_id,
|
|
93
|
+
agent_type: 'opencode',
|
|
94
|
+
status: 'blocked',
|
|
95
|
+
port: Number(opencodePort),
|
|
96
|
+
error_message: e.message
|
|
97
|
+
});
|
|
98
|
+
commentRepository.create({
|
|
99
|
+
ticketId: ticket.id,
|
|
100
|
+
author: 'opencode', // Use 'opencode' for consistency
|
|
101
|
+
content: `❌ **Failed to Initialize Worktree**\n\nThe agent could not prepare its isolated worktree.\nError: ${e.message}`
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// 3. Initialize Task
|
|
106
|
+
try {
|
|
107
|
+
// Create session
|
|
108
|
+
const session = await opencodeClient.session.create({
|
|
109
|
+
body: {
|
|
110
|
+
title: `Session for Ticket: ${ticket.title}`,
|
|
111
|
+
},
|
|
112
|
+
query: {
|
|
113
|
+
directory: worktreePath
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
if (!session.data)
|
|
117
|
+
throw new Error("Failed to create OpenCode session");
|
|
118
|
+
const sessionID = session.data.id;
|
|
119
|
+
activeSessions[ticket.id] = sessionID;
|
|
120
|
+
// Compute exact OpenCode Tracking URL
|
|
121
|
+
const encodedPath = Buffer.from(originalWorkspacePath).toString('base64url');
|
|
122
|
+
const agentUrl = `http://127.0.0.1:${opencodePort}/${encodedPath}/session/${sessionID}`;
|
|
123
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
124
|
+
column_id: ticket.column_id,
|
|
125
|
+
agent_type: 'opencode',
|
|
126
|
+
status: 'processing',
|
|
127
|
+
port: Number(opencodePort),
|
|
128
|
+
url: agentUrl
|
|
129
|
+
});
|
|
130
|
+
// --- Background Event Stream Listener ---
|
|
131
|
+
const events = await opencodeClient.event.subscribe();
|
|
132
|
+
// Start the event listener in the background without awaiting it
|
|
133
|
+
setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName);
|
|
134
|
+
// ------------------------------------------
|
|
135
|
+
// Send first message asynchronously (doesn't wait for completion)
|
|
136
|
+
// Fetch GH token so the LLM environment can execute `gh` commands
|
|
137
|
+
let ghTokenEnv = '';
|
|
138
|
+
try {
|
|
139
|
+
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath, 'opencode-agent');
|
|
140
|
+
if (ghToken.trim()) {
|
|
141
|
+
ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (authErr) {
|
|
145
|
+
console.warn(`[opencode-agent] Could not fetch GH token:`, authErr);
|
|
146
|
+
}
|
|
147
|
+
let promptText = `# TASK: ${ticket.title}\n\n## Description\n${ticket.description}\n\n## Instructions\n1. The current working directory you should focus on is ${worktreePath}.\n`;
|
|
148
|
+
if (existingPrUrl) {
|
|
149
|
+
promptText += `\n⚠️ **ATTENTION: CHANGES REQUESTED** ⚠️\nYou previously worked on this ticket and opened PR ${existingPrUrl}. However, changes were requested during code review.\n\nPlease use \`${ghTokenEnv}gh pr view ${existingPrUrl} --comments\` to read the requested changes, make the necessary code updates to fix the issues, and summarize your fixes.\n`;
|
|
150
|
+
}
|
|
151
|
+
const promptRes = await opencodeClient.session.promptAsync({
|
|
152
|
+
path: { id: sessionID },
|
|
153
|
+
body: {
|
|
154
|
+
parts: [{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: promptText
|
|
157
|
+
}]
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
if (promptRes.error) {
|
|
161
|
+
throw new Error(`OpenCode session error: ${JSON.stringify(promptRes.error)}`);
|
|
162
|
+
}
|
|
163
|
+
console.log(`[opencode-agent] Agent task dispatched for ticket ${ticket.id}. Waiting for completion in background.`);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
console.error(`[opencode-agent] Failed to initialize task over SDK: ${e.message}`);
|
|
167
|
+
delete activeSessions[ticket.id];
|
|
168
|
+
// Mark the ticket as blocked due to error
|
|
169
|
+
ticketRepository.updateAgentSession(ticket.id, {
|
|
170
|
+
column_id: ticket.column_id,
|
|
171
|
+
agent_type: 'opencode',
|
|
172
|
+
status: 'blocked',
|
|
173
|
+
url: undefined,
|
|
174
|
+
error_message: e.message
|
|
175
|
+
});
|
|
176
|
+
// Add a comment to explicitly tell the user what went wrong
|
|
177
|
+
commentRepository.create({
|
|
178
|
+
ticketId: ticket.id,
|
|
179
|
+
author: 'opencode',
|
|
180
|
+
content: `❌ **Agent Execution Failed**\n\nThe OpenCode agent failed to process this ticket.\nError: ${e.message}`
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|