Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions frontend/src/components/panels/RunHistoryPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { X, Square, Trash2 } from 'lucide-react';
import { X, RotateCcw, Square, Trash2 } from 'lucide-react';
import { useUIStore } from '../../store/uiStore';
import { useGraphStore } from '../../store/graphStore';
import { useDelayedUnmount } from '../../hooks/useDelayedUnmount';
Expand All @@ -20,10 +20,10 @@ const STATUS_LABEL: Record<RunRecord['status'], string> = {
};

/**
* Run History panel — a session-scoped, newest-first record of every graph /
* single-node / selection run (status · duration · nodes · age). Cancel stops
* the active run (reuses `resetExecution`, the existing cancel path); Clear
* empties the list. Frontend-only (Phase 1 of g-queue-history-manager).
* Run History panel — a persistent, newest-first record of graph / single-node /
* selection runs. Terminal records replay their frozen request snapshot, while
* failed records use the more explicit Retry failed action. Cancel reuses the
* existing reset path; Clear removes both UI and persisted history.
*/
export function RunHistoryPanel() {
const visible = useUIStore((s) => s.panels.history.visible);
Expand All @@ -33,6 +33,8 @@ export function RunHistoryPanel() {

const runHistory = useGraphStore((s) => s.runHistory);
const clearRunHistory = useGraphStore((s) => s.clearRunHistory);
const rerunHistoryRecord = useGraphStore((s) => s.rerunHistoryRecord);
const retryFailedRun = useGraphStore((s) => s.retryFailedRun);
const isExecuting = useGraphStore((s) => s.isExecuting);
const resetExecution = useGraphStore((s) => s.resetExecution);

Expand Down Expand Up @@ -116,7 +118,7 @@ export function RunHistoryPanel() {

<div className="panel__body panel__body--history">
{runHistory.length === 0 ? (
<div className="run-history__empty">No runs yet this session.</div>
<div className="run-history__empty">No saved runs yet.</div>
) : (
<ul className="run-history__list">
{runHistory.map((r) => (
Expand All @@ -140,7 +142,34 @@ export function RunHistoryPanel() {
{r.nodesExecuted != null && (
<> · {r.nodesExecuted} node{r.nodesExecuted === 1 ? '' : 's'}</>
)}
{r.targetNodeId && (
<span className="run-history__target" title={r.targetNodeId}>
{' '}· target {r.targetNodeId}
</span>
)}
</span>
{r.status !== 'running' && (
<span className="run-history__item-actions">
<button
type="button"
className="run-history__replay"
onClick={() => {
if (r.status === 'failed') void retryFailedRun(r.id);
else void rerunHistoryRecord(r.id);
}}
disabled={isExecuting}
aria-label={r.status === 'failed'
? `Retry failed ${runTriggerLabel(r.trigger)} run`
: `Rerun ${runTriggerLabel(r.trigger)}`}
title={isExecuting
? 'Wait for the active run to finish'
: 'Run the exact saved graph snapshot'}
>
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" focusable="false" />
{r.status === 'failed' ? 'Retry failed' : 'Rerun'}
</button>
</span>
)}
</span>
</li>
))}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface ExecutionStartResult {

export async function executeGraph(
nodes: Array<{ id: string; definitionId: string; params: Record<string, unknown>; outputs: Record<string, unknown> }>,
edges: Array<{ id: string; source: string; sourceHandle: string | null | undefined; target: string; targetHandle: string | null | undefined }>,
edges: Array<{ id: string; source: string; sourceHandle?: string | null; target: string; targetHandle?: string | null }>,
runId?: string,
): Promise<ExecutionStartResult> {
const response = await apiFetch('/api/execute', {
Expand All @@ -36,7 +36,7 @@ export async function executeGraph(

export async function executeNode(
nodes: Array<{ id: string; definitionId: string; params: Record<string, unknown>; outputs: Record<string, unknown> }>,
edges: Array<{ id: string; source: string; sourceHandle: string | null | undefined; target: string; targetHandle: string | null | undefined }>,
edges: Array<{ id: string; source: string; sourceHandle?: string | null; target: string; targetHandle?: string | null }>,
targetNodeId: string,
runId?: string,
): Promise<ExecutionStartResult> {
Expand Down
208 changes: 199 additions & 9 deletions frontend/src/lib/runHistory.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,84 @@
/** Session run-history records for the Run History panel. Pure transforms so the
* open/close lifecycle is unit-testable; the store holds the array + a current-run id. */
/** Persistent run-history records for the Run History panel. Records contain the
* exact JSON graph sent to the backend so a later replay never reads mutable
* canvas state. Storage helpers are exception-safe because history must never
* prevent the canvas from loading or executing. */

export type RunStatus = 'running' | 'complete' | 'failed' | 'cancelled';
export type RunTrigger = 'graph' | 'node' | 'cluster';
export type RunReplayAction = 'rerun' | 'retry-failed';

export interface RunSnapshotNode {
id: string;
definitionId: string;
params: Record<string, unknown>;
outputs: Record<string, unknown>;
}

export interface RunSnapshotEdge {
id: string;
source: string;
sourceHandle?: string | null;
target: string;
targetHandle?: string | null;
}

export interface RunGraphSnapshot {
nodes: RunSnapshotNode[];
edges: RunSnapshotEdge[];
}

export interface RunRecord {
id: string;
trigger: RunTrigger;
startedAt: number; // epoch ms
status: RunStatus;
snapshot: RunGraphSnapshot;
targetNodeId?: string;
sourceRunId?: string;
replayAction?: RunReplayAction;
durationSec?: number;
nodesExecuted?: number;
}

export const MAX_RUN_HISTORY = 50;
export type OpenRunRecord = Omit<RunRecord, 'status' | 'durationSec' | 'nodesExecuted'>;

/** Prepend a new running record, capping the list. Pure. */
export function openRunRecord(
history: RunRecord[],
rec: { id: string; trigger: RunTrigger; startedAt: number },
): RunRecord[] {
return [{ ...rec, status: 'running' as const }, ...history].slice(0, MAX_RUN_HISTORY);
export interface RunHistoryStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}

export const MAX_RUN_HISTORY = 100;
export const RUN_HISTORY_STORAGE_KEY = 'nebula:run-history:v1';
const RUN_HISTORY_STORAGE_VERSION = 1;

interface StoredRunHistory {
version: typeof RUN_HISTORY_STORAGE_VERSION;
records: RunRecord[];
}

function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
for (const child of Object.values(value as Record<string, unknown>)) {
deepFreeze(child);
}
Object.freeze(value);
}
return value;
}

/** JSON-clone + deep-freeze a backend request graph. JSON cloning intentionally
* matches the payload semantics of fetch(JSON.stringify(...)): undefined values
* are omitted and the retained data cannot drift when canvas params mutate. */
export function freezeRunSnapshot(snapshot: RunGraphSnapshot): RunGraphSnapshot {
return deepFreeze(JSON.parse(JSON.stringify(snapshot)) as RunGraphSnapshot);
}

/** Prepend a new running record, capping the list. Pure with respect to history;
* the incoming snapshot is cloned so later canvas mutations cannot alter it. */
export function openRunRecord(history: RunRecord[], rec: OpenRunRecord): RunRecord[] {
return [{ ...rec, snapshot: freezeRunSnapshot(rec.snapshot), status: 'running' as const }, ...history]
.slice(0, MAX_RUN_HISTORY);
}

/** Patch a record (by id) with its terminal status/metrics. Pure; no-op if absent. */
Expand All @@ -32,6 +90,138 @@ export function closeRunRecord(
return history.map((r) => (r.id === id ? { ...r, ...patch } : r));
}

function browserStorage(): RunHistoryStorage | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage;
} catch {
return null;
}
}

function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function isOptionalFiniteNumber(value: unknown): value is number | undefined {
return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
}

function isSnapshotNode(value: unknown): value is RunSnapshotNode {
if (!isObject(value)) return false;
return typeof value.id === 'string'
&& typeof value.definitionId === 'string'
&& isObject(value.params)
&& isObject(value.outputs);
}

function isOptionalHandle(value: unknown): value is string | null | undefined {
return value === undefined || value === null || typeof value === 'string';
}

function isSnapshotEdge(value: unknown): value is RunSnapshotEdge {
if (!isObject(value)) return false;
return typeof value.id === 'string'
&& typeof value.source === 'string'
&& typeof value.target === 'string'
&& isOptionalHandle(value.sourceHandle)
&& isOptionalHandle(value.targetHandle);
}

function isRunSnapshot(value: unknown): value is RunGraphSnapshot {
if (!isObject(value) || !Array.isArray(value.nodes) || !Array.isArray(value.edges)) return false;
return value.nodes.every(isSnapshotNode) && value.edges.every(isSnapshotEdge);
}

const RUN_STATUSES: RunStatus[] = ['running', 'complete', 'failed', 'cancelled'];
const RUN_TRIGGERS: RunTrigger[] = ['graph', 'node', 'cluster'];
const REPLAY_ACTIONS: RunReplayAction[] = ['rerun', 'retry-failed'];

function isRunRecord(value: unknown): value is RunRecord {
if (!isObject(value)) return false;
return typeof value.id === 'string'
&& RUN_TRIGGERS.includes(value.trigger as RunTrigger)
&& typeof value.startedAt === 'number'
&& Number.isFinite(value.startedAt)
&& value.startedAt >= 0
&& RUN_STATUSES.includes(value.status as RunStatus)
&& isRunSnapshot(value.snapshot)
&& (value.targetNodeId === undefined || typeof value.targetNodeId === 'string')
&& (value.sourceRunId === undefined || typeof value.sourceRunId === 'string')
&& (value.replayAction === undefined || REPLAY_ACTIONS.includes(value.replayAction as RunReplayAction))
&& isOptionalFiniteNumber(value.durationSec)
&& isOptionalFiniteNumber(value.nodesExecuted);
}

/** Persist a capped history list. Quota, privacy-mode, and unavailable-storage
* failures are deliberately non-fatal. */
export function persistRunHistory(
history: RunRecord[],
storage: RunHistoryStorage | null = browserStorage(),
): void {
if (!storage) return;
const payload: StoredRunHistory = {
version: RUN_HISTORY_STORAGE_VERSION,
records: history.slice(0, MAX_RUN_HISTORY),
};
try {
storage.setItem(RUN_HISTORY_STORAGE_KEY, JSON.stringify(payload));
} catch {
/* History persistence is best-effort and must never block execution. */
}
}

/** Load and normalize persisted history. Invalid individual records are dropped;
* invalid JSON/envelopes are removed. A browser reload cannot reconnect to a
* prior process, so orphaned running records recover as cancelled. */
export function loadRunHistory(
storage: RunHistoryStorage | null = browserStorage(),
): RunRecord[] {
if (!storage) return [];
try {
const raw = storage.getItem(RUN_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!isObject(parsed)
|| parsed.version !== RUN_HISTORY_STORAGE_VERSION
|| !Array.isArray(parsed.records)) {
throw new Error('Invalid run-history envelope');
}

const validRecords = parsed.records.filter(isRunRecord).slice(0, MAX_RUN_HISTORY);
const recovered = validRecords.map((record) => ({
...record,
snapshot: freezeRunSnapshot(record.snapshot),
status: record.status === 'running' ? 'cancelled' as const : record.status,
}));

if (validRecords.length !== parsed.records.length
|| parsed.records.length > MAX_RUN_HISTORY
|| validRecords.some((record) => record.status === 'running')) {
persistRunHistory(recovered, storage);
}
return recovered;
} catch {
try {
storage.removeItem(RUN_HISTORY_STORAGE_KEY);
} catch {
/* Ignore unavailable-storage failures during recovery too. */
}
return [];
}
}

export function clearPersistedRunHistory(
storage: RunHistoryStorage | null = browserStorage(),
): void {
if (!storage) return;
try {
storage.removeItem(RUN_HISTORY_STORAGE_KEY);
} catch {
/* Clearing UI state still succeeds when storage is unavailable. */
}
}

const TRIGGER_LABELS: Record<RunTrigger, string> = {
graph: 'Full graph',
node: 'Single node',
Expand Down
Loading