@prads01/blackbox 0.1.1 → 0.1.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.
@@ -1,10 +1,10 @@
1
1
  import { callAnalyzeApi } from "../api.js";
2
- import { readConfig } from "../config.js";
3
2
  import { loadEvidenceFromConfig } from "../files.js";
3
+ import { ensureRepoLinkedForCli } from "../linking.js";
4
4
  import { printTruthReport } from "../report.js";
5
5
  import { colors, section } from "../utils/format.js";
6
6
  export async function runAnalyzeOnce() {
7
- const config = await readConfig();
7
+ const config = await ensureRepoLinkedForCli();
8
8
  const evidence = await loadEvidenceFromConfig(config);
9
9
  const payload = {
10
10
  project: {
@@ -139,6 +139,7 @@ export async function runSetup() {
139
139
  expected_file: expectedFile,
140
140
  config_file: configFile,
141
141
  commit_file: base.commit_file || defaults.commit_file,
142
+ linked_repo: base.linked_repo,
142
143
  };
143
144
  const writeSpinner = ora("Writing .blackbox.yaml").start();
144
145
  const configPath = await writeConfig(configToWrite);
@@ -1,9 +1,9 @@
1
1
  import { isApiReachable } from "../api.js";
2
- import { readConfig } from "../config.js";
2
+ import { ensureRepoLinkedForCli } from "../linking.js";
3
3
  import { colors, row, section } from "../utils/format.js";
4
4
  export async function runStatus() {
5
5
  try {
6
- const config = await readConfig();
6
+ const config = await ensureRepoLinkedForCli();
7
7
  section("BlackBox Status");
8
8
  row("Project:", config.project_name);
9
9
  row("Environment:", config.environment);
@@ -12,6 +12,7 @@ export async function runStatus() {
12
12
  row("Expected file:", config.expected_file);
13
13
  row("Config file:", config.config_file);
14
14
  row("Commit file:", config.commit_file);
15
+ row("Linked repo:", config.linked_repo ?? colors.warn("not linked"));
15
16
  const reachable = await isApiReachable(config);
16
17
  row("API reachable:", reachable ? colors.ok("yes") : colors.bad("no"));
17
18
  }
@@ -1,13 +1,13 @@
1
1
  import chokidar from "chokidar";
2
2
  import path from "node:path";
3
- import { readConfig } from "../config.js";
3
+ import { ensureRepoLinkedForCli } from "../linking.js";
4
4
  import { runAnalyzeOnce } from "./analyze.js";
5
5
  import { colors, row, section } from "../utils/format.js";
6
6
  const WATCH_DEBOUNCE_MS = 1200;
7
7
  const WATCH_COOLDOWN_MS = 2000;
8
8
  export async function runWatch() {
9
9
  try {
10
- const config = await readConfig();
10
+ const config = await ensureRepoLinkedForCli();
11
11
  const absoluteLogPath = path.resolve(process.cwd(), config.log_file);
12
12
  let isAnalyzing = false;
13
13
  let debounceTimer = null;
package/dist/config.js CHANGED
@@ -10,10 +10,10 @@ export function createDefaultConfig(cwd = process.cwd()) {
10
10
  project_name: path.basename(cwd),
11
11
  environment: "local",
12
12
  api_base_url: "http://localhost:3000",
13
- log_file: "src/demo/cache-mismatch/logs.txt",
14
- expected_file: "src/demo/cache-mismatch/expected.md",
15
- config_file: "src/demo/cache-mismatch/config.json",
16
- commit_file: "src/demo/cache-mismatch/commit.diff",
13
+ log_file: "demo-system/logs/app.log",
14
+ expected_file: "demo-system/config/service-config.json",
15
+ config_file: "demo-system/state/system-state.json",
16
+ commit_file: "demo-system/config/recent-change.json",
17
17
  };
18
18
  }
19
19
  function assertConfigShape(value) {
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readConfig, writeConfig } from "./config.js";
3
+ import { colors } from "./utils/format.js";
4
+ function openBrowser(url) {
5
+ const platform = process.platform;
6
+ if (platform === "darwin") {
7
+ const child = spawn("open", [url], { detached: true, stdio: "ignore" });
8
+ child.unref();
9
+ return;
10
+ }
11
+ if (platform === "win32") {
12
+ const child = spawn("cmd", ["/c", "start", "", url], {
13
+ detached: true,
14
+ stdio: "ignore",
15
+ });
16
+ child.unref();
17
+ return;
18
+ }
19
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
20
+ child.unref();
21
+ }
22
+ async function startLinkFlow(config) {
23
+ const response = await fetch(`${config.api_base_url}/api/cli/link/start`, {
24
+ method: "POST",
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`Unable to start CLI link flow (${response.status}).`);
28
+ }
29
+ return (await response.json());
30
+ }
31
+ async function getLinkStatus(config, token) {
32
+ const response = await fetch(`${config.api_base_url}/api/cli/link/${token}`, {
33
+ method: "GET",
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`Unable to fetch CLI link status (${response.status}).`);
37
+ }
38
+ return (await response.json());
39
+ }
40
+ export async function ensureRepoLinkedForCli() {
41
+ const config = await readConfig();
42
+ if (config.linked_repo && config.linked_repo.trim().length > 0) {
43
+ return config;
44
+ }
45
+ console.log(colors.warn("No linked GitHub repo found for this CLI config."));
46
+ console.log(colors.muted("Starting browser-based auth and repo linking flow..."));
47
+ const start = await startLinkFlow(config);
48
+ console.log(colors.title(`Open this URL if browser does not open:\n${start.linkUrl}`));
49
+ try {
50
+ openBrowser(start.linkUrl);
51
+ }
52
+ catch {
53
+ // no-op, URL is already printed
54
+ }
55
+ const maxAttempts = 120;
56
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
57
+ await new Promise((resolve) => setTimeout(resolve, 2000));
58
+ const status = await getLinkStatus(config, start.token);
59
+ if (status.status !== "completed" || !status.repo)
60
+ continue;
61
+ const linkedRepo = status.repo.fullName;
62
+ const nextConfig = {
63
+ ...config,
64
+ linked_repo: linkedRepo,
65
+ };
66
+ await writeConfig(nextConfig);
67
+ console.log(colors.ok(`Linked repository: ${linkedRepo}`));
68
+ return nextConfig;
69
+ }
70
+ throw new Error("CLI link timed out. Complete browser auth and retry.");
71
+ }
package/dist/tui/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import blessed from "blessed";
2
- import { fetchApiReachability, fetchIncidents, loadTuiConfig, triggerCreatePr, triggerAnalysis, triggerSandboxFix, updateIncidentStatus, } from "./data.js";
2
+ import { fetchAnalysisRun, fetchApiReachability, fetchIncidents, loadTuiConfig, startAnalysisRun, triggerCreatePr, triggerSandboxFix, updateIncidentStatus, } from "./data.js";
3
3
  import { attachKeybindings } from "./keybindings.js";
4
4
  import { indexToView, palette, renderFooter, renderMain, renderSidebar, viewToIndex, } from "./render.js";
5
5
  import { TuiWatchService } from "./watch-service.js";
@@ -27,6 +27,7 @@ function createInitialState(config) {
27
27
  incidents: [],
28
28
  selectedIncidentIndex: 0,
29
29
  incidentsMode: "list",
30
+ contextSourceView: "logs",
30
31
  sandboxMode: "list",
31
32
  selectedSandboxIndex: 0,
32
33
  lastSandboxIncidentId: null,
@@ -39,6 +40,7 @@ function createInitialState(config) {
39
40
  sandboxPrError: null,
40
41
  sandboxPrInFlight: false,
41
42
  analyzeInFlight: false,
43
+ currentAnalysisRun: null,
42
44
  busyMessage: null,
43
45
  statusMessage: null,
44
46
  errorMessage: null,
@@ -220,12 +222,29 @@ export async function runTui() {
220
222
  state.analyzeInFlight = true;
221
223
  let didSucceed = false;
222
224
  await withBusy("Analyzing...", async () => {
223
- await triggerAnalysis(config);
224
- state.lastAnalysisAt = new Date().toISOString();
225
- await refreshData();
226
- state.view = "dashboard";
227
- state.selectedNavIndex = viewToIndex("dashboard");
228
- didSucceed = true;
225
+ const run = await startAnalysisRun(config);
226
+ state.currentAnalysisRun = run;
227
+ state.view = "analyze";
228
+ state.selectedNavIndex = viewToIndex("analyze");
229
+ render();
230
+ while (true) {
231
+ await new Promise((resolve) => setTimeout(resolve, 700));
232
+ const next = await fetchAnalysisRun(config, run.id);
233
+ state.currentAnalysisRun = next;
234
+ render();
235
+ if (next.status === "running")
236
+ continue;
237
+ if (next.status === "failed") {
238
+ state.errorMessage = next.error ?? "Analysis run failed.";
239
+ break;
240
+ }
241
+ didSucceed = true;
242
+ state.lastAnalysisAt = new Date().toISOString();
243
+ await refreshData();
244
+ state.view = "dashboard";
245
+ state.selectedNavIndex = viewToIndex("dashboard");
246
+ break;
247
+ }
229
248
  });
230
249
  state.analyzeInFlight = false;
231
250
  if (didSucceed) {
package/dist/tui/data.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isApiReachable } from "../api.js";
2
- import { readConfig } from "../config.js";
3
2
  import { loadEvidenceFromConfig } from "../files.js";
3
+ import { ensureRepoLinkedForCli } from "../linking.js";
4
4
  function assertIncidentsResponse(value) {
5
5
  if (!value || typeof value !== "object")
6
6
  return false;
@@ -25,11 +25,21 @@ function assertIncidentsResponse(value) {
25
25
  typeof i.impact === "string" &&
26
26
  typeof i.nextAction === "string" &&
27
27
  (i.evidencePoints === undefined || Array.isArray(i.evidencePoints)) &&
28
+ (i.investigation === undefined ||
29
+ (typeof i.investigation === "object" &&
30
+ i.investigation !== null &&
31
+ typeof i.investigation.investigationSummary ===
32
+ "string" &&
33
+ Array.isArray(i.investigation.evidence) &&
34
+ typeof i.investigation.confidence === "number")) &&
35
+ (i.investigationTimeline === undefined || Array.isArray(i.investigationTimeline)) &&
36
+ (i.investigationTools === undefined || Array.isArray(i.investigationTools)) &&
37
+ (i.toolOutputsSummary === undefined || Array.isArray(i.toolOutputsSummary)) &&
28
38
  typeof i.timestamp === "string");
29
39
  });
30
40
  }
31
41
  export async function loadTuiConfig() {
32
- return readConfig();
42
+ return ensureRepoLinkedForCli();
33
43
  }
34
44
  export async function fetchApiReachability(config) {
35
45
  return isApiReachable(config);
@@ -80,6 +90,69 @@ export async function triggerAnalysis(config) {
80
90
  }
81
91
  return result;
82
92
  }
93
+ function assertAnalysisRun(value) {
94
+ if (!value || typeof value !== "object")
95
+ return false;
96
+ const candidate = value;
97
+ if (typeof candidate.id !== "string" ||
98
+ (candidate.status !== "running" &&
99
+ candidate.status !== "completed" &&
100
+ candidate.status !== "failed") ||
101
+ typeof candidate.startedAt !== "string" ||
102
+ !Array.isArray(candidate.steps)) {
103
+ return false;
104
+ }
105
+ return candidate.steps.every((step) => {
106
+ if (!step || typeof step !== "object")
107
+ return false;
108
+ const s = step;
109
+ return (typeof s.id === "string" &&
110
+ typeof s.label === "string" &&
111
+ (s.status === "pending" || s.status === "running" || s.status === "completed"));
112
+ });
113
+ }
114
+ export async function startAnalysisRun(config) {
115
+ const evidence = await loadEvidenceFromConfig(config);
116
+ const payload = {
117
+ project: {
118
+ name: config.project_name,
119
+ environment: config.environment,
120
+ },
121
+ evidence,
122
+ };
123
+ const response = await fetch(`${config.api_base_url}/api/analyze/run`, {
124
+ method: "POST",
125
+ headers: {
126
+ "Content-Type": "application/json",
127
+ },
128
+ body: JSON.stringify(payload),
129
+ });
130
+ if (!response.ok) {
131
+ const body = await response.text();
132
+ throw new Error(`Analyze run start failed (${response.status}): ${body}`);
133
+ }
134
+ const payloadJson = (await response.json());
135
+ const run = payloadJson.run;
136
+ if (!assertAnalysisRun(run)) {
137
+ throw new Error("Analyze run start returned invalid payload.");
138
+ }
139
+ return run;
140
+ }
141
+ export async function fetchAnalysisRun(config, runId) {
142
+ const response = await fetch(`${config.api_base_url}/api/analyze/run/${runId}`, {
143
+ method: "GET",
144
+ });
145
+ if (!response.ok) {
146
+ const body = await response.text();
147
+ throw new Error(`Analyze run fetch failed (${response.status}): ${body}`);
148
+ }
149
+ const payload = (await response.json());
150
+ const run = payload.run;
151
+ if (!assertAnalysisRun(run)) {
152
+ throw new Error("Analyze run status payload invalid.");
153
+ }
154
+ return run;
155
+ }
83
156
  function assertSandboxResult(value) {
84
157
  if (!value || typeof value !== "object")
85
158
  return false;
@@ -56,6 +56,7 @@ export function attachKeybindings(screen, state, actions) {
56
56
  }
57
57
  if (state.view === "incidents" && state.incidentsMode === "list" && state.incidents.length > 0) {
58
58
  state.incidentsMode = "detail";
59
+ state.contextSourceView = "logs";
59
60
  actions.render();
60
61
  return;
61
62
  }
@@ -82,9 +83,34 @@ export function attachKeybindings(screen, state, actions) {
82
83
  screen.key(["i"], () => {
83
84
  state.view = "incidents";
84
85
  state.incidentsMode = "list";
86
+ state.contextSourceView = "logs";
85
87
  state.selectedNavIndex = 1;
86
88
  actions.render();
87
89
  });
90
+ screen.key(["1"], () => {
91
+ if (state.view === "incidents" && state.incidentsMode === "detail") {
92
+ state.contextSourceView = "logs";
93
+ actions.render();
94
+ }
95
+ });
96
+ screen.key(["2"], () => {
97
+ if (state.view === "incidents" && state.incidentsMode === "detail") {
98
+ state.contextSourceView = "config";
99
+ actions.render();
100
+ }
101
+ });
102
+ screen.key(["3"], () => {
103
+ if (state.view === "incidents" && state.incidentsMode === "detail") {
104
+ state.contextSourceView = "commits";
105
+ actions.render();
106
+ }
107
+ });
108
+ screen.key(["4"], () => {
109
+ if (state.view === "incidents" && state.incidentsMode === "detail") {
110
+ state.contextSourceView = "system_state";
111
+ actions.render();
112
+ }
113
+ });
88
114
  screen.key(["s"], () => {
89
115
  state.view = "sandbox";
90
116
  state.sandboxMode = "list";
@@ -108,6 +108,48 @@ function renderIncidentDetail(state) {
108
108
  if (!incident) {
109
109
  return "Incident not found.";
110
110
  }
111
+ const investigation = incident.investigation;
112
+ const evidence = investigation?.evidence && investigation.evidence.length > 0
113
+ ? investigation.evidence
114
+ : incident.evidencePoints ?? [];
115
+ const timeline = incident.investigationTimeline ?? [];
116
+ const investigationTools = incident.investigationTools ?? [];
117
+ const context = incident.context;
118
+ const contextHeader = [
119
+ "{bold}{white-fg}CONTEXT SOURCES{/white-fg}{/bold}",
120
+ `[1] Logs [2] Config [3] Commits [4] System State (active: ${state.contextSourceView})`,
121
+ ];
122
+ const contextBody = (() => {
123
+ if (!context)
124
+ return ["No context data available for this incident."];
125
+ if (state.contextSourceView === "logs") {
126
+ const logs = context.logs?.slice(-12) ?? [];
127
+ return logs.length > 0 ? logs : ["No log lines available."];
128
+ }
129
+ if (state.contextSourceView === "config") {
130
+ return [
131
+ `Service: ${context.systemState.service || "N/A"}`,
132
+ `Environment: ${context.systemState.environment || "N/A"}`,
133
+ `Expected Topic: ${context.systemState.invalidationTopicExpected || "N/A"}`,
134
+ ];
135
+ }
136
+ if (state.contextSourceView === "commits") {
137
+ const commits = context.recentCommits ?? [];
138
+ if (commits.length === 0)
139
+ return ["No commit context available."];
140
+ return commits.slice(0, 5).flatMap((commit) => [
141
+ `${commit.sha.slice(0, 7)} ${commit.message}`,
142
+ ` ${commit.summary}`,
143
+ ]);
144
+ }
145
+ return [
146
+ `Cache Version: ${context.systemState.cacheVersion || "N/A"}`,
147
+ `DB Version: ${context.systemState.dbVersion || "N/A"}`,
148
+ `Observed Topic: ${context.systemState.invalidationTopicObserved || "N/A"}`,
149
+ `Expected Topic: ${context.systemState.invalidationTopicExpected || "N/A"}`,
150
+ `Error Rate: ${context.systemState.recentErrorRate || "N/A"}`,
151
+ ];
152
+ })();
111
153
  return [
112
154
  "{bold}{white-fg}Incident Report{/white-fg}{/bold}",
113
155
  "{gray-fg}Backspace to return to incidents list.{/gray-fg}",
@@ -123,6 +165,10 @@ function renderIncidentDetail(state) {
123
165
  "{bold}{white-fg}Root Cause{/white-fg}{/bold}",
124
166
  incident.rootCause,
125
167
  "",
168
+ "{bold}{white-fg}Investigation Summary{/white-fg}{/bold}",
169
+ investigation?.investigationSummary ?? "No synthesized investigation summary available.",
170
+ `{bold}Confidence{/bold}: ${investigation ? `${Math.round(investigation.confidence * 100)}%` : "N/A"}`,
171
+ "",
126
172
  "{bold}{white-fg}Impact{/white-fg}{/bold}",
127
173
  incident.impact,
128
174
  "",
@@ -130,15 +176,37 @@ function renderIncidentDetail(state) {
130
176
  incident.fixSuggestion?.trim() || "No fix suggestion available.",
131
177
  "",
132
178
  "{bold}{white-fg}Evidence Points{/white-fg}{/bold}",
133
- ...(incident.evidencePoints?.length
134
- ? incident.evidencePoints.slice(0, 5).map((point) => `- ${point}`)
179
+ ...(evidence.length
180
+ ? evidence.slice(0, 5).map((point) => `- ${point}`)
135
181
  : ["- No evidence points available."]),
182
+ "",
183
+ "{bold}{white-fg}Investigation Timeline{/white-fg}{/bold}",
184
+ ...(timeline.length
185
+ ? timeline.slice(0, 8).map((entry) => {
186
+ const source = entry.source ? ` [${entry.source}]` : "";
187
+ return `${entry.step}. ${entry.title}${source}\n ${entry.detail}`;
188
+ })
189
+ : ["No timeline steps available."]),
190
+ "",
191
+ "{bold}{white-fg}Investigation Tools{/white-fg}{/bold}",
192
+ ...(investigationTools.length
193
+ ? investigationTools.map((tool) => {
194
+ const status = tool.status === "completed"
195
+ ? "{green-fg}completed{/green-fg}"
196
+ : "{red-fg}failed{/red-fg}";
197
+ return `- ${tool.name} [${status}]\n ${tool.summary}`;
198
+ })
199
+ : ["No tool execution records available."]),
200
+ "",
201
+ ...contextHeader,
202
+ ...contextBody,
136
203
  ].join("\n");
137
204
  }
138
205
  function renderIncidents(state) {
139
206
  return state.incidentsMode === "detail" ? renderIncidentDetail(state) : renderIncidentList(state);
140
207
  }
141
208
  function renderAnalyze(state) {
209
+ const run = state.currentAnalysisRun;
142
210
  return [
143
211
  "{bold}{white-fg}Analyze{/white-fg}{/bold}",
144
212
  "",
@@ -147,6 +215,29 @@ function renderAnalyze(state) {
147
215
  "",
148
216
  "Press {bold}a{/bold} to trigger analysis once.",
149
217
  "Repeated keypresses are ignored while analysis is running.",
218
+ "",
219
+ "{bold}{white-fg}Investigation Activity{/white-fg}{/bold}",
220
+ ...(run
221
+ ? [
222
+ `Run ID: ${run.id}`,
223
+ `Run Status: ${run.status === "completed"
224
+ ? "{green-fg}COMPLETED{/green-fg}"
225
+ : run.status === "failed"
226
+ ? "{red-fg}FAILED{/red-fg}"
227
+ : "{yellow-fg}RUNNING{/yellow-fg}"}`,
228
+ ...(run.incidentId ? [`Incident: ${run.incidentId}`] : []),
229
+ "",
230
+ ...run.steps.map((step, index) => {
231
+ const status = step.status === "completed"
232
+ ? "{green-fg}completed{/green-fg}"
233
+ : step.status === "running"
234
+ ? "{yellow-fg}running{/yellow-fg}"
235
+ : "{gray-fg}pending{/gray-fg}";
236
+ const detail = step.detail ? `\n ${step.detail}` : "";
237
+ return `${index + 1}. ${step.label} [${status}]${detail}`;
238
+ }),
239
+ ]
240
+ : ["No active investigation run."]),
150
241
  ].join("\n");
151
242
  }
152
243
  function renderWatch(state) {
@@ -269,9 +360,12 @@ export function renderMain(main, state) {
269
360
  }
270
361
  export function renderFooter(footer, state) {
271
362
  const hotkeysBase = "a analyze | w watch | i incidents | d dashboard | s sandbox | x sandbox-fix | f mark-fixed | v mark-investigating";
363
+ const contextHotkeys = state.view === "incidents" && state.incidentsMode === "detail"
364
+ ? " | 1 logs | 2 config | 3 commits | 4 system"
365
+ : "";
272
366
  const hotkeys = canCreatePrFromSandbox(state)
273
- ? `${hotkeysBase} | c create-pr | r refresh | q quit`
274
- : `${hotkeysBase} | r refresh | q quit`;
367
+ ? `${hotkeysBase}${contextHotkeys} | c create-pr | r refresh | q quit`
368
+ : `${hotkeysBase}${contextHotkeys} | r refresh | q quit`;
275
369
  const status = [
276
370
  `focus:${state.focusZone.toUpperCase()}`,
277
371
  `api:${state.apiReachable ? "up" : "down"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prads01/blackbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "BlackBox CLI for local incident analysis and sandboxed fixes",
5
5
  "license": "MIT",
6
6
  "type": "module",