@loicngr/kobo 1.1.1 → 1.3.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.
- package/dist/mcp-server/kobo-tasks-handlers.js +147 -0
- package/dist/mcp-server/kobo-tasks-server.js +236 -29
- package/dist/server/db/migrations.js +61 -10
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +10 -4
- package/dist/server/routes/images.js +57 -0
- package/dist/server/routes/workspaces.js +80 -19
- package/dist/server/services/agent-manager.js +91 -5
- package/dist/server/services/image-service.js +73 -0
- package/dist/server/services/pr-watcher-service.js +61 -0
- package/dist/server/services/settings-service.js +75 -10
- package/dist/server/services/workspace-service.js +13 -0
- package/dist/server/utils/git-ops.js +39 -0
- package/package.json +3 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CufaRX1M.js → ActivityFeed-Bie-lcn7.js} +7 -7
- package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +1 -0
- package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +1 -0
- package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +1 -0
- package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +1 -0
- package/src/client/dist/spa/assets/{QSpinnerDots-DcaNq8uL.js → QSpinnerDots-ByNZaBWw.js} +1 -1
- package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +1 -0
- package/src/client/dist/spa/assets/index-BoQWbZtE.js +5 -0
- package/src/client/dist/spa/assets/{nodes-DeIen-kp.js → nodes-CXdiSdC2.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +1 -0
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/README.md +179 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +238 -0
- package/src/mcp-server/kobo-tasks-server.ts +263 -29
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-Cyl-TRHT.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-D_vxGAPn.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-Dzy0I8lB.js +0 -1
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
- package/src/client/dist/spa/assets/QDialog-CMC1Ph52.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-DDjku8zz.js +0 -1
- package/src/client/dist/spa/assets/QPage-DaNo_vcd.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-BKHAAJ2p.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Blw7Qk7m.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DlnwomOE.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-HtatyhXN.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +0 -1
- package/src/client/dist/spa/assets/index-DJkEmbBM.js +0 -5
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +0 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
|
|
1
5
|
function rowToDto(row) {
|
|
2
6
|
return {
|
|
3
7
|
id: row.id,
|
|
@@ -25,3 +29,146 @@ export function markTaskDoneHandler(db, workspaceId, taskId) {
|
|
|
25
29
|
.get(taskId);
|
|
26
30
|
return { success: true, task: rowToDto(row) };
|
|
27
31
|
}
|
|
32
|
+
export function createTaskHandler(db, workspaceId, data) {
|
|
33
|
+
if (!data.title?.trim()) {
|
|
34
|
+
throw new Error('title is required');
|
|
35
|
+
}
|
|
36
|
+
// Verify workspace exists
|
|
37
|
+
const ws = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(workspaceId);
|
|
38
|
+
if (!ws) {
|
|
39
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
40
|
+
}
|
|
41
|
+
const id = nanoid();
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const isAC = data.is_acceptance_criterion ? 1 : 0;
|
|
44
|
+
// Append at the end: max(sort_order) + 1
|
|
45
|
+
const maxRow = db
|
|
46
|
+
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS max FROM tasks WHERE workspace_id = ?')
|
|
47
|
+
.get(workspaceId);
|
|
48
|
+
const sortOrder = maxRow.max + 1;
|
|
49
|
+
db.prepare('INSERT INTO tasks (id, workspace_id, title, status, is_acceptance_criterion, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, workspaceId, data.title.trim(), 'pending', isAC, sortOrder, now, now);
|
|
50
|
+
const row = db.prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE id = ?').get(id);
|
|
51
|
+
return rowToDto(row);
|
|
52
|
+
}
|
|
53
|
+
export function updateTaskHandler(db, workspaceId, taskId, data) {
|
|
54
|
+
// Verify task belongs to workspace
|
|
55
|
+
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
throw new Error(`Task '${taskId}' not found in workspace '${workspaceId}'`);
|
|
58
|
+
}
|
|
59
|
+
const sets = [];
|
|
60
|
+
const values = [];
|
|
61
|
+
if (data.title !== undefined) {
|
|
62
|
+
if (!data.title.trim())
|
|
63
|
+
throw new Error('title cannot be empty');
|
|
64
|
+
sets.push('title = ?');
|
|
65
|
+
values.push(data.title.trim());
|
|
66
|
+
}
|
|
67
|
+
if (data.status !== undefined) {
|
|
68
|
+
if (!VALID_TASK_STATUSES.includes(data.status)) {
|
|
69
|
+
throw new Error(`Invalid status '${data.status}'. Must be one of: ${VALID_TASK_STATUSES.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
sets.push('status = ?');
|
|
72
|
+
values.push(data.status);
|
|
73
|
+
}
|
|
74
|
+
if (data.is_acceptance_criterion !== undefined) {
|
|
75
|
+
sets.push('is_acceptance_criterion = ?');
|
|
76
|
+
values.push(data.is_acceptance_criterion ? 1 : 0);
|
|
77
|
+
}
|
|
78
|
+
if (sets.length === 0) {
|
|
79
|
+
throw new Error('No fields to update (provide title, status, or is_acceptance_criterion)');
|
|
80
|
+
}
|
|
81
|
+
sets.push('updated_at = ?');
|
|
82
|
+
values.push(new Date().toISOString());
|
|
83
|
+
values.push(taskId);
|
|
84
|
+
db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
85
|
+
const row = db
|
|
86
|
+
.prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE id = ?')
|
|
87
|
+
.get(taskId);
|
|
88
|
+
return rowToDto(row);
|
|
89
|
+
}
|
|
90
|
+
export function deleteTaskHandler(db, workspaceId, taskId) {
|
|
91
|
+
const result = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?').run(taskId, workspaceId);
|
|
92
|
+
if (result.changes === 0) {
|
|
93
|
+
throw new Error(`Task '${taskId}' not found in workspace '${workspaceId}'`);
|
|
94
|
+
}
|
|
95
|
+
return { success: true, task_id: taskId };
|
|
96
|
+
}
|
|
97
|
+
export function getDevServerStatusHandler(db, workspaceId) {
|
|
98
|
+
const row = db.prepare('SELECT dev_server_status FROM workspaces WHERE id = ?').get(workspaceId);
|
|
99
|
+
if (!row) {
|
|
100
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
101
|
+
}
|
|
102
|
+
return { workspaceId, status: row.dev_server_status };
|
|
103
|
+
}
|
|
104
|
+
export function getSettingsHandler(settingsPath, projectPath) {
|
|
105
|
+
// Shape is determined solely by whether projectPath was provided:
|
|
106
|
+
// - with projectPath → { global, project }
|
|
107
|
+
// - without → { global, projects }
|
|
108
|
+
// The `error` field is added on top when settings are unavailable.
|
|
109
|
+
if (!settingsPath || !fs.existsSync(settingsPath)) {
|
|
110
|
+
const base = projectPath ? { global: null, project: null } : { global: null, projects: [] };
|
|
111
|
+
return { ...base, error: 'Settings file not available' };
|
|
112
|
+
}
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
throw new Error(`Failed to read settings: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
const global = parsed.global ?? null;
|
|
121
|
+
const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
|
|
122
|
+
if (projectPath) {
|
|
123
|
+
const project = projects.find((p) => p.path === projectPath) ?? null;
|
|
124
|
+
return { global, project };
|
|
125
|
+
}
|
|
126
|
+
return { global, projects };
|
|
127
|
+
}
|
|
128
|
+
export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
129
|
+
const row = db
|
|
130
|
+
.prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, created_at, updated_at FROM workspaces WHERE id = ?')
|
|
131
|
+
.get(workspaceId);
|
|
132
|
+
if (!row) {
|
|
133
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: row.id,
|
|
137
|
+
name: row.name,
|
|
138
|
+
projectPath: row.project_path,
|
|
139
|
+
sourceBranch: row.source_branch,
|
|
140
|
+
workingBranch: row.working_branch,
|
|
141
|
+
worktreePath: path.join(row.project_path, '.worktrees', row.working_branch),
|
|
142
|
+
status: row.status,
|
|
143
|
+
model: row.model,
|
|
144
|
+
notionUrl: row.notion_url,
|
|
145
|
+
notionPageId: row.notion_page_id,
|
|
146
|
+
devServerStatus: row.dev_server_status,
|
|
147
|
+
createdAt: row.created_at,
|
|
148
|
+
updatedAt: row.updated_at,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function listWorkspaceImagesHandler(worktreePath) {
|
|
152
|
+
const imagesDir = path.join(worktreePath, '.ai', 'images');
|
|
153
|
+
const indexPath = path.join(imagesDir, 'index.json');
|
|
154
|
+
if (!fs.existsSync(indexPath))
|
|
155
|
+
return [];
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
// Read directory once — imagesDir is guaranteed to exist because indexPath does
|
|
164
|
+
const files = fs.readdirSync(imagesDir);
|
|
165
|
+
return entries.map((e) => {
|
|
166
|
+
const match = files.find((f) => f.startsWith(`${e.uid}.`));
|
|
167
|
+
return {
|
|
168
|
+
uid: e.uid,
|
|
169
|
+
originalName: e.originalName,
|
|
170
|
+
relativePath: match ? path.join('.ai', 'images', match) : '',
|
|
171
|
+
createdAt: e.createdAt,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -3,9 +3,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import Database from 'better-sqlite3';
|
|
6
|
-
import { listTasksHandler, markTaskDoneHandler } from './kobo-tasks-handlers.js';
|
|
6
|
+
import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSettingsHandler, getWorkspaceInfoHandler, listTasksHandler, listWorkspaceImagesHandler, markTaskDoneHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
7
7
|
const workspaceId = process.env.KOBO_WORKSPACE_ID;
|
|
8
8
|
const dbPath = process.env.KOBO_DB_PATH;
|
|
9
|
+
const settingsPath = process.env.KOBO_SETTINGS_PATH;
|
|
9
10
|
const backendUrl = process.env.KOBO_BACKEND_URL ?? 'http://localhost:3000';
|
|
10
11
|
if (!workspaceId) {
|
|
11
12
|
console.error('[kobo-tasks-server] KOBO_WORKSPACE_ID env var is required');
|
|
@@ -37,12 +38,36 @@ async function notifyBackend(taskId) {
|
|
|
37
38
|
console.error('[kobo-tasks-server] notify-done failed:', err);
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
async function notifyTasksUpdated() {
|
|
42
|
+
try {
|
|
43
|
+
const url = `${backendUrl}/api/workspaces/${workspaceId}/tasks/notify-updated`;
|
|
44
|
+
await fetch(url, { method: 'POST' });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error('[kobo-tasks-server] notify-updated failed:', err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function backendRequest(method, pathname, body) {
|
|
51
|
+
const url = `${backendUrl}${pathname}`;
|
|
52
|
+
const init = { method };
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
init.headers = { 'Content-Type': 'application/json' };
|
|
55
|
+
init.body = JSON.stringify(body);
|
|
56
|
+
}
|
|
57
|
+
const res = await fetch(url, init);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const errText = await res.text().catch(() => '');
|
|
60
|
+
throw new Error(`Backend ${method} ${pathname} returned ${res.status}: ${errText}`);
|
|
61
|
+
}
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
return text ? JSON.parse(text) : null;
|
|
64
|
+
}
|
|
40
65
|
const server = new Server({ name: 'kobo-tasks', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
41
66
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
42
67
|
tools: [
|
|
43
68
|
{
|
|
44
69
|
name: 'list_tasks',
|
|
45
|
-
description: 'List all tasks and acceptance criteria for the current workspace with their IDs and current status. Call this first to discover task IDs before calling mark_task_done.',
|
|
70
|
+
description: 'List all tasks and acceptance criteria for the current workspace with their IDs and current status. Call this first to discover task IDs before calling mark_task_done / update_task / delete_task.',
|
|
46
71
|
inputSchema: {
|
|
47
72
|
type: 'object',
|
|
48
73
|
properties: {},
|
|
@@ -63,43 +88,225 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
63
88
|
required: ['task_id'],
|
|
64
89
|
},
|
|
65
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'create_task',
|
|
93
|
+
description: 'Create a new task or acceptance criterion for the current workspace. Appended at the end of the list.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
title: { type: 'string', description: 'Task title' },
|
|
98
|
+
is_acceptance_criterion: {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
description: 'Whether this is an acceptance criterion (default: false)',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['title'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'update_task',
|
|
108
|
+
description: 'Update an existing task — change title, status, or is_acceptance_criterion flag. At least one field is required.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
task_id: { type: 'string', description: 'The ID of the task to update' },
|
|
113
|
+
title: { type: 'string', description: 'New title (optional)' },
|
|
114
|
+
status: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
enum: ['pending', 'in_progress', 'done'],
|
|
117
|
+
description: 'New status (optional)',
|
|
118
|
+
},
|
|
119
|
+
is_acceptance_criterion: {
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
description: 'Toggle acceptance criterion flag (optional)',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['task_id'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'delete_task',
|
|
129
|
+
description: 'Delete a task from the current workspace permanently.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
task_id: { type: 'string', description: 'The ID of the task to delete' },
|
|
134
|
+
},
|
|
135
|
+
required: ['task_id'],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'get_settings',
|
|
140
|
+
description: 'Read Kōbō settings (global + projects). Optionally filter to a specific project by path to get both global and that project override.',
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
project_path: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description: 'Optional project path to resolve a specific project entry',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: [],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'get_dev_server_status',
|
|
154
|
+
description: 'Check whether the dev server is running for the current workspace.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {},
|
|
158
|
+
required: [],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'get_workspace_info',
|
|
163
|
+
description: 'Get all metadata about the current workspace (name, project path, branches, model, notion URL, worktree path, status).',
|
|
164
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'start_dev_server',
|
|
168
|
+
description: 'Start the dev server configured for the current workspace.',
|
|
169
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'stop_dev_server',
|
|
173
|
+
description: 'Stop the dev server of the current workspace.',
|
|
174
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'get_dev_server_logs',
|
|
178
|
+
description: 'Fetch the last N lines of the dev server logs for the current workspace.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
tail: {
|
|
183
|
+
type: 'number',
|
|
184
|
+
description: 'Number of lines to fetch from the end (default: 200)',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: [],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'list_workspace_images',
|
|
192
|
+
description: 'List all images uploaded to the current workspace (from .ai/images/index.json). Returns uid, originalName, relativePath and createdAt for each image.',
|
|
193
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'get_git_info',
|
|
197
|
+
description: 'Get git stats for the current workspace (commit count, files changed, insertions, deletions, PR URL if any).',
|
|
198
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'set_workspace_status',
|
|
202
|
+
description: 'Update the current workspace status. Valid values: idle, completed, error. Transitions are validated by the backend.',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
status: {
|
|
207
|
+
type: 'string',
|
|
208
|
+
description: 'New status (e.g. idle, completed)',
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ['status'],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
66
214
|
],
|
|
67
215
|
}));
|
|
216
|
+
function ok(data) {
|
|
217
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
218
|
+
}
|
|
219
|
+
function fail(message) {
|
|
220
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
221
|
+
}
|
|
68
222
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
69
223
|
const { name, arguments: args } = request.params;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
if (name === 'mark_task_done') {
|
|
77
|
-
const taskId = args?.task_id;
|
|
78
|
-
if (!taskId) {
|
|
79
|
-
return {
|
|
80
|
-
content: [{ type: 'text', text: 'Error: task_id parameter is required' }],
|
|
81
|
-
isError: true,
|
|
82
|
-
};
|
|
224
|
+
const a = (args ?? {});
|
|
225
|
+
try {
|
|
226
|
+
if (name === 'list_tasks') {
|
|
227
|
+
return ok(listTasksHandler(db, workspaceId));
|
|
83
228
|
}
|
|
84
|
-
|
|
229
|
+
if (name === 'mark_task_done') {
|
|
230
|
+
const taskId = a.task_id;
|
|
231
|
+
if (!taskId)
|
|
232
|
+
return fail('task_id parameter is required');
|
|
85
233
|
const result = markTaskDoneHandler(db, workspaceId, taskId);
|
|
86
234
|
void notifyBackend(taskId);
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
235
|
+
return ok(result);
|
|
236
|
+
}
|
|
237
|
+
if (name === 'create_task') {
|
|
238
|
+
const title = a.title;
|
|
239
|
+
if (!title)
|
|
240
|
+
return fail('title parameter is required');
|
|
241
|
+
const task = createTaskHandler(db, workspaceId, {
|
|
242
|
+
title,
|
|
243
|
+
is_acceptance_criterion: a.is_acceptance_criterion,
|
|
244
|
+
});
|
|
245
|
+
void notifyTasksUpdated();
|
|
246
|
+
return ok(task);
|
|
90
247
|
}
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
248
|
+
if (name === 'update_task') {
|
|
249
|
+
const taskId = a.task_id;
|
|
250
|
+
if (!taskId)
|
|
251
|
+
return fail('task_id parameter is required');
|
|
252
|
+
const task = updateTaskHandler(db, workspaceId, taskId, {
|
|
253
|
+
title: a.title,
|
|
254
|
+
status: a.status,
|
|
255
|
+
is_acceptance_criterion: a.is_acceptance_criterion,
|
|
256
|
+
});
|
|
257
|
+
void notifyTasksUpdated();
|
|
258
|
+
return ok(task);
|
|
97
259
|
}
|
|
260
|
+
if (name === 'delete_task') {
|
|
261
|
+
const taskId = a.task_id;
|
|
262
|
+
if (!taskId)
|
|
263
|
+
return fail('task_id parameter is required');
|
|
264
|
+
const result = deleteTaskHandler(db, workspaceId, taskId);
|
|
265
|
+
void notifyTasksUpdated();
|
|
266
|
+
return ok(result);
|
|
267
|
+
}
|
|
268
|
+
if (name === 'get_settings') {
|
|
269
|
+
return ok(getSettingsHandler(settingsPath, a.project_path));
|
|
270
|
+
}
|
|
271
|
+
if (name === 'get_dev_server_status') {
|
|
272
|
+
return ok(getDevServerStatusHandler(db, workspaceId));
|
|
273
|
+
}
|
|
274
|
+
if (name === 'get_workspace_info') {
|
|
275
|
+
return ok(getWorkspaceInfoHandler(db, workspaceId));
|
|
276
|
+
}
|
|
277
|
+
if (name === 'start_dev_server') {
|
|
278
|
+
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
|
|
279
|
+
return ok(result);
|
|
280
|
+
}
|
|
281
|
+
if (name === 'stop_dev_server') {
|
|
282
|
+
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/stop`);
|
|
283
|
+
return ok(result);
|
|
284
|
+
}
|
|
285
|
+
if (name === 'get_dev_server_logs') {
|
|
286
|
+
const tail = a.tail ?? 200;
|
|
287
|
+
const result = await backendRequest('GET', `/api/dev-server/${workspaceId}/logs?tail=${tail}`);
|
|
288
|
+
return ok(result);
|
|
289
|
+
}
|
|
290
|
+
if (name === 'list_workspace_images') {
|
|
291
|
+
const info = getWorkspaceInfoHandler(db, workspaceId);
|
|
292
|
+
return ok(listWorkspaceImagesHandler(info.worktreePath));
|
|
293
|
+
}
|
|
294
|
+
if (name === 'get_git_info') {
|
|
295
|
+
const result = await backendRequest('GET', `/api/workspaces/${workspaceId}/git-stats`);
|
|
296
|
+
return ok(result);
|
|
297
|
+
}
|
|
298
|
+
if (name === 'set_workspace_status') {
|
|
299
|
+
const status = a.status;
|
|
300
|
+
if (!status)
|
|
301
|
+
return fail('status parameter is required');
|
|
302
|
+
const result = await backendRequest('PATCH', `/api/workspaces/${workspaceId}`, { status });
|
|
303
|
+
return ok(result);
|
|
304
|
+
}
|
|
305
|
+
return fail(`Unknown tool: ${name}`);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
98
309
|
}
|
|
99
|
-
return {
|
|
100
|
-
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
101
|
-
isError: true,
|
|
102
|
-
};
|
|
103
310
|
});
|
|
104
311
|
const transport = new StdioServerTransport();
|
|
105
312
|
server.connect(transport).catch((err) => {
|
|
@@ -1,20 +1,71 @@
|
|
|
1
1
|
import { initSchema } from './schema.js';
|
|
2
|
-
export const
|
|
2
|
+
export const migrations = [
|
|
3
|
+
{
|
|
4
|
+
version: 2,
|
|
5
|
+
name: 'add-permission-mode',
|
|
6
|
+
migrate: (db) => {
|
|
7
|
+
db.exec("ALTER TABLE workspaces ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'auto-accept'");
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
/** Current schema version — always equals the highest migration version. */
|
|
12
|
+
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
|
3
13
|
export function runMigrations(db) {
|
|
14
|
+
// Create the history table (replaces the old single-row schema_version table).
|
|
4
15
|
db.exec(`
|
|
5
|
-
CREATE TABLE IF NOT EXISTS
|
|
6
|
-
version
|
|
16
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
17
|
+
version INTEGER PRIMARY KEY,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
applied_at TEXT NOT NULL
|
|
7
20
|
)
|
|
8
21
|
`);
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
22
|
+
// ── Backward compat: migrate from legacy schema_version table ──────────────
|
|
23
|
+
const hasLegacy = db.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='schema_version'").get().c > 0;
|
|
24
|
+
if (hasLegacy) {
|
|
25
|
+
const legacyRow = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
26
|
+
const legacyVersion = legacyRow?.version ?? 0;
|
|
27
|
+
// Back-fill history for all migrations that were already applied under the old system.
|
|
28
|
+
if (legacyVersion >= 1) {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
// Version 1 = initSchema (always applied if legacyVersion >= 1)
|
|
31
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
|
|
32
|
+
for (const m of migrations) {
|
|
33
|
+
if (m.version <= legacyVersion) {
|
|
34
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
db.exec('DROP TABLE schema_version');
|
|
39
|
+
}
|
|
40
|
+
// ── Determine current state ────────────────────────────────────────────────
|
|
41
|
+
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
|
|
42
|
+
// Fresh install — no migrations applied yet.
|
|
43
|
+
if (!applied.has(1)) {
|
|
12
44
|
initSchema(db);
|
|
13
|
-
|
|
14
|
-
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
|
|
47
|
+
// Mark all incremental migrations as applied (initSchema creates the latest shape).
|
|
48
|
+
for (const m of migrations) {
|
|
49
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
|
|
15
50
|
}
|
|
16
|
-
|
|
17
|
-
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Apply pending migrations sequentially.
|
|
54
|
+
for (const m of migrations) {
|
|
55
|
+
if (!applied.has(m.version)) {
|
|
56
|
+
m.migrate(db);
|
|
57
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
|
|
18
58
|
}
|
|
19
59
|
}
|
|
20
60
|
}
|
|
61
|
+
/** Return the full migration history (for diagnostics / admin UI). */
|
|
62
|
+
export function getMigrationHistory(db) {
|
|
63
|
+
try {
|
|
64
|
+
return db
|
|
65
|
+
.prepare('SELECT version, name, applied_at FROM schema_migrations ORDER BY version')
|
|
66
|
+
.all();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/server/db/schema.js
CHANGED
|
@@ -10,6 +10,7 @@ export function initSchema(db) {
|
|
|
10
10
|
notion_url TEXT,
|
|
11
11
|
notion_page_id TEXT,
|
|
12
12
|
model TEXT NOT NULL DEFAULT 'claude-opus-4-6',
|
|
13
|
+
permission_mode TEXT NOT NULL DEFAULT 'auto-accept',
|
|
13
14
|
dev_server_status TEXT NOT NULL DEFAULT 'stopped',
|
|
14
15
|
archived_at TEXT,
|
|
15
16
|
created_at TEXT NOT NULL,
|
package/dist/server/index.js
CHANGED
|
@@ -9,10 +9,11 @@ import { getDb } from './db/index.js';
|
|
|
9
9
|
import { runMigrations } from './db/migrations.js';
|
|
10
10
|
import devServerRouter from './routes/dev-server.js';
|
|
11
11
|
import gitRouter from './routes/git.js';
|
|
12
|
+
import imagesRouter from './routes/images.js';
|
|
12
13
|
import notionRouter from './routes/notion.js';
|
|
13
14
|
import settingsRouter from './routes/settings.js';
|
|
14
15
|
import workspacesRouter from './routes/workspaces.js';
|
|
15
|
-
import { getAvailableSkills, sendMessage, startAgent, stopAgent } from './services/agent-manager.js';
|
|
16
|
+
import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, } from './services/agent-manager.js';
|
|
16
17
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
17
18
|
import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
18
19
|
import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
@@ -31,14 +32,18 @@ console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
|
|
|
31
32
|
// 1. Initialize DB + run migrations
|
|
32
33
|
const db = getDb();
|
|
33
34
|
runMigrations(db);
|
|
34
|
-
// 2. Initialize process cleanup
|
|
35
|
+
// 2. Initialize process cleanup, agent watchdog, and PR watcher
|
|
35
36
|
initProcessCleanup();
|
|
37
|
+
startWatchdog();
|
|
38
|
+
import { startPrWatcher } from './services/pr-watcher-service.js';
|
|
39
|
+
startPrWatcher();
|
|
36
40
|
// 3. Create Hono app
|
|
37
41
|
const app = new Hono();
|
|
38
42
|
// Health check (root / is handled by the SPA catch-all below)
|
|
39
43
|
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
|
40
44
|
// 4. Mount route sub-routers
|
|
41
45
|
app.route('/api/workspaces', workspacesRouter);
|
|
46
|
+
app.route('/api/workspaces', imagesRouter);
|
|
42
47
|
app.route('/api/notion', notionRouter);
|
|
43
48
|
app.route('/api/git', gitRouter);
|
|
44
49
|
app.route('/api/settings', settingsRouter);
|
|
@@ -91,6 +96,7 @@ const server = serve({
|
|
|
91
96
|
fetch: app.fetch,
|
|
92
97
|
port: PORT,
|
|
93
98
|
}, (info) => {
|
|
99
|
+
setBackendPort(info.port);
|
|
94
100
|
console.log(`Server running at http://localhost:${info.port}`);
|
|
95
101
|
});
|
|
96
102
|
// 6. Create WebSocketServer attached to the HTTP server
|
|
@@ -115,7 +121,7 @@ setMessageHandler((type, payload) => {
|
|
|
115
121
|
const workspace = getWorkspace(p.workspaceId);
|
|
116
122
|
if (workspace) {
|
|
117
123
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
118
|
-
startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true);
|
|
124
|
+
startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode);
|
|
119
125
|
updateWorkspaceStatus(p.workspaceId, 'executing');
|
|
120
126
|
}
|
|
121
127
|
}
|
|
@@ -133,7 +139,7 @@ setMessageHandler((type, payload) => {
|
|
|
133
139
|
}
|
|
134
140
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
135
141
|
const prompt = p.prompt ?? 'Continue the previous task where you left off.';
|
|
136
|
-
startAgent(p.workspaceId, worktreePath, prompt, workspace.model);
|
|
142
|
+
startAgent(p.workspaceId, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
|
|
137
143
|
}
|
|
138
144
|
catch (err) {
|
|
139
145
|
console.error('[ws] Failed to start agent:', err);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import * as imageService from '../services/image-service.js';
|
|
3
|
+
import * as workspaceService from '../services/workspace-service.js';
|
|
4
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
5
|
+
const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
// POST /:id/images — upload an image
|
|
8
|
+
app.post('/:id/images', async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const { id } = c.req.param();
|
|
11
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
12
|
+
if (!workspace) {
|
|
13
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
14
|
+
}
|
|
15
|
+
const body = await c.req.parseBody();
|
|
16
|
+
const file = body.image;
|
|
17
|
+
if (!file || !(file instanceof File)) {
|
|
18
|
+
return c.json({ error: 'Missing image field in multipart body' }, 400);
|
|
19
|
+
}
|
|
20
|
+
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
|
21
|
+
return c.json({ error: `Unsupported MIME type: '${file.type}'. Allowed: ${[...ALLOWED_MIME_TYPES].join(', ')}` }, 400);
|
|
22
|
+
}
|
|
23
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
24
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
25
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
26
|
+
return c.json({ error: `File too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max: 10 MB` }, 400);
|
|
27
|
+
}
|
|
28
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
29
|
+
const result = await imageService.saveImage(worktreePath, buffer, file.name);
|
|
30
|
+
return c.json({ uid: result.uid, path: result.relativePath }, 201);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
return c.json({ error: message }, 500);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
// DELETE /:id/images/:uid — delete an uploaded image
|
|
38
|
+
app.delete('/:id/images/:uid', async (c) => {
|
|
39
|
+
try {
|
|
40
|
+
const { id, uid } = c.req.param();
|
|
41
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
42
|
+
if (!workspace) {
|
|
43
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
44
|
+
}
|
|
45
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
46
|
+
await imageService.deleteImage(worktreePath, uid);
|
|
47
|
+
return c.body(null, 204);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (message.includes('not found')) {
|
|
52
|
+
return c.json({ error: message }, 404);
|
|
53
|
+
}
|
|
54
|
+
return c.json({ error: message }, 500);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
export default app;
|