archgraph 0.1.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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/bin/bgx.js +2 -0
  4. package/docs/examples/executors.example.json +15 -0
  5. package/docs/examples/view-spec.yaml +6 -0
  6. package/docs/release.md +15 -0
  7. package/docs/view-spec.md +28 -0
  8. package/integrations/README.md +20 -0
  9. package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
  10. package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
  11. package/integrations/claude/.claude-plugin/marketplace.json +18 -0
  12. package/integrations/claude/.claude-plugin/plugin.json +9 -0
  13. package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
  14. package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
  15. package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
  16. package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
  17. package/package.json +49 -0
  18. package/packages/cli/src/index.js +415 -0
  19. package/packages/core/src/analyze-project.js +1238 -0
  20. package/packages/core/src/export.js +77 -0
  21. package/packages/core/src/index.js +4 -0
  22. package/packages/core/src/types.js +37 -0
  23. package/packages/core/src/view.js +86 -0
  24. package/packages/viewer/public/app.js +226 -0
  25. package/packages/viewer/public/canvas.js +181 -0
  26. package/packages/viewer/public/comments.js +193 -0
  27. package/packages/viewer/public/index.html +95 -0
  28. package/packages/viewer/public/layout.js +72 -0
  29. package/packages/viewer/public/minimap.js +92 -0
  30. package/packages/viewer/public/render.js +366 -0
  31. package/packages/viewer/public/sidebar.js +107 -0
  32. package/packages/viewer/public/styles.css +728 -0
  33. package/packages/viewer/public/theme.js +19 -0
  34. package/packages/viewer/public/tooltip.js +44 -0
  35. package/packages/viewer/src/index.js +590 -0
