@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,38 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function formatTaskLine(t) {
|
|
3
|
+
const mark = t.status === 'done' ? 'x' : ' ';
|
|
4
|
+
return `- [${mark}] ${t.title}`;
|
|
5
|
+
}
|
|
6
|
+
function buildVariableMap(ctx) {
|
|
7
|
+
const regularTasks = ctx.tasks.filter((t) => !t.isAcceptanceCriterion);
|
|
8
|
+
const criteria = ctx.tasks.filter((t) => t.isAcceptanceCriterion);
|
|
9
|
+
return {
|
|
10
|
+
pr_number: String(ctx.prNumber),
|
|
11
|
+
pr_url: ctx.prUrl,
|
|
12
|
+
branch_name: ctx.workspace.workingBranch,
|
|
13
|
+
source_branch: ctx.workspace.sourceBranch,
|
|
14
|
+
workspace_name: ctx.workspace.name,
|
|
15
|
+
project_name: path.basename(ctx.workspace.projectPath),
|
|
16
|
+
notion_url: ctx.workspace.notionUrl ?? '',
|
|
17
|
+
commits: ctx.commits,
|
|
18
|
+
diff_stats: ctx.diffStats,
|
|
19
|
+
tasks: regularTasks.map(formatTaskLine).join('\n'),
|
|
20
|
+
acceptance_criteria: criteria.map(formatTaskLine).join('\n'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Render a PR prompt template by substituting {{variable}} placeholders.
|
|
25
|
+
*
|
|
26
|
+
* Known variables are substituted from the context. Unknown variables are
|
|
27
|
+
* left intact (useful for user-defined placeholders the agent may resolve
|
|
28
|
+
* itself). The function is pure: no I/O, no side effects.
|
|
29
|
+
*/
|
|
30
|
+
export function renderPrTemplate(template, ctx) {
|
|
31
|
+
const vars = buildVariableMap(ctx);
|
|
32
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
|
33
|
+
if (Object.hasOwn(vars, name)) {
|
|
34
|
+
return vars[name];
|
|
35
|
+
}
|
|
36
|
+
return match; // leave unknown variables as-is
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getSettingsPath } from '../utils/paths.js';
|
|
4
|
+
const DEFAULT_GIT_CONVENTIONS = `# Git conventions
|
|
5
|
+
|
|
6
|
+
## Commits
|
|
7
|
+
- Use Conventional Commits: \`type(scope): subject\`
|
|
8
|
+
- Types: feat, fix, docs, style, refactor, test, chore, perf, build, ci
|
|
9
|
+
- Subject: imperative mood, lowercase, no trailing period, max 72 chars
|
|
10
|
+
- Body: wrap at 72 chars, explain *why* not *what*
|
|
11
|
+
- Reference issues with \`Refs #123\` or \`Closes #123\`
|
|
12
|
+
|
|
13
|
+
## Branches
|
|
14
|
+
- Feature: \`feature/<short-kebab-case>\`
|
|
15
|
+
- Fix: \`fix/<short-kebab-case>\`
|
|
16
|
+
- Never commit directly to main/master/develop
|
|
17
|
+
|
|
18
|
+
## Workflow
|
|
19
|
+
- Rebase on the source branch before opening a PR, do not merge it in
|
|
20
|
+
- Keep commits atomic and self-contained (each compiles and passes tests)
|
|
21
|
+
- Squash fixup commits before pushing
|
|
22
|
+
- Never force-push to shared branches
|
|
23
|
+
|
|
24
|
+
## Safety
|
|
25
|
+
- Never run destructive commands (reset --hard, push --force, clean -fd) without explicit user confirmation
|
|
26
|
+
- Never skip hooks (--no-verify) unless the user explicitly asks
|
|
27
|
+
- Always inspect \`git status\` and \`git diff\` before staging
|
|
28
|
+
`;
|
|
29
|
+
const DEFAULT_PR_PROMPT_TEMPLATE = `A pull request has been opened: {{pr_url}} (#{{pr_number}})
|
|
30
|
+
|
|
31
|
+
Context:
|
|
32
|
+
- Workspace: {{workspace_name}}
|
|
33
|
+
- Project: {{project_name}}
|
|
34
|
+
- Branch: \`{{branch_name}}\` → \`{{source_branch}}\`
|
|
35
|
+
- Notion: {{notion_url}}
|
|
36
|
+
|
|
37
|
+
Changes:
|
|
38
|
+
{{diff_stats}}
|
|
39
|
+
|
|
40
|
+
Commits:
|
|
41
|
+
{{commits}}
|
|
42
|
+
|
|
43
|
+
Tasks:
|
|
44
|
+
{{tasks}}
|
|
45
|
+
|
|
46
|
+
Acceptance criteria:
|
|
47
|
+
{{acceptance_criteria}}
|
|
48
|
+
|
|
49
|
+
Please:
|
|
50
|
+
1. Review the PR description on GitHub and improve it if needed (add a proper summary, screenshots if relevant, a test plan)
|
|
51
|
+
2. Verify that all acceptance criteria are checked
|
|
52
|
+
3. Post a comment on the PR summarizing what was done and any follow-up items
|
|
53
|
+
`;
|
|
54
|
+
let settingsFilePath = getSettingsPath();
|
|
55
|
+
/** Override the settings file path (used by tests). */
|
|
56
|
+
export function _setSettingsPath(p) {
|
|
57
|
+
settingsFilePath = p;
|
|
58
|
+
}
|
|
59
|
+
function defaultSettings() {
|
|
60
|
+
return {
|
|
61
|
+
global: {
|
|
62
|
+
defaultModel: 'auto',
|
|
63
|
+
prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
|
|
64
|
+
gitConventions: DEFAULT_GIT_CONVENTIONS,
|
|
65
|
+
},
|
|
66
|
+
projects: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function defaultProjectSettings(projectPath) {
|
|
70
|
+
return {
|
|
71
|
+
path: projectPath,
|
|
72
|
+
displayName: '',
|
|
73
|
+
defaultSourceBranch: '',
|
|
74
|
+
defaultModel: '',
|
|
75
|
+
prPromptTemplate: '',
|
|
76
|
+
gitConventions: '',
|
|
77
|
+
devServer: {
|
|
78
|
+
startCommand: '',
|
|
79
|
+
stopCommand: '',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function pickKnownKeys(data, allowedKeys) {
|
|
84
|
+
return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
|
|
85
|
+
}
|
|
86
|
+
function readSettings() {
|
|
87
|
+
if (!fs.existsSync(settingsFilePath)) {
|
|
88
|
+
const defaults = defaultSettings();
|
|
89
|
+
writeSettings(defaults);
|
|
90
|
+
return defaults;
|
|
91
|
+
}
|
|
92
|
+
const raw = fs.readFileSync(settingsFilePath, 'utf-8');
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (!parsed || typeof parsed.global !== 'object' || !Array.isArray(parsed.projects)) {
|
|
95
|
+
const defaults = defaultSettings();
|
|
96
|
+
writeSettings(defaults);
|
|
97
|
+
return defaults;
|
|
98
|
+
}
|
|
99
|
+
// Backfill missing fields on load (forward compat for old settings.json)
|
|
100
|
+
if (typeof parsed.global.gitConventions !== 'string') {
|
|
101
|
+
parsed.global.gitConventions = '';
|
|
102
|
+
}
|
|
103
|
+
for (const p of parsed.projects) {
|
|
104
|
+
if (typeof p.gitConventions !== 'string')
|
|
105
|
+
p.gitConventions = '';
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
function writeSettings(settings) {
|
|
110
|
+
const tmpPath = `${settingsFilePath}.tmp`;
|
|
111
|
+
const dir = path.dirname(settingsFilePath);
|
|
112
|
+
if (!fs.existsSync(dir)) {
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
116
|
+
fs.renameSync(tmpPath, settingsFilePath);
|
|
117
|
+
}
|
|
118
|
+
export function getSettings() {
|
|
119
|
+
return readSettings();
|
|
120
|
+
}
|
|
121
|
+
export function getGlobalSettings() {
|
|
122
|
+
return readSettings().global;
|
|
123
|
+
}
|
|
124
|
+
export function getProjectSettings(projectPath) {
|
|
125
|
+
const settings = readSettings();
|
|
126
|
+
return settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
127
|
+
}
|
|
128
|
+
export function getEffectiveSettings(projectPath) {
|
|
129
|
+
const settings = readSettings();
|
|
130
|
+
const project = settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
131
|
+
if (!project) {
|
|
132
|
+
return {
|
|
133
|
+
model: settings.global.defaultModel,
|
|
134
|
+
prPromptTemplate: settings.global.prPromptTemplate,
|
|
135
|
+
gitConventions: settings.global.gitConventions,
|
|
136
|
+
sourceBranch: '',
|
|
137
|
+
devServer: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
model: project.defaultModel || settings.global.defaultModel,
|
|
142
|
+
prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
|
|
143
|
+
gitConventions: project.gitConventions || settings.global.gitConventions,
|
|
144
|
+
sourceBranch: project.defaultSourceBranch,
|
|
145
|
+
devServer: project.devServer,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function updateGlobalSettings(data) {
|
|
149
|
+
const settings = readSettings();
|
|
150
|
+
const allowedGlobalKeys = ['defaultModel', 'prPromptTemplate', 'gitConventions'];
|
|
151
|
+
const filtered = pickKnownKeys(data, allowedGlobalKeys);
|
|
152
|
+
settings.global = { ...settings.global, ...filtered };
|
|
153
|
+
writeSettings(settings);
|
|
154
|
+
return settings.global;
|
|
155
|
+
}
|
|
156
|
+
export function upsertProject(projectPath, data) {
|
|
157
|
+
const allowedProjectKeys = [
|
|
158
|
+
'displayName',
|
|
159
|
+
'defaultSourceBranch',
|
|
160
|
+
'defaultModel',
|
|
161
|
+
'prPromptTemplate',
|
|
162
|
+
'gitConventions',
|
|
163
|
+
'devServer',
|
|
164
|
+
];
|
|
165
|
+
const allowedDevServerKeys = ['startCommand', 'stopCommand'];
|
|
166
|
+
const filtered = pickKnownKeys(data, allowedProjectKeys);
|
|
167
|
+
if (filtered.devServer) {
|
|
168
|
+
filtered.devServer = pickKnownKeys(filtered.devServer, allowedDevServerKeys);
|
|
169
|
+
}
|
|
170
|
+
const settings = readSettings();
|
|
171
|
+
const idx = settings.projects.findIndex((p) => p.path === projectPath);
|
|
172
|
+
if (idx >= 0) {
|
|
173
|
+
// Update existing project — merge devServer separately to allow partial updates
|
|
174
|
+
const existing = settings.projects[idx];
|
|
175
|
+
const updatedDevServer = filtered.devServer ? { ...existing.devServer, ...filtered.devServer } : existing.devServer;
|
|
176
|
+
settings.projects[idx] = {
|
|
177
|
+
...existing,
|
|
178
|
+
...filtered,
|
|
179
|
+
path: projectPath,
|
|
180
|
+
devServer: updatedDevServer,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// Add new project
|
|
185
|
+
const newProject = {
|
|
186
|
+
...defaultProjectSettings(projectPath),
|
|
187
|
+
...filtered,
|
|
188
|
+
path: projectPath,
|
|
189
|
+
};
|
|
190
|
+
if (filtered.devServer) {
|
|
191
|
+
newProject.devServer = { ...defaultProjectSettings(projectPath).devServer, ...filtered.devServer };
|
|
192
|
+
}
|
|
193
|
+
settings.projects.push(newProject);
|
|
194
|
+
}
|
|
195
|
+
writeSettings(settings);
|
|
196
|
+
return settings.projects.find((p) => p.path === projectPath);
|
|
197
|
+
}
|
|
198
|
+
export function deleteProject(projectPath) {
|
|
199
|
+
const settings = readSettings();
|
|
200
|
+
settings.projects = settings.projects.filter((p) => p.path !== projectPath);
|
|
201
|
+
writeSettings(settings);
|
|
202
|
+
}
|
|
203
|
+
export function listProjects() {
|
|
204
|
+
return readSettings().projects;
|
|
205
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
4
|
+
/** Maps each WS client to the set of workspaceIds they are subscribed to */
|
|
5
|
+
const clients = new Map();
|
|
6
|
+
let messageHandler = null;
|
|
7
|
+
export function setMessageHandler(handler) {
|
|
8
|
+
messageHandler = handler;
|
|
9
|
+
}
|
|
10
|
+
// ── Connection handling ────────────────────────────────────────────────────────
|
|
11
|
+
export function handleConnection(ws) {
|
|
12
|
+
// Register client with empty subscription set
|
|
13
|
+
clients.set(ws, new Set());
|
|
14
|
+
ws.on('message', (data) => {
|
|
15
|
+
let msg;
|
|
16
|
+
try {
|
|
17
|
+
msg = JSON.parse(data.toString());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const { type, payload } = msg;
|
|
24
|
+
switch (type) {
|
|
25
|
+
case 'subscribe': {
|
|
26
|
+
const workspaceId = payload?.workspaceId;
|
|
27
|
+
if (!workspaceId) {
|
|
28
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const subs = clients.get(ws);
|
|
32
|
+
subs?.add(workspaceId);
|
|
33
|
+
ws.send(JSON.stringify({ type: 'subscribed', payload: { workspaceId } }));
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case 'unsubscribe': {
|
|
37
|
+
const workspaceId = payload?.workspaceId;
|
|
38
|
+
if (!workspaceId) {
|
|
39
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const subs = clients.get(ws);
|
|
43
|
+
subs?.delete(workspaceId);
|
|
44
|
+
ws.send(JSON.stringify({ type: 'unsubscribed', payload: { workspaceId } }));
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'sync:request': {
|
|
48
|
+
const p = payload;
|
|
49
|
+
const lastEventId = p?.lastEventId ?? '';
|
|
50
|
+
// I2: Accept optional workspaceIds so the client can sync even before re-subscribing
|
|
51
|
+
const workspaceIds = p?.workspaceIds;
|
|
52
|
+
handleSyncRequest(ws, lastEventId, workspaceIds);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
// Routed messages — delegated to agent-manager via messageHandler
|
|
56
|
+
case 'chat:message':
|
|
57
|
+
case 'workspace:start':
|
|
58
|
+
case 'workspace:stop':
|
|
59
|
+
case 'devserver:start':
|
|
60
|
+
case 'devserver:stop': {
|
|
61
|
+
if (messageHandler) {
|
|
62
|
+
messageHandler(type, payload);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
ws.on('close', () => {
|
|
71
|
+
clients.delete(ws);
|
|
72
|
+
});
|
|
73
|
+
ws.on('error', () => {
|
|
74
|
+
clients.delete(ws);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ── Broadcasting ───────────────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Broadcast an event to all clients subscribed to the given workspace.
|
|
80
|
+
* Persists the event to the ws_events table.
|
|
81
|
+
* Returns the event id.
|
|
82
|
+
*/
|
|
83
|
+
export function emit(workspaceId, type, payload, sessionId) {
|
|
84
|
+
const id = nanoid();
|
|
85
|
+
const createdAt = new Date().toISOString();
|
|
86
|
+
// C3: Persist to DB — best-effort only; don't let FK violation (deleted workspace) break the broadcast
|
|
87
|
+
try {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
|
|
93
|
+
}
|
|
94
|
+
// Build the event object to send
|
|
95
|
+
const event = { id, workspaceId, type, payload, sessionId, createdAt };
|
|
96
|
+
const message = JSON.stringify(event);
|
|
97
|
+
// Broadcast to subscribed clients
|
|
98
|
+
for (const [ws, subs] of clients) {
|
|
99
|
+
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
100
|
+
ws.send(message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Broadcast an event to subscribed clients WITHOUT persisting to the database.
|
|
107
|
+
* Used for ephemeral status updates (e.g., dev-server status) that don't need replay.
|
|
108
|
+
*/
|
|
109
|
+
export function emitEphemeral(workspaceId, type, payload) {
|
|
110
|
+
const id = nanoid();
|
|
111
|
+
const createdAt = new Date().toISOString();
|
|
112
|
+
const event = { id, workspaceId, type, payload, createdAt };
|
|
113
|
+
const message = JSON.stringify(event);
|
|
114
|
+
for (const [ws, subs] of clients) {
|
|
115
|
+
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
116
|
+
ws.send(message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ── Sync (replay missed events) ────────────────────────────────────────────────
|
|
121
|
+
/**
|
|
122
|
+
* Sends all events after lastEventId for workspaces the client is subscribed to.
|
|
123
|
+
* I2: If workspaceIds is provided, use those instead of the client's current subscriptions
|
|
124
|
+
* so the client can sync even before re-subscribing (e.g. after a reconnect).
|
|
125
|
+
*/
|
|
126
|
+
export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
127
|
+
// I2: Use provided workspaceIds first, fall back to current subscriptions
|
|
128
|
+
const resolvedIds = workspaceIds && workspaceIds.length > 0
|
|
129
|
+
? workspaceIds
|
|
130
|
+
: (() => {
|
|
131
|
+
const subs = clients.get(ws);
|
|
132
|
+
return subs ? [...subs] : [];
|
|
133
|
+
})();
|
|
134
|
+
if (resolvedIds.length === 0) {
|
|
135
|
+
ws.send(JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const db = getDb();
|
|
139
|
+
// Build a query with placeholders for all subscribed workspaces
|
|
140
|
+
const placeholders = resolvedIds.map(() => '?').join(', ');
|
|
141
|
+
let rows;
|
|
142
|
+
if (lastEventId) {
|
|
143
|
+
// Get the rowid of the last event to compare ordering
|
|
144
|
+
const lastRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId);
|
|
145
|
+
if (lastRow) {
|
|
146
|
+
rows = db
|
|
147
|
+
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) AND rowid > ? ORDER BY rowid ASC`)
|
|
148
|
+
.all(...resolvedIds, lastRow.rowid);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// lastEventId not found — send all events for subscribed workspaces
|
|
152
|
+
rows = db
|
|
153
|
+
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC`)
|
|
154
|
+
.all(...resolvedIds);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// No lastEventId — send all events
|
|
159
|
+
rows = db
|
|
160
|
+
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC`)
|
|
161
|
+
.all(...resolvedIds);
|
|
162
|
+
}
|
|
163
|
+
const events = rows.map((row) => {
|
|
164
|
+
let parsedPayload;
|
|
165
|
+
try {
|
|
166
|
+
parsedPayload = JSON.parse(row.payload);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
parsedPayload = { raw: row.payload };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
id: row.id,
|
|
173
|
+
workspaceId: row.workspace_id,
|
|
174
|
+
type: row.type,
|
|
175
|
+
payload: parsedPayload,
|
|
176
|
+
sessionId: row.session_id ?? undefined,
|
|
177
|
+
createdAt: row.created_at,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
ws.send(JSON.stringify({ type: 'sync:response', payload: { events } }));
|
|
181
|
+
}
|
|
182
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Delete old events keeping only the last N (default 1000) per workspace.
|
|
185
|
+
*/
|
|
186
|
+
export function cleanupOldEvents(workspaceId, keepCount = 1000) {
|
|
187
|
+
const db = getDb();
|
|
188
|
+
db.prepare(`
|
|
189
|
+
DELETE FROM ws_events
|
|
190
|
+
WHERE workspace_id = ?
|
|
191
|
+
AND rowid NOT IN (
|
|
192
|
+
SELECT rowid FROM ws_events
|
|
193
|
+
WHERE workspace_id = ?
|
|
194
|
+
ORDER BY rowid DESC
|
|
195
|
+
LIMIT ?
|
|
196
|
+
)
|
|
197
|
+
`).run(workspaceId, workspaceId, keepCount);
|
|
198
|
+
}
|
|
199
|
+
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Return number of connected clients.
|
|
202
|
+
*/
|
|
203
|
+
export function getClientCount() {
|
|
204
|
+
return clients.size;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get the internal clients map — exposed for testing only.
|
|
208
|
+
* @internal
|
|
209
|
+
*/
|
|
210
|
+
export function _getClients() {
|
|
211
|
+
return clients;
|
|
212
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
// Valid status transitions
|
|
4
|
+
const VALID_TRANSITIONS = {
|
|
5
|
+
created: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
6
|
+
extracting: ['brainstorming', 'idle', 'error'],
|
|
7
|
+
brainstorming: ['executing', 'completed', 'idle', 'error'],
|
|
8
|
+
executing: ['completed', 'idle', 'error', 'quota'],
|
|
9
|
+
completed: ['idle', 'executing'],
|
|
10
|
+
idle: ['executing', 'brainstorming', 'extracting', 'error'],
|
|
11
|
+
error: ['idle', 'executing', 'brainstorming', 'extracting'],
|
|
12
|
+
quota: ['idle', 'executing'],
|
|
13
|
+
};
|
|
14
|
+
function mapWorkspace(row) {
|
|
15
|
+
return {
|
|
16
|
+
id: row.id,
|
|
17
|
+
name: row.name,
|
|
18
|
+
projectPath: row.project_path,
|
|
19
|
+
sourceBranch: row.source_branch,
|
|
20
|
+
workingBranch: row.working_branch,
|
|
21
|
+
status: row.status,
|
|
22
|
+
notionUrl: row.notion_url,
|
|
23
|
+
notionPageId: row.notion_page_id,
|
|
24
|
+
model: row.model,
|
|
25
|
+
devServerStatus: row.dev_server_status,
|
|
26
|
+
archivedAt: row.archived_at,
|
|
27
|
+
createdAt: row.created_at,
|
|
28
|
+
updatedAt: row.updated_at,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function mapTask(row) {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
workspaceId: row.workspace_id,
|
|
35
|
+
title: row.title,
|
|
36
|
+
status: row.status,
|
|
37
|
+
isAcceptanceCriterion: row.is_acceptance_criterion === 1,
|
|
38
|
+
sortOrder: row.sort_order,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
updatedAt: row.updated_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createWorkspace(data) {
|
|
44
|
+
const db = getDb();
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const id = nanoid();
|
|
47
|
+
db.prepare(`
|
|
48
|
+
INSERT INTO workspaces (id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, created_at, updated_at)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?)
|
|
50
|
+
`).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-6', now, now);
|
|
51
|
+
return getWorkspace(id);
|
|
52
|
+
}
|
|
53
|
+
export function getWorkspace(id) {
|
|
54
|
+
const db = getDb();
|
|
55
|
+
const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
|
|
56
|
+
return row ? mapWorkspace(row) : null;
|
|
57
|
+
}
|
|
58
|
+
export function listWorkspaces(includeArchived = false) {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
const sql = includeArchived
|
|
61
|
+
? 'SELECT * FROM workspaces ORDER BY updated_at DESC'
|
|
62
|
+
: 'SELECT * FROM workspaces WHERE archived_at IS NULL ORDER BY updated_at DESC';
|
|
63
|
+
const rows = db.prepare(sql).all();
|
|
64
|
+
return rows.map(mapWorkspace);
|
|
65
|
+
}
|
|
66
|
+
export function listArchivedWorkspaces() {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
const rows = db
|
|
69
|
+
.prepare('SELECT * FROM workspaces WHERE archived_at IS NOT NULL ORDER BY archived_at DESC')
|
|
70
|
+
.all();
|
|
71
|
+
return rows.map(mapWorkspace);
|
|
72
|
+
}
|
|
73
|
+
export function updateWorkspaceStatus(id, status) {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const workspace = getWorkspace(id);
|
|
76
|
+
if (!workspace) {
|
|
77
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
78
|
+
}
|
|
79
|
+
const allowedTransitions = VALID_TRANSITIONS[workspace.status];
|
|
80
|
+
if (!allowedTransitions.includes(status)) {
|
|
81
|
+
throw new Error(`Invalid status transition from '${workspace.status}' to '${status}'. Allowed: ${allowedTransitions.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
|
|
85
|
+
return getWorkspace(id);
|
|
86
|
+
}
|
|
87
|
+
export function updateWorkspaceName(id, name) {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(name, now, id);
|
|
91
|
+
return getWorkspace(id);
|
|
92
|
+
}
|
|
93
|
+
export function updateDevServerStatus(id, status) {
|
|
94
|
+
const db = getDb();
|
|
95
|
+
db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
|
|
96
|
+
}
|
|
97
|
+
export function deleteWorkspace(id) {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
|
|
100
|
+
}
|
|
101
|
+
export function createTask(workspaceId, data) {
|
|
102
|
+
const db = getDb();
|
|
103
|
+
// I2: explicit workspace existence check before INSERT
|
|
104
|
+
const exists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId);
|
|
105
|
+
if (!exists) {
|
|
106
|
+
throw new Error(`Workspace not found: '${workspaceId}'`);
|
|
107
|
+
}
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
const id = nanoid();
|
|
110
|
+
db.prepare(`
|
|
111
|
+
INSERT INTO tasks (id, workspace_id, title, status, is_acceptance_criterion, sort_order, created_at, updated_at)
|
|
112
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)
|
|
113
|
+
`).run(id, workspaceId, data.title, data.isAcceptanceCriterion ? 1 : 0, data.sortOrder ?? 0, now, now);
|
|
114
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
115
|
+
return mapTask(row);
|
|
116
|
+
}
|
|
117
|
+
export function listTasks(workspaceId) {
|
|
118
|
+
const db = getDb();
|
|
119
|
+
const rows = db
|
|
120
|
+
.prepare('SELECT * FROM tasks WHERE workspace_id = ? ORDER BY sort_order ASC')
|
|
121
|
+
.all(workspaceId);
|
|
122
|
+
return rows.map(mapTask);
|
|
123
|
+
}
|
|
124
|
+
export function updateTaskStatus(taskId, status) {
|
|
125
|
+
const db = getDb();
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run(status, now, taskId);
|
|
128
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
129
|
+
if (!row) {
|
|
130
|
+
throw new Error(`Task '${taskId}' not found`);
|
|
131
|
+
}
|
|
132
|
+
return mapTask(row);
|
|
133
|
+
}
|
|
134
|
+
export function updateTaskTitle(taskId, title) {
|
|
135
|
+
if (!title?.trim()) {
|
|
136
|
+
throw new Error('Task title cannot be empty');
|
|
137
|
+
}
|
|
138
|
+
const db = getDb();
|
|
139
|
+
const now = new Date().toISOString();
|
|
140
|
+
const result = db.prepare('UPDATE tasks SET title = ?, updated_at = ? WHERE id = ?').run(title.trim(), now, taskId);
|
|
141
|
+
if (result.changes === 0) {
|
|
142
|
+
throw new Error(`Task '${taskId}' not found`);
|
|
143
|
+
}
|
|
144
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
145
|
+
return mapTask(row);
|
|
146
|
+
}
|
|
147
|
+
export function deleteTask(taskId) {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
|
150
|
+
}
|
|
151
|
+
export function getWorkspaceWithTasks(id) {
|
|
152
|
+
const workspace = getWorkspace(id);
|
|
153
|
+
if (!workspace)
|
|
154
|
+
return null;
|
|
155
|
+
const tasks = listTasks(id);
|
|
156
|
+
return { ...workspace, tasks };
|
|
157
|
+
}
|
|
158
|
+
export function archiveWorkspace(id) {
|
|
159
|
+
const db = getDb();
|
|
160
|
+
const workspace = getWorkspace(id);
|
|
161
|
+
if (!workspace) {
|
|
162
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
163
|
+
}
|
|
164
|
+
if (workspace.archivedAt) {
|
|
165
|
+
throw new Error(`Workspace '${id}' is already archived`);
|
|
166
|
+
}
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
|
|
169
|
+
return getWorkspace(id);
|
|
170
|
+
}
|
|
171
|
+
export function unarchiveWorkspace(id) {
|
|
172
|
+
const db = getDb();
|
|
173
|
+
const workspace = getWorkspace(id);
|
|
174
|
+
if (!workspace) {
|
|
175
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
176
|
+
}
|
|
177
|
+
if (!workspace.archivedAt) {
|
|
178
|
+
throw new Error(`Workspace '${id}' is not archived`);
|
|
179
|
+
}
|
|
180
|
+
const now = new Date().toISOString();
|
|
181
|
+
db.prepare('UPDATE workspaces SET archived_at = NULL, updated_at = ? WHERE id = ?').run(now, id);
|
|
182
|
+
return getWorkspace(id);
|
|
183
|
+
}
|
|
184
|
+
function mapSession(row) {
|
|
185
|
+
return {
|
|
186
|
+
id: row.id,
|
|
187
|
+
workspaceId: row.workspace_id,
|
|
188
|
+
pid: row.pid,
|
|
189
|
+
claudeSessionId: row.claude_session_id,
|
|
190
|
+
status: row.status,
|
|
191
|
+
startedAt: row.started_at,
|
|
192
|
+
endedAt: row.ended_at,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function listSessions(workspaceId) {
|
|
196
|
+
const db = getDb();
|
|
197
|
+
const rows = db
|
|
198
|
+
.prepare('SELECT * FROM agent_sessions WHERE workspace_id = ? ORDER BY started_at DESC')
|
|
199
|
+
.all(workspaceId);
|
|
200
|
+
return rows.map(mapSession);
|
|
201
|
+
}
|
|
202
|
+
export function getLatestSession(workspaceId) {
|
|
203
|
+
const db = getDb();
|
|
204
|
+
const row = db
|
|
205
|
+
.prepare('SELECT * FROM agent_sessions WHERE workspace_id = ? ORDER BY started_at DESC LIMIT 1')
|
|
206
|
+
.get(workspaceId);
|
|
207
|
+
return row ? mapSession(row) : null;
|
|
208
|
+
}
|