@oh-my-pi/omp-stats 18.13.3 → 18.14.1
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/CHANGELOG.md +10 -0
- package/README.md +18 -18
- package/dist/client/index.js +78 -78
- package/dist/types/chunked-insert.d.ts +32 -0
- package/dist/types/client/traces/SpanDrawer.d.ts +9 -3
- package/dist/types/client/traces/TimelineCanvas.d.ts +3 -1
- package/dist/types/client/traces/TraceView.d.ts +13 -0
- package/dist/types/client/traces/index.d.ts +5 -0
- package/dist/types/client/traces/trace-colors.d.ts +3 -1
- package/dist/types/client/ui/SearchInput.d.ts +2 -1
- package/dist/types/db.d.ts +5 -5
- package/dist/types/port-conflict.d.ts +9 -3
- package/dist/types/server.d.ts +7 -3
- package/dist/types/shared-types.d.ts +3 -2
- package/dist/types/trace.d.ts +27 -1
- package/dist/types/types.d.ts +6 -1
- package/package.json +10 -5
- package/src/chunked-insert.ts +89 -0
- package/src/client/traces/SpanDrawer.tsx +56 -9
- package/src/client/traces/TimelineCanvas.tsx +13 -4
- package/src/client/traces/TraceView.tsx +165 -158
- package/src/client/traces/index.ts +5 -0
- package/src/client/traces/trace-colors.ts +14 -10
- package/src/client/ui/SearchInput.tsx +3 -1
- package/src/db.ts +197 -147
- package/src/parser.ts +13 -2
- package/src/port-conflict.ts +67 -30
- package/src/server.ts +39 -31
- package/src/shared-types.ts +3 -2
- package/src/trace.ts +119 -35
- package/src/types.ts +6 -1
- package/THIRD-PARTY-NOTICES.txt +0 -22901
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
/** Shape of one ingest table write; see {@link ChunkedInsert}. */
|
|
3
|
+
export interface ChunkedInsertSpec {
|
|
4
|
+
/** `INSERT` or `INSERT OR IGNORE`. */
|
|
5
|
+
verb: "INSERT" | "INSERT OR IGNORE";
|
|
6
|
+
table: string;
|
|
7
|
+
columns: readonly string[];
|
|
8
|
+
/** Columns naming one transcript entry; fork copies repeat them under another `session_file`. */
|
|
9
|
+
identity: readonly string[];
|
|
10
|
+
/** Trailing `ON CONFLICT … DO UPDATE` clause, if any. */
|
|
11
|
+
upsert?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Multi-row writer for one ingest table: buffers rows and writes them as
|
|
15
|
+
* `INSERT … VALUES (…), (…)` chunks, so the per-statement costs (trigger
|
|
16
|
+
* program setup, statement journal, the temp b-tree SQLite stages trigger
|
|
17
|
+
* inserts through) are paid per chunk instead of per row.
|
|
18
|
+
*
|
|
19
|
+
* Every row passes a first-writer-wins fork guard: forked transcripts copy
|
|
20
|
+
* their parent's entries verbatim, so a row whose identity another
|
|
21
|
+
* `session_file` already stored is skipped. Buffered rows are invisible to
|
|
22
|
+
* that lookup, so a row colliding with a buffered row of another transcript
|
|
23
|
+
* flushes the buffer first; the stored outcome equals writing row by row.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ChunkedInsert {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(database: Database, spec: ChunkedInsertSpec);
|
|
28
|
+
/** Buffer one row unless another transcript already owns `identity`. */
|
|
29
|
+
add(sessionFile: string, identity: readonly SQLQueryBindings[], values: readonly SQLQueryBindings[]): void;
|
|
30
|
+
/** Write the remaining rows; returns how many stored rows the writer inserted or updated. */
|
|
31
|
+
finish(): number;
|
|
32
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Detail drawer for a selected trace span.
|
|
3
|
-
* demand (
|
|
2
|
+
* Detail drawer for a selected trace span. Loads the full journal entry on
|
|
3
|
+
* demand (live API or embedded snapshot) and renders per-kind detail: model usage grid
|
|
4
4
|
* + text blocks, tool args/result, user text, subagent task text.
|
|
5
5
|
*/
|
|
6
6
|
import type { TraceSpan, TraceTrack } from "../types.js";
|
|
7
|
+
export type TraceEntryLoader = (file: string, entryId: string, signal: AbortSignal) => Promise<{
|
|
8
|
+
entry: unknown;
|
|
9
|
+
}>;
|
|
7
10
|
export interface SpanDrawerProps {
|
|
8
11
|
span: TraceSpan | null;
|
|
9
12
|
track: TraceTrack | null;
|
|
10
13
|
onClose: () => void;
|
|
11
14
|
/** Selects the first span of the given child track ("open child track"). */
|
|
12
15
|
onOpenChildTrack?: (childTrackId: string) => void;
|
|
16
|
+
loadEntry?: TraceEntryLoader;
|
|
17
|
+
inline?: boolean;
|
|
18
|
+
onNavigate?: (trackId: string, entryId: string) => void;
|
|
13
19
|
}
|
|
14
|
-
export declare function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawerProps): import("react").JSX.Element | null;
|
|
20
|
+
export declare function SpanDrawer({ span, track, onClose, onOpenChildTrack, loadEntry, inline, onNavigate, }: SpanDrawerProps): import("react").JSX.Element | null;
|
|
@@ -27,6 +27,8 @@ export interface TimelineCanvasProps {
|
|
|
27
27
|
collapsed: ReadonlySet<string>;
|
|
28
28
|
onToggleCollapse: (trackId: string) => void;
|
|
29
29
|
traceStart: number;
|
|
30
|
+
/** Live route focuses immediately; embedded viewers wait for user interaction. */
|
|
31
|
+
autoFocus?: boolean;
|
|
30
32
|
}
|
|
31
33
|
/** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
|
|
32
34
|
interface LaneAxis {
|
|
@@ -42,5 +44,5 @@ export declare function buildLaneAxes(layout: {
|
|
|
42
44
|
}, scale: Pick<TraceScale, "toU">): LaneAxis[];
|
|
43
45
|
/** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
|
|
44
46
|
export declare function firstVisible(maxEndU: Float64Array, u0: number): number;
|
|
45
|
-
export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, }: TimelineCanvasProps): import("react").JSX.Element;
|
|
47
|
+
export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, autoFocus, }: TimelineCanvasProps): import("react").JSX.Element;
|
|
46
48
|
export {};
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
* flamegraph canvas, transcript list, tool aggregates, and the span drawer.
|
|
4
4
|
* Owns the shared viewport/selection state so all pieces stay in sync.
|
|
5
5
|
*/
|
|
6
|
+
import type { SessionTrace } from "../types.js";
|
|
7
|
+
import { type TraceEntryLoader } from "./SpanDrawer.js";
|
|
6
8
|
export interface TraceViewProps {
|
|
7
9
|
file: string;
|
|
8
10
|
active: boolean;
|
|
9
11
|
onBack: () => void;
|
|
10
12
|
}
|
|
11
13
|
export declare function TraceView({ file, active, onBack }: TraceViewProps): import("react").JSX.Element;
|
|
14
|
+
/** Data and host integration supplied to the shared live/offline trace viewer. */
|
|
15
|
+
export interface SessionTraceViewProps {
|
|
16
|
+
trace: SessionTrace;
|
|
17
|
+
view?: "timeline" | "full";
|
|
18
|
+
loadEntry?: TraceEntryLoader;
|
|
19
|
+
/** Embedded viewers keep details inside their own layout, without a modal. */
|
|
20
|
+
embedded?: boolean;
|
|
21
|
+
onNavigate?: (trackId: string, entryId: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/** Data-driven viewer shared by the live route and self-contained HTML exports. */
|
|
24
|
+
export declare function SessionTraceView({ trace, view, loadEntry, embedded, onNavigate, }: SessionTraceViewProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Shared trace UI for live dashboards and embedded offline viewers. */
|
|
2
|
+
export type { SessionTrace } from "../types.js";
|
|
3
|
+
export type { TraceEntryLoader } from "./SpanDrawer.js";
|
|
4
|
+
export { SessionTraceView, type SessionTraceViewProps } from "./TraceView.js";
|
|
5
|
+
export { TraceThemeRoot } from "./trace-colors.js";
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* design tokens so canvas drawing follows the active theme.
|
|
4
4
|
*/
|
|
5
5
|
import type { TraceSpanKind } from "../types.js";
|
|
6
|
+
/** Embedded viewers resolve canvas colors from their scoped style root. */
|
|
7
|
+
export declare const TraceThemeRoot: import("react").Context<HTMLElement | null>;
|
|
6
8
|
export interface TraceTheme {
|
|
7
9
|
/** Span fill per category. */
|
|
8
10
|
category: Record<TraceSpanKind, string>;
|
|
@@ -32,6 +34,6 @@ export interface TraceTheme {
|
|
|
32
34
|
export declare const CATEGORY_VARS: Record<TraceSpanKind, string>;
|
|
33
35
|
/**
|
|
34
36
|
* Current trace palette. Re-reads the tokens whenever the resolved theme
|
|
35
|
-
* changes
|
|
37
|
+
* changes, without loading the live dashboard's theme preference store.
|
|
36
38
|
*/
|
|
37
39
|
export declare function useTraceTheme(): TraceTheme;
|
|
@@ -2,7 +2,8 @@ export interface SearchInputProps {
|
|
|
2
2
|
value: string;
|
|
3
3
|
onChange: (value: string) => void;
|
|
4
4
|
placeholder?: string;
|
|
5
|
+
label?: string;
|
|
5
6
|
width?: number;
|
|
6
7
|
}
|
|
7
8
|
/** Text filter with a leading search glyph. */
|
|
8
|
-
export declare function SearchInput({ value, onChange, placeholder, width }: SearchInputProps): import("react").JSX.Element;
|
|
9
|
+
export declare function SearchInput({ value, onChange, placeholder, label, width }: SearchInputProps): import("react").JSX.Element;
|
package/dist/types/db.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export declare function completeSessionSync(reconcile: boolean): void;
|
|
|
77
77
|
* `provider`, token counts, and `responseId`. The `UNIQUE(session_file,
|
|
78
78
|
* entry_id)` constraint alone keys each row by file, so without the guard
|
|
79
79
|
* below the same provider request would land twice and inflate every
|
|
80
|
-
* aggregate. The
|
|
80
|
+
* aggregate. The {@link ChunkedInsert} fork guard skips inserts whose
|
|
81
81
|
* `(entry_id, timestamp)` already exists under a different `session_file` —
|
|
82
82
|
* first-write-wins across the lineage. Same-file re-syncs still hit the
|
|
83
83
|
* `ON CONFLICT(session_file, entry_id)` upsert below, which re-derives the
|
|
@@ -111,9 +111,9 @@ export declare function getDailyActivity(days?: number): Promise<DailyActivityPo
|
|
|
111
111
|
export declare function markSessionBackfillsComplete(): void;
|
|
112
112
|
/**
|
|
113
113
|
* Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
|
|
114
|
-
* The
|
|
115
|
-
*
|
|
116
|
-
*
|
|
114
|
+
* The fork guard matches {@link insertMessageStats}: forks copy user entries
|
|
115
|
+
* verbatim into the child JSONL, so the same `(entry_id, timestamp)` must not
|
|
116
|
+
* land twice across different session files.
|
|
117
117
|
*/
|
|
118
118
|
export declare function insertUserMessageStats(stats: Iterable<UserMessageStats>): number;
|
|
119
119
|
/**
|
|
@@ -172,7 +172,7 @@ export declare function getPendingFrustrationTotals(cutoff?: number | null): {
|
|
|
172
172
|
export declare function upsertFrustrationVerdicts(verdicts: readonly FrustrationVerdict[]): void;
|
|
173
173
|
/**
|
|
174
174
|
* Insert tool-call rows. Idempotent via UNIQUE(session_file, tool_call_id);
|
|
175
|
-
* the
|
|
175
|
+
* the fork guard mirrors {@link insertMessageStats}: forked
|
|
176
176
|
* sessions deep-copy assistant entries (same `entry_id`, `timestamp`, and
|
|
177
177
|
* tool-call ids under a new file), so first-write-wins across the lineage
|
|
178
178
|
* keeps aggregates from double counting. Keyed on the assistant entry
|
|
@@ -7,10 +7,16 @@ export declare const STATS_DASHBOARD_SECURITY_VERSION = "3";
|
|
|
7
7
|
/** IPv4 loopback address shared by the dashboard server and reuse probe. */
|
|
8
8
|
export declare const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* How {@link startServer} proceeds with the requested port: bind it, reuse the
|
|
11
|
+
* dashboard already serving it, or fall back to an ephemeral port because the
|
|
12
|
+
* holder is neither reusable nor safe to stop.
|
|
13
|
+
*/
|
|
14
|
+
export type StatsPortClaim = "bind" | "reuse" | "fallback";
|
|
15
|
+
/**
|
|
16
|
+
* Reuse a current dashboard, or upgrade an older one, before binding.
|
|
11
17
|
* The preflight is needed on platforms that permit wildcard and loopback-specific
|
|
12
18
|
* listeners to coexist on one port.
|
|
13
19
|
*/
|
|
14
|
-
export declare function prepareStatsPort(port: number, hostname?: string): Promise<
|
|
20
|
+
export declare function prepareStatsPort(port: number, hostname?: string): Promise<StatsPortClaim>;
|
|
15
21
|
/** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
|
|
16
|
-
export declare function recoverStatsPort(port: number, hostname?: string): Promise<
|
|
22
|
+
export declare function recoverStatsPort(port: number, hostname?: string): Promise<StatsPortClaim>;
|
package/dist/types/server.d.ts
CHANGED
|
@@ -5,9 +5,7 @@ import { type StatsJudgeProvider } from "./frustration.js";
|
|
|
5
5
|
export declare function handleApi(req: Request): Promise<Response>;
|
|
6
6
|
/** Format a dashboard origin, including brackets required by IPv6 literals. */
|
|
7
7
|
export declare function formatStatsDashboardUrl(hostname: string, port: number): string;
|
|
8
|
-
/**
|
|
9
|
-
* Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
|
|
10
|
-
*/
|
|
8
|
+
/** A dashboard this process started or found; `port` is where it listens, which may differ from the one requested. */
|
|
11
9
|
export interface StatsServerHandle {
|
|
12
10
|
hostname: string;
|
|
13
11
|
port: number;
|
|
@@ -17,4 +15,10 @@ export interface StartServerOptions {
|
|
|
17
15
|
/** Host judge for the Frustration dashboard's judge runs; replaces any previously registered one. */
|
|
18
16
|
judge?: StatsJudgeProvider;
|
|
19
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Start the dashboard on `port`, or reuse the current dashboard already serving it.
|
|
20
|
+
* An older dashboard there is stopped and replaced; any other holder (a foreign
|
|
21
|
+
* program, or a dashboard too busy to answer the identity probe) is left running
|
|
22
|
+
* and the dashboard binds an ephemeral port instead.
|
|
23
|
+
*/
|
|
20
24
|
export declare function startServer(port?: number, hostname?: string, options?: StartServerOptions): Promise<StatsServerHandle>;
|
|
@@ -566,7 +566,7 @@ export interface TraceTrack {
|
|
|
566
566
|
agent: string | null;
|
|
567
567
|
/** session_init.resolvedModel ?? first assistant model. */
|
|
568
568
|
model: string | null;
|
|
569
|
-
/** Absolute transcript path
|
|
569
|
+
/** Absolute transcript path for live entry fetch, or the embedded snapshot key. */
|
|
570
570
|
file: string;
|
|
571
571
|
/** Sorted by start. */
|
|
572
572
|
spans: TraceSpan[];
|
|
@@ -607,12 +607,13 @@ export interface SessionTrace {
|
|
|
607
607
|
cwd: string | null;
|
|
608
608
|
startedAt: number;
|
|
609
609
|
endedAt: number;
|
|
610
|
-
/** Root transcript mtime
|
|
610
|
+
/** Root transcript mtime, or recorded root activity/header time for offline snapshots. */
|
|
611
611
|
mtimeMs: number;
|
|
612
612
|
/**
|
|
613
613
|
* Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
|
|
614
614
|
* traceFingerprintForEtag): the server ETag is built from this, so a
|
|
615
615
|
* subagent-only append changes the ETag even when the root is untouched.
|
|
616
|
+
* Offline snapshots use "snapshot" and do not support HTTP revalidation.
|
|
616
617
|
*/
|
|
617
618
|
etag: string;
|
|
618
619
|
/** DFS order, main first. */
|
package/dist/types/trace.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare class TracePathError extends Error {
|
|
|
17
17
|
* span assembly changes so browsers don't revalidate stale cached bodies
|
|
18
18
|
* against an unchanged transcript mtime.
|
|
19
19
|
*/
|
|
20
|
-
export declare const TRACE_ETAG_VERSION =
|
|
20
|
+
export declare const TRACE_ETAG_VERSION = 4;
|
|
21
21
|
/** Freshness of a root trace: root mtime plus the child-transcript set fingerprint. */
|
|
22
22
|
export interface TraceFingerprint {
|
|
23
23
|
rootMtimeMs: number;
|
|
@@ -42,6 +42,32 @@ export declare function traceMemoForTests(): {
|
|
|
42
42
|
* file (its ETag pre-check), sparing a second walk.
|
|
43
43
|
*/
|
|
44
44
|
export declare function buildSessionTrace(fileParam: string, fingerprint?: TraceFingerprint): Promise<SessionTrace>;
|
|
45
|
+
/** Header metadata used by snapshot traces, including journals without a schema version. */
|
|
46
|
+
export interface TraceSnapshotHeader {
|
|
47
|
+
timestamp?: string;
|
|
48
|
+
cwd?: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The journal data for one embedded transcript; its header may be stored separately. */
|
|
52
|
+
export interface SessionTraceSnapshot {
|
|
53
|
+
header?: TraceSnapshotHeader | null;
|
|
54
|
+
entries: readonly SessionEntry[];
|
|
55
|
+
}
|
|
56
|
+
/** A collected child transcript, keyed by its slash-joined path from the root. */
|
|
57
|
+
export interface SubSessionTraceSnapshot extends SessionTraceSnapshot {
|
|
58
|
+
parent: string | null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Assemble an offline trace using the same timing joins and rollups as the live
|
|
62
|
+
* filesystem builder. Only supplied snapshots participate: no filesystem, DB,
|
|
63
|
+
* network, or wall-clock reads. Track files are snapshot keys, never disk paths.
|
|
64
|
+
*
|
|
65
|
+
* Missing parent transcripts attach their readable descendants to main. Unlike
|
|
66
|
+
* filesystem discovery, already-collected snapshots need no depth limit.
|
|
67
|
+
* `mtimeMs` is the root's last activity (or header time, then 0); the constant
|
|
68
|
+
* `etag` identifies a snapshot, not a conditional HTTP response.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildSessionTraceFromSnapshots(root: SessionTraceSnapshot, subSessions?: Readonly<Record<string, SubSessionTraceSnapshot>>): SessionTrace;
|
|
45
71
|
/** Fetch one full journal entry by id from a transcript, for the span drawer. */
|
|
46
72
|
export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
|
|
47
73
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -216,11 +216,16 @@ export interface ToolCallStats {
|
|
|
216
216
|
callsInTurn: number;
|
|
217
217
|
/** Serialized argument characters */
|
|
218
218
|
argsChars: number;
|
|
219
|
+
/** Result text characters, when the same parse also read the call's `toolResult` */
|
|
220
|
+
resultChars?: number;
|
|
221
|
+
/** Result error flag, set together with `resultChars` */
|
|
222
|
+
isError?: boolean;
|
|
219
223
|
}
|
|
220
224
|
/**
|
|
221
225
|
* Result linkage emitted when the parser sees a `toolResult` message entry.
|
|
222
226
|
* Applied as an UPDATE on the persisted tool-call row — results can land in a
|
|
223
|
-
* later incremental sync pass than the call that produced them
|
|
227
|
+
* later incremental sync pass than the call that produced them (calls parsed
|
|
228
|
+
* in the same pass already carry the result, so their UPDATE is a no-op).
|
|
224
229
|
*/
|
|
225
230
|
export interface ToolResultLink {
|
|
226
231
|
sessionFile: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.14.1",
|
|
5
5
|
"description": "Local observability dashboard for omp AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@oh-my-pi/pi-ai": "18.
|
|
46
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
47
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
+
"@oh-my-pi/pi-ai": "18.14.1",
|
|
46
|
+
"@oh-my-pi/pi-catalog": "18.14.1",
|
|
47
|
+
"@oh-my-pi/pi-utils": "18.14.1",
|
|
48
48
|
"lucide-react": "^1.24.0",
|
|
49
49
|
"react": "19.2.7",
|
|
50
50
|
"react-dom": "19.2.7"
|
|
@@ -63,7 +63,6 @@
|
|
|
63
63
|
"README.md",
|
|
64
64
|
"CHANGELOG.md",
|
|
65
65
|
"LICENSE",
|
|
66
|
-
"THIRD-PARTY-NOTICES.txt",
|
|
67
66
|
"dist/types",
|
|
68
67
|
"dist/client"
|
|
69
68
|
],
|
|
@@ -80,6 +79,12 @@
|
|
|
80
79
|
"types": "./dist/types/client/index.d.ts",
|
|
81
80
|
"import": "./src/client/index.tsx"
|
|
82
81
|
},
|
|
82
|
+
"./client/traces": {
|
|
83
|
+
"types": "./dist/types/client/traces/index.d.ts",
|
|
84
|
+
"import": "./src/client/traces/index.ts"
|
|
85
|
+
},
|
|
86
|
+
"./client/styles.css": "./src/client/styles.css",
|
|
87
|
+
"./client/traces/traces.css": "./src/client/traces/traces.css",
|
|
83
88
|
"./client/*": {
|
|
84
89
|
"types": "./dist/types/client/*.d.ts",
|
|
85
90
|
"import": "./src/client/*.ts"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/** Bind parameters per statement; SQLite builds older than 3.32 cap a statement at 999. */
|
|
4
|
+
const MAX_BINDS = 999;
|
|
5
|
+
|
|
6
|
+
/** Shape of one ingest table write; see {@link ChunkedInsert}. */
|
|
7
|
+
export interface ChunkedInsertSpec {
|
|
8
|
+
/** `INSERT` or `INSERT OR IGNORE`. */
|
|
9
|
+
verb: "INSERT" | "INSERT OR IGNORE";
|
|
10
|
+
table: string;
|
|
11
|
+
columns: readonly string[];
|
|
12
|
+
/** Columns naming one transcript entry; fork copies repeat them under another `session_file`. */
|
|
13
|
+
identity: readonly string[];
|
|
14
|
+
/** Trailing `ON CONFLICT … DO UPDATE` clause, if any. */
|
|
15
|
+
upsert?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Multi-row writer for one ingest table: buffers rows and writes them as
|
|
20
|
+
* `INSERT … VALUES (…), (…)` chunks, so the per-statement costs (trigger
|
|
21
|
+
* program setup, statement journal, the temp b-tree SQLite stages trigger
|
|
22
|
+
* inserts through) are paid per chunk instead of per row.
|
|
23
|
+
*
|
|
24
|
+
* Every row passes a first-writer-wins fork guard: forked transcripts copy
|
|
25
|
+
* their parent's entries verbatim, so a row whose identity another
|
|
26
|
+
* `session_file` already stored is skipped. Buffered rows are invisible to
|
|
27
|
+
* that lookup, so a row colliding with a buffered row of another transcript
|
|
28
|
+
* flushes the buffer first; the stored outcome equals writing row by row.
|
|
29
|
+
*/
|
|
30
|
+
export class ChunkedInsert {
|
|
31
|
+
readonly #database: Database;
|
|
32
|
+
readonly #spec: ChunkedInsertSpec;
|
|
33
|
+
readonly #chunkRows: number;
|
|
34
|
+
readonly #chunkSql: string;
|
|
35
|
+
readonly #claimed: string;
|
|
36
|
+
#params: SQLQueryBindings[] = [];
|
|
37
|
+
#rows = 0;
|
|
38
|
+
/** Identity → `session_file` of each buffered row. */
|
|
39
|
+
#buffered = new Map<string, string>();
|
|
40
|
+
#changes = 0;
|
|
41
|
+
|
|
42
|
+
constructor(database: Database, spec: ChunkedInsertSpec) {
|
|
43
|
+
this.#database = database;
|
|
44
|
+
this.#spec = spec;
|
|
45
|
+
this.#chunkRows = Math.floor(MAX_BINDS / spec.columns.length);
|
|
46
|
+
this.#chunkSql = this.#sql(this.#chunkRows);
|
|
47
|
+
this.#claimed = `SELECT 1 FROM ${spec.table} WHERE ${spec.identity.map(column => `${column} = ?`).join(" AND ")} AND session_file <> ? LIMIT 1`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Buffer one row unless another transcript already owns `identity`. */
|
|
51
|
+
add(sessionFile: string, identity: readonly SQLQueryBindings[], values: readonly SQLQueryBindings[]): void {
|
|
52
|
+
const key = identity.join("\0");
|
|
53
|
+
const owner = this.#buffered.get(key);
|
|
54
|
+
if (owner !== sessionFile) {
|
|
55
|
+
if (owner !== undefined) this.#flush();
|
|
56
|
+
if (this.#database.query(this.#claimed).get(...identity, sessionFile)) return;
|
|
57
|
+
this.#buffered.set(key, sessionFile);
|
|
58
|
+
}
|
|
59
|
+
for (const value of values) this.#params.push(value);
|
|
60
|
+
if (++this.#rows === this.#chunkRows) this.#flush();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Write the remaining rows; returns how many stored rows the writer inserted or updated. */
|
|
64
|
+
finish(): number {
|
|
65
|
+
this.#flush();
|
|
66
|
+
return this.#changes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#flush(): void {
|
|
70
|
+
if (this.#rows === 0) return;
|
|
71
|
+
if (this.#rows === this.#chunkRows) {
|
|
72
|
+
this.#changes += this.#database.query(this.#chunkSql).run(...this.#params).changes;
|
|
73
|
+
} else {
|
|
74
|
+
// Partial chunks vary in size; keep them out of the statement cache.
|
|
75
|
+
using statement = this.#database.prepare(this.#sql(this.#rows));
|
|
76
|
+
this.#changes += statement.run(...this.#params).changes;
|
|
77
|
+
}
|
|
78
|
+
this.#params = [];
|
|
79
|
+
this.#rows = 0;
|
|
80
|
+
this.#buffered.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#sql(rows: number): string {
|
|
84
|
+
const { verb, table, columns, upsert } = this.#spec;
|
|
85
|
+
const row = `(${columns.map(() => "?").join(", ")})`;
|
|
86
|
+
const values = Array.from({ length: rows }, () => row).join(", ");
|
|
87
|
+
return `${verb} INTO ${table} (${columns.join(", ")}) VALUES ${values}${upsert ? ` ${upsert}` : ""}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Detail drawer for a selected trace span.
|
|
3
|
-
* demand (
|
|
2
|
+
* Detail drawer for a selected trace span. Loads the full journal entry on
|
|
3
|
+
* demand (live API or embedded snapshot) and renders per-kind detail: model usage grid
|
|
4
4
|
* + text blocks, tool args/result, user text, subagent task text.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { Check, Copy } from "lucide-react";
|
|
7
|
+
import { Check, Copy, X } from "lucide-react";
|
|
8
8
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
9
9
|
import { getSessionEntryDetail } from "../api";
|
|
10
10
|
import { formatDurationMs, formatInteger } from "../data/formatters";
|
|
11
11
|
import type { TraceSpan, TraceTrack } from "../types";
|
|
12
|
-
import { Badge, Drawer, ErrorState, JsonBlock, KeyValues, Skeleton } from "../ui";
|
|
12
|
+
import { Badge, Drawer, type DrawerProps, ErrorState, JsonBlock, KeyValues, Skeleton } from "../ui";
|
|
13
|
+
|
|
14
|
+
export type TraceEntryLoader = (file: string, entryId: string, signal: AbortSignal) => Promise<{ entry: unknown }>;
|
|
13
15
|
|
|
14
16
|
export interface SpanDrawerProps {
|
|
15
17
|
span: TraceSpan | null;
|
|
@@ -17,6 +19,9 @@ export interface SpanDrawerProps {
|
|
|
17
19
|
onClose: () => void;
|
|
18
20
|
/** Selects the first span of the given child track ("open child track"). */
|
|
19
21
|
onOpenChildTrack?: (childTrackId: string) => void;
|
|
22
|
+
loadEntry?: TraceEntryLoader;
|
|
23
|
+
inline?: boolean;
|
|
24
|
+
onNavigate?: (trackId: string, entryId: string) => void;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/** Loose structural view of a fetched journal entry (validated field-by-field). */
|
|
@@ -60,7 +65,33 @@ function textBlocks(content: unknown): Array<{ kind: string; text: string }> {
|
|
|
60
65
|
return blocks;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
function InlineSpanDetail({ title, subtitle, actions, onClose, children }: DrawerProps) {
|
|
69
|
+
return (
|
|
70
|
+
<section className="traces-inline-detail" aria-label="Selected span details">
|
|
71
|
+
<header className="drawer-header">
|
|
72
|
+
<div className="drawer-titles">
|
|
73
|
+
<h2 className="drawer-title">{title}</h2>
|
|
74
|
+
{subtitle !== undefined && <div className="drawer-subtitle">{subtitle}</div>}
|
|
75
|
+
</div>
|
|
76
|
+
{actions}
|
|
77
|
+
<button type="button" className="btn" data-icon="true" onClick={onClose} aria-label="Close span details">
|
|
78
|
+
<X size={16} />
|
|
79
|
+
</button>
|
|
80
|
+
</header>
|
|
81
|
+
<div className="drawer-body">{children}</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function SpanDrawer({
|
|
87
|
+
span,
|
|
88
|
+
track,
|
|
89
|
+
onClose,
|
|
90
|
+
onOpenChildTrack,
|
|
91
|
+
loadEntry = getSessionEntryDetail,
|
|
92
|
+
inline = false,
|
|
93
|
+
onNavigate,
|
|
94
|
+
}: SpanDrawerProps) {
|
|
64
95
|
const [entry, setEntry] = useState<unknown>(null);
|
|
65
96
|
const [loading, setLoading] = useState(false);
|
|
66
97
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -75,7 +106,7 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
|
|
|
75
106
|
if (!span || !track || !span.entryId) return;
|
|
76
107
|
setLoading(true);
|
|
77
108
|
const controller = new AbortController();
|
|
78
|
-
|
|
109
|
+
loadEntry(track.file, span.entryId, controller.signal)
|
|
79
110
|
.then(data => {
|
|
80
111
|
if (!controller.signal.aborted) setEntry(data.entry);
|
|
81
112
|
})
|
|
@@ -87,7 +118,7 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
|
|
|
87
118
|
if (!controller.signal.aborted) setLoading(false);
|
|
88
119
|
});
|
|
89
120
|
return () => controller.abort();
|
|
90
|
-
}, [span, track]);
|
|
121
|
+
}, [span, track, loadEntry]);
|
|
91
122
|
|
|
92
123
|
useEffect(() => () => window.clearTimeout(copyResetRef.current), []);
|
|
93
124
|
|
|
@@ -151,8 +182,10 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
|
|
|
151
182
|
metrics.push({ key: "stop", label: "Stop reason", value: <span className="mono">{msg.stopReason}</span> });
|
|
152
183
|
}
|
|
153
184
|
|
|
185
|
+
const DetailShell = inline ? InlineSpanDetail : Drawer;
|
|
186
|
+
|
|
154
187
|
return (
|
|
155
|
-
<
|
|
188
|
+
<DetailShell
|
|
156
189
|
open
|
|
157
190
|
onClose={onClose}
|
|
158
191
|
title={span.label}
|
|
@@ -179,6 +212,20 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
|
|
|
179
212
|
>
|
|
180
213
|
<div className="stack traces-drawer">
|
|
181
214
|
<KeyValues items={metrics} />
|
|
215
|
+
{onNavigate && track && span.entryId && (
|
|
216
|
+
<div>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="btn"
|
|
220
|
+
data-size="sm"
|
|
221
|
+
onClick={() => {
|
|
222
|
+
if (span.entryId) onNavigate(track.id, span.entryId);
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
Jump to transcript
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
182
229
|
|
|
183
230
|
{msg?.errorMessage && (
|
|
184
231
|
<div className="stack" style={{ gap: 6 }}>
|
|
@@ -238,6 +285,6 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
|
|
|
238
285
|
|
|
239
286
|
{!loading && entry !== null && <JsonBlock data={entry} title="Raw entry" initialCollapsed={true} />}
|
|
240
287
|
</div>
|
|
241
|
-
</
|
|
288
|
+
</DetailShell>
|
|
242
289
|
);
|
|
243
290
|
}
|
|
@@ -32,6 +32,8 @@ export interface TimelineCanvasProps {
|
|
|
32
32
|
collapsed: ReadonlySet<string>;
|
|
33
33
|
onToggleCollapse: (trackId: string) => void;
|
|
34
34
|
traceStart: number;
|
|
35
|
+
/** Live route focuses immediately; embedded viewers wait for user interaction. */
|
|
36
|
+
autoFocus?: boolean;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const RULER_H = 28;
|
|
@@ -222,6 +224,7 @@ export function TimelineCanvas({
|
|
|
222
224
|
collapsed,
|
|
223
225
|
onToggleCollapse,
|
|
224
226
|
traceStart,
|
|
227
|
+
autoFocus = true,
|
|
225
228
|
}: TimelineCanvasProps) {
|
|
226
229
|
const colors = useTraceTheme();
|
|
227
230
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -463,12 +466,12 @@ export function TimelineCanvas({
|
|
|
463
466
|
return;
|
|
464
467
|
}
|
|
465
468
|
event.preventDefault();
|
|
469
|
+
event.stopPropagation();
|
|
466
470
|
};
|
|
467
471
|
|
|
468
|
-
// Focus the canvas on mount so keyboard navigation works immediately.
|
|
469
472
|
useEffect(() => {
|
|
470
|
-
canvasRef.current?.focus({ preventScroll: true });
|
|
471
|
-
}, []);
|
|
473
|
+
if (autoFocus) canvasRef.current?.focus({ preventScroll: true });
|
|
474
|
+
}, [autoFocus]);
|
|
472
475
|
|
|
473
476
|
// Scene: everything except hover/selection, so pointer moves never repaint it.
|
|
474
477
|
useEffect(() => {
|
|
@@ -603,7 +606,13 @@ export function TimelineCanvas({
|
|
|
603
606
|
)}
|
|
604
607
|
</div>
|
|
605
608
|
|
|
606
|
-
{tooltip &&
|
|
609
|
+
{tooltip &&
|
|
610
|
+
createPortal(
|
|
611
|
+
tooltip,
|
|
612
|
+
containerRef.current?.getRootNode() instanceof ShadowRoot
|
|
613
|
+
? (containerRef.current.getRootNode() as ShadowRoot)
|
|
614
|
+
: document.body,
|
|
615
|
+
)}
|
|
607
616
|
</div>
|
|
608
617
|
);
|
|
609
618
|
}
|