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.
Files changed (47) hide show
  1. package/README.md +101 -1
  2. package/package.json +9 -3
  3. package/src/cli/config.ts +118 -2
  4. package/src/config.ts +23 -3
  5. package/src/index.ts +262 -6
  6. package/src/integrations/elasticsearch/client.ts +210 -0
  7. package/src/integrations/grafana/client.ts +186 -0
  8. package/src/integrations/kubernetes/multi-cluster.ts +199 -0
  9. package/src/integrations/kubernetes/types.ts +24 -0
  10. package/src/integrations/loki/client.ts +219 -0
  11. package/src/integrations/prometheus/client.ts +163 -0
  12. package/src/integrations/slack/client.ts +265 -0
  13. package/src/integrations/teams/client.ts +199 -0
  14. package/src/mastra/agents/debugger.ts +164 -109
  15. package/src/mastra/index.ts +2 -2
  16. package/src/mastra/tools/approval-store.ts +180 -0
  17. package/src/mastra/tools/cli.ts +94 -2
  18. package/src/mastra/tools/cost.ts +389 -0
  19. package/src/mastra/tools/logs.ts +210 -0
  20. package/src/mastra/tools/network.ts +253 -0
  21. package/src/mastra/tools/prometheus.ts +221 -0
  22. package/src/mastra/tools/remediation.ts +365 -0
  23. package/src/mastra/tools/runbook.ts +186 -0
  24. package/src/sandbox/bashlet.ts +76 -10
  25. package/src/server/routes/history.ts +207 -0
  26. package/src/server/routes/notifications.ts +236 -0
  27. package/src/server/webhook.ts +36 -2
  28. package/src/storage/index.ts +3 -0
  29. package/src/storage/investigation-history.ts +277 -0
  30. package/src/storage/runbook-index.ts +330 -0
  31. package/src/storage/types.ts +72 -0
  32. package/src/tui/app.tsx +278 -198
  33. package/src/tui/components/approval-dialog.tsx +147 -0
  34. package/src/tui/components/approval-modal.tsx +278 -0
  35. package/src/tui/components/centered-layout.tsx +33 -0
  36. package/src/tui/components/editor.tsx +87 -0
  37. package/src/tui/components/header.tsx +53 -0
  38. package/src/tui/components/index.ts +55 -0
  39. package/src/tui/components/message-item.tsx +131 -0
  40. package/src/tui/components/messages-panel.tsx +71 -0
  41. package/src/tui/components/status-badge.tsx +20 -0
  42. package/src/tui/components/status-bar.tsx +39 -0
  43. package/src/tui/components/styled-span.tsx +24 -0
  44. package/src/tui/components/timeline.tsx +223 -0
  45. package/src/tui/components/toast.tsx +104 -0
  46. package/src/tui/theme/index.ts +21 -0
  47. package/src/tui/theme/tokens.ts +180 -0