@@ -0,0 +1,19 @@
1
+ // theme.js — Dark / light mode toggle
2
+
3
+ const STORAGE_KEY = 'bgx-theme';
4
+
5
+ export function initTheme() {
6
+ const saved = localStorage.getItem(STORAGE_KEY) ?? 'light';
7
+ document.documentElement.setAttribute('data-theme', saved);
8
+ }
9
+
10
+ export function toggleTheme() {
11
+ const current = document.documentElement.getAttribute('data-theme');
12
+ const next = current === 'dark' ? 'light' : 'dark';
13
+ document.documentElement.setAttribute('data-theme', next);
14
+ localStorage.setItem(STORAGE_KEY, next);
15
+ }
16
+
17
+ export function isDark() {
18
+ return document.documentElement.getAttribute('data-theme') === 'dark';
19
+ }
@@ -0,0 +1,44 @@
1
+ // tooltip.js — Node hover tooltip
2
+
3
+ let tooltipEl = null;
4
+
5
+ export function initTooltip() {
6
+ tooltipEl = document.getElementById('nodeTooltip');
7
+ }
8
+
9
+ export function showTooltip(clientX, clientY, node) {
10
+ if (!tooltipEl) return;
11
+ tooltipEl.innerHTML = `
12
+ <strong>${escHtml(node.label)}</strong>
13
+ <div class="tooltip-kind">${escHtml(node.kind)}</div>
14
+ ${node.file ? `<div class="tooltip-file">${escHtml(node.file)}</div>` : ''}
15
+ `;
16
+ tooltipEl.classList.add('tooltip--visible');
17
+ moveTooltip(clientX, clientY);
18
+ }
19
+
20
+ export function moveTooltip(clientX, clientY) {
21
+ if (!tooltipEl) return;
22
+ const TW = tooltipEl.offsetWidth || 200;
23
+ const TH = tooltipEl.offsetHeight || 60;
24
+ let left = clientX + 14;
25
+ let top = clientY - 10;
26
+ if (left + TW > window.innerWidth - 8) left = clientX - TW - 8;
27
+ if (top + TH > window.innerHeight - 8) top = clientY - TH - 8;
28
+ tooltipEl.style.left = `${left}px`;
29
+ tooltipEl.style.top = `${top}px`;
30
+ }
31
+
32
+ export function hideTooltip() {
33
+ if (!tooltipEl) return;
34
+ tooltipEl.classList.remove('tooltip--visible');
35
+ }
36
+
37
+ function escHtml(value) {
38
+ return String(value)
39
+ .replaceAll('&', '&amp;')
40
+ .replaceAll('<', '&lt;')
41
+ .replaceAll('>', '&gt;')
42
+ .replaceAll('"', '&quot;')
43
+ .replaceAll("'", '&#39;');
44
+ }
@@ -0,0 +1,590 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createServer } from 'node:http';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const TASK_STATUSES = new Set(['Open', 'Approved', 'Running', 'Done', 'Failed']);
8
+
9
+ function resolvePublicRoot() {
10
+ const thisFile = fileURLToPath(import.meta.url);
11
+ const thisDir = dirname(thisFile);
12
+ return join(thisDir, '..', 'public');
13
+ }
14
+
15
+ function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+
19
+ function slug(value) {
20
+ return String(value)
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, '-')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, 36);
25
+ }
26
+
27
+ function makeApiError(status, code, message) {
28
+ const error = new Error(message);
29
+ error.status = status;
30
+ error.code = code;
31
+ return error;
32
+ }
33
+
34
+ function toBool(value) {
35
+ if (typeof value === 'boolean') return value;
36
+ if (typeof value !== 'string') return false;
37
+ return value === '1' || value.toLowerCase() === 'true';
38
+ }
39
+
40
+ function normalizeLoc(loc) {
41
+ if (!loc || typeof loc !== 'object') return null;
42
+ if (!Number.isInteger(loc.startLine) || !Number.isInteger(loc.endLine)) return null;
43
+ return {
44
+ startLine: loc.startLine,
45
+ startCol: Number.isInteger(loc.startCol) ? loc.startCol : 1,
46
+ endLine: loc.endLine,
47
+ endCol: Number.isInteger(loc.endCol) ? loc.endCol : 1,
48
+ };
49
+ }
50
+
51
+ function lineRangeText(target) {
52
+ const file = target.file ?? 'unknown-file';
53
+ const loc = normalizeLoc(target.loc);
54
+ if (!loc) return file;
55
+ return `${file}:${loc.startLine}-${loc.endLine}`;
56
+ }
57
+
58
+ function formatTaskPrompt(task) {
59
+ const target = task.target ?? {};
60
+ const symbol = target.symbol ?? target.nodeId ?? 'unknown-symbol';
61
+ const location = lineRangeText(target);
62
+ return `@${location} > ${symbol} + {${task.userPrompt}}`;
63
+ }
64
+
65
+ function getSnippet(text, loc, radius = 5) {
66
+ const lines = text.split('\n');
67
+ const normalized = normalizeLoc(loc);
68
+
69
+ const start = normalized ? Math.max(normalized.startLine - radius, 1) : 1;
70
+ const end = normalized ? Math.min(normalized.endLine + radius, lines.length) : Math.min(lines.length, 120);
71
+
72
+ const snippet = [];
73
+ for (let i = start; i <= end; i += 1) {
74
+ const raw = lines[i - 1] ?? '';
75
+ snippet.push(`${String(i).padStart(4, ' ')} | ${raw}`);
76
+ }
77
+
78
+ return {
79
+ startLine: start,
80
+ endLine: end,
81
+ text: snippet.join('\n'),
82
+ };
83
+ }
84
+
85
+ async function pathExists(path) {
86
+ try {
87
+ await readFile(path, 'utf8');
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function runProcess(command, args, cwd) {
95
+ return new Promise((resolveResult) => {
96
+ const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
97
+ let stdout = '';
98
+ let stderr = '';
99
+
100
+ child.stdout.on('data', (chunk) => {
101
+ stdout += chunk;
102
+ });
103
+ child.stderr.on('data', (chunk) => {
104
+ stderr += chunk;
105
+ });
106
+
107
+ child.on('error', (error) => {
108
+ resolveResult({ ok: false, exitCode: -1, stdout, stderr: `${stderr}${error.message}` });
109
+ });
110
+
111
+ child.on('close', (code) => {
112
+ resolveResult({ ok: code === 0, exitCode: code ?? -1, stdout, stderr });
113
+ });
114
+ });
115
+ }
116
+
117
+ async function readJson(path, fallback = null) {
118
+ try {
119
+ const text = await readFile(path, 'utf8');
120
+ return JSON.parse(text);
121
+ } catch {
122
+ return fallback;
123
+ }
124
+ }
125
+
126
+ async function writeJsonAtomic(path, payload) {
127
+ await mkdir(dirname(path), { recursive: true });
128
+ const tempPath = `${path}.tmp-${Date.now()}`;
129
+ await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
130
+ await rename(tempPath, path);
131
+ }
132
+
133
+ export async function createViewerService(options) {
134
+ const graph = options.graph;
135
+ const view = options.view ?? { spec: { version: '1' }, nodes: graph.nodes, edges: graph.edges, groups: graph.groups };
136
+ const repoRoot = resolve(options.repoRoot ?? graph?.project?.rootPath ?? process.cwd());
137
+ const tasksFile = options.tasksFile ?? join(repoRoot, '.bgx', 'comment-tasks.json');
138
+ const executorConfigPath = options.executorConfigPath ?? join(repoRoot, '.bgx', 'executors.json');
139
+ const descriptionsPath = options.descriptionsPath ?? null;
140
+
141
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
142
+ const edges = graph.edges ?? [];
143
+
144
+ async function loadTasksStore() {
145
+ const initial = {
146
+ version: '1',
147
+ tasks: [],
148
+ };
149
+ const existing = await readJson(tasksFile, initial);
150
+ if (!existing || typeof existing !== 'object' || !Array.isArray(existing.tasks)) {
151
+ return initial;
152
+ }
153
+ return existing;
154
+ }
155
+
156
+ async function saveTasksStore(store) {
157
+ await writeJsonAtomic(tasksFile, store);
158
+ }
159
+
160
+ function getTaskById(store, taskId) {
161
+ return store.tasks.find((task) => task.id === taskId) ?? null;
162
+ }
163
+
164
+ function nextTaskId(store) {
165
+ let max = 0;
166
+ for (const task of store.tasks) {
167
+ const match = /^task-(\d+)$/.exec(task.id ?? '');
168
+ if (!match) continue;
169
+ const n = Number(match[1]);
170
+ if (Number.isFinite(n) && n > max) max = n;
171
+ }
172
+ return `task-${String(max + 1).padStart(6, '0')}`;
173
+ }
174
+
175
+ function resolveNodeFilePath(node) {
176
+ if (!node?.file) return null;
177
+ return isAbsolute(node.file) ? node.file : resolve(repoRoot, node.file);
178
+ }
179
+
180
+ async function getNodeCode(nodeId) {
181
+ const node = nodeById.get(nodeId);
182
+ if (!node) {
183
+ throw makeApiError(404, 'NODE_NOT_FOUND', `Node not found: ${nodeId}`);
184
+ }
185
+
186
+ const absolutePath = resolveNodeFilePath(node);
187
+ let snippet = '';
188
+ if (absolutePath && (await pathExists(absolutePath))) {
189
+ const text = await readFile(absolutePath, 'utf8');
190
+ snippet = getSnippet(text, node.meta?.loc).text;
191
+ }
192
+
193
+ const relatedEdgeEntries = edges.filter((edge) => edge.from === nodeId || edge.to === nodeId).slice(0, 24);
194
+ const related = [];
195
+ for (const edge of relatedEdgeEntries) {
196
+ const relatedNodeId = edge.from === nodeId ? edge.to : edge.from;
197
+ const relatedNode = nodeById.get(relatedNodeId);
198
+ if (!relatedNode) continue;
199
+
200
+ const relatedFilePath = resolveNodeFilePath(relatedNode);
201
+ let relatedSnippet = '';
202
+ if (relatedFilePath && (await pathExists(relatedFilePath))) {
203
+ const text = await readFile(relatedFilePath, 'utf8');
204
+ relatedSnippet = getSnippet(text, relatedNode.meta?.loc, 3).text;
205
+ }
206
+
207
+ related.push({
208
+ edge,
209
+ node: relatedNode,
210
+ snippet: relatedSnippet,
211
+ });
212
+ }
213
+
214
+ return {
215
+ node,
216
+ filePath: node.file ?? null,
217
+ snippet,
218
+ related,
219
+ };
220
+ }
221
+
222
+ async function listTasks({ status, nodeId, includeDeleted } = {}) {
223
+ const store = await loadTasksStore();
224
+ let tasks = [...store.tasks];
225
+
226
+ if (!toBool(includeDeleted)) {
227
+ tasks = tasks.filter((task) => !task.deleted);
228
+ }
229
+
230
+ if (status) {
231
+ const statuses = new Set(String(status).split(',').map((value) => value.trim()).filter(Boolean));
232
+ tasks = tasks.filter((task) => statuses.has(task.status));
233
+ }
234
+
235
+ if (nodeId) {
236
+ tasks = tasks.filter((task) => task.target?.nodeId === nodeId);
237
+ }
238
+
239
+ tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
240
+ return { tasks };
241
+ }
242
+
243
+ async function createTask(input) {
244
+ const nodeId = input?.nodeId;
245
+ const userPrompt = (input?.userPrompt ?? '').trim();
246
+ const executor = input?.executor ?? 'codex';
247
+
248
+ if (!nodeId || !nodeById.has(nodeId)) {
249
+ throw makeApiError(400, 'INVALID_NODE', 'nodeId is required and must exist in graph');
250
+ }
251
+ if (!userPrompt) {
252
+ throw makeApiError(400, 'INVALID_PROMPT', 'userPrompt is required');
253
+ }
254
+ if (!['codex', 'claude'].includes(executor)) {
255
+ throw makeApiError(400, 'INVALID_EXECUTOR', 'executor must be codex or claude');
256
+ }
257
+
258
+ const node = nodeById.get(nodeId);
259
+ const target = {
260
+ nodeId,
261
+ file: node.file ?? null,
262
+ symbol: node.symbol ?? node.label ?? nodeId,
263
+ loc: normalizeLoc(node.meta?.loc),
264
+ };
265
+
266
+ const store = await loadTasksStore();
267
+ const createdAt = nowIso();
268
+
269
+ const task = {
270
+ id: nextTaskId(store),
271
+ createdAt,
272
+ updatedAt: createdAt,
273
+ deleted: false,
274
+ status: 'Open',
275
+ executor,
276
+ target,
277
+ userPrompt,
278
+ formattedPrompt: '',
279
+ branch: null,
280
+ commit: null,
281
+ logs: [],
282
+ error: null,
283
+ };
284
+
285
+ task.formattedPrompt = formatTaskPrompt(task);
286
+
287
+ store.tasks.push(task);
288
+ await saveTasksStore(store);
289
+ return task;
290
+ }
291
+
292
+ async function updateTask(taskId, input) {
293
+ const store = await loadTasksStore();
294
+ const task = getTaskById(store, taskId);
295
+ if (!task) {
296
+ throw makeApiError(404, 'TASK_NOT_FOUND', `Task not found: ${taskId}`);
297
+ }
298
+
299
+ if (typeof input?.status === 'string') {
300
+ if (!TASK_STATUSES.has(input.status)) {
301
+ throw makeApiError(400, 'INVALID_STATUS', `Unsupported status: ${input.status}`);
302
+ }
303
+ task.status = input.status;
304
+ }
305
+
306
+ if (typeof input?.executor === 'string') {
307
+ if (!['codex', 'claude'].includes(input.executor)) {
308
+ throw makeApiError(400, 'INVALID_EXECUTOR', 'executor must be codex or claude');
309
+ }
310
+ task.executor = input.executor;
311
+ }
312
+
313
+ if (typeof input?.userPrompt === 'string') {
314
+ task.userPrompt = input.userPrompt.trim();
315
+ if (!task.userPrompt) {
316
+ throw makeApiError(400, 'INVALID_PROMPT', 'userPrompt cannot be empty');
317
+ }
318
+ }
319
+
320
+ task.formattedPrompt = formatTaskPrompt(task);
321
+ task.updatedAt = nowIso();
322
+ await saveTasksStore(store);
323
+ return task;
324
+ }
325
+
326
+ async function deleteTask(taskId) {
327
+ const store = await loadTasksStore();
328
+ const task = getTaskById(store, taskId);
329
+ if (!task) {
330
+ throw makeApiError(404, 'TASK_NOT_FOUND', `Task not found: ${taskId}`);
331
+ }
332
+
333
+ task.deleted = true;
334
+ task.updatedAt = nowIso();
335
+ await saveTasksStore(store);
336
+ return task;
337
+ }
338
+
339
+ async function getTaskLogs(taskId) {
340
+ const store = await loadTasksStore();
341
+ const task = getTaskById(store, taskId);
342
+ if (!task) {
343
+ throw makeApiError(404, 'TASK_NOT_FOUND', `Task not found: ${taskId}`);
344
+ }
345
+ return { logs: task.logs ?? [] };
346
+ }
347
+
348
+ async function isGitRepo() {
349
+ const result = await runProcess('git', ['rev-parse', '--is-inside-work-tree'], repoRoot);
350
+ return result.ok && String(result.stdout).trim() === 'true';
351
+ }
352
+
353
+ async function runTask(taskId) {
354
+ const store = await loadTasksStore();
355
+ const task = getTaskById(store, taskId);
356
+ if (!task) {
357
+ throw makeApiError(404, 'TASK_NOT_FOUND', `Task not found: ${taskId}`);
358
+ }
359
+ if (task.status !== 'Approved') {
360
+ throw makeApiError(409, 'TASK_NOT_APPROVED', 'Task must be approved before execution');
361
+ }
362
+
363
+ task.status = 'Running';
364
+ task.error = null;
365
+ task.updatedAt = nowIso();
366
+ task.logs = task.logs ?? [];
367
+ task.logs.push({ at: nowIso(), type: 'status', message: 'Execution started' });
368
+ await saveTasksStore(store);
369
+
370
+ const config = await readJson(executorConfigPath, null);
371
+ const executorDef = config?.[task.executor];
372
+
373
+ if (!executorDef?.cmd || !Array.isArray(executorDef.args)) {
374
+ task.status = 'Failed';
375
+ task.error = `Executor not configured: ${task.executor}`;
376
+ task.updatedAt = nowIso();
377
+ task.logs.push({ at: nowIso(), type: 'error', message: task.error });
378
+ await saveTasksStore(store);
379
+ return task;
380
+ }
381
+
382
+ const prompt = task.formattedPrompt || formatTaskPrompt(task);
383
+ const args = executorDef.args.map((value) => String(value).replaceAll('{PROMPT}', prompt));
384
+
385
+ let gitEnabled = false;
386
+ if (await isGitRepo()) {
387
+ gitEnabled = true;
388
+ const branch = `codex/comment-${task.id}-${slug(task.target?.symbol ?? task.id) || 'task'}`;
389
+ task.branch = branch;
390
+ const checkout = await runProcess('git', ['checkout', '-B', branch], repoRoot);
391
+ task.logs.push({ at: nowIso(), type: 'git', message: `checkout -B ${branch}`, stdout: checkout.stdout, stderr: checkout.stderr });
392
+ if (!checkout.ok) {
393
+ gitEnabled = false;
394
+ }
395
+ }
396
+
397
+ const execution = await runProcess(executorDef.cmd, args, repoRoot);
398
+ task.logs.push({
399
+ at: nowIso(),
400
+ type: 'executor',
401
+ message: `${executorDef.cmd} ${args.join(' ')}`,
402
+ exitCode: execution.exitCode,
403
+ stdout: execution.stdout,
404
+ stderr: execution.stderr,
405
+ });
406
+
407
+ if (gitEnabled) {
408
+ const status = await runProcess('git', ['status', '--porcelain'], repoRoot);
409
+ const hasChanges = Boolean(status.stdout.trim());
410
+ if (hasChanges) {
411
+ const add = await runProcess('git', ['add', '-A'], repoRoot);
412
+ task.logs.push({ at: nowIso(), type: 'git', message: 'git add -A', stdout: add.stdout, stderr: add.stderr });
413
+
414
+ const commit = await runProcess('git', ['commit', '-m', `feat(comment-task): apply ${task.id}`], repoRoot);
415
+ task.logs.push({
416
+ at: nowIso(),
417
+ type: 'git',
418
+ message: `git commit for ${task.id}`,
419
+ stdout: commit.stdout,
420
+ stderr: commit.stderr,
421
+ });
422
+
423
+ if (commit.ok) {
424
+ const sha = await runProcess('git', ['rev-parse', 'HEAD'], repoRoot);
425
+ if (sha.ok) {
426
+ task.commit = sha.stdout.trim();
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ if (execution.ok) {
433
+ task.status = 'Done';
434
+ } else {
435
+ task.status = 'Failed';
436
+ task.error = execution.stderr || `Executor exited with ${execution.exitCode}`;
437
+ }
438
+ task.updatedAt = nowIso();
439
+ await saveTasksStore(store);
440
+ return task;
441
+ }
442
+
443
+ async function getDescriptions() {
444
+ if (!descriptionsPath) return { descriptions: null };
445
+ const descriptions = await readJson(descriptionsPath, null);
446
+ return { descriptions };
447
+ }
448
+
449
+ return {
450
+ graph,
451
+ view,
452
+ repoRoot,
453
+ listTasks,
454
+ createTask,
455
+ updateTask,
456
+ deleteTask,
457
+ runTask,
458
+ getTaskLogs,
459
+ getNodeCode,
460
+ getDescriptions,
461
+ };
462
+ }
463
+
464
+ async function serveStatic(pathname) {
465
+ const publicRoot = resolvePublicRoot();
466
+ const target = pathname === '/' ? 'index.html' : pathname.slice(1);
467
+ const filePath = join(publicRoot, target);
468
+ const body = await readFile(filePath, 'utf8');
469
+ const type = pathname.endsWith('.css')
470
+ ? 'text/css; charset=utf-8'
471
+ : pathname.endsWith('.js')
472
+ ? 'text/javascript; charset=utf-8'
473
+ : 'text/html; charset=utf-8';
474
+ return { body, type };
475
+ }
476
+
477
+ function sendJson(res, status, payload) {
478
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
479
+ res.end(JSON.stringify(payload));
480
+ }
481
+
482
+ function parseBody(req) {
483
+ return new Promise((resolveBody, rejectBody) => {
484
+ const chunks = [];
485
+ req.on('data', (chunk) => chunks.push(chunk));
486
+ req.on('end', () => {
487
+ if (!chunks.length) {
488
+ resolveBody({});
489
+ return;
490
+ }
491
+ try {
492
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
493
+ resolveBody(parsed);
494
+ } catch {
495
+ rejectBody(makeApiError(400, 'INVALID_JSON', 'Request body must be valid JSON'));
496
+ }
497
+ });
498
+ req.on('error', rejectBody);
499
+ });
500
+ }
501
+
502
+ export async function startViewerServer(options) {
503
+ const port = options.port ?? 4310;
504
+ const service = await createViewerService(options);
505
+
506
+ const server = createServer(async (req, res) => {
507
+ try {
508
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
509
+
510
+ if (url.pathname === '/api/graph') {
511
+ sendJson(res, 200, service.graph);
512
+ return;
513
+ }
514
+ if (url.pathname === '/api/view') {
515
+ sendJson(res, 200, service.view);
516
+ return;
517
+ }
518
+ if (url.pathname === '/api/descriptions') {
519
+ sendJson(res, 200, await service.getDescriptions());
520
+ return;
521
+ }
522
+
523
+ if (url.pathname.startsWith('/api/node/') && url.pathname.endsWith('/code') && req.method === 'GET') {
524
+ const nodeId = decodeURIComponent(url.pathname.slice('/api/node/'.length, -'/code'.length));
525
+ sendJson(res, 200, await service.getNodeCode(nodeId));
526
+ return;
527
+ }
528
+
529
+ if (url.pathname === '/api/tasks') {
530
+ if (req.method === 'GET') {
531
+ sendJson(
532
+ res,
533
+ 200,
534
+ await service.listTasks({
535
+ status: url.searchParams.get('status') ?? undefined,
536
+ nodeId: url.searchParams.get('nodeId') ?? undefined,
537
+ includeDeleted: url.searchParams.get('includeDeleted') ?? undefined,
538
+ }),
539
+ );
540
+ return;
541
+ }
542
+
543
+ if (req.method === 'POST') {
544
+ const body = await parseBody(req);
545
+ sendJson(res, 201, await service.createTask(body));
546
+ return;
547
+ }
548
+ }
549
+
550
+ if (url.pathname.startsWith('/api/tasks/')) {
551
+ const remainder = url.pathname.slice('/api/tasks/'.length);
552
+ if (remainder.endsWith('/run') && req.method === 'POST') {
553
+ const taskId = decodeURIComponent(remainder.slice(0, -'/run'.length));
554
+ sendJson(res, 200, await service.runTask(taskId));
555
+ return;
556
+ }
557
+ if (remainder.endsWith('/logs') && req.method === 'GET') {
558
+ const taskId = decodeURIComponent(remainder.slice(0, -'/logs'.length));
559
+ sendJson(res, 200, await service.getTaskLogs(taskId));
560
+ return;
561
+ }
562
+
563
+ const taskId = decodeURIComponent(remainder);
564
+ if (req.method === 'PATCH') {
565
+ const body = await parseBody(req);
566
+ sendJson(res, 200, await service.updateTask(taskId, body));
567
+ return;
568
+ }
569
+ if (req.method === 'DELETE') {
570
+ sendJson(res, 200, await service.deleteTask(taskId));
571
+ return;
572
+ }
573
+ }
574
+
575
+ const asset = await serveStatic(url.pathname);
576
+ res.writeHead(200, { 'Content-Type': asset.type });
577
+ res.end(asset.body);
578
+ } catch (error) {
579
+ if (error?.status) {
580
+ sendJson(res, error.status, { error: error.message, code: error.code ?? 'UNKNOWN' });
581
+ return;
582
+ }
583
+ sendJson(res, 500, { error: String(error), code: 'INTERNAL_ERROR' });
584
+ }
585
+ });
586
+
587
+ await new Promise((resolveReady) => server.listen(port, '127.0.0.1', resolveReady));
588
+ console.log(`Viewer running at http://localhost:${server.address().port}`);
589
+ return server;
590
+ }