@zhijiewang/openharness 2.38.0 → 2.39.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/dist/commands/info.js
CHANGED
|
@@ -10,7 +10,7 @@ import { estimateMessageTokens } from "../harness/context-warning.js";
|
|
|
10
10
|
import { getContextWindow } from "../harness/cost.js";
|
|
11
11
|
import { getHooks, invalidateHookCache } from "../harness/hooks.js";
|
|
12
12
|
import { discoverPlugins, discoverSkills } from "../harness/plugins.js";
|
|
13
|
-
import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
|
|
13
|
+
import { formatFlameGraph, formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
|
|
14
14
|
import { getVerificationConfig, invalidateVerificationCache } from "../harness/verification.js";
|
|
15
15
|
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
16
16
|
import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
|
|
@@ -358,13 +358,18 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
358
358
|
register("hooks", "List loaded hooks grouped by event", () => {
|
|
359
359
|
return { output: formatHooksReport(getHooks()), handled: true };
|
|
360
360
|
});
|
|
361
|
-
register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId
|
|
362
|
-
|
|
361
|
+
register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId>; add --flame for a flame-graph view)", (args) => {
|
|
362
|
+
// Parse: `<sessionId>` for tree view, `<sessionId> --flame` (or `--flamegraph`)
|
|
363
|
+
// for the time-axis flame view. Order doesn't matter — accept the flag
|
|
364
|
+
// before or after the id.
|
|
365
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
366
|
+
const flame = tokens.some((t) => t === "--flame" || t === "--flamegraph" || t === "--flame-graph");
|
|
367
|
+
const id = tokens.find((t) => !t.startsWith("--"));
|
|
363
368
|
if (id) {
|
|
364
369
|
const spans = loadTrace(id);
|
|
365
370
|
if (spans.length === 0)
|
|
366
371
|
return { output: `No trace found for session ${id}.`, handled: true };
|
|
367
|
-
return { output: formatTrace(spans), handled: true };
|
|
372
|
+
return { output: flame ? formatFlameGraph(spans) : formatTrace(spans), handled: true };
|
|
368
373
|
}
|
|
369
374
|
const sessions = listTracedSessions();
|
|
370
375
|
if (sessions.length === 0) {
|
package/dist/harness/traces.d.ts
CHANGED
|
@@ -83,6 +83,31 @@ export declare function loadTrace(sessionId: string): TraceSpan[];
|
|
|
83
83
|
export declare function listTracedSessions(): string[];
|
|
84
84
|
/** Format trace for display */
|
|
85
85
|
export declare function formatTrace(spans: TraceSpan[]): string;
|
|
86
|
+
/**
|
|
87
|
+
* Render spans as a flame-graph (icicle-graph really — top-down by depth).
|
|
88
|
+
* Each span gets one row: indent by tree depth, then a bar of `█` characters
|
|
89
|
+
* positioned along a wall-time axis sized to `width` columns. Bars start at
|
|
90
|
+
* the column corresponding to the span's `startTime` relative to the trace's
|
|
91
|
+
* minimum startTime, and span as many columns as their `durationMs` requires
|
|
92
|
+
* (minimum 1 column so even sub-millisecond spans are visible).
|
|
93
|
+
*
|
|
94
|
+
* Total trace duration sets the time-axis scale: a 5-second trace and a
|
|
95
|
+
* 50-second trace both fit the same `width`, so the same view works at any
|
|
96
|
+
* scale without scrolling. Per-span ms label appears to the right of the bar;
|
|
97
|
+
* span name appears at the left, indented by parent depth.
|
|
98
|
+
*
|
|
99
|
+
* Errored spans (status: "error") render in red; others use a stable
|
|
100
|
+
* per-name color so the same tool keeps the same color across the trace.
|
|
101
|
+
*
|
|
102
|
+
* The bottom row is a time ruler with ticks at 0ms, 25%, 50%, 75%, 100%.
|
|
103
|
+
*
|
|
104
|
+
* @param spans the spans to render — typically `loadTrace(sessionId)`
|
|
105
|
+
* @param width target width in columns (defaults to terminal width or 100)
|
|
106
|
+
* @param opts.color emit ANSI color codes (defaults to true; set false for tests)
|
|
107
|
+
*/
|
|
108
|
+
export declare function formatFlameGraph(spans: TraceSpan[], width?: number, opts?: {
|
|
109
|
+
color?: boolean;
|
|
110
|
+
}): string;
|
|
86
111
|
/** Export trace in OpenTelemetry-compatible format */
|
|
87
112
|
export declare function exportTraceOTLP(sessionId: string, spans: TraceSpan[]): object;
|
|
88
113
|
//# sourceMappingURL=traces.d.ts.map
|
package/dist/harness/traces.js
CHANGED
|
@@ -220,6 +220,174 @@ export function formatTrace(spans) {
|
|
|
220
220
|
lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
|
|
221
221
|
return lines.join("\n");
|
|
222
222
|
}
|
|
223
|
+
// ── Flame-graph rendering ──
|
|
224
|
+
/** ANSI 256 colors picked for distinguishability across span names. */
|
|
225
|
+
const FLAME_COLORS = [
|
|
226
|
+
"\x1b[38;5;202m", // orange (query)
|
|
227
|
+
"\x1b[38;5;39m", // light blue (tool:Read)
|
|
228
|
+
"\x1b[38;5;208m", // bright orange (tool:Bash)
|
|
229
|
+
"\x1b[38;5;105m", // purple (tool:Edit)
|
|
230
|
+
"\x1b[38;5;118m", // green (tool:Glob/Grep)
|
|
231
|
+
"\x1b[38;5;226m", // yellow (tool:Web*)
|
|
232
|
+
"\x1b[38;5;213m", // pink (think tools)
|
|
233
|
+
"\x1b[38;5;245m", // grey (other)
|
|
234
|
+
];
|
|
235
|
+
const ANSI_RESET = "\x1b[0m";
|
|
236
|
+
const ANSI_DIM = "\x1b[2m";
|
|
237
|
+
const ANSI_RED = "\x1b[38;5;196m";
|
|
238
|
+
function colorForSpan(name) {
|
|
239
|
+
// Stable hash so the same span name always lands the same color across renders.
|
|
240
|
+
let hash = 0;
|
|
241
|
+
for (let i = 0; i < name.length; i++)
|
|
242
|
+
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
243
|
+
return FLAME_COLORS[hash % FLAME_COLORS.length];
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Render spans as a flame-graph (icicle-graph really — top-down by depth).
|
|
247
|
+
* Each span gets one row: indent by tree depth, then a bar of `█` characters
|
|
248
|
+
* positioned along a wall-time axis sized to `width` columns. Bars start at
|
|
249
|
+
* the column corresponding to the span's `startTime` relative to the trace's
|
|
250
|
+
* minimum startTime, and span as many columns as their `durationMs` requires
|
|
251
|
+
* (minimum 1 column so even sub-millisecond spans are visible).
|
|
252
|
+
*
|
|
253
|
+
* Total trace duration sets the time-axis scale: a 5-second trace and a
|
|
254
|
+
* 50-second trace both fit the same `width`, so the same view works at any
|
|
255
|
+
* scale without scrolling. Per-span ms label appears to the right of the bar;
|
|
256
|
+
* span name appears at the left, indented by parent depth.
|
|
257
|
+
*
|
|
258
|
+
* Errored spans (status: "error") render in red; others use a stable
|
|
259
|
+
* per-name color so the same tool keeps the same color across the trace.
|
|
260
|
+
*
|
|
261
|
+
* The bottom row is a time ruler with ticks at 0ms, 25%, 50%, 75%, 100%.
|
|
262
|
+
*
|
|
263
|
+
* @param spans the spans to render — typically `loadTrace(sessionId)`
|
|
264
|
+
* @param width target width in columns (defaults to terminal width or 100)
|
|
265
|
+
* @param opts.color emit ANSI color codes (defaults to true; set false for tests)
|
|
266
|
+
*/
|
|
267
|
+
export function formatFlameGraph(spans, width = process.stdout.columns || 100, opts = {}) {
|
|
268
|
+
if (spans.length === 0)
|
|
269
|
+
return "No trace spans recorded.";
|
|
270
|
+
const useColor = opts.color !== false;
|
|
271
|
+
const c = (style, text) => (useColor ? `${style}${text}${ANSI_RESET}` : text);
|
|
272
|
+
// Trace bounds — every other timestamp is relative to minStart.
|
|
273
|
+
let minStart = Infinity;
|
|
274
|
+
let maxEnd = 0;
|
|
275
|
+
for (const s of spans) {
|
|
276
|
+
if (s.startTime < minStart)
|
|
277
|
+
minStart = s.startTime;
|
|
278
|
+
if (s.endTime > maxEnd)
|
|
279
|
+
maxEnd = s.endTime;
|
|
280
|
+
}
|
|
281
|
+
const totalMs = maxEnd > minStart ? maxEnd - minStart : 1;
|
|
282
|
+
// Layout: name column gets up to 30 chars; ms label gets up to 10; the rest
|
|
283
|
+
// is the bar canvas. We need at least ~20 cols of bar canvas to be useful.
|
|
284
|
+
const NAME_WIDTH = 30;
|
|
285
|
+
const MS_WIDTH = 10;
|
|
286
|
+
const PADDING = 3; // spaces between sections
|
|
287
|
+
const barWidth = Math.max(20, width - NAME_WIDTH - MS_WIDTH - PADDING);
|
|
288
|
+
// Build the depth map by walking the parent chain (spans are typically in
|
|
289
|
+
// start-order but we don't rely on it). Caps recursion to prevent infinite
|
|
290
|
+
// loops on a malformed trace where parent references form a cycle.
|
|
291
|
+
const byId = new Map(spans.map((s) => [s.spanId, s]));
|
|
292
|
+
const depthOf = new Map();
|
|
293
|
+
function depth(span, hops = 0) {
|
|
294
|
+
if (hops > 50)
|
|
295
|
+
return hops;
|
|
296
|
+
if (depthOf.has(span.spanId))
|
|
297
|
+
return depthOf.get(span.spanId);
|
|
298
|
+
let d = 0;
|
|
299
|
+
if (span.parentSpanId) {
|
|
300
|
+
const parent = byId.get(span.parentSpanId);
|
|
301
|
+
if (parent)
|
|
302
|
+
d = depth(parent, hops + 1) + 1;
|
|
303
|
+
}
|
|
304
|
+
depthOf.set(span.spanId, d);
|
|
305
|
+
return d;
|
|
306
|
+
}
|
|
307
|
+
for (const s of spans)
|
|
308
|
+
depth(s);
|
|
309
|
+
// Sort by start time, ties broken by depth (parents before children).
|
|
310
|
+
const sorted = [...spans].sort((a, b) => a.startTime - b.startTime || depthOf.get(a.spanId) - depthOf.get(b.spanId));
|
|
311
|
+
const lines = [];
|
|
312
|
+
for (const span of sorted) {
|
|
313
|
+
const d = depthOf.get(span.spanId);
|
|
314
|
+
const offset = Math.floor(((span.startTime - minStart) / totalMs) * barWidth);
|
|
315
|
+
const length = Math.max(1, Math.floor((span.durationMs / totalMs) * barWidth));
|
|
316
|
+
const indent = " ".repeat(Math.min(d, 4)); // visual cap at 4 indent levels
|
|
317
|
+
const name = `${indent}${span.name}`.padEnd(NAME_WIDTH).slice(0, NAME_WIDTH);
|
|
318
|
+
const bar = " ".repeat(offset) + "█".repeat(Math.min(length, barWidth - offset));
|
|
319
|
+
const paddedBar = bar.padEnd(barWidth);
|
|
320
|
+
const color = span.status === "error" ? ANSI_RED : colorForSpan(span.name);
|
|
321
|
+
const msLabel = `${span.durationMs}ms`.padStart(MS_WIDTH);
|
|
322
|
+
lines.push(`${name} ${c(color, paddedBar)} ${c(ANSI_DIM, msLabel)}`);
|
|
323
|
+
}
|
|
324
|
+
// Time ruler: 3-5 ticks depending on canvas width. We need ~8 columns per
|
|
325
|
+
// tick to fit timestamp labels without overlap; choose count that fits.
|
|
326
|
+
const tickCount = barWidth >= 50 ? 5 : barWidth >= 30 ? 3 : 2;
|
|
327
|
+
const tickPcts = [];
|
|
328
|
+
for (let i = 0; i < tickCount; i++)
|
|
329
|
+
tickPcts.push(i / (tickCount - 1));
|
|
330
|
+
const tickValues = tickPcts.map((pct) => `${Math.round(totalMs * pct)}ms`);
|
|
331
|
+
const rulerLine = " ".repeat(NAME_WIDTH + 3) + buildTimeRuler(barWidth, tickValues);
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(c(ANSI_DIM, rulerLine));
|
|
334
|
+
// Per-name summary: count + total ms, descending by total ms.
|
|
335
|
+
const summary = {};
|
|
336
|
+
for (const s of spans) {
|
|
337
|
+
const e = summary[s.name] ?? { count: 0, totalMs: 0 };
|
|
338
|
+
e.count++;
|
|
339
|
+
e.totalMs += s.durationMs;
|
|
340
|
+
summary[s.name] = e;
|
|
341
|
+
}
|
|
342
|
+
const ranked = Object.entries(summary).sort((a, b) => b[1].totalMs - a[1].totalMs);
|
|
343
|
+
lines.push("");
|
|
344
|
+
lines.push(c(ANSI_DIM, "Span breakdown (top by total time):"));
|
|
345
|
+
for (const [name, { count, totalMs: tms }] of ranked.slice(0, 10)) {
|
|
346
|
+
const pct = totalMs > 0 ? Math.round((tms / totalMs) * 100) : 0;
|
|
347
|
+
lines.push(` ${c(colorForSpan(name), "█")} ${name.padEnd(28)} ${count.toString().padStart(4)}× ${tms.toString().padStart(6)}ms ${pct}%`);
|
|
348
|
+
}
|
|
349
|
+
const errors = spans.filter((s) => s.status === "error").length;
|
|
350
|
+
lines.push("");
|
|
351
|
+
lines.push(c(ANSI_DIM, `${spans.length} spans, ${totalMs}ms total${errors > 0 ? `, ${errors} error(s)` : ""}`));
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Build a time ruler line of exactly `width` columns with N tick labels
|
|
356
|
+
* distributed evenly. Strategy: anchor the last tick right-aligned to the
|
|
357
|
+
* width, then place earlier ticks at their proportional positions while
|
|
358
|
+
* truncating any label that would overlap the next tick (or the last
|
|
359
|
+
* tick's reserved start). Produces a clean ruler at any (width × N).
|
|
360
|
+
*
|
|
361
|
+
* The last tick's right-anchor means the rightmost timestamp always lands
|
|
362
|
+
* exactly at the canvas edge, matching where bars end.
|
|
363
|
+
*/
|
|
364
|
+
function buildTimeRuler(width, ticks) {
|
|
365
|
+
if (ticks.length === 0 || width <= 0)
|
|
366
|
+
return "";
|
|
367
|
+
const buf = new Array(width).fill(" ");
|
|
368
|
+
// Step 1: place last tick right-aligned. Its start column constrains all
|
|
369
|
+
// earlier ticks (they must end before lastStart - 1 so there's a gap).
|
|
370
|
+
const lastLabel = ticks[ticks.length - 1];
|
|
371
|
+
const lastStart = Math.max(0, width - lastLabel.length);
|
|
372
|
+
for (let j = 0; j < lastLabel.length && lastStart + j < width; j++) {
|
|
373
|
+
buf[lastStart + j] = lastLabel[j];
|
|
374
|
+
}
|
|
375
|
+
// Step 2: place earlier ticks left-to-right. Each can occupy from its
|
|
376
|
+
// proportional start column up to either the next tick's start (minus 1
|
|
377
|
+
// for a separator space) or, for the second-to-last tick, lastStart - 1.
|
|
378
|
+
for (let i = 0; i < ticks.length - 1; i++) {
|
|
379
|
+
const label = ticks[i];
|
|
380
|
+
const start = Math.round((i / (ticks.length - 1)) * (width - 1));
|
|
381
|
+
const nextProportional = Math.round(((i + 1) / (ticks.length - 1)) * (width - 1));
|
|
382
|
+
const isPenultimate = i === ticks.length - 2;
|
|
383
|
+
const endExclusive = isPenultimate ? lastStart - 1 : nextProportional - 1;
|
|
384
|
+
const maxLen = Math.max(0, endExclusive - start);
|
|
385
|
+
const out = label.slice(0, maxLen);
|
|
386
|
+
for (let j = 0; j < out.length; j++)
|
|
387
|
+
buf[start + j] = out[j];
|
|
388
|
+
}
|
|
389
|
+
return buf.join("");
|
|
390
|
+
}
|
|
223
391
|
/**
|
|
224
392
|
* Coerce an arbitrary string (UUID with hyphens, "span-N", etc.) into a fixed-length
|
|
225
393
|
* lowercase hex string suitable for OTLP. OTLP collectors (Jaeger, Tempo, OTel
|
|
@@ -23,7 +23,7 @@ declare const inputSchema: z.ZodObject<{
|
|
|
23
23
|
context?: number | undefined;
|
|
24
24
|
glob?: string | undefined;
|
|
25
25
|
offset?: number | undefined;
|
|
26
|
-
output_mode?: "content" | "
|
|
26
|
+
output_mode?: "content" | "count" | "files_with_matches" | undefined;
|
|
27
27
|
head_limit?: number | undefined;
|
|
28
28
|
multiline?: boolean | undefined;
|
|
29
29
|
"-A"?: number | undefined;
|
|
@@ -38,7 +38,7 @@ declare const inputSchema: z.ZodObject<{
|
|
|
38
38
|
context?: number | undefined;
|
|
39
39
|
glob?: string | undefined;
|
|
40
40
|
offset?: number | undefined;
|
|
41
|
-
output_mode?: "content" | "
|
|
41
|
+
output_mode?: "content" | "count" | "files_with_matches" | undefined;
|
|
42
42
|
head_limit?: number | undefined;
|
|
43
43
|
multiline?: boolean | undefined;
|
|
44
44
|
"-A"?: number | undefined;
|