mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import type { TimelineItem } from "./build-timeline";
|
|
|
|
/**
|
|
* A run's steps, derived from the flat event stream.
|
|
*
|
|
* The stream stores a tool call and its result as two independent rows
|
|
* (`tool_use` then `tool_result`), which is how the transcript ended up twice
|
|
* as long as the work it describes: a 75-call run rendered 150 rows, and the
|
|
* output row was where raw base64 and 8KB command dumps landed. A step folds
|
|
* the pair back together, so one call reads as one line and its result is that
|
|
* line's detail.
|
|
*
|
|
* Identified events pair by their opaque call ID, regardless of completion
|
|
* order or tool name. Only events without identity use the legacy tool-name
|
|
* FIFO; mixing the two would attach orphan results to unrelated calls.
|
|
*/
|
|
|
|
/** One tool call. Either side can be missing: a call still running has no
|
|
* result yet, and a stream that reconnected mid-flight can deliver a result
|
|
* whose call was never recorded. */
|
|
export interface TraceCallStep {
|
|
kind: "call";
|
|
/** Ordering key: the call's seq, or the result's when the call is missing. */
|
|
seq: number;
|
|
tool: string;
|
|
call?: TimelineItem;
|
|
result?: TimelineItem;
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
/** Wall-clock ms between call and result. Undefined when either side is
|
|
* missing a timestamp — never guessed. */
|
|
durationMs?: number;
|
|
}
|
|
|
|
/** Agent prose, model thinking, or an error: one message, nothing to pair. */
|
|
export interface TraceMessageStep {
|
|
kind: "text" | "thinking" | "error";
|
|
seq: number;
|
|
item: TimelineItem;
|
|
startedAt?: string;
|
|
}
|
|
|
|
export type TraceStep = TraceCallStep | TraceMessageStep;
|
|
|
|
/** Consecutive same-tool calls, folded. Expands back to its members. */
|
|
export interface TraceGroupRow {
|
|
kind: "group";
|
|
seq: number;
|
|
tool: string;
|
|
steps: TraceCallStep[];
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
durationMs?: number;
|
|
}
|
|
|
|
export type TraceRow = TraceStep | TraceGroupRow;
|
|
|
|
/**
|
|
* How many consecutive same-tool calls it takes to fold into one row.
|
|
*
|
|
* Three, not two: two file reads are still two lines worth reading, five are
|
|
* one act.
|
|
*/
|
|
export const MIN_GROUP_SIZE = 3;
|
|
|
|
/**
|
|
* A shell call never folds, however many run in a row.
|
|
*
|
|
* Reading a run, `pnpm typecheck` and `pnpm exec playwright test` are two
|
|
* different things that happened; "Bash · 5 calls" hides exactly what the
|
|
* reader came for, and a group duration spanning the gaps between them reads
|
|
* as one ten-minute command. Keyed on the input carrying a `command` string
|
|
* rather than a tool-name allowlist, so it holds for every backend.
|
|
*/
|
|
function isShellCall(step: TraceCallStep): boolean {
|
|
return typeof step.call?.input?.command === "string";
|
|
}
|
|
|
|
function timeMs(iso: string | undefined): number | undefined {
|
|
if (!iso) return undefined;
|
|
const ms = new Date(iso).getTime();
|
|
return Number.isFinite(ms) ? ms : undefined;
|
|
}
|
|
|
|
function durationBetween(start?: string, end?: string): number | undefined {
|
|
const a = timeMs(start);
|
|
const b = timeMs(end);
|
|
if (a === undefined || b === undefined) return undefined;
|
|
// Clamp rather than drop: a daemon wall-clock adjustment can make a result
|
|
// appear just before its call. Zero is preserved as "unknown" in the UI.
|
|
return Math.max(0, b - a);
|
|
}
|
|
|
|
/** Fold `tool_use` / `tool_result` pairs into single steps, in stream order. */
|
|
export function buildSteps(items: TimelineItem[]): TraceStep[] {
|
|
const steps: TraceStep[] = [];
|
|
// Separate ID and tool-name keys so a missing/unmatched ID never consumes
|
|
// an identified call through the legacy fallback (or vice versa).
|
|
const open = new Map<string, TraceCallStep[]>();
|
|
|
|
for (const item of items) {
|
|
const pairingKey = item.callId ? `id:${item.callId}` : `tool:${item.tool ?? ""}`;
|
|
if (item.type === "tool_use") {
|
|
const tool = item.tool ?? "";
|
|
const step: TraceCallStep = {
|
|
kind: "call",
|
|
seq: item.seq,
|
|
tool,
|
|
call: item,
|
|
startedAt: item.created_at,
|
|
};
|
|
steps.push(step);
|
|
const queue = open.get(pairingKey);
|
|
if (queue) queue.push(step);
|
|
else open.set(pairingKey, [step]);
|
|
continue;
|
|
}
|
|
|
|
if (item.type === "tool_result") {
|
|
const tool = item.tool ?? "";
|
|
const pending = open.get(pairingKey)?.shift();
|
|
if (pending) {
|
|
pending.result = item;
|
|
pending.endedAt = item.created_at;
|
|
pending.durationMs = durationBetween(pending.startedAt, item.created_at);
|
|
continue;
|
|
}
|
|
// Orphan result: keep it as a step of its own so no output is dropped.
|
|
steps.push({
|
|
kind: "call",
|
|
seq: item.seq,
|
|
tool,
|
|
result: item,
|
|
startedAt: item.created_at,
|
|
endedAt: item.created_at,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
steps.push({
|
|
kind: item.type,
|
|
seq: item.seq,
|
|
item,
|
|
startedAt: item.created_at,
|
|
});
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
/** Fold runs of `MIN_GROUP_SIZE`+ consecutive same-tool calls into group rows. */
|
|
export function groupSteps(steps: TraceStep[]): TraceRow[] {
|
|
const rows: TraceRow[] = [];
|
|
let run: TraceCallStep[] = [];
|
|
|
|
const flush = () => {
|
|
if (run.length === 0) return;
|
|
if (run.length < MIN_GROUP_SIZE) {
|
|
rows.push(...run);
|
|
} else {
|
|
const first = run[0]!;
|
|
// Call order no longer implies completion order. Do not display a
|
|
// completed span while any member still lacks its end timestamp.
|
|
const endedAt = run.every((step) => timeMs(step.endedAt) !== undefined)
|
|
? run.reduce((latest, step) =>
|
|
timeMs(step.endedAt)! > timeMs(latest)! ? step.endedAt : latest,
|
|
first.endedAt)
|
|
: undefined;
|
|
rows.push({
|
|
kind: "group",
|
|
seq: first.seq,
|
|
tool: first.tool,
|
|
steps: run,
|
|
startedAt: first.startedAt,
|
|
endedAt,
|
|
durationMs: durationBetween(first.startedAt, endedAt),
|
|
});
|
|
}
|
|
run = [];
|
|
};
|
|
|
|
for (const step of steps) {
|
|
if (
|
|
step.kind === "call" &&
|
|
!isShellCall(step) &&
|
|
(run.length === 0 || run[0]!.tool === step.tool)
|
|
) {
|
|
run.push(step);
|
|
continue;
|
|
}
|
|
flush();
|
|
if (step.kind === "call") run.push(step);
|
|
else rows.push(step);
|
|
}
|
|
flush();
|
|
|
|
return rows;
|
|
}
|
|
|
|
// `TraceMessageStep` carries three kinds on one interface, so a `kind` check
|
|
// alone narrows the property without dropping the constituent. These predicates
|
|
// are what let callers switch on a row and get a usable type back.
|
|
export function isGroupRow(row: TraceRow): row is TraceGroupRow {
|
|
return row.kind === "group";
|
|
}
|
|
|
|
export function isCallStep(row: TraceRow): row is TraceCallStep {
|
|
return row.kind === "call";
|
|
}
|
|
|
|
export function isMessageStep(row: TraceRow): row is TraceMessageStep {
|
|
return row.kind === "text" || row.kind === "thinking" || row.kind === "error";
|
|
}
|
|
|
|
/** Every call inside a row, so a group and a lone call read the same way. */
|
|
export function rowCalls(row: TraceRow): TraceCallStep[] {
|
|
if (row.kind === "group") return row.steps;
|
|
return row.kind === "call" ? [row] : [];
|
|
}
|
|
|
|
// ─── Lanes ──────────────────────────────────────────────────────────────────
|
|
|
|
export type LaneSegmentKind = "tool" | "think" | "report" | "error";
|
|
|
|
export interface LaneSegment {
|
|
/** ms from the run's start. */
|
|
startMs: number;
|
|
durationMs: number;
|
|
kind: LaneSegmentKind;
|
|
}
|
|
|
|
export interface TraceLanes {
|
|
/** Model turns: the complement of the tool lane. */
|
|
model: LaneSegment[];
|
|
tool: LaneSegment[];
|
|
modelMs: number;
|
|
toolMs: number;
|
|
totalMs: number;
|
|
}
|
|
|
|
interface Interval {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
function mergeIntervals(intervals: Interval[]): Interval[] {
|
|
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
|
const out: Interval[] = [];
|
|
for (const interval of sorted) {
|
|
const prev = out[out.length - 1];
|
|
if (prev && interval.start <= prev.end) {
|
|
prev.end = Math.max(prev.end, interval.end);
|
|
continue;
|
|
}
|
|
out.push({ ...interval });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Two lanes: what the tools were doing, and what the model was doing.
|
|
*
|
|
* The model lane is derived as the complement of the tool lane rather than
|
|
* from the model's own events, because that is the only honest source we have
|
|
* — a `text` or `thinking` row carries one arrival timestamp, not a span. The
|
|
* gaps between tool calls ARE the model's time, which is what makes the
|
|
* "28 minutes went where" question answerable at all.
|
|
*
|
|
* Returns null when the events carry no usable timestamps; callers drop the
|
|
* timeline entirely rather than draw an axis that means nothing.
|
|
*/
|
|
export function buildLanes(
|
|
steps: TraceStep[],
|
|
runStart: string | undefined,
|
|
runEnd: string | undefined,
|
|
): TraceLanes | null {
|
|
const stamps = steps
|
|
.flatMap((step) => [timeMs(step.startedAt), timeMs(step.kind === "call" ? step.endedAt : undefined)])
|
|
.filter((ms): ms is number => ms !== undefined);
|
|
if (stamps.length === 0) return null;
|
|
|
|
const startMs = Math.min(timeMs(runStart) ?? Infinity, ...stamps);
|
|
const endMs = Math.max(timeMs(runEnd) ?? -Infinity, ...stamps);
|
|
const totalMs = endMs - startMs;
|
|
if (!Number.isFinite(totalMs) || totalMs <= 0) return null;
|
|
|
|
const calls: Interval[] = [];
|
|
for (const step of steps) {
|
|
if (step.kind !== "call") continue;
|
|
const start = timeMs(step.startedAt);
|
|
const end = timeMs(step.endedAt);
|
|
if (start === undefined || end === undefined || end <= start) continue;
|
|
calls.push({
|
|
start: Math.max(startMs, start),
|
|
end: Math.min(endMs, end),
|
|
});
|
|
}
|
|
|
|
const merged = mergeIntervals(calls.filter((c) => c.end > c.start));
|
|
const tool: LaneSegment[] = merged.map((c) => ({
|
|
startMs: c.start - startMs,
|
|
durationMs: c.end - c.start,
|
|
kind: "tool",
|
|
}));
|
|
|
|
// Model segments fill every gap the tools left, including the head and tail.
|
|
const model: LaneSegment[] = [];
|
|
let cursor = startMs;
|
|
const pushModel = (from: number, to: number) => {
|
|
if (to <= from) return;
|
|
model.push({ startMs: from - startMs, durationMs: to - from, kind: kindForGap(steps, from, to) });
|
|
};
|
|
for (const interval of merged) {
|
|
pushModel(cursor, interval.start);
|
|
cursor = Math.max(cursor, interval.end);
|
|
}
|
|
pushModel(cursor, endMs);
|
|
|
|
return {
|
|
model,
|
|
tool,
|
|
modelMs: model.reduce((sum, s) => sum + s.durationMs, 0),
|
|
toolMs: tool.reduce((sum, s) => sum + s.durationMs, 0),
|
|
totalMs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What the model was doing in this gap, judged by the messages that landed in
|
|
* it: an error outranks a report, a report outranks plain thinking. Colour
|
|
* therefore carries state (fine / delivered / failed), never taxonomy — the
|
|
* token set has no categorical ramp to spend on five event types.
|
|
*/
|
|
function kindForGap(steps: TraceStep[], from: number, to: number): LaneSegmentKind {
|
|
let kind: LaneSegmentKind = "think";
|
|
for (const step of steps) {
|
|
if (step.kind === "call") continue;
|
|
const at = timeMs(step.startedAt);
|
|
if (at === undefined || at < from || at > to) continue;
|
|
if (step.kind === "error") return "error";
|
|
if (step.kind === "text") kind = "report";
|
|
}
|
|
return kind;
|
|
}
|
|
|
|
/**
|
|
* Axis ticks at a round interval.
|
|
*
|
|
* `maxTicks` scales with zoom: a timeline stretched to 4x its container has
|
|
* four screenfuls to label, so it needs four times the ticks to keep the same
|
|
* on-screen density.
|
|
*/
|
|
export function timelineTicks(totalMs: number, maxTicks = 6): number[] {
|
|
if (!Number.isFinite(totalMs) || totalMs <= 0) return [];
|
|
const steps = [
|
|
1_000, 5_000, 15_000, 30_000, 60_000, 2 * 60_000, 5 * 60_000, 10 * 60_000,
|
|
15 * 60_000, 30 * 60_000, 60 * 60_000, 2 * 60 * 60_000, 6 * 60 * 60_000,
|
|
];
|
|
const interval = steps.find((step) => totalMs / step <= maxTicks) ?? steps[steps.length - 1]!;
|
|
const ticks: number[] = [];
|
|
for (let at = 0; at < totalMs; at += interval) ticks.push(at);
|
|
const lastRoundTick = ticks[ticks.length - 1];
|
|
// The exact run end is more useful than a nearby round tick. Keeping both
|
|
// makes labels such as `2m` and `2m 2s` occupy the same right-edge pixels.
|
|
if (
|
|
lastRoundTick !== undefined &&
|
|
lastRoundTick > 0 &&
|
|
totalMs - lastRoundTick < interval / 2
|
|
) {
|
|
ticks.pop();
|
|
}
|
|
ticks.push(totalMs);
|
|
return ticks;
|
|
}
|
|
|
|
/** A bar thinner than this would vanish; a fast call has to stay clickable. */
|
|
const SEGMENT_MIN_PX = 3;
|
|
|
|
/**
|
|
* Where one segment sits on its lane, as CSS.
|
|
*
|
|
* The width floor is what makes this more than two percentages: a 40ms call in
|
|
* a 40-minute run rounds to a fraction of a pixel, so every bar is given a
|
|
* visible minimum and zooming is what makes its width truthful. That floor is
|
|
* also why `left` is clamped — a sliver starting at 99.99% would otherwise
|
|
* paint its 3px past the end of the axis, and those 3px of scrollable overflow
|
|
* hand the track a horizontal scrollbar it has nothing to scroll with (#6278).
|
|
*/
|
|
export function laneSegmentPosition(
|
|
segment: LaneSegment,
|
|
totalMs: number,
|
|
): { left: string; width: string } {
|
|
const width = `max(${SEGMENT_MIN_PX}px, ${(segment.durationMs / totalMs) * 100}%)`;
|
|
return { left: `min(${(segment.startMs / totalMs) * 100}%, 100% - ${width})`, width };
|
|
}
|
|
|
|
/**
|
|
* How the tool lane's time splits by what the calls were doing.
|
|
*
|
|
* The lane itself stays whole: in a real run one kind dominates (commands run
|
|
* for minutes, reads and edits finish in milliseconds), so a lane per kind
|
|
* would draw three near-empty tracks to say what one line of text says. This
|
|
* is that line of text.
|
|
*/
|
|
export interface ToolKindTotals {
|
|
command: number;
|
|
write: number;
|
|
read: number;
|
|
other: number;
|
|
}
|
|
|
|
export function toolKindTotals(steps: TraceStep[]): ToolKindTotals {
|
|
const totals: ToolKindTotals = { command: 0, write: 0, read: 0, other: 0 };
|
|
for (const step of steps) {
|
|
if (step.kind !== "call" || step.durationMs === undefined) continue;
|
|
const input = step.call?.input;
|
|
const kind: keyof ToolKindTotals =
|
|
typeof input?.command === "string"
|
|
? "command"
|
|
: typeof input?.old_string === "string" ||
|
|
typeof input?.content === "string" ||
|
|
input?.changes !== undefined
|
|
? "write"
|
|
: typeof input?.file_path === "string" ||
|
|
typeof input?.path === "string" ||
|
|
typeof input?.pattern === "string" ||
|
|
typeof input?.query === "string"
|
|
? "read"
|
|
: "other";
|
|
totals[kind] += step.durationMs;
|
|
}
|
|
return totals;
|
|
}
|