package/src/index.ts CHANGED
@@ -16,14 +16,20 @@ import {
16
16
  import type { AIProvider } from "./config.js";
17
17
 
18
18
  interface CliArgs {
19
- command: "run" | "config";
19
+ command: "run" | "config" | "cluster";
20
20
  configAction?: "set" | "get" | "list" | "path";
21
21
  configKey?: string;
22
22
  configValue?: string;
23
+ clusterAction?: "add" | "remove" | "list" | "use" | "status";
24
+ clusterName?: string;
25
+ clusterContext?: string;
26
+ clusterKubeConfig?: string;
27
+ clusterEnvironment?: string;
23
28
  webhookOnly: boolean;
24
29
  incident: string | null;
25
30
  help: boolean;
26
31
  host: boolean;
32
+ remote: string | null;
27
33
  }
28
34
 
29
35
  function parseArgs(): CliArgs {
@@ -34,6 +40,7 @@ function parseArgs(): CliArgs {
34
40
  incident: null,
35
41
  help: false,
36
42
  host: false,
43
+ remote: null,
37
44
  };
38
45
 
39
46
  // Check for config subcommand
@@ -45,6 +52,30 @@ function parseArgs(): CliArgs {
45
52
  return result;
46
53
  }
47
54
 
55
+ // Check for cluster subcommand
56
+ if (args[0] === "cluster") {
57
+ result.command = "cluster";
58
+ result.clusterAction = args[1] as "add" | "remove" | "list" | "use" | "status";
59
+
60
+ // Parse cluster sub-command arguments
61
+ for (let i = 2; i < args.length; i++) {
62
+ const arg = args[i];
63
+ if (arg === "--name" || arg === "-n") {
64
+ result.clusterName = args[++i];
65
+ } else if (arg === "--context" || arg === "-c") {
66
+ result.clusterContext = args[++i];
67
+ } else if (arg === "--kubeconfig" || arg === "-k") {
68
+ result.clusterKubeConfig = args[++i];
69
+ } else if (arg === "--environment" || arg === "-e") {
70
+ result.clusterEnvironment = args[++i];
71
+ } else if (!arg.startsWith("-")) {
72
+ // Positional argument - cluster name
73
+ result.clusterName = arg;
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+
48
79
  for (let i = 0; i < args.length; i++) {
49
80
  const arg = args[i];
50
81
 
@@ -56,6 +87,8 @@ function parseArgs(): CliArgs {
56
87
  result.help = true;
57
88
  } else if (arg === "--host") {
58
89
  result.host = true;
90
+ } else if (arg === "--remote" || arg === "-r") {
91
+ result.remote = args[++i] || null;
59
92
  }
60
93
  }
61
94
 
@@ -69,12 +102,14 @@ function printHelp(): void {
69
102
  USAGE:
70
103
  triagent [OPTIONS]
71
104
  triagent config <action> [key] [value]
105
+ triagent cluster <action> [options]
72
106
 
73
107
  OPTIONS:
74
108
  -h, --help Show this help message
75
109
  -w, --webhook-only Run only the webhook server (no TUI)
76
110
  -i, --incident Direct incident input (runs once and exits)
77
111
  --host Run commands on host machine (no sandbox)
112
+ -r, --remote Run commands on remote server via SSH (user@host)
78
113
 
79
114
  CONFIG COMMANDS:
80
115
  triagent config set <key> <value> Set a configuration value
@@ -88,9 +123,29 @@ CONFIG KEYS:
88
123
  apiKey - API key for the provider
89
124
  baseUrl - Custom API base URL (for proxies or local models)
90
125
  webhookPort - Webhook server port (default: 3000)
91
- codebasePath - Path to codebase (default: ./)
126
+ codebasePath - Path to codebase (default: ./) - for single codebase
92
127
  kubeConfigPath - Kubernetes config path (default: ~/.kube)
93
128
 
129
+ For multiple codebases, edit ~/.config/triagent/config.json directly:
130
+ "codebasePaths": [
131
+ { "name": "frontend", "path": "/path/to/frontend" },
132
+ { "name": "backend", "path": "/path/to/backend" }
133
+ ]
134
+ Each codebase will be mounted at /workspace/<name> in the sandbox.
135
+
136
+ CLUSTER COMMANDS:
137
+ triagent cluster add <name> --context <ctx> Add a cluster
138
+ triagent cluster remove <name> Remove a cluster
139
+ triagent cluster list List all clusters
140
+ triagent cluster use <name> Set active cluster
141
+ triagent cluster status [name] Check cluster status
142
+
143
+ CLUSTER OPTIONS:
144
+ -n, --name Cluster name
145
+ -c, --context Kubernetes context name
146
+ -k, --kubeconfig Path to kubeconfig file
147
+ -e, --environment Environment (development, staging, production)
148
+
94
149
  MODES:
95
150
  Interactive (default):
96
151
  Run with no arguments to start the interactive TUI.
@@ -101,9 +156,12 @@ MODES:
101
156
  incident webhooks from alerting systems.
102
157
 
103
158
  Endpoints:
104
- POST /webhook/incident - Submit an incident
159
+ POST /webhook/incident - Submit an incident
105
160
  GET /investigations/:id - Get investigation results
106
- GET /health - Health check
161
+ GET /history - List investigation history
162
+ GET /history/:id - Get investigation details
163
+ GET /history/stats - Get investigation statistics
164
+ GET /health - Health check
107
165
 
108
166
  Direct Input:
109
167
  Use --incident "description" for one-shot debugging.
@@ -130,6 +188,14 @@ EXAMPLES:
130
188
  # Direct incident investigation
131
189
  triagent -i "API gateway returning 503 errors"
132
190
 
191
+ # Run commands on a remote server via SSH
192
+ triagent --remote user@debug-container.local
193
+
194
+ # Multi-cluster management
195
+ triagent cluster add prod --context prod-cluster -e production
196
+ triagent cluster use prod
197
+ triagent cluster status
198
+
133
199
  # Submit via curl (webhook mode)
134
200
  curl -X POST http://localhost:3000/webhook/incident \\
135
201
  -H "Content-Type: application/json" \\
@@ -178,6 +244,165 @@ async function runDirectIncident(description: string): Promise<void> {
178
244
  }
179
245
  }
180
246
 
247
+ import { initClusterManager, getClusterManager } from "./integrations/kubernetes/multi-cluster.js";
248
+ import type { ClusterConfig } from "./cli/config.js";
249
+
250
+ /** Parse remote target string: user@host[:port] */
251
+ function parseRemoteTarget(target: string): { user: string; host: string; port?: number } {
252
+ let user = "root";
253
+ let host = target;
254
+ let port: number | undefined;
255
+
256
+ if (target.includes("@")) {
257
+ [user, host] = target.split("@");
258
+ }
259
+ if (host.includes(":")) {
260
+ const parts = host.split(":");
261
+ host = parts[0];
262
+ port = parseInt(parts[1], 10);
263
+ }
264
+
265
+ return { user, host, port };
266
+ }
267
+
268
+ async function handleClusterCommand(args: CliArgs): Promise<void> {
269
+ const config = await loadStoredConfig();
270
+ const clusterManager = initClusterManager(config.clusters, config.activeCluster);
271
+
272
+ switch (args.clusterAction) {
273
+ case "add": {
274
+ if (!args.clusterName) {
275
+ console.error("Usage: triagent cluster add <name> --context <context>");
276
+ process.exit(1);
277
+ }
278
+ const context = args.clusterContext || args.clusterName;
279
+ const newCluster: ClusterConfig = {
280
+ name: args.clusterName,
281
+ context,
282
+ kubeConfigPath: args.clusterKubeConfig,
283
+ environment: args.clusterEnvironment as ClusterConfig["environment"],
284
+ };
285
+
286
+ await clusterManager.addCluster(newCluster);
287
+
288
+ // Save to config
289
+ config.clusters = config.clusters || [];
290
+ config.clusters.push(newCluster);
291
+ await saveStoredConfig(config);
292
+
293
+ console.log(`✅ Added cluster: ${args.clusterName} (context: ${context})`);
294
+ break;
295
+ }
296
+ case "remove": {
297
+ if (!args.clusterName) {
298
+ console.error("Usage: triagent cluster remove <name>");
299
+ process.exit(1);
300
+ }
301
+
302
+ const removed = await clusterManager.removeCluster(args.clusterName);
303
+ if (!removed) {
304
+ console.error(`Cluster not found: ${args.clusterName}`);
305
+ process.exit(1);
306
+ }
307
+
308
+ // Remove from config
309
+ config.clusters = config.clusters?.filter((c) => c.name !== args.clusterName);
310
+ if (config.activeCluster === args.clusterName) {
311
+ config.activeCluster = undefined;
312
+ }
313
+ await saveStoredConfig(config);
314
+
315
+ console.log(`✅ Removed cluster: ${args.clusterName}`);
316
+ break;
317
+ }
318
+ case "list": {
319
+ const clusters = clusterManager.listClusters();
320
+ if (clusters.length === 0) {
321
+ console.log("No clusters configured.");
322
+ console.log("\nDiscover available contexts:");
323
+ const discovered = await clusterManager.discoverClusters();
324
+ if (discovered.length > 0) {
325
+ console.log("\nAvailable Kubernetes contexts:");
326
+ for (const c of discovered) {
327
+ console.log(` - ${c.context} (${c.server})`);
328
+ }
329
+ console.log("\nAdd a cluster with: triagent cluster add <name> --context <context>");
330
+ } else {
331
+ console.log("No Kubernetes contexts found.");
332
+ }
333
+ } else {
334
+ console.log("Configured clusters:\n");
335
+ for (const c of clusters) {
336
+ const active = c.isActive ? " (active)" : "";
337
+ const env = c.environment ? ` [${c.environment}]` : "";
338
+ console.log(` ${c.name}${active}${env}`);
339
+ console.log(` context: ${c.context}`);
340
+ if (c.kubeConfigPath) {
341
+ console.log(` kubeconfig: ${c.kubeConfigPath}`);
342
+ }
343
+ }
344
+ }
345
+ break;
346
+ }
347
+ case "use": {
348
+ if (!args.clusterName) {
349
+ console.error("Usage: triagent cluster use <name>");
350
+ process.exit(1);
351
+ }
352
+
353
+ const success = await clusterManager.setActiveCluster(args.clusterName);
354
+ if (!success) {
355
+ console.error(`Cluster not found: ${args.clusterName}`);
356
+ process.exit(1);
357
+ }
358
+
359
+ // Save to config
360
+ config.activeCluster = args.clusterName;
361
+ await saveStoredConfig(config);
362
+
363
+ console.log(`✅ Active cluster set to: ${args.clusterName}`);
364
+ break;
365
+ }
366
+ case "status": {
367
+ const clusterName = args.clusterName;
368
+ if (clusterName) {
369
+ const status = await clusterManager.checkClusterStatus(clusterName);
370
+ console.log(`Cluster: ${status.name}`);
371
+ console.log(` Connected: ${status.connected ? "✅ Yes" : "❌ No"}`);
372
+ if (status.connected) {
373
+ console.log(` Version: ${status.version}`);
374
+ console.log(` Nodes: ${status.nodeCount}`);
375
+ }
376
+ if (status.error) {
377
+ console.log(` Error: ${status.error}`);
378
+ }
379
+ } else {
380
+ const clusters = clusterManager.listClusters();
381
+ if (clusters.length === 0) {
382
+ console.log("No clusters configured.");
383
+ } else {
384
+ console.log("Cluster status:\n");
385
+ for (const c of clusters) {
386
+ const status = await clusterManager.checkClusterStatus(c.name);
387
+ const active = c.isActive ? " (active)" : "";
388
+ const connected = status.connected ? "✅" : "❌";
389
+ console.log(` ${connected} ${c.name}${active}`);
390
+ if (status.connected) {
391
+ console.log(` v${status.version}, ${status.nodeCount} nodes`);
392
+ } else if (status.error) {
393
+ console.log(` Error: ${status.error}`);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ break;
399
+ }
400
+ default:
401
+ console.error("Usage: triagent cluster <add|remove|list|use|status> [options]");
402
+ process.exit(1);
403
+ }
404
+ }
405
+
181
406
  async function handleConfigCommand(args: CliArgs): Promise<void> {
182
407
  const validKeys: (keyof StoredConfig)[] = [
183
408
  "aiProvider",
@@ -266,6 +491,12 @@ async function main(): Promise<void> {
266
491
  process.exit(0);
267
492
  }
268
493
 
494
+ // Handle cluster command
495
+ if (args.command === "cluster") {
496
+ await handleClusterCommand(args);
497
+ process.exit(0);
498
+ }
499
+
269
500
  // Load configuration
270
501
  let config;
271
502
  try {
@@ -277,12 +508,37 @@ async function main(): Promise<void> {
277
508
  process.exit(1);
278
509
  }
279
510
 
511
+ // Validate mutually exclusive options
512
+ if (args.host && args.remote) {
513
+ console.error("❌ Cannot use --host and --remote together");
514
+ process.exit(1);
515
+ }
516
+
280
517
  // Initialize sandbox and Mastra
281
518
  try {
282
- initSandboxFromConfig(config, args.host);
283
- createMastraInstance(config);
519
+ const sandboxOptions: {
520
+ useHost?: boolean;
521
+ backend?: "docker" | "ssh";
522
+ ssh?: { host: string; user: string; port?: number };
523
+ } = {};
524
+ if (args.host) {
525
+ sandboxOptions.useHost = true;
526
+ } else if (args.remote) {
527
+ const { user, host, port } = parseRemoteTarget(args.remote);
528
+ sandboxOptions.backend = "ssh";
529
+ sandboxOptions.ssh = { host, user, port };
530
+ }
531
+
532
+ await initSandboxFromConfig(config, sandboxOptions);
533
+ await createMastraInstance(config);
534
+
284
535
  if (args.host) {
285
536
  console.log("⚠️ Running in host mode (no sandbox)\n");
537
+ } else if (args.remote) {
538
+ const { getRemoteInfo } = await import("./sandbox/bashlet.js");
539
+ const info = getRemoteInfo();
540
+ console.log(`🌐 Running in remote mode: ${args.remote}`);
541
+ console.log(` Workspace: ${info?.workdir} (session: ${info?.sessionId})\n`);
286
542
  }
287
543
  } catch (error) {
288
544
  console.error("❌ Initialization error:", error);
@@ -0,0 +1,210 @@
1
+ import type { ElasticsearchConfig } from "../../cli/config.js";
2
+
3
+ export interface ESSearchResult {
4
+ hits: {
5
+ total: { value: number; relation: string };
6
+ hits: Array<{
7
+ _index: string;
8
+ _id: string;
9
+ _source: Record<string, unknown>;
10
+ sort?: unknown[];
11
+ }>;
12
+ };
13
+ aggregations?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface ESLogEntry {
17
+ timestamp: string;
18
+ message: string;
19
+ level?: string;
20
+ pod?: string;
21
+ namespace?: string;
22
+ container?: string;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export class ElasticsearchClient {
27
+ private baseUrl: string;
28
+ private index: string;
29
+ private apiKey?: string;
30
+
31
+ constructor(config: ElasticsearchConfig) {
32
+ this.baseUrl = config.url.replace(/\/$/, "");
33
+ this.index = config.index;
34
+ this.apiKey = config.auth?.apiKey;
35
+ }
36
+
37
+ private async request<T>(
38
+ endpoint: string,
39
+ options?: Omit<RequestInit, 'body'> & { body?: unknown }
40
+ ): Promise<T> {
41
+ const headers: Record<string, string> = {
42
+ "Accept": "application/json",
43
+ "Content-Type": "application/json",
44
+ };
45
+
46
+ if (this.apiKey) {
47
+ headers["Authorization"] = `ApiKey ${this.apiKey}`;
48
+ }
49
+
50
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
51
+ ...options,
52
+ headers: { ...headers, ...options?.headers },
53
+ body: options?.body ? JSON.stringify(options.body) : undefined,
54
+ });
55
+
56
+ if (!response.ok) {
57
+ const text = await response.text();
58
+ throw new Error(`Elasticsearch API error: ${response.status} ${text}`);
59
+ }
60
+
61
+ return response.json() as Promise<T>;
62
+ }
63
+
64
+ async search(options: {
65
+ query: string;
66
+ timeRange?: { start: string; end?: string };
67
+ limit?: number;
68
+ sort?: "asc" | "desc";
69
+ }): Promise<ESLogEntry[]> {
70
+ const { query, timeRange, limit = 100, sort = "desc" } = options;
71
+
72
+ const must: unknown[] = [
73
+ {
74
+ query_string: {
75
+ query,
76
+ default_field: "message",
77
+ },
78
+ },
79
+ ];
80
+
81
+ if (timeRange) {
82
+ const range: Record<string, string> = { gte: timeRange.start };
83
+ if (timeRange.end) {
84
+ range.lte = timeRange.end;
85
+ }
86
+ must.push({
87
+ range: {
88
+ "@timestamp": range,
89
+ },
90
+ });
91
+ }
92
+
93
+ const body = {
94
+ query: {
95
+ bool: { must },
96
+ },
97
+ size: limit,
98
+ sort: [{ "@timestamp": { order: sort } }],
99
+ };
100
+
101
+ const result = await this.request<ESSearchResult>(`/${this.index}/_search`, {
102
+ method: "POST",
103
+ body,
104
+ });
105
+
106
+ return result.hits.hits.map((hit) => {
107
+ const source = hit._source as Record<string, unknown>;
108
+ const k8s = source.kubernetes as Record<string, unknown> | undefined;
109
+ const k8sPod = k8s?.pod as Record<string, unknown> | undefined;
110
+ const k8sContainer = k8s?.container as Record<string, unknown> | undefined;
111
+ return {
112
+ timestamp: (source["@timestamp"] as string) || new Date().toISOString(),
113
+ message: (source.message as string) || JSON.stringify(source),
114
+ level: source.level as string | undefined,
115
+ pod: (k8sPod?.name || source.pod) as string | undefined,
116
+ namespace: (k8s?.namespace || source.namespace) as string | undefined,
117
+ container: (k8sContainer?.name || source.container) as string | undefined,
118
+ ...source,
119
+ };
120
+ });
121
+ }
122
+
123
+ async aggregate(options: {
124
+ query: string;
125
+ field: string;
126
+ timeRange?: { start: string; end?: string };
127
+ interval?: string;
128
+ }): Promise<Array<{ key: string; count: number }>> {
129
+ const { query, field, timeRange, interval = "1m" } = options;
130
+
131
+ const must: unknown[] = [
132
+ {
133
+ query_string: {
134
+ query,
135
+ default_field: "message",
136
+ },
137
+ },
138
+ ];
139
+
140
+ if (timeRange) {
141
+ const range: Record<string, string> = { gte: timeRange.start };
142
+ if (timeRange.end) {
143
+ range.lte = timeRange.end;
144
+ }
145
+ must.push({
146
+ range: {
147
+ "@timestamp": range,
148
+ },
149
+ });
150
+ }
151
+
152
+ const body = {
153
+ query: {
154
+ bool: { must },
155
+ },
156
+ size: 0,
157
+ aggs: {
158
+ by_field: {
159
+ terms: { field, size: 50 },
160
+ },
161
+ over_time: {
162
+ date_histogram: {
163
+ field: "@timestamp",
164
+ fixed_interval: interval,
165
+ },
166
+ },
167
+ },
168
+ };
169
+
170
+ const result = await this.request<{
171
+ aggregations: {
172
+ by_field: { buckets: Array<{ key: string; doc_count: number }> };
173
+ over_time: { buckets: Array<{ key_as_string: string; doc_count: number }> };
174
+ };
175
+ }>(`/${this.index}/_search`, {
176
+ method: "POST",
177
+ body,
178
+ });
179
+
180
+ return result.aggregations.by_field.buckets.map((bucket) => ({
181
+ key: bucket.key,
182
+ count: bucket.doc_count,
183
+ }));
184
+ }
185
+
186
+ formatLogs(logs: ESLogEntry[]): string {
187
+ const lines: string[] = [];
188
+ for (const log of logs) {
189
+ const level = log.level ? `[${log.level.toUpperCase()}]` : "";
190
+ const source = log.pod ? `${log.namespace}/${log.pod}` : "";
191
+ const prefix = [log.timestamp, level, source].filter(Boolean).join(" ");
192
+ lines.push(`${prefix}: ${log.message}`);
193
+ }
194
+ return lines.join("\n");
195
+ }
196
+ }
197
+
198
+ // Singleton instance
199
+ let esClient: ElasticsearchClient | null = null;
200
+
201
+ export function getElasticsearchClient(): ElasticsearchClient | null {
202
+ return esClient;
203
+ }
204
+
205
+ export function initElasticsearchClient(config?: ElasticsearchConfig): ElasticsearchClient | null {
206
+ if (config) {
207
+ esClient = new ElasticsearchClient(config);
208
+ }
209
+ return esClient;
210
+ }