triagent 0.1.0-alpha8 → 0.1.0-beta2
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 +101 -1
- package/package.json +9 -3
- package/src/cli/config.ts +118 -2
- package/src/config.ts +23 -3
- package/src/index.ts +262 -6
- package/src/integrations/elasticsearch/client.ts +210 -0
- package/src/integrations/grafana/client.ts +186 -0
- package/src/integrations/kubernetes/multi-cluster.ts +199 -0
- package/src/integrations/kubernetes/types.ts +24 -0
- package/src/integrations/loki/client.ts +219 -0
- package/src/integrations/prometheus/client.ts +163 -0
- package/src/integrations/slack/client.ts +265 -0
- package/src/integrations/teams/client.ts +199 -0
- package/src/mastra/agents/debugger.ts +164 -109
- package/src/mastra/index.ts +2 -2
- package/src/mastra/tools/approval-store.ts +180 -0
- package/src/mastra/tools/cli.ts +94 -2
- package/src/mastra/tools/cost.ts +389 -0
- package/src/mastra/tools/logs.ts +210 -0
- package/src/mastra/tools/network.ts +253 -0
- package/src/mastra/tools/prometheus.ts +221 -0
- package/src/mastra/tools/remediation.ts +365 -0
- package/src/mastra/tools/runbook.ts +186 -0
- package/src/sandbox/bashlet.ts +76 -10
- package/src/server/routes/history.ts +207 -0
- package/src/server/routes/notifications.ts +236 -0
- package/src/server/webhook.ts +36 -2
- package/src/storage/index.ts +3 -0
- package/src/storage/investigation-history.ts +277 -0
- package/src/storage/runbook-index.ts +330 -0
- package/src/storage/types.ts +72 -0
- package/src/tui/app.tsx +278 -198
- package/src/tui/components/approval-dialog.tsx +147 -0
- package/src/tui/components/approval-modal.tsx +278 -0
- package/src/tui/components/centered-layout.tsx +33 -0
- package/src/tui/components/editor.tsx +87 -0
- package/src/tui/components/header.tsx +53 -0
- package/src/tui/components/index.ts +55 -0
- package/src/tui/components/message-item.tsx +131 -0
- package/src/tui/components/messages-panel.tsx +71 -0
- package/src/tui/components/status-badge.tsx +20 -0
- package/src/tui/components/status-bar.tsx +39 -0
- package/src/tui/components/styled-span.tsx +24 -0
- package/src/tui/components/timeline.tsx +223 -0
- package/src/tui/components/toast.tsx +104 -0
- package/src/tui/theme/index.ts +21 -0
- package/src/tui/theme/tokens.ts +180 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, unlink, stat } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type {
|
|
5
|
+
InvestigationHistory,
|
|
6
|
+
HistoryQueryOptions,
|
|
7
|
+
HistoryStats,
|
|
8
|
+
InvestigationEvent,
|
|
9
|
+
ToolCallRecord,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
const HISTORY_DIR = join(homedir(), ".config", "triagent", "history");
|
|
13
|
+
|
|
14
|
+
export class InvestigationHistoryStore {
|
|
15
|
+
private historyDir: string;
|
|
16
|
+
private retentionDays: number;
|
|
17
|
+
|
|
18
|
+
constructor(retentionDays: number = 30) {
|
|
19
|
+
this.historyDir = HISTORY_DIR;
|
|
20
|
+
this.retentionDays = retentionDays;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async init(): Promise<void> {
|
|
24
|
+
await mkdir(this.historyDir, { recursive: true });
|
|
25
|
+
await this.cleanupOldRecords();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private getFilePath(id: string): string {
|
|
29
|
+
return join(this.historyDir, `${id}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async save(investigation: InvestigationHistory): Promise<void> {
|
|
33
|
+
await mkdir(this.historyDir, { recursive: true });
|
|
34
|
+
const filePath = this.getFilePath(investigation.id);
|
|
35
|
+
const serialized = JSON.stringify(investigation, (key, value) => {
|
|
36
|
+
if (value instanceof Date) {
|
|
37
|
+
return { __type: "Date", value: value.toISOString() };
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}, 2);
|
|
41
|
+
await writeFile(filePath, serialized, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(id: string): Promise<InvestigationHistory | null> {
|
|
45
|
+
try {
|
|
46
|
+
const filePath = this.getFilePath(id);
|
|
47
|
+
const content = await readFile(filePath, "utf-8");
|
|
48
|
+
return this.deserialize(content);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async delete(id: string): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
const filePath = this.getFilePath(id);
|
|
57
|
+
await unlink(filePath);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async list(options: HistoryQueryOptions = {}): Promise<InvestigationHistory[]> {
|
|
65
|
+
const {
|
|
66
|
+
limit = 50,
|
|
67
|
+
offset = 0,
|
|
68
|
+
status,
|
|
69
|
+
cluster,
|
|
70
|
+
tags,
|
|
71
|
+
startDate,
|
|
72
|
+
endDate,
|
|
73
|
+
searchQuery,
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const files = await readdir(this.historyDir);
|
|
78
|
+
const investigations: InvestigationHistory[] = [];
|
|
79
|
+
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
if (!file.endsWith(".json")) continue;
|
|
82
|
+
|
|
83
|
+
const content = await readFile(join(this.historyDir, file), "utf-8");
|
|
84
|
+
const investigation = this.deserialize(content);
|
|
85
|
+
|
|
86
|
+
// Apply filters
|
|
87
|
+
if (status && investigation.status !== status) continue;
|
|
88
|
+
if (cluster && investigation.cluster !== cluster) continue;
|
|
89
|
+
if (tags && tags.length > 0) {
|
|
90
|
+
const hasAllTags = tags.every((tag) =>
|
|
91
|
+
investigation.tags?.includes(tag)
|
|
92
|
+
);
|
|
93
|
+
if (!hasAllTags) continue;
|
|
94
|
+
}
|
|
95
|
+
if (startDate && investigation.startedAt < startDate) continue;
|
|
96
|
+
if (endDate && investigation.startedAt > endDate) continue;
|
|
97
|
+
if (searchQuery) {
|
|
98
|
+
const searchLower = searchQuery.toLowerCase();
|
|
99
|
+
const matchesTitle = investigation.incident.title
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.includes(searchLower);
|
|
102
|
+
const matchesDescription = investigation.incident.description
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.includes(searchLower);
|
|
105
|
+
if (!matchesTitle && !matchesDescription) continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
investigations.push(investigation);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sort by startedAt descending
|
|
112
|
+
investigations.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
|
|
113
|
+
|
|
114
|
+
// Apply pagination
|
|
115
|
+
return investigations.slice(offset, offset + limit);
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getStats(): Promise<HistoryStats> {
|
|
122
|
+
const investigations = await this.list({ limit: 10000 });
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
125
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
126
|
+
|
|
127
|
+
const stats: HistoryStats = {
|
|
128
|
+
total: investigations.length,
|
|
129
|
+
byStatus: {
|
|
130
|
+
pending: 0,
|
|
131
|
+
running: 0,
|
|
132
|
+
completed: 0,
|
|
133
|
+
failed: 0,
|
|
134
|
+
},
|
|
135
|
+
byCluster: {},
|
|
136
|
+
averageDuration: 0,
|
|
137
|
+
last24Hours: 0,
|
|
138
|
+
last7Days: 0,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let totalDuration = 0;
|
|
142
|
+
let completedCount = 0;
|
|
143
|
+
|
|
144
|
+
for (const inv of investigations) {
|
|
145
|
+
stats.byStatus[inv.status]++;
|
|
146
|
+
|
|
147
|
+
if (inv.cluster) {
|
|
148
|
+
stats.byCluster[inv.cluster] = (stats.byCluster[inv.cluster] || 0) + 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (inv.startedAt >= oneDayAgo) {
|
|
152
|
+
stats.last24Hours++;
|
|
153
|
+
}
|
|
154
|
+
if (inv.startedAt >= sevenDaysAgo) {
|
|
155
|
+
stats.last7Days++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (inv.completedAt && inv.startedAt) {
|
|
159
|
+
totalDuration += inv.completedAt.getTime() - inv.startedAt.getTime();
|
|
160
|
+
completedCount++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (completedCount > 0) {
|
|
165
|
+
stats.averageDuration = totalDuration / completedCount;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return stats;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async addEvent(
|
|
172
|
+
investigationId: string,
|
|
173
|
+
event: Omit<InvestigationEvent, "id" | "timestamp">
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const investigation = await this.get(investigationId);
|
|
176
|
+
if (!investigation) return;
|
|
177
|
+
|
|
178
|
+
const newEvent: InvestigationEvent = {
|
|
179
|
+
...event,
|
|
180
|
+
id: crypto.randomUUID(),
|
|
181
|
+
timestamp: new Date(),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
investigation.events.push(newEvent);
|
|
185
|
+
await this.save(investigation);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async addToolCall(
|
|
189
|
+
investigationId: string,
|
|
190
|
+
toolCall: Omit<ToolCallRecord, "id" | "timestamp">
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const investigation = await this.get(investigationId);
|
|
193
|
+
if (!investigation) return;
|
|
194
|
+
|
|
195
|
+
const newToolCall: ToolCallRecord = {
|
|
196
|
+
...toolCall,
|
|
197
|
+
id: crypto.randomUUID(),
|
|
198
|
+
timestamp: new Date(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
investigation.toolCalls.push(newToolCall);
|
|
202
|
+
await this.save(investigation);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async updateStatus(
|
|
206
|
+
investigationId: string,
|
|
207
|
+
status: InvestigationHistory["status"],
|
|
208
|
+
result?: InvestigationHistory["result"],
|
|
209
|
+
rawResult?: string,
|
|
210
|
+
error?: string
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const investigation = await this.get(investigationId);
|
|
213
|
+
if (!investigation) return;
|
|
214
|
+
|
|
215
|
+
investigation.status = status;
|
|
216
|
+
if (status === "completed" || status === "failed") {
|
|
217
|
+
investigation.completedAt = new Date();
|
|
218
|
+
}
|
|
219
|
+
if (result) {
|
|
220
|
+
investigation.result = result;
|
|
221
|
+
}
|
|
222
|
+
if (rawResult) {
|
|
223
|
+
investigation.rawResult = rawResult;
|
|
224
|
+
}
|
|
225
|
+
if (error) {
|
|
226
|
+
investigation.error = error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await this.save(investigation);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async cleanupOldRecords(): Promise<void> {
|
|
233
|
+
try {
|
|
234
|
+
const files = await readdir(this.historyDir);
|
|
235
|
+
const cutoffDate = new Date();
|
|
236
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays);
|
|
237
|
+
|
|
238
|
+
for (const file of files) {
|
|
239
|
+
if (!file.endsWith(".json")) continue;
|
|
240
|
+
|
|
241
|
+
const filePath = join(this.historyDir, file);
|
|
242
|
+
const fileStat = await stat(filePath);
|
|
243
|
+
|
|
244
|
+
if (fileStat.mtime < cutoffDate) {
|
|
245
|
+
await unlink(filePath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Ignore cleanup errors
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private deserialize(content: string): InvestigationHistory {
|
|
254
|
+
return JSON.parse(content, (key, value) => {
|
|
255
|
+
if (value && typeof value === "object" && value.__type === "Date") {
|
|
256
|
+
return new Date(value.value);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Singleton instance
|
|
264
|
+
let historyStore: InvestigationHistoryStore | null = null;
|
|
265
|
+
|
|
266
|
+
export function getHistoryStore(retentionDays?: number): InvestigationHistoryStore {
|
|
267
|
+
if (!historyStore) {
|
|
268
|
+
historyStore = new InvestigationHistoryStore(retentionDays);
|
|
269
|
+
}
|
|
270
|
+
return historyStore;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function initHistoryStore(retentionDays?: number): Promise<InvestigationHistoryStore> {
|
|
274
|
+
const store = getHistoryStore(retentionDays);
|
|
275
|
+
await store.init();
|
|
276
|
+
return store;
|
|
277
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, stat } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, extname, basename } from "path";
|
|
4
|
+
import type { RunbookEntry, RunbookIndex } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const INDEX_FILE = join(homedir(), ".config", "triagent", "runbook-index.json");
|
|
7
|
+
|
|
8
|
+
export class RunbookIndexer {
|
|
9
|
+
private index: RunbookIndex;
|
|
10
|
+
private paths: string[];
|
|
11
|
+
|
|
12
|
+
constructor(paths: string[] = []) {
|
|
13
|
+
this.paths = paths;
|
|
14
|
+
this.index = {
|
|
15
|
+
entries: [],
|
|
16
|
+
vocabulary: [],
|
|
17
|
+
idfValues: {},
|
|
18
|
+
lastIndexed: new Date(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const content = await readFile(INDEX_FILE, "utf-8");
|
|
25
|
+
this.index = JSON.parse(content, (key, value) => {
|
|
26
|
+
if (key === "lastIndexed" || key === "lastModified") {
|
|
27
|
+
return new Date(value);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
// Index doesn't exist yet
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async save(): Promise<void> {
|
|
37
|
+
await writeFile(INDEX_FILE, JSON.stringify(this.index, null, 2), "utf-8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async indexPaths(paths: string[]): Promise<number> {
|
|
41
|
+
this.paths = paths;
|
|
42
|
+
const entries: RunbookEntry[] = [];
|
|
43
|
+
|
|
44
|
+
for (const path of paths) {
|
|
45
|
+
const files = await this.findMarkdownFiles(path);
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const entry = await this.parseRunbook(file);
|
|
48
|
+
if (entry) {
|
|
49
|
+
entries.push(entry);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build vocabulary and IDF values
|
|
55
|
+
this.buildVocabulary(entries);
|
|
56
|
+
|
|
57
|
+
// Compute TF-IDF vectors for each entry
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
entry.tfidfVector = this.computeTfIdf(entry.content);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.index = {
|
|
63
|
+
entries,
|
|
64
|
+
vocabulary: this.index.vocabulary,
|
|
65
|
+
idfValues: this.index.idfValues,
|
|
66
|
+
lastIndexed: new Date(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await this.save();
|
|
70
|
+
return entries.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async findMarkdownFiles(dirPath: string): Promise<string[]> {
|
|
74
|
+
const files: string[] = [];
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
78
|
+
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const fullPath = join(dirPath, entry.name);
|
|
81
|
+
|
|
82
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
83
|
+
const subFiles = await this.findMarkdownFiles(fullPath);
|
|
84
|
+
files.push(...subFiles);
|
|
85
|
+
} else if (entry.isFile() && [".md", ".markdown"].includes(extname(entry.name).toLowerCase())) {
|
|
86
|
+
files.push(fullPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Directory not accessible
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return files;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async parseRunbook(filePath: string): Promise<RunbookEntry | null> {
|
|
97
|
+
try {
|
|
98
|
+
const content = await readFile(filePath, "utf-8");
|
|
99
|
+
const fileStat = await stat(filePath);
|
|
100
|
+
|
|
101
|
+
// Extract title from first heading or filename
|
|
102
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
103
|
+
const title = titleMatch ? titleMatch[1] : basename(filePath, extname(filePath));
|
|
104
|
+
|
|
105
|
+
// Extract tags from frontmatter or content
|
|
106
|
+
const tags = this.extractTags(content);
|
|
107
|
+
|
|
108
|
+
// Extract symptoms (lines that might describe problems)
|
|
109
|
+
const symptoms = this.extractSymptoms(content);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
id: Buffer.from(filePath).toString("base64"),
|
|
113
|
+
path: filePath,
|
|
114
|
+
title,
|
|
115
|
+
content,
|
|
116
|
+
tags,
|
|
117
|
+
symptoms,
|
|
118
|
+
lastModified: fileStat.mtime,
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private extractTags(content: string): string[] {
|
|
126
|
+
const tags: string[] = [];
|
|
127
|
+
|
|
128
|
+
// Check for YAML frontmatter tags
|
|
129
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
130
|
+
if (frontmatterMatch) {
|
|
131
|
+
const tagsMatch = frontmatterMatch[1].match(/tags:\s*\[([^\]]+)\]/);
|
|
132
|
+
if (tagsMatch) {
|
|
133
|
+
tags.push(...tagsMatch[1].split(",").map((t) => t.trim().replace(/['"]/g, "")));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Extract keywords from headings
|
|
138
|
+
const headings = content.match(/^#+\s+(.+)$/gm) || [];
|
|
139
|
+
for (const heading of headings) {
|
|
140
|
+
const text = heading.replace(/^#+\s+/, "").toLowerCase();
|
|
141
|
+
if (text.includes("error") || text.includes("issue") || text.includes("problem")) {
|
|
142
|
+
tags.push("troubleshooting");
|
|
143
|
+
}
|
|
144
|
+
if (text.includes("deploy") || text.includes("rollback")) {
|
|
145
|
+
tags.push("deployment");
|
|
146
|
+
}
|
|
147
|
+
if (text.includes("scale") || text.includes("performance")) {
|
|
148
|
+
tags.push("scaling");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return [...new Set(tags)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private extractSymptoms(content: string): string[] {
|
|
156
|
+
const symptoms: string[] = [];
|
|
157
|
+
|
|
158
|
+
// Look for sections that describe symptoms
|
|
159
|
+
const lines = content.split("\n");
|
|
160
|
+
let inSymptomsSection = false;
|
|
161
|
+
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
const lowerLine = line.toLowerCase();
|
|
164
|
+
|
|
165
|
+
// Check if entering a symptoms section
|
|
166
|
+
if (lowerLine.includes("symptom") || lowerLine.includes("sign") || lowerLine.includes("indicator")) {
|
|
167
|
+
inSymptomsSection = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if leaving symptoms section
|
|
172
|
+
if (inSymptomsSection && line.match(/^#+\s/)) {
|
|
173
|
+
inSymptomsSection = false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Collect bullet points in symptoms section
|
|
177
|
+
if (inSymptomsSection && line.match(/^[-*]\s/)) {
|
|
178
|
+
symptoms.push(line.replace(/^[-*]\s+/, "").trim());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Also look for error patterns
|
|
182
|
+
if (lowerLine.includes("error:") || lowerLine.includes("exception:") || lowerLine.includes("failed:")) {
|
|
183
|
+
symptoms.push(line.trim());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return symptoms.slice(0, 10); // Limit to 10 symptoms
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private buildVocabulary(entries: RunbookEntry[]): void {
|
|
191
|
+
const docFrequency = new Map<string, number>();
|
|
192
|
+
const vocabulary = new Set<string>();
|
|
193
|
+
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const words = this.tokenize(entry.content);
|
|
196
|
+
const uniqueWords = new Set(words);
|
|
197
|
+
|
|
198
|
+
for (const word of uniqueWords) {
|
|
199
|
+
vocabulary.add(word);
|
|
200
|
+
docFrequency.set(word, (docFrequency.get(word) || 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.index.vocabulary = Array.from(vocabulary);
|
|
205
|
+
|
|
206
|
+
// Compute IDF values
|
|
207
|
+
const numDocs = entries.length;
|
|
208
|
+
for (const [word, df] of docFrequency) {
|
|
209
|
+
this.index.idfValues[word] = Math.log((numDocs + 1) / (df + 1)) + 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private computeTfIdf(content: string): Record<string, number> {
|
|
214
|
+
const words = this.tokenize(content);
|
|
215
|
+
const tf = new Map<string, number>();
|
|
216
|
+
|
|
217
|
+
for (const word of words) {
|
|
218
|
+
tf.set(word, (tf.get(word) || 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const vector: Record<string, number> = {};
|
|
222
|
+
const maxTf = Math.max(...tf.values());
|
|
223
|
+
|
|
224
|
+
for (const [word, count] of tf) {
|
|
225
|
+
const normalizedTf = count / maxTf;
|
|
226
|
+
const idf = this.index.idfValues[word] || 1;
|
|
227
|
+
vector[word] = normalizedTf * idf;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return vector;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private tokenize(text: string): string[] {
|
|
234
|
+
return text
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
237
|
+
.split(/\s+/)
|
|
238
|
+
.filter((word) => word.length > 2 && !this.isStopWord(word));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private isStopWord(word: string): boolean {
|
|
242
|
+
const stopWords = new Set([
|
|
243
|
+
"the", "is", "at", "which", "on", "a", "an", "and", "or", "but",
|
|
244
|
+
"in", "with", "to", "for", "of", "as", "by", "that", "this",
|
|
245
|
+
"it", "from", "be", "are", "was", "were", "been", "being",
|
|
246
|
+
"have", "has", "had", "do", "does", "did", "will", "would",
|
|
247
|
+
"could", "should", "may", "might", "must", "can", "not", "no",
|
|
248
|
+
"all", "any", "both", "each", "few", "more", "most", "other",
|
|
249
|
+
"some", "such", "only", "own", "same", "than", "too", "very",
|
|
250
|
+
]);
|
|
251
|
+
return stopWords.has(word);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
search(query: string, limit: number = 5): RunbookEntry[] {
|
|
255
|
+
const queryVector = this.computeTfIdf(query);
|
|
256
|
+
const scores: Array<{ entry: RunbookEntry; score: number }> = [];
|
|
257
|
+
|
|
258
|
+
for (const entry of this.index.entries) {
|
|
259
|
+
if (!entry.tfidfVector) continue;
|
|
260
|
+
|
|
261
|
+
const score = this.cosineSimilarity(queryVector, entry.tfidfVector);
|
|
262
|
+
if (score > 0) {
|
|
263
|
+
scores.push({ entry, score });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Sort by score descending
|
|
268
|
+
scores.sort((a, b) => b.score - a.score);
|
|
269
|
+
|
|
270
|
+
return scores.slice(0, limit).map((s) => s.entry);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private cosineSimilarity(a: Record<string, number>, b: Record<string, number>): number {
|
|
274
|
+
let dotProduct = 0;
|
|
275
|
+
let normA = 0;
|
|
276
|
+
let normB = 0;
|
|
277
|
+
|
|
278
|
+
for (const key of Object.keys(a)) {
|
|
279
|
+
const aVal = a[key] || 0;
|
|
280
|
+
const bVal = b[key] || 0;
|
|
281
|
+
dotProduct += aVal * bVal;
|
|
282
|
+
normA += aVal * aVal;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const key of Object.keys(b)) {
|
|
286
|
+
const bVal = b[key] || 0;
|
|
287
|
+
normB += bVal * bVal;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (normA === 0 || normB === 0) return 0;
|
|
291
|
+
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
searchBySymptoms(symptoms: string[], limit: number = 5): RunbookEntry[] {
|
|
295
|
+
const query = symptoms.join(" ");
|
|
296
|
+
return this.search(query, limit);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
getByTags(tags: string[]): RunbookEntry[] {
|
|
300
|
+
return this.index.entries.filter((entry) =>
|
|
301
|
+
tags.some((tag) => entry.tags.includes(tag.toLowerCase()))
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
getStats(): { totalRunbooks: number; lastIndexed: Date } {
|
|
306
|
+
return {
|
|
307
|
+
totalRunbooks: this.index.entries.length,
|
|
308
|
+
lastIndexed: this.index.lastIndexed,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Singleton instance
|
|
314
|
+
let runbookIndexer: RunbookIndexer | null = null;
|
|
315
|
+
|
|
316
|
+
export function getRunbookIndexer(): RunbookIndexer {
|
|
317
|
+
if (!runbookIndexer) {
|
|
318
|
+
runbookIndexer = new RunbookIndexer();
|
|
319
|
+
}
|
|
320
|
+
return runbookIndexer;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function initRunbookIndexer(paths?: string[]): Promise<RunbookIndexer> {
|
|
324
|
+
const indexer = getRunbookIndexer();
|
|
325
|
+
await indexer.load();
|
|
326
|
+
if (paths && paths.length > 0) {
|
|
327
|
+
await indexer.indexPaths(paths);
|
|
328
|
+
}
|
|
329
|
+
return indexer;
|
|
330
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { IncidentInput, InvestigationResult } from "../mastra/agents/debugger.js";
|
|
2
|
+
|
|
3
|
+
export interface InvestigationEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: Date;
|
|
6
|
+
type: "tool_call" | "alert" | "k8s_event" | "log_entry" | "user_action";
|
|
7
|
+
source: string;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InvestigationHistory {
|
|
12
|
+
id: string;
|
|
13
|
+
incident: IncidentInput;
|
|
14
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
15
|
+
startedAt: Date;
|
|
16
|
+
completedAt?: Date;
|
|
17
|
+
result?: InvestigationResult;
|
|
18
|
+
rawResult?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
cluster?: string;
|
|
21
|
+
events: InvestigationEvent[];
|
|
22
|
+
toolCalls: ToolCallRecord[];
|
|
23
|
+
tags?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToolCallRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
timestamp: Date;
|
|
29
|
+
toolName: string;
|
|
30
|
+
args: Record<string, unknown>;
|
|
31
|
+
result?: string;
|
|
32
|
+
duration?: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HistoryQueryOptions {
|
|
37
|
+
limit?: number;
|
|
38
|
+
offset?: number;
|
|
39
|
+
status?: InvestigationHistory["status"];
|
|
40
|
+
cluster?: string;
|
|
41
|
+
tags?: string[];
|
|
42
|
+
startDate?: Date;
|
|
43
|
+
endDate?: Date;
|
|
44
|
+
searchQuery?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HistoryStats {
|
|
48
|
+
total: number;
|
|
49
|
+
byStatus: Record<InvestigationHistory["status"], number>;
|
|
50
|
+
byCluster: Record<string, number>;
|
|
51
|
+
averageDuration: number;
|
|
52
|
+
last24Hours: number;
|
|
53
|
+
last7Days: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunbookEntry {
|
|
57
|
+
id: string;
|
|
58
|
+
path: string;
|
|
59
|
+
title: string;
|
|
60
|
+
content: string;
|
|
61
|
+
tags: string[];
|
|
62
|
+
symptoms: string[];
|
|
63
|
+
lastModified: Date;
|
|
64
|
+
tfidfVector?: Record<string, number>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RunbookIndex {
|
|
68
|
+
entries: RunbookEntry[];
|
|
69
|
+
vocabulary: string[];
|
|
70
|
+
idfValues: Record<string, number>;
|
|
71
|
+
lastIndexed: Date;
|
|
72
|
+
}
|