archbyte 0.4.0 → 0.4.2

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.
@@ -14,6 +14,9 @@ const UI_DIST = path.resolve(__dirname, "../../../ui/dist");
14
14
  let config;
15
15
  let sseClients = new Set();
16
16
  let diagramWatcher = null;
17
+ let isAnalyzing = false;
18
+ const sessionChanges = [];
19
+ const MAX_SESSION_CHANGES = 50;
17
20
  let currentArchitecture = null;
18
21
  // Process tracking for run-from-UI
19
22
  const runningWorkflows = new Map();
@@ -103,7 +106,7 @@ function createHttpServer() {
103
106
  const reqOrigin = req.headers.origin || "";
104
107
  const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(reqOrigin) ? reqOrigin : `http://localhost:${config.port}`;
105
108
  res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
106
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
109
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
107
110
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
108
111
  res.setHeader("X-Content-Type-Options", "nosniff");
109
112
  res.setHeader("X-Frame-Options", "SAMEORIGIN");
@@ -254,8 +257,81 @@ function createHttpServer() {
254
257
  }));
255
258
  return;
256
259
  }
257
- // API: Validate
258
- if (url === "/api/validate" && req.method === "GET") {
260
+ // API: Impact analysis — blast radius BFS from a node
261
+ if (url.startsWith("/api/impact/") && req.method === "GET") {
262
+ const arch = currentArchitecture || (await loadArchitecture());
263
+ if (!arch) {
264
+ res.writeHead(404, { "Content-Type": "application/json" });
265
+ res.end(JSON.stringify({ error: "No architecture loaded" }));
266
+ return;
267
+ }
268
+ const urlParts = url.split("?");
269
+ const nodeId = decodeURIComponent(urlParts[0].replace("/api/impact/", ""));
270
+ const params = new URL(url, `http://localhost:${config.port}`).searchParams;
271
+ const depth = Math.min(Math.max(parseInt(params.get("depth") || "2", 10) || 2, 1), 5);
272
+ const direction = params.get("direction") || "both";
273
+ // Build adjacency lists
274
+ const forward = new Map();
275
+ const reverse = new Map();
276
+ for (const nd of arch.nodes) {
277
+ forward.set(nd.id, []);
278
+ reverse.set(nd.id, []);
279
+ }
280
+ for (const edge of arch.edges) {
281
+ forward.get(edge.source)?.push(edge.target);
282
+ reverse.get(edge.target)?.push(edge.source);
283
+ }
284
+ const nodeMap = new Map();
285
+ for (const nd of arch.nodes)
286
+ nodeMap.set(nd.id, nd);
287
+ // BFS helper
288
+ function bfs(startId, adj, maxDepth) {
289
+ const visited = new Map();
290
+ visited.set(startId, 0);
291
+ const queue = [{ id: startId, d: 0 }];
292
+ const result = [];
293
+ while (queue.length > 0) {
294
+ const { id, d } = queue.shift();
295
+ if (d >= maxDepth)
296
+ continue;
297
+ for (const neighbor of adj.get(id) || []) {
298
+ if (!visited.has(neighbor)) {
299
+ const nd = d + 1;
300
+ visited.set(neighbor, nd);
301
+ result.push({ id: neighbor, label: nodeMap.get(neighbor)?.label.split("\n")[0] || neighbor, depth: nd });
302
+ queue.push({ id: neighbor, d: nd });
303
+ }
304
+ }
305
+ }
306
+ return result;
307
+ }
308
+ const upstream = (direction === "downstream") ? [] : bfs(nodeId, reverse, depth);
309
+ const downstream = (direction === "upstream") ? [] : bfs(nodeId, forward, depth);
310
+ // Collect all affected node IDs (including origin)
311
+ const affectedSet = new Set([nodeId]);
312
+ for (const n of upstream)
313
+ affectedSet.add(n.id);
314
+ for (const n of downstream)
315
+ affectedSet.add(n.id);
316
+ // Affected edges: edges between any two nodes in the affected set
317
+ const affectedEdges = [];
318
+ for (const edge of arch.edges) {
319
+ if (affectedSet.has(edge.source) && affectedSet.has(edge.target)) {
320
+ affectedEdges.push(edge.id);
321
+ }
322
+ }
323
+ res.writeHead(200, { "Content-Type": "application/json" });
324
+ res.end(JSON.stringify({
325
+ nodeId,
326
+ upstream,
327
+ downstream,
328
+ affectedEdges,
329
+ totalAffected: affectedSet.size - 1,
330
+ }));
331
+ return;
332
+ }
333
+ // API: Audit — unified validation + health endpoint
334
+ if ((url === "/api/audit" || url === "/api/validate") && req.method === "GET") {
259
335
  const arch = currentArchitecture || (await loadArchitecture());
260
336
  if (!arch) {
261
337
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -282,6 +358,7 @@ function createHttpServer() {
282
358
  level: "error",
283
359
  message: `"${src.label}" (${src.layer}) -> "${tgt.label}" (${tgt.layer})`,
284
360
  nodeIds: [src.id, tgt.id],
361
+ ruleType: "builtin",
285
362
  });
286
363
  }
287
364
  }
