@loicngr/kobo 0.1.1
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/AGENTS.md +227 -0
- package/LICENSE +674 -0
- package/README.md +199 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
- package/dist/mcp-server/kobo-tasks-server.js +116 -0
- package/dist/server/db/index.js +22 -0
- package/dist/server/db/migrations.js +20 -0
- package/dist/server/db/schema.js +49 -0
- package/dist/server/index.js +178 -0
- package/dist/server/routes/dev-server.js +74 -0
- package/dist/server/routes/git.js +20 -0
- package/dist/server/routes/notion.js +24 -0
- package/dist/server/routes/settings.js +92 -0
- package/dist/server/routes/workspaces.js +730 -0
- package/dist/server/services/agent-manager.js +435 -0
- package/dist/server/services/dev-server-service.js +298 -0
- package/dist/server/services/notion-service.js +369 -0
- package/dist/server/services/pr-template-service.js +38 -0
- package/dist/server/services/settings-service.js +205 -0
- package/dist/server/services/websocket-service.js +212 -0
- package/dist/server/services/workspace-service.js +208 -0
- package/dist/server/services/worktree-service.js +117 -0
- package/dist/server/utils/git-ops.js +117 -0
- package/dist/server/utils/paths.js +95 -0
- package/dist/server/utils/process-tracker.js +46 -0
- package/package.json +84 -0
- package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-6hzaLlYO.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
- package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
- package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
- package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
- package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
- package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
- package/src/client/dist/spa/index.html +4 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
- package/src/mcp-server/kobo-tasks-server.ts +128 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import * as agentManager from '../services/agent-manager.js';
|
|
4
|
+
import * as devServerService from '../services/dev-server-service.js';
|
|
5
|
+
import * as notionService from '../services/notion-service.js';
|
|
6
|
+
import * as settingsService from '../services/settings-service.js';
|
|
7
|
+
import * as wsService from '../services/websocket-service.js';
|
|
8
|
+
import * as workspaceService from '../services/workspace-service.js';
|
|
9
|
+
import * as worktreeService from '../services/worktree-service.js';
|
|
10
|
+
import * as gitOps from '../utils/git-ops.js';
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
// GET /api/workspaces — list all workspaces
|
|
13
|
+
app.get('/', (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const workspaces = workspaceService.listWorkspaces();
|
|
16
|
+
return c.json(workspaces);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
20
|
+
return c.json({ error: message }, 500);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
// POST /api/workspaces — create workspace
|
|
24
|
+
app.post('/', async (c) => {
|
|
25
|
+
try {
|
|
26
|
+
const body = await c.req.json();
|
|
27
|
+
if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
|
|
28
|
+
return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
|
|
29
|
+
}
|
|
30
|
+
// 1. Create workspace
|
|
31
|
+
let workspace = workspaceService.createWorkspace({
|
|
32
|
+
name: body.name,
|
|
33
|
+
projectPath: body.projectPath,
|
|
34
|
+
sourceBranch: body.sourceBranch,
|
|
35
|
+
workingBranch: body.workingBranch,
|
|
36
|
+
notionUrl: body.notionUrl,
|
|
37
|
+
notionPageId: body.notionPageId,
|
|
38
|
+
model: body.model,
|
|
39
|
+
});
|
|
40
|
+
let notionContent = null;
|
|
41
|
+
// 2. If notionUrl provided, extract Notion page
|
|
42
|
+
if (body.notionUrl) {
|
|
43
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
44
|
+
try {
|
|
45
|
+
notionContent = await notionService.extractNotionPage(body.notionUrl);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
console.error(`[workspaces] Failed to extract Notion page: ${message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 3. Create tasks from extracted data
|
|
53
|
+
if (notionContent) {
|
|
54
|
+
let sortOrder = 0;
|
|
55
|
+
for (const todo of notionContent.todos) {
|
|
56
|
+
workspaceService.createTask(workspace.id, {
|
|
57
|
+
title: todo.title,
|
|
58
|
+
isAcceptanceCriterion: false,
|
|
59
|
+
sortOrder: sortOrder++,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
for (const feature of notionContent.gherkinFeatures) {
|
|
63
|
+
workspaceService.createTask(workspace.id, {
|
|
64
|
+
title: feature,
|
|
65
|
+
isAcceptanceCriterion: true,
|
|
66
|
+
sortOrder: sortOrder++,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Update workspace name with Notion page title only if user didn't provide a custom name
|
|
70
|
+
if (notionContent.title && workspace.name === 'workspace') {
|
|
71
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, notionContent.title);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Create manual tasks/criteria if no Notion content was extracted
|
|
75
|
+
if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
|
|
76
|
+
let sortOrder = 0;
|
|
77
|
+
if (Array.isArray(body.tasks)) {
|
|
78
|
+
for (const title of body.tasks) {
|
|
79
|
+
if (typeof title === 'string' && title.trim()) {
|
|
80
|
+
workspaceService.createTask(workspace.id, {
|
|
81
|
+
title: title.trim(),
|
|
82
|
+
isAcceptanceCriterion: false,
|
|
83
|
+
sortOrder: sortOrder++,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(body.acceptanceCriteria)) {
|
|
89
|
+
for (const title of body.acceptanceCriteria) {
|
|
90
|
+
if (typeof title === 'string' && title.trim()) {
|
|
91
|
+
workspaceService.createTask(workspace.id, {
|
|
92
|
+
title: title.trim(),
|
|
93
|
+
isAcceptanceCriterion: true,
|
|
94
|
+
sortOrder: sortOrder++,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 4. Create worktree
|
|
101
|
+
let worktreePath;
|
|
102
|
+
try {
|
|
103
|
+
worktreePath = worktreeService.createWorktree(body.projectPath, body.workingBranch, body.sourceBranch);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
108
|
+
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
109
|
+
}
|
|
110
|
+
// 4b. Write git conventions to the worktree if configured
|
|
111
|
+
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
112
|
+
if (effectiveSettings.gitConventions) {
|
|
113
|
+
try {
|
|
114
|
+
const fs = await import('node:fs');
|
|
115
|
+
const path = await import('node:path');
|
|
116
|
+
const aiDir = path.default.join(worktreePath, '.ai');
|
|
117
|
+
fs.mkdirSync(aiDir, { recursive: true });
|
|
118
|
+
const conventionsPath = path.default.join(aiDir, 'git-conventions.md');
|
|
119
|
+
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error('[workspaces] Failed to write git-conventions.md:', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 5. Save Notion content as markdown in worktree
|
|
126
|
+
let notionFilePath = null;
|
|
127
|
+
if (notionContent && body.notionUrl) {
|
|
128
|
+
try {
|
|
129
|
+
const fs = await import('node:fs');
|
|
130
|
+
const path = await import('node:path');
|
|
131
|
+
const thoughtsDir = path.default.join(worktreePath, '.ai', 'thoughts');
|
|
132
|
+
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
133
|
+
// Derive filename from title (TK-XXX pattern or slug)
|
|
134
|
+
const tkMatch = workspace.name.match(/TK-\d+/i);
|
|
135
|
+
const filename = tkMatch
|
|
136
|
+
? `${tkMatch[0]}.md`
|
|
137
|
+
: `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
|
|
138
|
+
notionFilePath = path.default.join(thoughtsDir, filename);
|
|
139
|
+
const today = new Date().toISOString().split('T')[0];
|
|
140
|
+
let md = `# ${workspace.name}\n\n`;
|
|
141
|
+
md += `## Source\n\n`;
|
|
142
|
+
md += `- Notion: ${body.notionUrl}\n`;
|
|
143
|
+
md += `- Retrieved: ${today}\n\n`;
|
|
144
|
+
if (notionContent.goal) {
|
|
145
|
+
md += `## Goal\n\n${notionContent.goal}\n\n`;
|
|
146
|
+
}
|
|
147
|
+
if (notionContent.todos.length > 0) {
|
|
148
|
+
md += `## Tasks\n\n`;
|
|
149
|
+
for (const todo of notionContent.todos) {
|
|
150
|
+
md += `- [${todo.checked ? 'x' : ' '}] ${todo.title}\n`;
|
|
151
|
+
}
|
|
152
|
+
md += '\n';
|
|
153
|
+
}
|
|
154
|
+
if (notionContent.gherkinFeatures.length > 0) {
|
|
155
|
+
md += `## Acceptance Criteria\n\n`;
|
|
156
|
+
for (const feature of notionContent.gherkinFeatures) {
|
|
157
|
+
md += `${feature}\n\n`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
fs.writeFileSync(notionFilePath, md, 'utf-8');
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
console.error('[workspaces] Failed to save Notion content:', err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 6. Update workspace status to 'brainstorming'
|
|
167
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
|
|
168
|
+
// 6. Build prompt with tasks and acceptance criteria
|
|
169
|
+
const allTasks = workspaceService.listTasks(workspace.id);
|
|
170
|
+
const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
|
|
171
|
+
const criteria = allTasks.filter((t) => t.isAcceptanceCriterion);
|
|
172
|
+
let brainstormPrompt = `You are working on: ${workspace.name}\n`;
|
|
173
|
+
if (notionContent?.goal) {
|
|
174
|
+
brainstormPrompt += `\nGoal: ${notionContent.goal}\n`;
|
|
175
|
+
}
|
|
176
|
+
brainstormPrompt += `\nBranch: ${body.workingBranch}\n`;
|
|
177
|
+
if (notionFilePath) {
|
|
178
|
+
brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
|
|
179
|
+
brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
|
|
180
|
+
}
|
|
181
|
+
if (todos.length > 0) {
|
|
182
|
+
brainstormPrompt += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
183
|
+
}
|
|
184
|
+
if (criteria.length > 0) {
|
|
185
|
+
brainstormPrompt += `\nAcceptance criteria:\n${criteria.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
186
|
+
}
|
|
187
|
+
if (criteria.length > 0 || todos.length > 0) {
|
|
188
|
+
brainstormPrompt += `\nYou have access to MCP tools via the 'kobo-tasks' server:\n`;
|
|
189
|
+
brainstormPrompt += `- list_tasks() — list all tasks and criteria with their IDs and current status\n`;
|
|
190
|
+
brainstormPrompt += `- mark_task_done(task_id) — mark a task or criterion as done\n`;
|
|
191
|
+
brainstormPrompt += `\nAs you implement the work and validate each criterion, call mark_task_done with the corresponding task_id. Call list_tasks first to see the current IDs.\n`;
|
|
192
|
+
}
|
|
193
|
+
if (effectiveSettings.gitConventions) {
|
|
194
|
+
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
|
|
195
|
+
}
|
|
196
|
+
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
197
|
+
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
198
|
+
// Persist the initial prompt in the feed so it's visible in the chat
|
|
199
|
+
const { emit } = await import('../services/websocket-service.js');
|
|
200
|
+
emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
201
|
+
try {
|
|
202
|
+
agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
206
|
+
console.error(`[workspaces] Failed to start agent: ${message}`);
|
|
207
|
+
try {
|
|
208
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* already logged */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Return created workspace with tasks
|
|
215
|
+
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
216
|
+
return c.json(workspaceWithTasks, 201);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
return c.json({ error: message }, 500);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// GET /api/workspaces/:id/sessions — list sessions for a workspace
|
|
224
|
+
app.get('/:id/sessions', (c) => {
|
|
225
|
+
try {
|
|
226
|
+
const id = c.req.param('id');
|
|
227
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
228
|
+
if (!workspace)
|
|
229
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
230
|
+
const sessions = workspaceService.listSessions(id);
|
|
231
|
+
return c.json(sessions);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
return c.json({ error: message }, 500);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
// POST /api/workspaces/:id/refresh-notion — re-extract Notion page and update tasks
|
|
239
|
+
app.post('/:id/refresh-notion', async (c) => {
|
|
240
|
+
try {
|
|
241
|
+
const id = c.req.param('id');
|
|
242
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
243
|
+
if (!workspace)
|
|
244
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
245
|
+
if (!workspace.notionUrl)
|
|
246
|
+
return c.json({ error: 'No Notion URL configured' }, 400);
|
|
247
|
+
const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
|
|
248
|
+
// Delete existing tasks and recreate from Notion
|
|
249
|
+
const db = (await import('../db/index.js')).getDb();
|
|
250
|
+
db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
|
|
251
|
+
let sortOrder = 0;
|
|
252
|
+
for (const todo of notionContent.todos) {
|
|
253
|
+
workspaceService.createTask(id, {
|
|
254
|
+
title: todo.title,
|
|
255
|
+
isAcceptanceCriterion: false,
|
|
256
|
+
sortOrder: sortOrder++,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
for (const feature of notionContent.gherkinFeatures) {
|
|
260
|
+
workspaceService.createTask(id, {
|
|
261
|
+
title: feature,
|
|
262
|
+
isAcceptanceCriterion: true,
|
|
263
|
+
sortOrder: sortOrder++,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Update name if it was the default
|
|
267
|
+
if (notionContent.title && workspace.name === 'workspace') {
|
|
268
|
+
workspaceService.updateWorkspaceName(id, notionContent.title);
|
|
269
|
+
}
|
|
270
|
+
const updated = workspaceService.getWorkspaceWithTasks(id);
|
|
271
|
+
return c.json(updated);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
275
|
+
return c.json({ error: message }, 500);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// POST /api/workspaces/:id/tasks — create a new task
|
|
279
|
+
app.post('/:id/tasks', async (c) => {
|
|
280
|
+
try {
|
|
281
|
+
const id = c.req.param('id');
|
|
282
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
283
|
+
if (!workspace) {
|
|
284
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
285
|
+
}
|
|
286
|
+
const body = await c.req.json();
|
|
287
|
+
if (!body.title?.trim()) {
|
|
288
|
+
return c.json({ error: 'Title is required' }, 400);
|
|
289
|
+
}
|
|
290
|
+
const existing = workspaceService.listTasks(id);
|
|
291
|
+
const nextSortOrder = existing.length > 0 ? Math.max(...existing.map((t) => t.sortOrder)) + 1 : 0;
|
|
292
|
+
const task = workspaceService.createTask(id, {
|
|
293
|
+
title: body.title.trim(),
|
|
294
|
+
isAcceptanceCriterion: !!body.isAcceptanceCriterion,
|
|
295
|
+
sortOrder: nextSortOrder,
|
|
296
|
+
});
|
|
297
|
+
return c.json(task, 201);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
return c.json({ error: message }, 500);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
|
|
305
|
+
app.patch('/:id/tasks/:taskId', async (c) => {
|
|
306
|
+
try {
|
|
307
|
+
const taskId = c.req.param('taskId');
|
|
308
|
+
const body = await c.req.json();
|
|
309
|
+
if (body.status === undefined && body.title === undefined) {
|
|
310
|
+
return c.json({ error: 'At least one of status or title is required' }, 400);
|
|
311
|
+
}
|
|
312
|
+
if (body.title !== undefined) {
|
|
313
|
+
if (!body.title.trim()) {
|
|
314
|
+
return c.json({ error: 'Title cannot be empty' }, 400);
|
|
315
|
+
}
|
|
316
|
+
workspaceService.updateTaskTitle(taskId, body.title.trim());
|
|
317
|
+
}
|
|
318
|
+
if (body.status !== undefined) {
|
|
319
|
+
const validStatuses = ['pending', 'in_progress', 'done'];
|
|
320
|
+
if (!validStatuses.includes(body.status)) {
|
|
321
|
+
return c.json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` }, 400);
|
|
322
|
+
}
|
|
323
|
+
workspaceService.updateTaskStatus(taskId, body.status);
|
|
324
|
+
}
|
|
325
|
+
return c.json({ ok: true });
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
329
|
+
return c.json({ error: message }, 500);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
// DELETE /api/workspaces/:id/tasks/:taskId — delete a task
|
|
333
|
+
app.delete('/:id/tasks/:taskId', (c) => {
|
|
334
|
+
try {
|
|
335
|
+
const id = c.req.param('id');
|
|
336
|
+
const taskId = c.req.param('taskId');
|
|
337
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
338
|
+
if (!workspace) {
|
|
339
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
340
|
+
}
|
|
341
|
+
workspaceService.deleteTask(taskId);
|
|
342
|
+
return new Response(null, { status: 204 });
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
346
|
+
return c.json({ error: message }, 500);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
// POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
|
|
350
|
+
app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
351
|
+
try {
|
|
352
|
+
const id = c.req.param('id');
|
|
353
|
+
const taskId = c.req.param('taskId');
|
|
354
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
355
|
+
if (!workspace) {
|
|
356
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
357
|
+
}
|
|
358
|
+
wsService.emit(id, 'task:updated', { taskId, status: 'done' });
|
|
359
|
+
return new Response(null, { status: 204 });
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
363
|
+
return c.json({ error: message }, 500);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
// GET /api/workspaces/archived — list archived workspaces (must be before GET /:id)
|
|
367
|
+
app.get('/archived', (c) => {
|
|
368
|
+
try {
|
|
369
|
+
return c.json(workspaceService.listArchivedWorkspaces());
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
373
|
+
return c.json({ error: message }, 500);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
// GET /api/workspaces/:id — get workspace details with tasks
|
|
377
|
+
app.get('/:id', (c) => {
|
|
378
|
+
try {
|
|
379
|
+
const id = c.req.param('id');
|
|
380
|
+
const workspace = workspaceService.getWorkspaceWithTasks(id);
|
|
381
|
+
if (!workspace) {
|
|
382
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
383
|
+
}
|
|
384
|
+
return c.json(workspace);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
388
|
+
return c.json({ error: message }, 500);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
// PATCH /api/workspaces/:id — update workspace status
|
|
392
|
+
app.patch('/:id', async (c) => {
|
|
393
|
+
try {
|
|
394
|
+
const id = c.req.param('id');
|
|
395
|
+
const body = await c.req.json();
|
|
396
|
+
if (!body.status) {
|
|
397
|
+
return c.json({ error: 'Missing required field: status' }, 400);
|
|
398
|
+
}
|
|
399
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
400
|
+
if (!workspace) {
|
|
401
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
402
|
+
}
|
|
403
|
+
const updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
404
|
+
return c.json(updated);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
408
|
+
if (message.includes('not found')) {
|
|
409
|
+
return c.json({ error: message }, 404);
|
|
410
|
+
}
|
|
411
|
+
if (message.includes('Invalid status transition')) {
|
|
412
|
+
return c.json({ error: message }, 400);
|
|
413
|
+
}
|
|
414
|
+
return c.json({ error: message }, 500);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
// POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
|
|
418
|
+
app.post('/:id/archive', (c) => {
|
|
419
|
+
try {
|
|
420
|
+
const id = c.req.param('id');
|
|
421
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
422
|
+
if (!workspace) {
|
|
423
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
424
|
+
}
|
|
425
|
+
if (workspace.archivedAt) {
|
|
426
|
+
return c.json({ error: 'Already archived' }, 400);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
agentManager.stopAgent(id);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Agent may not be running — ignore
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
devServerService.stopDevServer(id);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
439
|
+
console.error(`[workspaces] stopDevServer during archive failed: ${message}`);
|
|
440
|
+
}
|
|
441
|
+
const updated = workspaceService.archiveWorkspace(id);
|
|
442
|
+
wsService.emitEphemeral(id, 'workspace:archived', { workspace: updated });
|
|
443
|
+
return c.json(updated);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
447
|
+
return c.json({ error: message }, 500);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
// POST /api/workspaces/:id/unarchive — restore an archived workspace
|
|
451
|
+
app.post('/:id/unarchive', (c) => {
|
|
452
|
+
try {
|
|
453
|
+
const id = c.req.param('id');
|
|
454
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
455
|
+
if (!workspace) {
|
|
456
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
457
|
+
}
|
|
458
|
+
if (!workspace.archivedAt) {
|
|
459
|
+
return c.json({ error: 'Not archived' }, 400);
|
|
460
|
+
}
|
|
461
|
+
const updated = workspaceService.unarchiveWorkspace(id);
|
|
462
|
+
wsService.emitEphemeral(id, 'workspace:unarchived', { workspace: updated });
|
|
463
|
+
return c.json(updated);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
467
|
+
return c.json({ error: message }, 500);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// DELETE /api/workspaces/:id — delete workspace
|
|
471
|
+
app.delete('/:id', async (c) => {
|
|
472
|
+
try {
|
|
473
|
+
const id = c.req.param('id');
|
|
474
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
475
|
+
if (!workspace) {
|
|
476
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
477
|
+
}
|
|
478
|
+
// Parse optional body for branch deletion options
|
|
479
|
+
const body = await c.req
|
|
480
|
+
.json()
|
|
481
|
+
.catch(() => ({}));
|
|
482
|
+
// 1. Stop agent if running
|
|
483
|
+
try {
|
|
484
|
+
agentManager.stopAgent(id);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// Agent may not be running — ignore
|
|
488
|
+
}
|
|
489
|
+
// 2. Remove worktree
|
|
490
|
+
const worktreesDir = `${workspace.projectPath}/.worktrees`;
|
|
491
|
+
const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
|
|
492
|
+
try {
|
|
493
|
+
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
497
|
+
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
498
|
+
}
|
|
499
|
+
// 3. Delete local branch if requested
|
|
500
|
+
if (body.deleteLocalBranch) {
|
|
501
|
+
try {
|
|
502
|
+
gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
506
|
+
console.error(`[workspaces] Failed to delete local branch: ${message}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// 4. Delete remote branch if requested
|
|
510
|
+
if (body.deleteRemoteBranch) {
|
|
511
|
+
try {
|
|
512
|
+
gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
516
|
+
console.error(`[workspaces] Failed to delete remote branch: ${message}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// 5. Delete workspace from DB
|
|
520
|
+
workspaceService.deleteWorkspace(id);
|
|
521
|
+
return new Response(null, { status: 204 });
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
525
|
+
return c.json({ error: message }, 500);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
// POST /api/workspaces/:id/start — start/restart agent
|
|
529
|
+
app.post('/:id/start', async (c) => {
|
|
530
|
+
try {
|
|
531
|
+
const id = c.req.param('id');
|
|
532
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
533
|
+
if (!workspace) {
|
|
534
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
535
|
+
}
|
|
536
|
+
const body = await c.req.json().catch(() => ({ prompt: undefined }));
|
|
537
|
+
const prompt = body.prompt ?? 'Continue the previous task where you left off.';
|
|
538
|
+
// Stop existing agent if running
|
|
539
|
+
try {
|
|
540
|
+
agentManager.stopAgent(id);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// Agent may not be running — ignore
|
|
544
|
+
}
|
|
545
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
546
|
+
agentManager.startAgent(id, worktreePath, prompt, workspace.model);
|
|
547
|
+
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
548
|
+
return c.json({ status: 'started' });
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
552
|
+
return c.json({ error: message }, 500);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
// POST /api/workspaces/:id/push — push working branch to origin
|
|
556
|
+
app.post('/:id/push', async (c) => {
|
|
557
|
+
try {
|
|
558
|
+
const id = c.req.param('id');
|
|
559
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
560
|
+
if (!workspace) {
|
|
561
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
562
|
+
}
|
|
563
|
+
const path = await import('node:path');
|
|
564
|
+
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
565
|
+
try {
|
|
566
|
+
gitOps.pushBranch(worktreePath, workspace.workingBranch);
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
570
|
+
return c.json({ error: message }, 500);
|
|
571
|
+
}
|
|
572
|
+
// Emit a trace into the chat feed so the user sees the action
|
|
573
|
+
const { emit } = await import('../services/websocket-service.js');
|
|
574
|
+
const session = workspaceService.getLatestSession(id);
|
|
575
|
+
const sessionId = session?.claudeSessionId ?? undefined;
|
|
576
|
+
emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
|
|
577
|
+
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
581
|
+
return c.json({ error: message }, 500);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
// POST /api/workspaces/:id/open-pr — create a GitHub PR and send a templated prompt to the agent
|
|
585
|
+
app.post('/:id/open-pr', async (c) => {
|
|
586
|
+
try {
|
|
587
|
+
const id = c.req.param('id');
|
|
588
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
589
|
+
if (!workspace) {
|
|
590
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
591
|
+
}
|
|
592
|
+
const path = await import('node:path');
|
|
593
|
+
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
594
|
+
// 1. Check branch is on remote
|
|
595
|
+
let lsRemoteOut = '';
|
|
596
|
+
try {
|
|
597
|
+
const result = execFileSync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
|
|
598
|
+
cwd: worktreePath,
|
|
599
|
+
});
|
|
600
|
+
lsRemoteOut = result.toString();
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
lsRemoteOut = '';
|
|
604
|
+
}
|
|
605
|
+
if (!lsRemoteOut.trim()) {
|
|
606
|
+
return c.json({ error: 'Branch is not on remote', code: 'branch_not_pushed' }, 409);
|
|
607
|
+
}
|
|
608
|
+
// 2. Check all local commits are pushed
|
|
609
|
+
try {
|
|
610
|
+
const result = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
|
|
611
|
+
const countStr = result.toString().trim();
|
|
612
|
+
const count = parseInt(countStr, 10) || 0;
|
|
613
|
+
if (count > 0) {
|
|
614
|
+
return c.json({ error: 'Local commits not pushed', code: 'unpushed_commits' }, 409);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
619
|
+
const stderr = err.stderr?.toString() ?? '';
|
|
620
|
+
const combined = `${message} ${stderr}`.toLowerCase();
|
|
621
|
+
if (combined.includes('no upstream') || combined.includes('aucun amont') || combined.includes('no such ref')) {
|
|
622
|
+
return c.json({ error: 'Branch has no upstream', code: 'branch_not_pushed' }, 409);
|
|
623
|
+
}
|
|
624
|
+
return c.json({ error: `Failed to check branch state: ${message}` }, 500);
|
|
625
|
+
}
|
|
626
|
+
// 3. Create PR via gh
|
|
627
|
+
let ghOutput;
|
|
628
|
+
try {
|
|
629
|
+
const placeholderBody = 'Automated PR — description will be updated by the agent.';
|
|
630
|
+
const result = execFileSync('gh', [
|
|
631
|
+
'pr',
|
|
632
|
+
'create',
|
|
633
|
+
'--base',
|
|
634
|
+
workspace.sourceBranch,
|
|
635
|
+
'--head',
|
|
636
|
+
workspace.workingBranch,
|
|
637
|
+
'--title',
|
|
638
|
+
workspace.name,
|
|
639
|
+
'--body',
|
|
640
|
+
placeholderBody,
|
|
641
|
+
], { cwd: worktreePath });
|
|
642
|
+
ghOutput = result.toString();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
646
|
+
const stderr = err.stderr?.toString() ?? '';
|
|
647
|
+
return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
|
|
648
|
+
}
|
|
649
|
+
// 4. Parse PR URL and number
|
|
650
|
+
const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
|
|
651
|
+
if (!urlMatch) {
|
|
652
|
+
return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
|
|
653
|
+
}
|
|
654
|
+
const prUrl = urlMatch[0];
|
|
655
|
+
const prNumber = parseInt(urlMatch[1], 10);
|
|
656
|
+
// ── From here on, PR exists. No more 5xx responses. ──
|
|
657
|
+
// 5. Resolve the template; skip message steps if empty
|
|
658
|
+
const effective = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
659
|
+
if (!effective.prPromptTemplate) {
|
|
660
|
+
return c.json({ ok: true, prNumber, prUrl, messageSent: false });
|
|
661
|
+
}
|
|
662
|
+
// 6. Build context and render the template
|
|
663
|
+
const { renderPrTemplate } = await import('../services/pr-template-service.js');
|
|
664
|
+
const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
665
|
+
const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
666
|
+
const tasks = workspaceService.listTasks(workspace.id);
|
|
667
|
+
const rendered = renderPrTemplate(effective.prPromptTemplate, {
|
|
668
|
+
workspace,
|
|
669
|
+
prNumber,
|
|
670
|
+
prUrl,
|
|
671
|
+
commits,
|
|
672
|
+
diffStats,
|
|
673
|
+
tasks,
|
|
674
|
+
});
|
|
675
|
+
// 7. Emit user:message into the chat feed
|
|
676
|
+
const { emit } = await import('../services/websocket-service.js');
|
|
677
|
+
const session = workspaceService.getLatestSession(workspace.id);
|
|
678
|
+
const sessionId = session?.claudeSessionId ?? undefined;
|
|
679
|
+
emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
680
|
+
// 8. Send to the running agent (degrade on failure)
|
|
681
|
+
try {
|
|
682
|
+
agentManager.sendMessage(workspace.id, rendered);
|
|
683
|
+
return c.json({ ok: true, prNumber, prUrl, messageSent: true });
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
687
|
+
console.warn(`[workspaces] open-pr: PR created but sendMessage failed: ${message}`);
|
|
688
|
+
return c.json({
|
|
689
|
+
ok: true,
|
|
690
|
+
prNumber,
|
|
691
|
+
prUrl,
|
|
692
|
+
messageSent: false,
|
|
693
|
+
warning: `Agent is not active — message was not sent (${message})`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
699
|
+
return c.json({ error: message }, 500);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
// POST /api/workspaces/:id/stop — stop agent
|
|
703
|
+
app.post('/:id/stop', (c) => {
|
|
704
|
+
try {
|
|
705
|
+
const id = c.req.param('id');
|
|
706
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
707
|
+
if (!workspace) {
|
|
708
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
agentManager.stopAgent(id);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// Agent may not be tracked (e.g. server restarted) — just update status
|
|
715
|
+
}
|
|
716
|
+
// Always transition to idle so the UI reflects the stopped state
|
|
717
|
+
try {
|
|
718
|
+
workspaceService.updateWorkspaceStatus(id, 'idle');
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Status transition may not be valid
|
|
722
|
+
}
|
|
723
|
+
return c.json({ status: 'stopped' });
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
727
|
+
return c.json({ error: message }, 500);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
export default app;
|