@worca/ui 0.1.0-rc.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/server/app.js ADDED
@@ -0,0 +1,421 @@
1
+ // server/app.js
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import { createHmac, randomUUID } from 'node:crypto';
5
+ import { basename, dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import express from 'express';
8
+
9
+ import { dbExists, getIssue, listIssues } from './beads-reader.js';
10
+ import { ProcessManager } from './process-manager.js';
11
+ import {
12
+ createProjectRoutes,
13
+ createProjectScopedRoutes,
14
+ projectResolver,
15
+ } from './project-routes.js';
16
+ import { createInbox } from './webhook-inbox.js';
17
+
18
+ export function createApp(options = {}) {
19
+ const app = express();
20
+ const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
21
+ const { settingsPath, worcaDir, projectRoot, prefsDir } = options;
22
+
23
+ app.use(express.json());
24
+
25
+ // ─── Security headers ──────────────────────────────────────────────────
26
+ app.use((_req, res, next) => {
27
+ res.setHeader('X-Content-Type-Options', 'nosniff');
28
+ res.setHeader('X-Frame-Options', 'DENY');
29
+ next();
30
+ });
31
+
32
+ // ─── CSRF Origin check ────────────────────────────────────────────────
33
+ // Block cross-origin state-mutating requests. Webhooks from pipeline
34
+ // processes use X-Worca-Event header to bypass (they aren't browsers).
35
+ app.use((req, res, next) => {
36
+ if (
37
+ req.method === 'GET' ||
38
+ req.method === 'HEAD' ||
39
+ req.method === 'OPTIONS'
40
+ ) {
41
+ return next();
42
+ }
43
+ // Allow non-browser clients (webhook callbacks, curl, etc.)
44
+ if (req.headers['x-worca-event']) return next();
45
+
46
+ const origin = req.headers.origin;
47
+ if (!origin) return next(); // non-browser request (curl, server-to-server)
48
+
49
+ try {
50
+ const parsed = new URL(origin);
51
+ const host = parsed.hostname;
52
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
53
+ return next();
54
+ }
55
+ } catch {
56
+ // malformed origin — reject
57
+ }
58
+ res
59
+ .status(403)
60
+ .json({ ok: false, error: 'Forbidden: cross-origin request' });
61
+ });
62
+
63
+ // Webhook inbox — shared in-memory store (also exposed for WS server)
64
+ const webhookInbox = options.webhookInbox || createInbox();
65
+ app.locals.webhookInbox = webhookInbox;
66
+
67
+ // ─── Legacy single-project API ─────────────────────────────────────────
68
+ // Mounts the shared project-scoped routes at /api with a middleware that
69
+ // injects req.project from the closure options, so /api/runs, /api/settings,
70
+ // etc. work identically to /api/projects/:projectId/runs, etc.
71
+ app.use(
72
+ '/api',
73
+ (req, _res, next) => {
74
+ req.project = {
75
+ name: 'default',
76
+ path: projectRoot || process.cwd(),
77
+ worcaDir,
78
+ settingsPath,
79
+ projectRoot: projectRoot || process.cwd(),
80
+ pm: worcaDir
81
+ ? new ProcessManager({
82
+ worcaDir,
83
+ projectRoot: projectRoot || process.cwd(),
84
+ })
85
+ : null,
86
+ };
87
+ next();
88
+ },
89
+ createProjectScopedRoutes(),
90
+ );
91
+
92
+ // ─── Unique routes (not in project-scoped router) ──────────────────────
93
+
94
+ // GET /api/beads/issues
95
+ app.get('/api/beads/issues', (_req, res) => {
96
+ if (!worcaDir)
97
+ return res
98
+ .status(501)
99
+ .json({ ok: false, error: 'worcaDir not configured' });
100
+ const beadsDbPath = join(worcaDir, '..', '.beads', 'beads.db');
101
+ if (!dbExists(beadsDbPath)) {
102
+ return res.json({
103
+ ok: true,
104
+ issues: [],
105
+ dbExists: false,
106
+ dbPath: beadsDbPath,
107
+ });
108
+ }
109
+ try {
110
+ const issues = listIssues(beadsDbPath);
111
+ res.json({ ok: true, issues, dbExists: true, dbPath: beadsDbPath });
112
+ } catch (err) {
113
+ res.status(500).json({ ok: false, error: err.message });
114
+ }
115
+ });
116
+
117
+ // POST /api/beads/issues/:id/start
118
+ app.post('/api/beads/issues/:id/start', async (req, res) => {
119
+ if (!worcaDir)
120
+ return res
121
+ .status(501)
122
+ .json({ ok: false, error: 'worcaDir not configured' });
123
+ const issueId = parseInt(req.params.id, 10);
124
+ if (!Number.isInteger(issueId) || issueId <= 0) {
125
+ return res
126
+ .status(400)
127
+ .json({ ok: false, error: 'Issue ID must be a positive integer' });
128
+ }
129
+ const beadsDbPath = join(worcaDir, '..', '.beads', 'beads.db');
130
+ const issue = getIssue(beadsDbPath, issueId);
131
+ if (!issue) {
132
+ return res
133
+ .status(404)
134
+ .json({ ok: false, error: `Issue ${issueId} not found` });
135
+ }
136
+ if (issue.status !== 'ready') {
137
+ return res.status(409).json({
138
+ ok: false,
139
+ error: `Issue ${issueId} is not in 'ready' state (current: ${issue.status})`,
140
+ });
141
+ }
142
+ if (issue.blocked_by.length > 0) {
143
+ return res.status(409).json({
144
+ ok: false,
145
+ error: `Issue ${issueId} is blocked by issues: ${issue.blocked_by.join(', ')}`,
146
+ });
147
+ }
148
+ try {
149
+ const pm = new ProcessManager({
150
+ worcaDir,
151
+ projectRoot: projectRoot || process.cwd(),
152
+ });
153
+ const prompt =
154
+ `[Beads #${issue.id}] ${issue.title}\n\n${(issue.body || '').trim()}`.trim();
155
+ const result = await pm.startPipeline({
156
+ inputType: 'prompt',
157
+ inputValue: prompt,
158
+ msize: 1,
159
+ mloops: 1,
160
+ });
161
+ if (app.locals.broadcast) {
162
+ app.locals.broadcast('run-started', { pid: result.pid });
163
+ }
164
+ res.json({ ok: true, pid: result.pid, issueId, prompt });
165
+ } catch (err) {
166
+ const status = (err.message || '').includes('already running')
167
+ ? 409
168
+ : 500;
169
+ res.status(status).json({ ok: false, error: err.message });
170
+ }
171
+ });
172
+
173
+ // POST /api/webhooks/test — send a pipeline.test.ping to a webhook URL
174
+ app.post('/api/webhooks/test', async (req, res) => {
175
+ const { url, secret, timeout_ms } = req.body || {};
176
+
177
+ const trimmedUrl = typeof url === 'string' ? url.trim() : '';
178
+ if (!trimmedUrl) {
179
+ return res.status(400).json({ ok: false, error: 'url is required' });
180
+ }
181
+
182
+ let parsedUrl;
183
+ try {
184
+ parsedUrl = new URL(trimmedUrl);
185
+ } catch {
186
+ return res
187
+ .status(400)
188
+ .json({ ok: false, error: 'url is not a valid URL' });
189
+ }
190
+
191
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
192
+ return res
193
+ .status(400)
194
+ .json({ ok: false, error: 'url must use http or https protocol' });
195
+ }
196
+
197
+ const event = {
198
+ schema_version: '1',
199
+ event_id: randomUUID(),
200
+ event_type: 'pipeline.test.ping',
201
+ timestamp: new Date().toISOString(),
202
+ run_id: null,
203
+ pipeline: null,
204
+ payload: { test: true },
205
+ };
206
+
207
+ const body = JSON.stringify(event);
208
+ const headers = { 'Content-Type': 'application/json' };
209
+
210
+ if (secret && typeof secret === 'string' && secret.length > 0) {
211
+ const hmac = createHmac('sha256', secret);
212
+ hmac.update(body);
213
+ headers['X-Worca-Signature'] = `sha256=${hmac.digest('hex')}`;
214
+ }
215
+
216
+ const timeoutMs =
217
+ typeof timeout_ms === 'number' && timeout_ms > 0
218
+ ? Math.min(timeout_ms, 30000)
219
+ : 10000;
220
+
221
+ const startMs = Date.now();
222
+ try {
223
+ const controller = new AbortController();
224
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
225
+ let response;
226
+ try {
227
+ response = await fetch(trimmedUrl, {
228
+ method: 'POST',
229
+ headers,
230
+ body,
231
+ signal: controller.signal,
232
+ });
233
+ } finally {
234
+ clearTimeout(timer);
235
+ }
236
+ res.json({
237
+ ok: true,
238
+ status_code: response.status,
239
+ response_ms: Date.now() - startMs,
240
+ });
241
+ } catch (err) {
242
+ res.json({
243
+ ok: false,
244
+ error: err.message,
245
+ response_ms: Date.now() - startMs,
246
+ });
247
+ }
248
+ });
249
+
250
+ // POST /api/webhooks/inbox — receive webhook events
251
+ app.post('/api/webhooks/inbox', (req, res) => {
252
+ const headers = {
253
+ 'x-worca-event': req.headers['x-worca-event'] || '',
254
+ 'x-worca-delivery': req.headers['x-worca-delivery'] || '',
255
+ 'x-worca-signature': req.headers['x-worca-signature'] || '',
256
+ 'content-type': req.headers['content-type'] || '',
257
+ };
258
+ const runId = req.body?.run_id || null;
259
+ const projectId =
260
+ runId && app.locals.resolveRunProject
261
+ ? app.locals.resolveRunProject(runId)
262
+ : null;
263
+ const stored = webhookInbox.push({
264
+ headers,
265
+ envelope: req.body || {},
266
+ projectId,
267
+ });
268
+ if (app.locals.broadcast) {
269
+ app.locals.broadcast('webhook-inbox-event', stored);
270
+ }
271
+ res.json({ control: { action: webhookInbox.getControlAction() } });
272
+ });
273
+
274
+ // GET /api/webhooks/inbox — list stored events
275
+ app.get('/api/webhooks/inbox', (req, res) => {
276
+ const since =
277
+ req.query.since != null ? parseInt(req.query.since, 10) : undefined;
278
+ const projectId = req.query.projectId || undefined;
279
+ res.json({
280
+ ok: true,
281
+ events: webhookInbox.list(since, projectId),
282
+ controlAction: webhookInbox.getControlAction(),
283
+ });
284
+ });
285
+
286
+ // DELETE /api/webhooks/inbox — clear all events
287
+ app.delete('/api/webhooks/inbox', (_req, res) => {
288
+ webhookInbox.clear();
289
+ if (app.locals.broadcast) {
290
+ app.locals.broadcast('webhook-inbox-cleared', {});
291
+ }
292
+ res.json({ ok: true });
293
+ });
294
+
295
+ // GET /api/webhooks/inbox/control — get current control action
296
+ app.get('/api/webhooks/inbox/control', (_req, res) => {
297
+ res.json({ ok: true, action: webhookInbox.getControlAction() });
298
+ });
299
+
300
+ // PUT /api/webhooks/inbox/control — set control action
301
+ app.put('/api/webhooks/inbox/control', (req, res) => {
302
+ const { action } = req.body || {};
303
+ if (!['continue', 'pause', 'abort'].includes(action)) {
304
+ return res.status(400).json({
305
+ ok: false,
306
+ error: 'action must be "continue", "pause", or "abort"',
307
+ });
308
+ }
309
+ webhookInbox.setControlAction(action);
310
+ if (app.locals.broadcast) {
311
+ app.locals.broadcast('webhook-control-changed', { action });
312
+ }
313
+ res.json({ ok: true, action });
314
+ });
315
+
316
+ // GET /api/project-info
317
+ app.get('/api/project-info', (_req, res) => {
318
+ res.json({ name: projectRoot ? basename(projectRoot) : '' });
319
+ });
320
+
321
+ // POST /api/projects/inbox — webhook hint for immediate status refresh
322
+ app.post('/api/projects/inbox', (req, res) => {
323
+ const body = req.body || {};
324
+ const projectId =
325
+ body.project_id ||
326
+ req.headers['x-worca-project'] ||
327
+ (body.run_id && app.locals.resolveRunProject?.(body.run_id)) ||
328
+ null;
329
+
330
+ if (!projectId) {
331
+ return res.status(400).json({
332
+ ok: false,
333
+ error:
334
+ 'Could not identify project. Provide project_id, X-Worca-Project header, or run_id.',
335
+ });
336
+ }
337
+
338
+ const refreshed = app.locals.scheduleRefresh?.(projectId);
339
+ if (refreshed === false) {
340
+ console.warn(`[webhook-hint] unknown project: ${projectId}`);
341
+ }
342
+
343
+ res.json({ ok: true, project: projectId });
344
+ });
345
+
346
+ // POST /api/choose-directory — native folder picker (cross-platform)
347
+ app.post('/api/choose-directory', (_req, res) => {
348
+ try {
349
+ let chosenPath;
350
+ if (process.platform === 'darwin') {
351
+ chosenPath = execFileSync(
352
+ 'osascript',
353
+ [
354
+ '-e',
355
+ 'POSIX path of (choose folder with prompt "Select project directory")',
356
+ ],
357
+ { encoding: 'utf8' },
358
+ ).trim();
359
+ } else if (process.platform === 'win32') {
360
+ const ps = execFileSync(
361
+ 'powershell.exe',
362
+ [
363
+ '-NoProfile',
364
+ '-Command',
365
+ 'Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = "Select project directory"; if ($d.ShowDialog() -eq "OK") { $d.SelectedPath } else { exit 1 }',
366
+ ],
367
+ { encoding: 'utf8' },
368
+ ).trim();
369
+ chosenPath = ps;
370
+ } else {
371
+ // Linux: try zenity, then kdialog
372
+ try {
373
+ chosenPath = execFileSync(
374
+ 'zenity',
375
+ [
376
+ '--file-selection',
377
+ '--directory',
378
+ '--title=Select project directory',
379
+ ],
380
+ { encoding: 'utf8' },
381
+ ).trim();
382
+ } catch {
383
+ chosenPath = execFileSync(
384
+ 'kdialog',
385
+ [
386
+ '--getexistingdirectory',
387
+ '.',
388
+ '--title',
389
+ 'Select project directory',
390
+ ],
391
+ { encoding: 'utf8' },
392
+ ).trim();
393
+ }
394
+ }
395
+ chosenPath = chosenPath.replace(/[\\/]+$/, '');
396
+ if (chosenPath) {
397
+ res.json({ ok: true, path: chosenPath });
398
+ } else {
399
+ res.json({ ok: false });
400
+ }
401
+ } catch {
402
+ res.json({ ok: false });
403
+ }
404
+ });
405
+
406
+ // ─── Multi-project routes ──────────────────────────────────────────────
407
+ if (prefsDir) {
408
+ app.use('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
409
+ app.use(
410
+ '/api/projects/:projectId',
411
+ projectResolver({ prefsDir, projectRoot }),
412
+ createProjectScopedRoutes(),
413
+ );
414
+ }
415
+
416
+ app.use(express.static(appDir));
417
+ app.get('/{*splat}', (_req, res) => {
418
+ res.sendFile('index.html', { root: appDir });
419
+ });
420
+ return app;
421
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync } from 'node:fs';
2
+ import Database from 'better-sqlite3';
3
+
4
+ export function dbExists(beadsDb) {
5
+ return existsSync(beadsDb);
6
+ }
7
+
8
+ export function listIssues(beadsDb) {
9
+ let db;
10
+ try {
11
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
12
+ const rows = db
13
+ .prepare(
14
+ `SELECT id, title, description AS body, status, priority, created_at, external_ref
15
+ FROM issues
16
+ WHERE status NOT IN ('closed','tombstone')
17
+ ORDER BY priority ASC, id ASC`,
18
+ )
19
+ .all();
20
+
21
+ const depStmt = db.prepare(
22
+ `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
23
+ );
24
+ const statusMap = new Map(rows.map((r) => [r.id, r.status]));
25
+
26
+ return rows.map((row) => {
27
+ const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
28
+ const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
29
+ return { ...row, depends_on, blocked_by };
30
+ });
31
+ } catch {
32
+ return [];
33
+ } finally {
34
+ try {
35
+ db?.close();
36
+ } catch {
37
+ /* ignore */
38
+ }
39
+ }
40
+ }
41
+
42
+ export function listIssuesByLabel(beadsDb, label) {
43
+ let db;
44
+ try {
45
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
46
+ const rows = db
47
+ .prepare(
48
+ `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
49
+ FROM issues i
50
+ JOIN labels l ON l.issue_id = i.id
51
+ WHERE l.label = ?
52
+ ORDER BY i.priority ASC, i.id ASC`,
53
+ )
54
+ .all(label);
55
+
56
+ const depStmt = db.prepare(
57
+ `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
58
+ );
59
+ const statusMap = new Map(rows.map((r) => [r.id, r.status]));
60
+
61
+ return rows.map((row) => {
62
+ const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
63
+ const blocked_by = depends_on.filter((depId) => {
64
+ const s = statusMap.get(depId);
65
+ return s && s !== 'closed';
66
+ });
67
+ return { ...row, depends_on, blocked_by };
68
+ });
69
+ } catch {
70
+ return [];
71
+ } finally {
72
+ try {
73
+ db?.close();
74
+ } catch {
75
+ /* ignore */
76
+ }
77
+ }
78
+ }
79
+
80
+ export function listUnlinkedIssues(beadsDb) {
81
+ let db;
82
+ try {
83
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
84
+ const rows = db
85
+ .prepare(
86
+ `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
87
+ FROM issues i
88
+ WHERE NOT EXISTS (
89
+ SELECT 1 FROM labels l WHERE l.issue_id = i.id AND l.label LIKE 'run:%'
90
+ )
91
+ AND i.status NOT IN ('closed','tombstone')
92
+ ORDER BY i.priority ASC, i.id ASC`,
93
+ )
94
+ .all();
95
+
96
+ const depStmt = db.prepare(
97
+ `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
98
+ );
99
+ const statusMap = new Map(rows.map((r) => [r.id, r.status]));
100
+
101
+ return rows.map((row) => {
102
+ const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
103
+ const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
104
+ return { ...row, depends_on, blocked_by };
105
+ });
106
+ } catch {
107
+ return [];
108
+ } finally {
109
+ try {
110
+ db?.close();
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ }
116
+
117
+ export function countIssuesByRunLabel(beadsDb) {
118
+ let db;
119
+ try {
120
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
121
+ const rows = db
122
+ .prepare(
123
+ `SELECT l.label, COUNT(*) AS count FROM labels l
124
+ WHERE l.label LIKE 'run:%' GROUP BY l.label`,
125
+ )
126
+ .all();
127
+ const counts = {};
128
+ for (const row of rows) {
129
+ const runId = row.label.replace('run:', '');
130
+ counts[runId] = row.count;
131
+ }
132
+ return counts;
133
+ } catch {
134
+ return {};
135
+ } finally {
136
+ try {
137
+ db?.close();
138
+ } catch {
139
+ /* ignore */
140
+ }
141
+ }
142
+ }
143
+
144
+ export function listDistinctRunLabels(beadsDb) {
145
+ let db;
146
+ try {
147
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
148
+ const rows = db
149
+ .prepare(`SELECT DISTINCT label FROM labels WHERE label LIKE 'run:%'`)
150
+ .all();
151
+ return rows.map((r) => r.label);
152
+ } catch {
153
+ return [];
154
+ } finally {
155
+ try {
156
+ db?.close();
157
+ } catch {
158
+ /* ignore */
159
+ }
160
+ }
161
+ }
162
+
163
+ export function getIssue(beadsDb, id) {
164
+ let db;
165
+ try {
166
+ db = new Database(beadsDb, { readonly: true, fileMustExist: true });
167
+ const row = db
168
+ .prepare(
169
+ `SELECT id, title, description AS body, status, priority, created_at, external_ref
170
+ FROM issues WHERE id = ?`,
171
+ )
172
+ .get(id);
173
+ if (!row) return null;
174
+
175
+ const depends_on = db
176
+ .prepare(`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`)
177
+ .all(id)
178
+ .map((d) => d.depends_on_id);
179
+
180
+ const blocked_by = [];
181
+ for (const depId of depends_on) {
182
+ const dep = db
183
+ .prepare(`SELECT status FROM issues WHERE id = ?`)
184
+ .get(depId);
185
+ if (dep && dep.status !== 'closed' && dep.status !== 'tombstone') {
186
+ blocked_by.push(depId);
187
+ }
188
+ }
189
+ return { ...row, depends_on, blocked_by };
190
+ } catch {
191
+ return null;
192
+ } finally {
193
+ try {
194
+ db?.close();
195
+ } catch {
196
+ /* ignore */
197
+ }
198
+ }
199
+ }