@@ -299,6 +376,7 @@ function createHttpServer() {
299
376
  level: "warn",
300
377
  message: `"${nd.label}" has ${count} connections (threshold: 6)`,
301
378
  nodeIds: [nd.id],
379
+ ruleType: "builtin",
302
380
  });
303
381
  }
304
382
  }
@@ -315,6 +393,7 @@ function createHttpServer() {
315
393
  level: "warn",
316
394
  message: `"${nd.label}" has no connections`,
317
395
  nodeIds: [nd.id],
396
+ ruleType: "builtin",
318
397
  });
319
398
  }
320
399
  }
@@ -356,19 +435,288 @@ function createHttpServer() {
356
435
  level: "error",
357
436
  message: `Cycle: ${cycle.map((id) => nodeMap.get(id)?.label || id).join(" -> ")}`,
358
437
  nodeIds: cycle.filter((id, i, arr) => arr.indexOf(id) === i),
438
+ ruleType: "builtin",
359
439
  });
360
440
  }
441
+ // Custom rules from archbyte.yaml
442
+ try {
443
+ const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
444
+ if (existsSync(yamlPath)) {
445
+ const yamlContent = readFileSync(yamlPath, "utf-8");
446
+ const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
447
+ if (rulesMatch) {
448
+ const rulesBlock = rulesMatch[1];
449
+ const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
450
+ let m;
451
+ const customRules = [];
452
+ while ((m = ruleRegex.exec(rulesBlock)) !== null) {
453
+ customRules.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
454
+ }
455
+ // Helper: check if a node matches a custom rule matcher (type:xxx, layer:xxx, or exact id)
456
+ const matchesNode = (nd, matcher) => {
457
+ if (matcher.startsWith("type:"))
458
+ return nd.type === matcher.slice(5);
459
+ if (matcher.startsWith("layer:"))
460
+ return nd.layer === matcher.slice(6);
461
+ return nd.id === matcher;
462
+ };
463
+ for (const rule of customRules) {
464
+ for (const edge of arch.edges) {
465
+ const src = nodeMap.get(edge.source);
466
+ const tgt = nodeMap.get(edge.target);
467
+ if (!src || !tgt)
468
+ continue;
469
+ if (matchesNode(src, rule.from) && matchesNode(tgt, rule.to)) {
470
+ violations.push({
471
+ rule: rule.name,
472
+ level: rule.level === "error" ? "error" : "warn",
473
+ message: `"${src.label}" -> "${tgt.label}" violates custom rule "${rule.name}"`,
474
+ nodeIds: [src.id, tgt.id],
475
+ ruleType: "custom",
476
+ });
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+ catch { /* ignore custom rule errors */ }
484
+ // === STRUCTURAL ISSUES ===
485
+ // Build degree maps for health metrics
486
+ const inDeg = new Map();
487
+ const outDeg = new Map();
488
+ const connCount = new Map();
489
+ const fwdAdj = new Map();
490
+ for (const nd of realNodes) {
491
+ inDeg.set(nd.id, 0);
492
+ outDeg.set(nd.id, 0);
493
+ connCount.set(nd.id, 0);
494
+ fwdAdj.set(nd.id, []);
495
+ }
496
+ for (const edge of arch.edges) {
497
+ outDeg.set(edge.source, (outDeg.get(edge.source) || 0) + 1);
498
+ inDeg.set(edge.target, (inDeg.get(edge.target) || 0) + 1);
499
+ connCount.set(edge.source, (connCount.get(edge.source) || 0) + 1);
500
+ connCount.set(edge.target, (connCount.get(edge.target) || 0) + 1);
501
+ fwdAdj.get(edge.source)?.push(edge.target);
502
+ }
503
+ // DFS cycle detection for metrics
504
+ const visitedH = new Set();
505
+ const inStackH = new Set();
506
+ const nodesInCycles = new Set();
507
+ let circularDependenciesCount = 0;
508
+ function dfsHealth(nodeId) {
509
+ visitedH.add(nodeId);
510
+ inStackH.add(nodeId);
511
+ for (const neighbor of fwdAdj.get(nodeId) || []) {
512
+ if (!visitedH.has(neighbor)) {
513
+ dfsHealth(neighbor);
514
+ }
515
+ else if (inStackH.has(neighbor)) {
516
+ nodesInCycles.add(neighbor);
517
+ nodesInCycles.add(nodeId);
518
+ circularDependenciesCount++;
519
+ }
520
+ }
521
+ inStackH.delete(nodeId);
522
+ }
523
+ for (const nd of realNodes) {
524
+ if (!visitedH.has(nd.id))
525
+ dfsHealth(nd.id);
526
+ }
527
+ // Health nodes
528
+ const totalConnections = Array.from(connCount.values()).reduce((a, b) => a + b, 0);
529
+ const avgConn = realNodes.length > 0 ? totalConnections / realNodes.length : 0;
530
+ const maxConn = Math.max(...Array.from(connCount.values()), 1);
531
+ const healthNodes = realNodes.map((nd) => {
532
+ const cc = connCount.get(nd.id) || 0;
533
+ const isOrphan = cc === 0;
534
+ const isHub = cc > 6;
535
+ const inCycle = nodesInCycles.has(nd.id);
536
+ const normalizedFanInOutRatio = maxConn > 0 ? Math.round((cc / maxConn) * 10 * 10) / 10 : 0;
537
+ let modularityIndex = 100;
538
+ if (isOrphan)
539
+ modularityIndex -= 30;
540
+ if (isHub)
541
+ modularityIndex -= 20;
542
+ if (inCycle)
543
+ modularityIndex -= 25;
544
+ if (avgConn > 0 && cc > avgConn * 1.5)
545
+ modularityIndex -= Math.min(25, Math.round(((cc - avgConn) / avgConn) * 25));
546
+ modularityIndex = Math.max(0, modularityIndex);
547
+ return {
548
+ id: nd.id,
549
+ label: nd.label.split("\n")[0],
550
+ connectionCount: cc,
551
+ inDegree: inDeg.get(nd.id) || 0,
552
+ outDegree: outDeg.get(nd.id) || 0,
553
+ isOrphan,
554
+ isHub,
555
+ inCycle,
556
+ normalizedFanInOutRatio,
557
+ modularityIndex,
558
+ couplingScore: normalizedFanInOutRatio,
559
+ healthScore: modularityIndex,
560
+ };
561
+ });
562
+ // Calculate metrics
563
+ const disconnectedComponentCount = healthNodes.filter((n) => n.isOrphan).length;
564
+ const meanCouplingRatio = avgConn > 0 ? Math.round((totalConnections / realNodes.length / maxConn) * 10 * 10) / 10 : 0;
565
+ const maxFanInOutDegree = maxConn;
566
+ const disconnectionRatio = realNodes.length > 0 ? Math.round((disconnectedComponentCount / realNodes.length) * 100) : 0;
567
+ const highDegreeHubCount = healthNodes.filter((n) => n.isHub).length;
568
+ // Convert violations to issues
569
+ const ruleViolations = violations.map((v) => ({
570
+ type: 'rule-violation',
571
+ rule: v.rule,
572
+ level: v.level,
573
+ message: v.message,
574
+ nodeIds: v.nodeIds,
575
+ ruleType: v.ruleType,
576
+ }));
577
+ // Generate structural issues from metrics
578
+ const structuralIssues = [];
579
+ if (circularDependenciesCount > 0) {
580
+ for (const nodeId of Array.from(nodesInCycles).slice(0, 5)) {
581
+ structuralIssues.push({
582
+ type: 'structural',
583
+ metric: 'circular-deps',
584
+ level: 'warn',
585
+ message: `${nodeMap.get(nodeId)?.label || nodeId} is part of a circular dependency`,
586
+ nodeIds: [nodeId],
587
+ });
588
+ }
589
+ }
590
+ for (const node of healthNodes) {
591
+ if (node.isHub) {
592
+ structuralIssues.push({
593
+ type: 'structural',
594
+ metric: 'high-hub',
595
+ level: 'warn',
596
+ message: `${node.label} is a high-degree hub with ${node.connectionCount} connections`,
597
+ nodeIds: [node.id],
598
+ });
599
+ }
600
+ if (node.isOrphan) {
601
+ structuralIssues.push({
602
+ type: 'structural',
603
+ metric: 'orphan',
604
+ level: 'warn',
605
+ message: `${node.label} has no connections (orphan component)`,
606
+ nodeIds: [node.id],
607
+ });
608
+ }
609
+ }
610
+ const allIssues = [...ruleViolations, ...structuralIssues];
611
+ const ruleViolationCount = ruleViolations.length;
612
+ const structuralIssueCount = structuralIssues.length;
361
613
  const errors = violations.filter((v) => v.level === "error").length;
362
- const warnings = violations.filter((v) => v.level === "warn").length;
363
614
  res.writeHead(200, { "Content-Type": "application/json" });
364
615
  res.end(JSON.stringify({
365
616
  passed: errors === 0,
366
- errors,
367
- warnings,
368
- violations,
617
+ issues: allIssues,
618
+ summary: {
619
+ ruleViolations: ruleViolationCount,
620
+ structuralIssues: structuralIssueCount,
621
+ totalIssues: allIssues.length,
622
+ },
623
+ metrics: {
624
+ circularDependencies: circularDependenciesCount,
625
+ highDegreeHubCount,
626
+ disconnectedComponentCount,
627
+ meanCouplingRatio,
628
+ maxFanInOutDegree,
629
+ },
630
+ nodes: healthNodes,
631
+ rules: {
632
+ builtin: [
633
+ { name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
634
+ { name: "max-connections", level: "warn", description: "Flag components with too many connections" },
635
+ { name: "no-orphans", level: "warn", description: "Flag components with no connections" },
636
+ { name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
637
+ ],
638
+ custom: [],
639
+ },
369
640
  }));
370
641
  return;
371
642
  }
643
+ // API: Health (deprecated - redirect to audit for backward compat)
644
+ if (url === "/api/health" && req.method === "GET") {
645
+ res.writeHead(307, { "Location": "/api/audit" });
646
+ res.end();
647
+ return;
648
+ }
649
+ // API: Rules — GET returns current rules config
650
+ if (url === "/api/rules" && req.method === "GET") {
651
+ const builtin = [
652
+ { name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
653
+ { name: "max-connections", level: "warn", threshold: 6, description: "Flag components with too many connections" },
654
+ { name: "no-orphans", level: "warn", description: "Flag components with no connections" },
655
+ { name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
656
+ ];
657
+ let custom = [];
658
+ try {
659
+ const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
660
+ if (existsSync(yamlPath)) {
661
+ const yamlContent = readFileSync(yamlPath, "utf-8");
662
+ // Parse custom rules from YAML
663
+ const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
664
+ if (rulesMatch) {
665
+ const rulesBlock = rulesMatch[1];
666
+ const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
667
+ let m;
668
+ while ((m = ruleRegex.exec(rulesBlock)) !== null) {
669
+ custom.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
670
+ }
671
+ }
672
+ }
673
+ }
674
+ catch { /* ignore */ }
675
+ res.writeHead(200, { "Content-Type": "application/json" });
676
+ res.end(JSON.stringify({ builtin, custom }));
677
+ return;
678
+ }
679
+ // API: Rules — PUT saves rules config
680
+ if (url === "/api/rules" && req.method === "PUT") {
681
+ let body = "";
682
+ req.on("data", (chunk) => { body += chunk.toString(); });
683
+ req.on("end", async () => {
684
+ try {
685
+ const { custom } = JSON.parse(body);
686
+ const yamlDir = path.join(config.workspaceRoot, ".archbyte");
687
+ if (!existsSync(yamlDir))
688
+ await mkdir(yamlDir, { recursive: true });
689
+ const yamlPath = path.join(yamlDir, "archbyte.yaml");
690
+ let yamlContent = "";
691
+ if (existsSync(yamlPath)) {
692
+ yamlContent = readFileSync(yamlPath, "utf-8");
693
+ }
694
+ // Remove existing rules section
695
+ yamlContent = yamlContent.replace(/^rules:\s*\n((?:\s+.*\n)*)/m, "");
696
+ // Build new rules section
697
+ if (custom && custom.length > 0) {
698
+ let rulesYaml = "rules:\n";
699
+ for (const rule of custom) {
700
+ // Validate rule fields to prevent YAML injection
701
+ const safeName = rule.name.replace(/["\n\r]/g, "");
702
+ const safeFrom = rule.from.replace(/["\n\r]/g, "");
703
+ const safeTo = rule.to.replace(/["\n\r]/g, "");
704
+ const safeLevel = (rule.level === "error" || rule.level === "warn") ? rule.level : "warn";
705
+ rulesYaml += ` - name: "${safeName}"\n from: "${safeFrom}"\n to: "${safeTo}"\n level: "${safeLevel}"\n`;
706
+ }
707
+ yamlContent = yamlContent.trimEnd() + "\n\n" + rulesYaml;
708
+ }
709
+ await writeFile(yamlPath, yamlContent.trimEnd() + "\n");
710
+ res.writeHead(200, { "Content-Type": "application/json" });
711
+ res.end(JSON.stringify({ ok: true }));
712
+ }
713
+ catch (error) {
714
+ res.writeHead(500, { "Content-Type": "application/json" });
715
+ res.end(JSON.stringify({ error: String(error) }));
716
+ }
717
+ });
718
+ return;
719
+ }
372
720
  // API: Export architecture as text format
373
721
  if (url.startsWith("/api/export") && req.method === "GET") {
374
722
  const arch = currentArchitecture || (await loadArchitecture());
@@ -820,6 +1168,35 @@ function createHttpServer() {
820
1168
  res.end(JSON.stringify({ running: runningWorkflows.has("__generate__") }));
821
1169
  return;
822
1170
  }
1171
+ // API: Run analyze (static or LLM) + generate
1172
+ if (url === "/api/analyze" && req.method === "POST") {
1173
+ if (isAnalyzing) {
1174
+ res.writeHead(409, { "Content-Type": "application/json" });
1175
+ res.end(JSON.stringify({ error: "Analysis already running" }));
1176
+ return;
1177
+ }
1178
+ let body = "";
1179
+ req.on("data", (chunk) => { body += chunk.toString(); });
1180
+ req.on("end", () => {
1181
+ const { mode } = JSON.parse(body || "{}");
1182
+ runAnalyzePipeline(mode === "llm" ? "llm" : "static");
1183
+ res.writeHead(200, { "Content-Type": "application/json" });
1184
+ res.end(JSON.stringify({ ok: true, mode: mode || "static" }));
1185
+ });
1186
+ return;
1187
+ }
1188
+ // API: Analyze status
1189
+ if (url === "/api/analyze/status" && req.method === "GET") {
1190
+ res.writeHead(200, { "Content-Type": "application/json" });
1191
+ res.end(JSON.stringify({ running: isAnalyzing }));
1192
+ return;
1193
+ }
1194
+ // API: Session changes feed — returns in-memory session change records (newest first)
1195
+ if (url === "/api/session-changes" && req.method === "GET") {
1196
+ res.writeHead(200, { "Content-Type": "application/json" });
1197
+ res.end(JSON.stringify(sessionChanges));
1198
+ return;
1199
+ }
823
1200
  // API: Run workflow
824
1201
  if (url.startsWith("/api/workflow/run/") && req.method === "POST") {
825
1202
  const id = url.split("/").pop();
@@ -904,13 +1281,13 @@ function createHttpServer() {
904
1281
  req.on("end", () => {
905
1282
  const { interval } = JSON.parse(body || "{}");
906
1283
  const bin = getArchbyteBin();
907
- const args = [bin, "patrol", "--watch"];
1284
+ const args = [bin, "patrol"];
908
1285
  if (interval)
909
1286
  args.push("--interval", String(interval));
910
1287
  patrolProcess = spawn(process.execPath, args, {
911
1288
  cwd: config.workspaceRoot,
912
- stdio: ["ignore", "pipe", "pipe"],
913
- env: { ...process.env, FORCE_COLOR: "0" },
1289
+ stdio: ["ignore", "inherit", "inherit"],
1290
+ env: { ...process.env, FORCE_COLOR: "1" },
914
1291
  });
915
1292
  patrolRunning = true;
916
1293
  broadcastOpsEvent({ type: "patrol:started" });
@@ -1317,12 +1694,135 @@ async function startHttpServer() {
1317
1694
  });
1318
1695
  });
1319
1696
  }
1320
- // Setup file watcher
1321
- function setupWatcher() {
1322
- if (!existsSync(config.diagramPath))
1697
+ // Diff two architecture snapshots (lightweight, inline version of cli/diff.ts)
1698
+ function diffArchitectures(prev, curr) {
1699
+ const prevNodeMap = new Map();
1700
+ for (const n of prev.nodes)
1701
+ prevNodeMap.set(n.id, n);
1702
+ const currNodeMap = new Map();
1703
+ for (const n of curr.nodes)
1704
+ currNodeMap.set(n.id, n);
1705
+ const prevNodeIds = new Set(prev.nodes.map((n) => n.id));
1706
+ const currNodeIds = new Set(curr.nodes.map((n) => n.id));
1707
+ const addedNodes = curr.nodes
1708
+ .filter((n) => !prevNodeIds.has(n.id))
1709
+ .map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
1710
+ const removedNodes = prev.nodes
1711
+ .filter((n) => !currNodeIds.has(n.id))
1712
+ .map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
1713
+ const modifiedNodes = [];
1714
+ for (const n of curr.nodes) {
1715
+ const old = prevNodeMap.get(n.id);
1716
+ if (!old)
1717
+ continue;
1718
+ for (const field of ["label", "type", "layer"]) {
1719
+ if (old[field] !== n[field]) {
1720
+ modifiedNodes.push({ id: n.id, field, from: old[field], to: n[field] });
1721
+ }
1722
+ }
1723
+ }
1724
+ const edgeKey = (e) => `${e.source}->${e.target}`;
1725
+ const prevEdgeKeys = new Set(prev.edges.map(edgeKey));
1726
+ const currEdgeKeys = new Set(curr.edges.map(edgeKey));
1727
+ const addedEdges = curr.edges
1728
+ .filter((e) => !prevEdgeKeys.has(edgeKey(e)))
1729
+ .map((e) => ({ source: e.source, target: e.target, label: e.label }));
1730
+ const removedEdges = prev.edges
1731
+ .filter((e) => !currEdgeKeys.has(edgeKey(e)))
1732
+ .map((e) => ({ source: e.source, target: e.target, label: e.label }));
1733
+ return { addedNodes, removedNodes, modifiedNodes, addedEdges, removedEdges };
1734
+ }
1735
+ // Run analyze → generate pipeline (used by /api/analyze and patrol)
1736
+ function runAnalyzePipeline(mode = "static", fileChanges) {
1737
+ if (isAnalyzing)
1323
1738
  return;
1324
- diagramWatcher = watch(config.diagramPath, { ignoreInitial: true });
1325
- diagramWatcher.on("change", async () => {
1739
+ isAnalyzing = true;
1740
+ const pipelineStart = Date.now();
1741
+ // Snapshot current architecture for diffing after pipeline completes
1742
+ const prevArchitecture = currentArchitecture
1743
+ ? { ...currentArchitecture, nodes: [...currentArchitecture.nodes], edges: [...currentArchitecture.edges] }
1744
+ : null;
1745
+ broadcastOpsEvent({ type: "analyzing:started", mode });
1746
+ const bin = getArchbyteBin();
1747
+ const analyzeArgs = [bin, "analyze", "--force"];
1748
+ if (mode === "static")
1749
+ analyzeArgs.push("--static");
1750
+ const analyzeChild = spawn(process.execPath, analyzeArgs, {
1751
+ cwd: config.workspaceRoot,
1752
+ stdio: ["ignore", "pipe", "pipe"],
1753
+ env: { ...process.env, FORCE_COLOR: "0" },
1754
+ });
1755
+ analyzeChild.on("close", (analyzeCode) => {
1756
+ if (analyzeCode !== 0) {
1757
+ isAnalyzing = false;
1758
+ broadcastOpsEvent({ type: "analyzing:finished", code: analyzeCode, success: false });
1759
+ return;
1760
+ }
1761
+ // Chain: generate after successful analyze
1762
+ const genChild = spawn(process.execPath, [bin, "generate"], {
1763
+ cwd: config.workspaceRoot,
1764
+ stdio: ["ignore", "pipe", "pipe"],
1765
+ env: { ...process.env, FORCE_COLOR: "0" },
1766
+ });
1767
+ genChild.on("close", async (genCode) => {
1768
+ isAnalyzing = false;
1769
+ const durationMs = Date.now() - pipelineStart;
1770
+ // Build session change record if we have a previous architecture to diff against
1771
+ if (genCode === 0) {
1772
+ try {
1773
+ const newArch = await loadArchitecture();
1774
+ if (newArch && prevArchitecture) {
1775
+ const diff = diffArchitectures(prevArchitecture, newArch);
1776
+ // Deduplicate file changes: keep latest event per path
1777
+ const dedupedFiles = new Map();
1778
+ for (const fc of (fileChanges || [])) {
1779
+ dedupedFiles.set(fc.path, fc);
1780
+ }
1781
+ const filesChanged = [...dedupedFiles.values()];
1782
+ const record = {
1783
+ id: `sc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1784
+ timestamp: new Date().toISOString(),
1785
+ filesChanged,
1786
+ diff,
1787
+ summary: {
1788
+ filesChanged: filesChanged.length,
1789
+ nodesAdded: diff.addedNodes.length,
1790
+ nodesRemoved: diff.removedNodes.length,
1791
+ nodesModified: diff.modifiedNodes.length,
1792
+ edgesAdded: diff.addedEdges.length,
1793
+ edgesRemoved: diff.removedEdges.length,
1794
+ },
1795
+ durationMs,
1796
+ mode,
1797
+ };
1798
+ sessionChanges.unshift(record);
1799
+ if (sessionChanges.length > MAX_SESSION_CHANGES)
1800
+ sessionChanges.pop();
1801
+ broadcastOpsEvent({ type: "session:change", change: record });
1802
+ }
1803
+ }
1804
+ catch {
1805
+ // Non-fatal — session change tracking is best-effort
1806
+ }
1807
+ }
1808
+ broadcastOpsEvent({ type: "analyzing:finished", code: genCode, success: genCode === 0 });
1809
+ });
1810
+ });
1811
+ }
1812
+ // Setup file watcher — watches the .archbyte/ directory so it works
1813
+ // even when architecture.json doesn't exist yet (first-time users)
1814
+ function setupWatcher() {
1815
+ const archbyteDir = path.dirname(config.diagramPath);
1816
+ const diagramFilename = path.basename(config.diagramPath);
1817
+ diagramWatcher = watch(archbyteDir, {
1818
+ ignoreInitial: true,
1819
+ depth: 0,
1820
+ });
1821
+ diagramWatcher.on("all", async (event, filePath) => {
1822
+ if (path.basename(filePath) !== diagramFilename)
1823
+ return;
1824
+ if (event !== "change" && event !== "add")
1825
+ return;
1326
1826
  console.error("[archbyte] Diagram changed, reloading...");
1327
1827
  currentArchitecture = await loadArchitecture();
1328
1828
  broadcastUpdate();
@@ -1369,7 +1869,8 @@ function setupShutdown() {
1369
1869
  process.on("SIGTERM", shutdown);
1370
1870
  process.on("SIGINT", shutdown);
1371
1871
  }
1372
- // License info helper — reads ~/.archbyte/credentials.json
1872
+ // License info helper — reads login state from credentials.json,
1873
+ // tier from server-verified tier-cache.json (1-hour TTL, email-stamped)
1373
1874
  function loadLicenseInfo() {
1374
1875
  const defaults = {
1375
1876
  loggedIn: false,
@@ -1404,7 +1905,26 @@ function loadLicenseInfo() {
1404
1905
  if (creds.expiresAt && new Date(creds.expiresAt) < new Date()) {
1405
1906
  return { ...defaults, loggedIn: true, email: creds.email ?? null };
1406
1907
  }
1407
- const isPremium = creds.tier === "premium";
1908
+ // Read tier from server-verified tier-cache.json (not user-editable credentials)
1909
+ let isPremium = false;
1910
+ const tierCachePath = path.join(home, ".archbyte", "tier-cache.json");
1911
+ if (existsSync(tierCachePath)) {
1912
+ try {
1913
+ const cache = JSON.parse(readFileSync(tierCachePath, "utf-8"));
1914
+ // Only trust cache if it matches current account and is within 1-hour TTL
1915
+ const cacheAge = cache.verifiedAt ? Date.now() - new Date(cache.verifiedAt).getTime() : Infinity;
1916
+ if (cache.email === creds.email && cacheAge < 60 * 60 * 1000) {
1917
+ isPremium = cache.tier === "premium";
1918
+ }
1919
+ }
1920
+ catch {
1921
+ // Corrupt cache — fall through to credentials fallback
1922
+ }
1923
+ }
1924
+ // Fallback: if no valid tier cache, use credentials tier (less secure but functional)
1925
+ if (!isPremium && creds.tier === "premium") {
1926
+ isPremium = true;
1927
+ }
1408
1928
  return {
1409
1929
  loggedIn: true,
1410
1930
  email: creds.email ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archbyte",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "ArchByte - See what agents build. As they build it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,3 +101,11 @@ rules:
101
101
  # from: { type: service, not: { id: api-gateway } }
102
102
  # to: { type: external }
103
103
  # level: warn
104
+
105
+ # ── Patrol ──
106
+ # Configure the continuous monitoring daemon (archbyte patrol).
107
+ # patrol:
108
+ # ignore:
109
+ # - "docs/"
110
+ # - "*.md"
111
+ # - "scripts/"