maestro/ui/src/lib/delegateRuns.ts
oss-sync 2044f0a2c4
Some checks failed
CI / build-and-test (push) Failing after 7m0s
sync: update from private repo (91d8d79c)
2026-07-09 23:57:26 +00:00

149 lines
5.1 KiB
TypeScript

export interface DelegateRun {
delegateRunId: string;
parentRunId: string | null;
description: string;
depth: number;
status: 'success' | 'aborted' | 'needs_user_input' | 'running';
startTs: string;
endTs: string | null;
eventCount: number;
toolCalls: number;
resultPreview?: string | null;
totalTokens?: number;
toolSummary?: Array<{ tool: string; count: number }>;
filesChanged?: string[];
lastErrorTool?: string | null;
}
export interface DelegateRunNode extends DelegateRun {
children: DelegateRunNode[];
}
/** サブタスクジョブに紐づく delegate run のグループ。Task 7 でレンダリングされる。 */
export interface SubtaskDelegateGroup {
jobId: string;
issueNumber: number;
depth: number;
status: string;
runs: DelegateRun[];
}
/** GET /:id/delegate-runs のレスポンス型。 */
export interface DelegateRunsResult {
runs: DelegateRun[];
subtasks: SubtaskDelegateGroup[];
}
/** delegate run の status に対応する i18n ラベルキーと Tailwind 色クラス。 */
export function delegateStatusBadge(
status: DelegateRun['status'],
): { labelKey: string; cls: string } {
switch (status) {
case 'success': return { labelKey: 'subtasks.delegateStatus.success', cls: 'bg-emerald-100 text-emerald-800' };
case 'aborted': return { labelKey: 'subtasks.delegateStatus.aborted', cls: 'bg-red-100 text-red-800' };
case 'needs_user_input': return { labelKey: 'subtasks.delegateStatus.needs_user_input', cls: 'bg-amber-100 text-amber-800' };
default: return { labelKey: 'subtasks.delegateStatus.running', cls: 'bg-blue-100 text-blue-800' };
}
}
/** endTs があればそこまで、無ければ now までの経過。clock skew で負にならないようガード。 */
export function formatElapsed(startTs: string, endTs: string | null, now: number): string {
const end = endTs ? new Date(endTs).getTime() : now;
const ms = Math.max(0, end - new Date(startTs).getTime());
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
}
/** トークン数を人間が読みやすい形式にフォーマット。例: 999 → "999 tok", 1000 → "1.0k tok"。 */
export function formatTokens(n: number): string {
if (n < 1000) return `${n} tok`;
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k tok`;
return `${(n / 1_000_000).toFixed(1)}M tok`;
}
/**
* Build a tree from a flat list of DelegateRun records.
* Runs whose parentRunId is null, or whose parent is not in the set,
* are treated as roots (orphan fallback — no data loss).
* Sorts roots and children by startTs (ascending), with delegateRunId as tiebreaker.
*/
export function buildDelegateRunTree(runs: DelegateRun[]): DelegateRunNode[] {
const nodes = new Map<string, DelegateRunNode>();
for (const r of runs) {
nodes.set(r.delegateRunId, { ...r, children: [] });
}
const roots: DelegateRunNode[] = [];
for (const node of nodes.values()) {
const parent = node.parentRunId ? nodes.get(node.parentRunId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
// Sort roots and children by startTs (ascending), tiebreak by delegateRunId
const sortFn = (a: DelegateRunNode, b: DelegateRunNode) => {
const cmp = a.startTs.localeCompare(b.startTs);
return cmp !== 0 ? cmp : a.delegateRunId.localeCompare(b.delegateRunId);
};
roots.sort(sortFn);
for (const node of nodes.values()) {
node.children.sort(sortFn);
}
return roots;
}
/**
* Determine the currently running tool from a sequence of trace events.
* Scans events in order: tool_call marks a tool as pending,
* tool_result for the same tool clears the pending state.
* Returns the name of the tool still pending (not yet completed),
* or null if no tool is currently running.
*/
export function currentRunningTool(
events: Array<{ kind: string; payload: unknown }>
): string | null {
let pending: string | null = null;
for (const event of events) {
if (event.kind === 'tool_call') {
const payload = event.payload as Record<string, unknown> | null;
const tool = payload?.tool;
if (typeof tool === 'string') {
pending = tool;
}
} else if (event.kind === 'tool_result') {
const payload = event.payload as Record<string, unknown> | null;
const tool = payload?.tool;
if (typeof tool === 'string' && tool === pending) {
pending = null;
}
}
}
return pending;
}
/**
* Summarize descendants of a node (children recursively, excluding self).
* Returns { total, failed, running } counts.
* - failed = runs with status === 'aborted'
* - running = runs with status === 'running'
*/
export function summarizeDescendants(node: DelegateRunNode): { total: number; failed: number; running: number } {
let total = 0;
let failed = 0;
let running = 0;
const traverse = (n: DelegateRunNode) => {
for (const child of n.children) {
total++;
if (child.status === 'aborted') failed++;
if (child.status === 'running') running++;
traverse(child);
}
};
traverse(node);
return { total, failed, running };
}