kachow 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.
- package/README.md +77 -0
- package/_server/dist/app.js +130 -0
- package/_server/dist/db/index.js +50 -0
- package/_server/dist/db/schema.js +247 -0
- package/_server/dist/queues/ingestQueue.js +49 -0
- package/_server/dist/queues/redis.js +58 -0
- package/_server/dist/routes/agents.js +162 -0
- package/_server/dist/routes/architecture.js +88 -0
- package/_server/dist/routes/config.js +24 -0
- package/_server/dist/routes/github.js +158 -0
- package/_server/dist/routes/graph.js +112 -0
- package/_server/dist/routes/healing.js +137 -0
- package/_server/dist/routes/impact.js +100 -0
- package/_server/dist/routes/ingest.js +182 -0
- package/_server/dist/routes/manager.js +179 -0
- package/_server/dist/routes/notifications.js +85 -0
- package/_server/dist/routes/qa.js +68 -0
- package/_server/dist/routes/scanner.js +221 -0
- package/_server/dist/routes/stream.js +179 -0
- package/_server/dist/routes/webhooks.js +168 -0
- package/_server/dist/server.js +46 -0
- package/_server/dist/services/agentService.js +715 -0
- package/_server/dist/services/architectureService.js +172 -0
- package/_server/dist/services/demoSeed.js +181 -0
- package/_server/dist/services/graphLayout.js +102 -0
- package/_server/dist/services/graphService.js +532 -0
- package/_server/dist/services/healingService.js +253 -0
- package/_server/dist/services/impactService.js +304 -0
- package/_server/dist/services/ingestService.js +129 -0
- package/_server/dist/services/managerService.js +260 -0
- package/_server/dist/services/notificationService.js +283 -0
- package/_server/dist/services/qaService.js +413 -0
- package/_server/dist/services/scannerService.js +748 -0
- package/_server/dist/services/seedService.js +215 -0
- package/_server/dist/sse/sseManager.js +101 -0
- package/_server/dist/types/index.js +38 -0
- package/_server/dist/workers/ingestWorker.js +274 -0
- package/_server/public/assets/index-BTkbB_YF.js +4546 -0
- package/_server/public/assets/index-Bmh3jWBm.css +1 -0
- package/_server/public/favicon.ico +0 -0
- package/_server/public/images/glass-waves-bg.png +0 -0
- package/_server/public/index.html +29 -0
- package/_server/public/placeholder.svg +1 -0
- package/_server/public/robots.txt +14 -0
- package/dist/config.js +133 -0
- package/dist/index.js +510 -0
- package/dist/setup.js +223 -0
- package/package.json +62 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Graph read service — Phase 2.
|
|
4
|
+
* Pure read queries against SQLite. No mutations here.
|
|
5
|
+
* All write paths live in ingestWorker.ts.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.getNodes = getNodes;
|
|
9
|
+
exports.getEdges = getEdges;
|
|
10
|
+
exports.getNodeDetail = getNodeDetail;
|
|
11
|
+
exports.getCriticalIssues = getCriticalIssues;
|
|
12
|
+
exports.getSystemHealth = getSystemHealth;
|
|
13
|
+
exports.getSnapshot = getSnapshot;
|
|
14
|
+
exports.saveSnapshot = saveSnapshot;
|
|
15
|
+
const crypto_1 = require("crypto");
|
|
16
|
+
const index_js_1 = require("../db/index.js");
|
|
17
|
+
const graphLayout_js_1 = require("./graphLayout.js");
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Parses a JSON endpoints string stored in the DB into a string array.
|
|
21
|
+
* Returns an empty array on any parse error.
|
|
22
|
+
*
|
|
23
|
+
* @param raw - JSON string from the DB or null
|
|
24
|
+
* @returns Parsed string array
|
|
25
|
+
*/
|
|
26
|
+
function parseEndpoints(raw) {
|
|
27
|
+
if (!raw)
|
|
28
|
+
return [];
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ── Query functions ───────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Returns all service nodes with computed layout coordinates and connection counts.
|
|
40
|
+
* When teamName is supplied only that team's services + their external references are returned.
|
|
41
|
+
* When teamId is supplied, filters by FK team_id for multi-tenant isolation.
|
|
42
|
+
* Used by GET /api/graph/nodes.
|
|
43
|
+
*
|
|
44
|
+
* @param teamName - Optional team name (label) to filter services by
|
|
45
|
+
* @param teamId - Optional team ID (FK) for multi-tenant scoping
|
|
46
|
+
* @returns Array of GraphNode objects sorted by health score descending
|
|
47
|
+
*/
|
|
48
|
+
function getNodes(teamName, teamId) {
|
|
49
|
+
const db = (0, index_js_1.getDb)();
|
|
50
|
+
let rows;
|
|
51
|
+
if (teamId) {
|
|
52
|
+
// Multi-tenant: filter by FK
|
|
53
|
+
rows = db.prepare(`
|
|
54
|
+
SELECT s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
55
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
56
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
57
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
58
|
+
FROM services s WHERE s.is_external = 0 AND s.team_id = ?
|
|
59
|
+
ORDER BY s.health_score DESC
|
|
60
|
+
`).all(teamId);
|
|
61
|
+
if (rows.length === 0) {
|
|
62
|
+
// fallback to all
|
|
63
|
+
rows = db.prepare(`
|
|
64
|
+
SELECT s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
65
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
66
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
67
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
68
|
+
FROM services s WHERE s.is_external = 0
|
|
69
|
+
ORDER BY s.health_score DESC
|
|
70
|
+
`).all();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (teamName) {
|
|
74
|
+
rows = db.prepare(`
|
|
75
|
+
SELECT s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
76
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
77
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
78
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
79
|
+
FROM services s WHERE s.is_external = 0 AND s.team = ?
|
|
80
|
+
ORDER BY s.health_score DESC
|
|
81
|
+
`).all(teamName);
|
|
82
|
+
if (rows.length === 0) {
|
|
83
|
+
rows = db.prepare(`
|
|
84
|
+
SELECT s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
85
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
86
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
87
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
88
|
+
FROM services s WHERE s.is_external = 0
|
|
89
|
+
ORDER BY s.health_score DESC
|
|
90
|
+
`).all();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
rows = db.prepare(`
|
|
95
|
+
SELECT s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
96
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
97
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
98
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
99
|
+
FROM services s WHERE s.is_external = 0
|
|
100
|
+
ORDER BY s.health_score DESC
|
|
101
|
+
`).all();
|
|
102
|
+
}
|
|
103
|
+
const internalIds = rows.map(r => r.id);
|
|
104
|
+
const idPlaceholders = internalIds.length ? internalIds.map(() => '?').join(',') : "''";
|
|
105
|
+
// External nodes that are referenced by the (filtered) internal services
|
|
106
|
+
const externalRows = db.prepare(`
|
|
107
|
+
SELECT
|
|
108
|
+
s.id, s.name, s.team, s.language, s.health_score, s.health_tier,
|
|
109
|
+
s.repo_url, s.doc_coverage, s.test_coverage, s.last_ingested,
|
|
110
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
111
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
112
|
+
FROM services s
|
|
113
|
+
WHERE s.is_external = 1
|
|
114
|
+
AND (
|
|
115
|
+
EXISTS (SELECT 1 FROM dependencies WHERE target_id = s.id AND source_id IN (${idPlaceholders}))
|
|
116
|
+
OR EXISTS (SELECT 1 FROM dependencies WHERE source_id = s.id AND target_id IN (${idPlaceholders}))
|
|
117
|
+
)
|
|
118
|
+
ORDER BY s.name
|
|
119
|
+
`).all(...internalIds, ...internalIds);
|
|
120
|
+
const edgeRows = db.prepare('SELECT source_id, target_id FROM dependencies').all();
|
|
121
|
+
const layout = (0, graphLayout_js_1.computeLayout)(rows, edgeRows);
|
|
122
|
+
// Position external nodes in a dedicated far-right column
|
|
123
|
+
const EXT_X = 1350;
|
|
124
|
+
const extStep = externalRows.length > 0 ? 700 / (externalRows.length + 1) : 350;
|
|
125
|
+
const result = rows.map((r) => ({
|
|
126
|
+
id: r.id,
|
|
127
|
+
name: r.name,
|
|
128
|
+
healthScore: r.health_score,
|
|
129
|
+
healthTier: r.health_tier,
|
|
130
|
+
team: r.team,
|
|
131
|
+
language: r.language,
|
|
132
|
+
connectionCount: r.connection_count,
|
|
133
|
+
x: layout.get(r.id)?.x ?? 700,
|
|
134
|
+
y: layout.get(r.id)?.y ?? 350,
|
|
135
|
+
}));
|
|
136
|
+
externalRows.forEach((r, i) => {
|
|
137
|
+
result.push({
|
|
138
|
+
id: r.id,
|
|
139
|
+
name: r.name,
|
|
140
|
+
healthScore: r.health_score,
|
|
141
|
+
healthTier: r.health_tier,
|
|
142
|
+
team: r.team,
|
|
143
|
+
language: r.language,
|
|
144
|
+
connectionCount: r.connection_count,
|
|
145
|
+
x: EXT_X,
|
|
146
|
+
y: Math.round(extStep * (i + 1)),
|
|
147
|
+
isExternal: true,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Returns all dependency edges in the graph.
|
|
154
|
+
* When teamName is supplied only edges where at least one endpoint belongs to
|
|
155
|
+
* that team are returned.
|
|
156
|
+
* Used by GET /api/graph/edges.
|
|
157
|
+
*
|
|
158
|
+
* @param teamName - Optional team name (label) to filter edges by
|
|
159
|
+
* @param teamId - Optional team ID (FK) for multi-tenant scoping
|
|
160
|
+
* @returns Array of GraphEdge objects
|
|
161
|
+
*/
|
|
162
|
+
function getEdges(teamName, teamId) {
|
|
163
|
+
const db = (0, index_js_1.getDb)();
|
|
164
|
+
let rows;
|
|
165
|
+
if (teamId) {
|
|
166
|
+
rows = db.prepare(`
|
|
167
|
+
SELECT d.id, d.source_id, d.target_id, d.type, d.endpoints, d.strength
|
|
168
|
+
FROM dependencies d
|
|
169
|
+
WHERE d.source_id IN (SELECT id FROM services WHERE team_id = ?)
|
|
170
|
+
OR d.target_id IN (SELECT id FROM services WHERE team_id = ?)
|
|
171
|
+
`).all(teamId, teamId);
|
|
172
|
+
if (rows.length === 0) {
|
|
173
|
+
rows = db.prepare('SELECT id, source_id, target_id, type, endpoints, strength FROM dependencies').all();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (teamName) {
|
|
177
|
+
rows = db.prepare(`
|
|
178
|
+
SELECT d.id, d.source_id, d.target_id, d.type, d.endpoints, d.strength
|
|
179
|
+
FROM dependencies d
|
|
180
|
+
WHERE d.source_id IN (SELECT id FROM services WHERE team = ?)
|
|
181
|
+
OR d.target_id IN (SELECT id FROM services WHERE team = ?)
|
|
182
|
+
`).all(teamName, teamName);
|
|
183
|
+
if (rows.length === 0) {
|
|
184
|
+
rows = db.prepare('SELECT id, source_id, target_id, type, endpoints, strength FROM dependencies').all();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
rows = db.prepare('SELECT id, source_id, target_id, type, endpoints, strength FROM dependencies').all();
|
|
189
|
+
}
|
|
190
|
+
return rows.map((r) => ({
|
|
191
|
+
id: r.id,
|
|
192
|
+
sourceId: r.source_id,
|
|
193
|
+
targetId: r.target_id,
|
|
194
|
+
type: r.type,
|
|
195
|
+
endpoints: parseEndpoints(r.endpoints),
|
|
196
|
+
strength: r.strength,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Returns full detail for a single service node.
|
|
201
|
+
* Includes endpoints, incidents, inbound/outbound dependencies, and recent activity.
|
|
202
|
+
* Used by GET /api/graph/node/:id.
|
|
203
|
+
*
|
|
204
|
+
* @param id - Service node ID
|
|
205
|
+
* @returns GraphNodeDetail or null if not found
|
|
206
|
+
*/
|
|
207
|
+
function getNodeDetail(id) {
|
|
208
|
+
const db = (0, index_js_1.getDb)();
|
|
209
|
+
const svcRow = db.prepare(`
|
|
210
|
+
SELECT s.*,
|
|
211
|
+
(SELECT COUNT(*) FROM dependencies WHERE source_id = s.id) +
|
|
212
|
+
(SELECT COUNT(*) FROM dependencies WHERE target_id = s.id) AS connection_count
|
|
213
|
+
FROM services s WHERE s.id = ?
|
|
214
|
+
`).get(id);
|
|
215
|
+
if (!svcRow)
|
|
216
|
+
return null;
|
|
217
|
+
const edgeRows = db.prepare('SELECT source_id, target_id FROM dependencies').all();
|
|
218
|
+
const layout = (0, graphLayout_js_1.computeLayout)(db.prepare('SELECT id FROM services').all(), edgeRows);
|
|
219
|
+
const endpoints = db.prepare('SELECT id, method, path, deprecated, has_spec FROM endpoints WHERE service_id = ?').all(id);
|
|
220
|
+
const incidents = db.prepare(`
|
|
221
|
+
SELECT id, severity, title, status, occurred_at, resolved_at
|
|
222
|
+
FROM incidents WHERE service_id = ? ORDER BY occurred_at DESC LIMIT 10
|
|
223
|
+
`).all(id);
|
|
224
|
+
const dependsOn = db.prepare(`
|
|
225
|
+
SELECT d.id, d.source_id, d.target_id, d.type, d.endpoints, d.strength,
|
|
226
|
+
s.id as peer_id, s.name as peer_name
|
|
227
|
+
FROM dependencies d JOIN services s ON s.id = d.target_id
|
|
228
|
+
WHERE d.source_id = ?
|
|
229
|
+
`).all(id);
|
|
230
|
+
const dependedOnBy = db.prepare(`
|
|
231
|
+
SELECT d.id, d.source_id, d.target_id, d.type, d.endpoints, d.strength,
|
|
232
|
+
s.id as peer_id, s.name as peer_name
|
|
233
|
+
FROM dependencies d JOIN services s ON s.id = d.source_id
|
|
234
|
+
WHERE d.target_id = ?
|
|
235
|
+
`).all(id);
|
|
236
|
+
const recentActivity = db.prepare(`
|
|
237
|
+
SELECT id, type, title, detail, created_at FROM activity_feed
|
|
238
|
+
WHERE service_id = ? ORDER BY created_at DESC LIMIT 10
|
|
239
|
+
`).all(id);
|
|
240
|
+
return {
|
|
241
|
+
service: {
|
|
242
|
+
id: svcRow.id, name: svcRow.name,
|
|
243
|
+
healthScore: svcRow.health_score, healthTier: svcRow.health_tier,
|
|
244
|
+
team: svcRow.team, language: svcRow.language,
|
|
245
|
+
connectionCount: svcRow.connection_count,
|
|
246
|
+
x: layout.get(id)?.x ?? 700, y: layout.get(id)?.y ?? 350,
|
|
247
|
+
repoUrl: svcRow.repo_url, docCoverage: svcRow.doc_coverage,
|
|
248
|
+
testCoverage: svcRow.test_coverage, lastIngested: svcRow.last_ingested,
|
|
249
|
+
},
|
|
250
|
+
endpoints: endpoints.map((e) => ({
|
|
251
|
+
id: e.id, method: e.method, path: e.path,
|
|
252
|
+
deprecated: e.deprecated === 1, hasSpec: e.has_spec === 1,
|
|
253
|
+
})),
|
|
254
|
+
incidents: incidents.map((i) => ({
|
|
255
|
+
id: i.id, severity: i.severity, title: i.title, status: i.status,
|
|
256
|
+
occurredAt: i.occurred_at, resolvedAt: i.resolved_at,
|
|
257
|
+
})),
|
|
258
|
+
dependsOn: dependsOn.map((d) => ({
|
|
259
|
+
serviceId: d.peer_id, serviceName: d.peer_name,
|
|
260
|
+
type: d.type, endpoints: parseEndpoints(d.endpoints),
|
|
261
|
+
strength: d.strength,
|
|
262
|
+
})),
|
|
263
|
+
dependedOnBy: dependedOnBy.map((d) => ({
|
|
264
|
+
serviceId: d.peer_id, serviceName: d.peer_name,
|
|
265
|
+
type: d.type, endpoints: parseEndpoints(d.endpoints),
|
|
266
|
+
strength: d.strength,
|
|
267
|
+
})),
|
|
268
|
+
recentActivity: recentActivity.map((a) => ({
|
|
269
|
+
id: a.id, type: a.type, title: a.title, detail: a.detail, createdAt: a.created_at,
|
|
270
|
+
})),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Aggregates critical issues from three sources:
|
|
275
|
+
* 1. Agent KG — healthIssues per node + selfHealingSuggestions
|
|
276
|
+
* 2. healing_prs table — issues detected by self-healing agents
|
|
277
|
+
* 3. incidents table — open incidents
|
|
278
|
+
*
|
|
279
|
+
* Returns a flat CriticalIssue[] sorted by severity (critical → warning → info).
|
|
280
|
+
*/
|
|
281
|
+
function getCriticalIssues(teamId) {
|
|
282
|
+
const db = (0, index_js_1.getDb)();
|
|
283
|
+
const issues = [];
|
|
284
|
+
// ── 1) Agent Knowledge Graph health issues + self-healing ─────────────────
|
|
285
|
+
// Service lookup for enrichment (scoped by team when provided)
|
|
286
|
+
const svcMap = new Map();
|
|
287
|
+
const svcQuery = teamId
|
|
288
|
+
? 'SELECT id, name, health_score, health_tier FROM services WHERE is_external = 0 AND team_id = ?'
|
|
289
|
+
: 'SELECT id, name, health_score, health_tier FROM services WHERE is_external = 0';
|
|
290
|
+
const svcRows = teamId
|
|
291
|
+
? db.prepare(svcQuery).all(teamId)
|
|
292
|
+
: db.prepare(svcQuery).all();
|
|
293
|
+
for (const s of svcRows)
|
|
294
|
+
svcMap.set(s.id, { name: s.name, healthScore: s.health_score, healthTier: s.health_tier });
|
|
295
|
+
// Also build a name→id reverse lookup (agent KG uses service names, not IDs)
|
|
296
|
+
const nameToId = new Map();
|
|
297
|
+
for (const s of svcRows)
|
|
298
|
+
nameToId.set(s.name.toLowerCase(), s.id);
|
|
299
|
+
// Read the latest agent KG from knowledge_graph table (team-scoped when provided)
|
|
300
|
+
const kgQuery = teamId
|
|
301
|
+
? `SELECT nodes, self_healing FROM knowledge_graph WHERE team_id = ? ORDER BY created_at DESC LIMIT 1`
|
|
302
|
+
: `SELECT nodes, self_healing FROM knowledge_graph ORDER BY created_at DESC LIMIT 1`;
|
|
303
|
+
const kgRow = teamId
|
|
304
|
+
? db.prepare(kgQuery).get(teamId)
|
|
305
|
+
: db.prepare(kgQuery).get();
|
|
306
|
+
if (kgRow) {
|
|
307
|
+
try {
|
|
308
|
+
const kgNodes = JSON.parse(kgRow.nodes);
|
|
309
|
+
const selfHealing = JSON.parse(kgRow.self_healing);
|
|
310
|
+
// Per-node health issues
|
|
311
|
+
for (const n of kgNodes) {
|
|
312
|
+
const svcId = n.id ?? nameToId.get(n.name.toLowerCase());
|
|
313
|
+
const svc = svcId ? svcMap.get(svcId) : undefined;
|
|
314
|
+
for (const issue of n.healthIssues ?? []) {
|
|
315
|
+
const severity = classifySeverity(issue);
|
|
316
|
+
issues.push({
|
|
317
|
+
id: `agent-hi-${svcId ?? n.name}-${issues.length}`,
|
|
318
|
+
serviceId: svcId ?? n.name,
|
|
319
|
+
serviceName: svc?.name ?? n.name,
|
|
320
|
+
healthScore: svc?.healthScore ?? 0,
|
|
321
|
+
healthTier: svc?.healthTier ?? 'warning',
|
|
322
|
+
severity,
|
|
323
|
+
category: classifyCategory(issue),
|
|
324
|
+
title: issue.length > 80 ? issue.slice(0, 77) + '…' : issue,
|
|
325
|
+
description: issue,
|
|
326
|
+
file: null,
|
|
327
|
+
line: null,
|
|
328
|
+
suggestedFix: null,
|
|
329
|
+
source: 'agent',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Self-healing suggestions (richer — include file/line/fix)
|
|
334
|
+
for (const sh of selfHealing) {
|
|
335
|
+
const svcId = sh.serviceId ?? nameToId.get(sh.serviceName.toLowerCase());
|
|
336
|
+
const svc = svcId ? svcMap.get(svcId) : undefined;
|
|
337
|
+
issues.push({
|
|
338
|
+
id: `agent-sh-${svcId ?? sh.serviceName}-${issues.length}`,
|
|
339
|
+
serviceId: svcId ?? sh.serviceName,
|
|
340
|
+
serviceName: svc?.name ?? sh.serviceName,
|
|
341
|
+
healthScore: svc?.healthScore ?? 0,
|
|
342
|
+
healthTier: svc?.healthTier ?? 'warning',
|
|
343
|
+
severity: (sh.severity === 'critical' || sh.severity === 'warning' || sh.severity === 'info')
|
|
344
|
+
? sh.severity : 'warning',
|
|
345
|
+
category: classifyCategory(sh.issue),
|
|
346
|
+
title: sh.issue.length > 80 ? sh.issue.slice(0, 77) + '…' : sh.issue,
|
|
347
|
+
description: sh.issue,
|
|
348
|
+
file: sh.file || null,
|
|
349
|
+
line: sh.line || null,
|
|
350
|
+
suggestedFix: sh.suggestion || null,
|
|
351
|
+
source: 'agent',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch { /* ignore malformed KG */ }
|
|
356
|
+
}
|
|
357
|
+
const healingQuery = teamId
|
|
358
|
+
? `SELECT id, service_id, issue_type, explanation, status
|
|
359
|
+
FROM healing_prs WHERE status IN ('detected', 'open') AND team_id = ?`
|
|
360
|
+
: `SELECT id, service_id, issue_type, explanation, status
|
|
361
|
+
FROM healing_prs WHERE status IN ('detected', 'open')`;
|
|
362
|
+
const healingRows = teamId
|
|
363
|
+
? db.prepare(healingQuery).all(teamId)
|
|
364
|
+
: db.prepare(healingQuery).all();
|
|
365
|
+
for (const h of healingRows) {
|
|
366
|
+
const svc = svcMap.get(h.service_id);
|
|
367
|
+
// Avoid duplicate if agent KG already has the same issue text
|
|
368
|
+
const dup = issues.some(i => i.serviceId === h.service_id && i.description === h.explanation);
|
|
369
|
+
if (!dup) {
|
|
370
|
+
issues.push({
|
|
371
|
+
id: `heal-${h.id}`,
|
|
372
|
+
serviceId: h.service_id,
|
|
373
|
+
serviceName: svc?.name ?? h.service_id,
|
|
374
|
+
healthScore: svc?.healthScore ?? 0,
|
|
375
|
+
healthTier: svc?.healthTier ?? 'warning',
|
|
376
|
+
severity: classifySeverity(h.issue_type + ' ' + h.explanation),
|
|
377
|
+
category: classifyCategory(h.issue_type),
|
|
378
|
+
title: h.issue_type,
|
|
379
|
+
description: h.explanation,
|
|
380
|
+
file: null,
|
|
381
|
+
line: null,
|
|
382
|
+
suggestedFix: null,
|
|
383
|
+
source: 'healing',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ── 3) Open incidents (scoped through service FK when teamId provided) ──
|
|
388
|
+
const incidentQuery = teamId
|
|
389
|
+
? `SELECT i.id, i.service_id, i.severity, i.title, i.status, i.occurred_at
|
|
390
|
+
FROM incidents i
|
|
391
|
+
JOIN services s ON s.id = i.service_id
|
|
392
|
+
WHERE i.status != 'resolved' AND s.team_id = ?
|
|
393
|
+
ORDER BY i.occurred_at DESC LIMIT 30`
|
|
394
|
+
: `SELECT id, service_id, severity, title, status, occurred_at
|
|
395
|
+
FROM incidents WHERE status != 'resolved' ORDER BY occurred_at DESC LIMIT 30`;
|
|
396
|
+
const incidentRows = teamId
|
|
397
|
+
? db.prepare(incidentQuery).all(teamId)
|
|
398
|
+
: db.prepare(incidentQuery).all();
|
|
399
|
+
for (const inc of incidentRows) {
|
|
400
|
+
const svc = svcMap.get(inc.service_id);
|
|
401
|
+
issues.push({
|
|
402
|
+
id: `inc-${inc.id}`,
|
|
403
|
+
serviceId: inc.service_id,
|
|
404
|
+
serviceName: svc?.name ?? inc.service_id,
|
|
405
|
+
healthScore: svc?.healthScore ?? 0,
|
|
406
|
+
healthTier: svc?.healthTier ?? 'warning',
|
|
407
|
+
severity: inc.severity === 'critical' ? 'critical' : inc.severity === 'warning' ? 'warning' : 'info',
|
|
408
|
+
category: 'reliability',
|
|
409
|
+
title: inc.title,
|
|
410
|
+
description: `${inc.title} — ${inc.status} since ${inc.occurred_at}`,
|
|
411
|
+
file: null,
|
|
412
|
+
line: null,
|
|
413
|
+
suggestedFix: null,
|
|
414
|
+
source: 'incident',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
// ── Sort: critical first, then warning, then info ─────────────────────────
|
|
418
|
+
const ORDER = { critical: 0, warning: 1, info: 2 };
|
|
419
|
+
issues.sort((a, b) => (ORDER[a.severity] ?? 2) - (ORDER[b.severity] ?? 2));
|
|
420
|
+
return issues;
|
|
421
|
+
}
|
|
422
|
+
// Heuristic severity from free-text
|
|
423
|
+
function classifySeverity(text) {
|
|
424
|
+
const t = text.toLowerCase();
|
|
425
|
+
if (/hardcoded.*(key|secret|password|token)|sql.?inject|xss|remote.?code|auth.?bypass|critical/i.test(t))
|
|
426
|
+
return 'critical';
|
|
427
|
+
if (/missing.*header|no.*(rate.?limit|input.?valid|error.?hand)|warning|deprecated/i.test(t))
|
|
428
|
+
return 'warning';
|
|
429
|
+
return 'info';
|
|
430
|
+
}
|
|
431
|
+
// Heuristic category from free-text
|
|
432
|
+
function classifyCategory(text) {
|
|
433
|
+
const t = text.toLowerCase();
|
|
434
|
+
if (/secur|key|secret|token|inject|xss|auth|password|cors|header/i.test(t))
|
|
435
|
+
return 'security';
|
|
436
|
+
if (/perf|latency|cache|throughput|slow|memory|cpu/i.test(t))
|
|
437
|
+
return 'performance';
|
|
438
|
+
if (/reliab|error.?hand|circuit|retry|timeout|crash|incident|health/i.test(t))
|
|
439
|
+
return 'reliability';
|
|
440
|
+
return 'quality';
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Returns aggregate health metrics for the entire system.
|
|
444
|
+
* Used by GET /api/graph/health — polled every 30s by the frontend.
|
|
445
|
+
*
|
|
446
|
+
* @returns SystemHealth object with score breakdown
|
|
447
|
+
*/
|
|
448
|
+
function getSystemHealth(teamId) {
|
|
449
|
+
const db = (0, index_js_1.getDb)();
|
|
450
|
+
const baseWhere = teamId
|
|
451
|
+
? 'WHERE is_external = 0 AND team_id = ?'
|
|
452
|
+
: 'WHERE is_external = 0';
|
|
453
|
+
const params = teamId ? [teamId] : [];
|
|
454
|
+
const row = db.prepare(`
|
|
455
|
+
SELECT
|
|
456
|
+
ROUND(AVG(health_score), 1) AS overall,
|
|
457
|
+
COUNT(*) AS total,
|
|
458
|
+
SUM(CASE WHEN health_tier = 'critical' THEN 1 ELSE 0 END) AS critical,
|
|
459
|
+
SUM(CASE WHEN health_tier = 'warning' THEN 1 ELSE 0 END) AS warning,
|
|
460
|
+
SUM(CASE WHEN health_tier = 'healthy' THEN 1 ELSE 0 END) AS healthy,
|
|
461
|
+
MAX(last_ingested) AS last_updated
|
|
462
|
+
FROM services
|
|
463
|
+
${baseWhere}
|
|
464
|
+
`).get(...params);
|
|
465
|
+
const edgeCount = teamId
|
|
466
|
+
? (db.prepare(`SELECT COUNT(*) AS cnt FROM dependencies d
|
|
467
|
+
WHERE EXISTS (SELECT 1 FROM services s WHERE s.id = d.source_id AND s.team_id = ?)
|
|
468
|
+
OR EXISTS (SELECT 1 FROM services s WHERE s.id = d.target_id AND s.team_id = ?)`).get(teamId, teamId)?.cnt ?? 0)
|
|
469
|
+
: (db.prepare(`SELECT COUNT(*) AS cnt FROM dependencies`).get()?.cnt ?? 0);
|
|
470
|
+
return {
|
|
471
|
+
overallScore: row?.overall ?? 0,
|
|
472
|
+
totalServices: row?.total ?? 0,
|
|
473
|
+
critical: row?.critical ?? 0,
|
|
474
|
+
warning: row?.warning ?? 0,
|
|
475
|
+
healthy: row?.healthy ?? 0,
|
|
476
|
+
totalEdges: edgeCount,
|
|
477
|
+
lastUpdated: row?.last_updated ?? null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Retrieves a stored graph snapshot by version string.
|
|
482
|
+
* Version 'latest' returns the most recently saved snapshot.
|
|
483
|
+
* Used by GET /api/graph/snapshot/:version.
|
|
484
|
+
*
|
|
485
|
+
* @param version - Git tag, ISO timestamp string, or 'latest'
|
|
486
|
+
* @returns GraphSnapshot or null if not found
|
|
487
|
+
*/
|
|
488
|
+
function getSnapshot(version) {
|
|
489
|
+
const db = (0, index_js_1.getDb)();
|
|
490
|
+
let row;
|
|
491
|
+
if (version === 'latest') {
|
|
492
|
+
row = db.prepare('SELECT nodes, edges, version, created_at FROM snapshots ORDER BY created_at DESC LIMIT 1').get();
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
row = db.prepare('SELECT nodes, edges, version, created_at FROM snapshots WHERE version = ?').get(version);
|
|
496
|
+
}
|
|
497
|
+
if (!row)
|
|
498
|
+
return null;
|
|
499
|
+
try {
|
|
500
|
+
return {
|
|
501
|
+
nodes: JSON.parse(row.nodes),
|
|
502
|
+
edges: JSON.parse(row.edges),
|
|
503
|
+
version: row.version,
|
|
504
|
+
timestamp: row.created_at,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Saves the current graph state as a named snapshot.
|
|
513
|
+
* Called by the ingest worker after a successful full scan.
|
|
514
|
+
*
|
|
515
|
+
* @param version - Snapshot version label (git tag or ISO timestamp)
|
|
516
|
+
* @param teamId - Optional team ID for multi-tenant scoping
|
|
517
|
+
* @returns void
|
|
518
|
+
*/
|
|
519
|
+
function saveSnapshot(version, teamId) {
|
|
520
|
+
const db = (0, index_js_1.getDb)();
|
|
521
|
+
const nodes = teamId ? getNodes(undefined, teamId) : getNodes();
|
|
522
|
+
const edges = getEdges();
|
|
523
|
+
db.prepare(`
|
|
524
|
+
INSERT INTO snapshots (id, version, team_id, nodes, edges)
|
|
525
|
+
VALUES (?, ?, ?, ?, ?)
|
|
526
|
+
ON CONFLICT(version, team_id) DO UPDATE SET
|
|
527
|
+
nodes = excluded.nodes,
|
|
528
|
+
edges = excluded.edges,
|
|
529
|
+
created_at = datetime('now')
|
|
530
|
+
`).run((0, crypto_1.randomUUID)(), version, teamId ?? null, JSON.stringify(nodes), JSON.stringify(edges));
|
|
531
|
+
}
|
|
532
|
+
//# sourceMappingURL=graphService.js.map
|