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
17 changes: 7 additions & 10 deletions dashboard/src/v2/NodesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import { NodePalette } from "./components/nodes/NodePalette.js";
import { NodeGovernancePanel } from "./components/nodes/NodeGovernancePanel.js";
import { NodeRunDebugger } from "./components/nodes/NodeRunDebugger.js";
import { useProjectData } from "./context/project-data.js";
import type { NodeDefinitionManifest, NodeFlowDraftReview, NodeFlowGraph, NodeFlowNode, NodeFlowNodeAttemptRecord, NodeFlowNodeRunRecord, NodeFlowRecord, NodeFlowRunRecord } from "./types.js";
import type { AutomationApprovalRecord, NodeDefinitionManifest, NodeFlowDraftReview, NodeFlowGraph, NodeFlowNode, NodeFlowNodeAttemptRecord, NodeFlowNodeRunRecord, NodeFlowRecord, NodeFlowRunRecord } from "./types.js";
import { createDefaultNodeFlowGraph, isNodeFlowDirty, updateNodeInGraph } from "./lib/node-flow-view-models.js";
import { deserializeNodeCanvasGraphWithMigration, toCanonicalNodeFlowGraph } from "./lib/nodes-canvas-state.js";
import {
cancelNodeFlowRun, compareNodeFlowVersions, createNodeFlowDraft, deleteNodeFlow, dryRunNodeFlowDraft,
fetchNodeDefinition, fetchNodeFlow, fetchNodeFlowAttempts, fetchNodeFlowCatalog, fetchNodeFlowNodeRuns,
cancelNodeFlowRun, compareNodeFlowVersions, createNodeFlowDraft, decideNodeFlowApproval, deleteNodeFlow, dryRunNodeFlowDraft,
fetchNodeDefinition, fetchNodeFlow, fetchNodeFlowApprovals, fetchNodeFlowAttempts, fetchNodeFlowCatalog, fetchNodeFlowNodeRuns,
fetchNodeFlowRuns, fetchNodeFlows, patchNodeFlowDraft, publishNodeFlowDraft, requestNodeFlowCredential,
retryNodeFlowRun, rollbackNodeFlow, runNodeFlow, validateNodeFlowDraft,
type NodeDefinitionSummary, type NodeFlowDryRunResponse, type NodeFlowVersionDiff,
Expand Down Expand Up @@ -47,6 +47,7 @@ export const NodesPage: FunctionComponent = () => {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [nodeRuns, setNodeRuns] = useState<NodeFlowNodeRunRecord[]>([]);
const [attempts, setAttempts] = useState<NodeFlowNodeAttemptRecord[]>([]);
const [approvals, setApprovals] = useState<AutomationApprovalRecord[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -69,11 +70,7 @@ export const NodesPage: FunctionComponent = () => {
let nextFlows = library.flows;
const legacy = typeof window !== "undefined" ? window.localStorage.getItem(NODES_CANVAS_STORAGE_KEY) : null;
if (legacy && !window.localStorage.getItem(migrationMarker(nextProjectId))) {
const legacySnapshot: unknown = JSON.parse(legacy);
const importedGraph = toCanonicalNodeFlowGraph(
deserializeNodeCanvasGraphWithMigration(legacy).graph,
legacySnapshot,
);
const importedGraph = toCanonicalNodeFlowGraph(deserializeNodeCanvasGraphWithMigration(legacy).graph);
const imported = await createNodeFlowDraft(nextProjectId, { title: "Imported Nodes Canvas", description: "One-time import from the legacy browser canvas.", graph: importedGraph });
window.localStorage.setItem(migrationMarker(nextProjectId), imported.flowId);
window.localStorage.removeItem(NODES_CANVAS_STORAGE_KEY);
Expand Down Expand Up @@ -108,7 +105,7 @@ export const NodesPage: FunctionComponent = () => {
const response = await fetchNodeFlowRuns(record.id); setRuns(response.runs); setSelectedRunId((current) => current ?? response.runs[0]?.id ?? null);
}, [record]);
useEffect(() => { setRuns([]); setSelectedRunId(null); if (record) void refreshRuns().catch((requestError) => setError(errorMessage(requestError))); }, [record?.id]);
useEffect(() => { if (!selectedRunId) { setNodeRuns([]); setAttempts([]); return; } const controller = new AbortController(); void Promise.all([fetchNodeFlowNodeRuns(selectedRunId, controller.signal), fetchNodeFlowAttempts(selectedRunId, controller.signal)]).then(([nodes, history]) => { setNodeRuns(nodes.nodeRuns); setAttempts(history.attempts); }).catch((requestError) => { if (!controller.signal.aborted) setError(errorMessage(requestError)); }); return () => controller.abort(); }, [selectedRunId]);
useEffect(() => { if (!selectedRunId) { setNodeRuns([]); setAttempts([]); setApprovals([]); return; } const controller = new AbortController(); void Promise.all([fetchNodeFlowNodeRuns(selectedRunId, controller.signal), fetchNodeFlowAttempts(selectedRunId, controller.signal), fetchNodeFlowApprovals(selectedRunId, controller.signal)]).then(([nodes, history, governed]) => { setNodeRuns(nodes.nodeRuns); setAttempts(history.attempts); setApprovals(governed.approvals); }).catch((requestError) => { if (!controller.signal.aborted) setError(errorMessage(requestError)); }); return () => controller.abort(); }, [selectedRunId]);

const act = async (action: () => Promise<void>): Promise<void> => { setBusy(true); setError(null); setNotice(null); try { await action(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setBusy(false); } };
const selectFlow = (flowId: string): void => { const flow = flows.find((item) => item.id === flowId); if (!flow || !projectId) return; applyRecord(flow); void validateNodeFlowDraft(projectId, flow.id).then(setReview).catch((requestError) => setError(errorMessage(requestError))); };
Expand All @@ -133,6 +130,6 @@ export const NodesPage: FunctionComponent = () => {
<NodePalette definitions={catalog} loading={loading} disabled={!record || busy} onCreateNode={addNode} />
{record ? <NodeFlowInspector selectedNode={selectedNode} definition={selectedDefinition} validation={review ? { valid: review.valid, errors: review.validationIssues } : null} requiredCredentials={review?.requiredCredentials.filter((item) => item.nodeId === selectedNode?.id) ?? []} agents={[]} attachments={[]} attachAgentId="" onAttachAgentIdChange={() => undefined} onAttachAgent={() => undefined} onDetachAgent={() => undefined} onNodeChange={(id, update) => setGraph((current) => updateNodeInGraph(current, id, update))} onRequestCredential={(nodeId, slot) => { if (projectId && record) void act(async () => { await requestNodeFlowCredential(projectId, record.id, nodeId, slot); setNotice("Credential binding request recorded; secret material remains outside the graph."); }); }} /> : null}
</div>
{record ? <><NodeGovernancePanel review={review} dryRun={dryRun} diff={diff} busy={busy} onValidate={validate} onDryRun={runDry} onCompare={compare} onPublish={publish} onRollback={rollback} /><NodeRunDebugger runs={runs} selectedRunId={selectedRunId} nodeRuns={nodeRuns} attempts={attempts} busy={busy} onSelectRun={setSelectedRunId} onRefresh={() => void act(refreshRuns)} onCancel={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { await cancelNodeFlowRun(projectId, active.id); await refreshRuns(); }); }} onRetry={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { const result = await retryNodeFlowRun(projectId, active.id); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); }); }} /></> : null}
{record ? <><NodeGovernancePanel review={review} dryRun={dryRun} diff={diff} busy={busy} onValidate={validate} onDryRun={runDry} onCompare={compare} onPublish={publish} onRollback={rollback} /><NodeRunDebugger runs={runs} selectedRunId={selectedRunId} nodeRuns={nodeRuns} attempts={attempts} approvals={approvals} busy={busy} onSelectRun={setSelectedRunId} onRefresh={() => void act(refreshRuns)} onCancel={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { await cancelNodeFlowRun(projectId, active.id); await refreshRuns(); }); }} onRetry={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { const result = await retryNodeFlowRun(projectId, active.id); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); }); }} onApprovalDecision={(approvalId, decision) => void act(async () => { const result = await decideNodeFlowApproval(approvalId, decision); setRuns((current) => current.map((item) => item.id === result.run.id ? result.run : item)); setNodeRuns(result.nodeRuns); setAttempts(result.attempts ?? []); setApprovals((current) => current.map((item) => item.id === approvalId ? { ...item, status: result.status, decidedAt: result.decidedAt, decidedBy: result.decidedBy, decision: result.decision, updatedAt: result.updatedAt } : item)); })} /></> : null}
</PageContainer>;
};
10 changes: 5 additions & 5 deletions dashboard/src/v2/components/nodes/NodeRunDebugger.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type { FunctionComponent } from "preact";
import { Ban, CalendarClock, ExternalLink, RefreshCw, RotateCw } from "lucide-preact";
import type { NodeFlowNodeAttemptRecord, NodeFlowNodeRunRecord, NodeFlowRunRecord } from "../../types.js";
import { Ban, CalendarClock, Check, ExternalLink, RefreshCw, RotateCw, X } from "lucide-preact";
import type { AutomationApprovalRecord, NodeFlowNodeAttemptRecord, NodeFlowNodeRunRecord, NodeFlowRunRecord } from "../../types.js";
import { redactNodeFlowSecrets } from "../../lib/node-flow-view-models.js";
import { Button } from "../ui/Button.js";

interface NodeRunDebuggerProps { runs: NodeFlowRunRecord[]; selectedRunId: string | null; nodeRuns: NodeFlowNodeRunRecord[]; attempts: NodeFlowNodeAttemptRecord[]; busy?: boolean; onSelectRun: (id: string) => void; onRefresh: () => void; onCancel: () => void; onRetry: () => void; }
interface NodeRunDebuggerProps { runs: NodeFlowRunRecord[]; selectedRunId: string | null; nodeRuns: NodeFlowNodeRunRecord[]; attempts: NodeFlowNodeAttemptRecord[]; approvals?: AutomationApprovalRecord[]; busy?: boolean; onSelectRun: (id: string) => void; onRefresh: () => void; onCancel: () => void; onRetry: () => void; onApprovalDecision?: (approvalId: string, decision: "approve" | "reject") => void; }
const terminal = new Set(["succeeded", "failed", "cancelled"]);
const duration = (start: string | null, end: string | null): string => start ? `${Math.max(0, new Date(end ?? Date.now()).getTime() - new Date(start).getTime())} ms` : "Not started";
export const NodeRunDebugger: FunctionComponent<NodeRunDebuggerProps> = ({ runs, selectedRunId, nodeRuns, attempts, busy, onSelectRun, onRefresh, onCancel, onRetry }) => {
export const NodeRunDebugger: FunctionComponent<NodeRunDebuggerProps> = ({ runs, selectedRunId, nodeRuns, attempts, approvals = [], busy, onSelectRun, onRefresh, onCancel, onRetry, onApprovalDecision }) => {
const run = runs.find((item) => item.id === selectedRunId) ?? runs[0] ?? null;
return <section className="rounded-[var(--radius-panel)] border border-black/[0.06] bg-white/70 p-4 shadow-[var(--elevation-soft)] dark:border-white/[0.06] dark:bg-white/[0.035]" aria-labelledby="debugger-heading">
<div className="flex flex-wrap items-start justify-between gap-3"><div><p className="text-[10px] font-bold uppercase tracking-[0.18em] text-signal-600 dark:text-signal-400">Redacted operations</p><h2 id="debugger-heading" className="text-base font-bold text-slate-900 dark:text-white">Run debugger</h2></div><div className="flex gap-2"><a href="/scheduler" className="inline-flex items-center gap-2 rounded-xl border border-black/[0.08] px-3 py-2 text-sm font-bold text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 dark:border-white/[0.08] dark:text-slate-300"><CalendarClock className="h-4 w-4" aria-hidden="true" />Schedule</a><Button size="sm" variant="secondary" icon={RefreshCw} onClick={onRefresh} disabled={busy}>Refresh</Button></div></div>
<div className="mt-4 grid gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]"><div className="flex max-h-80 flex-col gap-2 overflow-auto">{runs.length ? runs.map((item) => <button key={item.id} type="button" onClick={() => onSelectRun(item.id)} aria-current={run?.id === item.id ? "true" : undefined} className="rounded-xl border border-black/[0.06] p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 dark:border-white/[0.06]"><span className="block text-xs font-bold uppercase text-slate-500">{item.status} · v{item.version}</span><span className="mt-1 block truncate font-mono text-[11px] text-slate-400">{item.id}</span></button>) : <p role="status" className="text-sm text-slate-500">No persisted runs.</p>}</div>
<div className="min-w-0">{run ? <><div className="flex flex-wrap gap-2"><Button size="sm" variant="secondary" icon={Ban} onClick={onCancel} disabled={busy || terminal.has(run.status)}>Cancel</Button><Button size="sm" variant="secondary" icon={RotateCw} onClick={onRetry} disabled={busy || !["failed", "cancelled", "attention_required"].includes(run.status)}>Safe retry</Button></div><p className="mt-3 text-xs text-slate-500">Timing: {duration(run.startedAt, run.finishedAt)} · trigger {run.triggerType}</p><div className="mt-3 grid gap-2">{nodeRuns.map((nodeRun) => <div key={nodeRun.id} className="rounded-xl border border-black/[0.06] p-3 dark:border-white/[0.06]"><p className="text-sm font-bold text-slate-800 dark:text-slate-100">{nodeRun.nodeId} · {nodeRun.status} · {duration(nodeRun.startedAt, nodeRun.finishedAt)}</p>{nodeRun.executionInvocationId ? <a className="mt-1 inline-flex items-center gap-1 text-xs text-signal-600" href={`/?invocation=${encodeURIComponent(nodeRun.executionInvocationId)}`}>Invocation {nodeRun.executionInvocationId}<ExternalLink className="h-3 w-3" aria-hidden="true" /></a> : null}{attempts.filter((attempt) => attempt.nodeRunId === nodeRun.id).map((attempt) => <p key={attempt.id} className="mt-1 text-xs text-slate-500">Attempt {attempt.attemptNumber}: {attempt.status}{attempt.failureClassification ? ` · ${attempt.failureClassification}` : ""}{attempt.retryDecision ? ` · ${attempt.retryDecision}` : ""}</p>)}</div>)}</div><pre className="mt-3 max-h-72 overflow-auto rounded-xl bg-slate-950 p-3 text-xs text-slate-100">{JSON.stringify(redactNodeFlowSecrets(run.output ?? run.input ?? {}), null, 2)}</pre></> : <p className="text-sm text-slate-500">Select a run to inspect its graph state and attempts.</p>}</div></div>
<div className="min-w-0">{run ? <><div className="flex flex-wrap gap-2"><Button size="sm" variant="secondary" icon={Ban} onClick={onCancel} disabled={busy || terminal.has(run.status)}>Cancel</Button><Button size="sm" variant="secondary" icon={RotateCw} onClick={onRetry} disabled={busy || !["failed", "cancelled", "attention_required"].includes(run.status)}>Safe retry</Button></div>{approvals.map((approval) => <div key={approval.id} className="mt-3 rounded-xl border border-amber-400/30 bg-amber-400/[0.08] p-3"><p className="text-sm font-bold text-slate-800 dark:text-slate-100">Approval · {approval.nodeId} · {approval.status}</p><p className="mt-1 text-xs text-slate-500">Logical item: {approval.logicalItem}</p>{approval.status === "pending" && onApprovalDecision ? <div className="mt-3 flex gap-2"><Button size="sm" icon={Check} onClick={() => onApprovalDecision(approval.id, "approve")} disabled={busy}>Approve &amp; continue</Button><Button size="sm" variant="secondary" icon={X} onClick={() => onApprovalDecision(approval.id, "reject")} disabled={busy}>Reject</Button></div> : null}</div>)}<p className="mt-3 text-xs text-slate-500">Timing: {duration(run.startedAt, run.finishedAt)} · trigger {run.triggerType}</p><div className="mt-3 grid gap-2">{nodeRuns.map((nodeRun) => <div key={nodeRun.id} className="rounded-xl border border-black/[0.06] p-3 dark:border-white/[0.06]"><p className="text-sm font-bold text-slate-800 dark:text-slate-100">{nodeRun.nodeId} · {nodeRun.status} · {duration(nodeRun.startedAt, nodeRun.finishedAt)}</p>{nodeRun.executionInvocationId ? <a className="mt-1 inline-flex items-center gap-1 text-xs text-signal-600" href={`/?invocation=${encodeURIComponent(nodeRun.executionInvocationId)}`}>Invocation {nodeRun.executionInvocationId}<ExternalLink className="h-3 w-3" aria-hidden="true" /></a> : null}{attempts.filter((attempt) => attempt.nodeRunId === nodeRun.id).map((attempt) => <p key={attempt.id} className="mt-1 text-xs text-slate-500">Attempt {attempt.attemptNumber}: {attempt.status}{attempt.failureClassification ? ` · ${attempt.failureClassification}` : ""}{attempt.retryDecision ? ` · ${attempt.retryDecision}` : ""}</p>)}</div>)}</div><pre className="mt-3 max-h-72 overflow-auto rounded-xl bg-slate-950 p-3 text-xs text-slate-100">{JSON.stringify(redactNodeFlowSecrets(run.output ?? run.input ?? {}), null, 2)}</pre></> : <p className="text-sm text-slate-500">Select a run to inspect its graph state and attempts.</p>}</div></div>
</section>;
};
18 changes: 18 additions & 0 deletions dashboard/src/v2/lib/node-flow-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AutomationApprovalRecord,
AttachNodeFlowSkillInput,
CreateNodeFlowInput,
NodeFlowListResponse,
Expand Down Expand Up @@ -131,6 +132,23 @@ export const retryNodeFlowRun = async (projectId: string, runId: string): Promis
export const fetchNodeFlowAttempts = async (runId: string, signal?: AbortSignal): Promise<{ attempts: NodeFlowNodeAttemptRecord[] }> =>
fetchJson<{ attempts: NodeFlowNodeAttemptRecord[] }>(`/api/node-flow-runs/${encodeURIComponent(runId)}/attempts`, { signal });

export const fetchNodeFlowApprovals = async (runId: string, signal?: AbortSignal): Promise<{ approvals: AutomationApprovalRecord[] }> =>
fetchJson<{ approvals: AutomationApprovalRecord[] }>(`/api/node-flow-runs/${encodeURIComponent(runId)}/approvals`, { signal });

export const decideNodeFlowApproval = async (
approvalId: string,
decision: "approve" | "reject",
decidedBy = "dashboard",
): Promise<AutomationApprovalRecord & NodeFlowRunSummaryResponse> =>
fetchJson<AutomationApprovalRecord & NodeFlowRunSummaryResponse>(`/api/automation-approvals/${encodeURIComponent(approvalId)}/decision`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision, decidedBy }),
});

export const resumeNodeFlowApproval = async (projectId: string, runId: string, approvalId: string): Promise<NodeFlowRunSummaryResponse> =>
fetchJson<NodeFlowRunSummaryResponse>(`/api/node-flow-runs/${encodeURIComponent(runId)}/resume-approval`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, approvalId }),
});

export const fetchNodeFlows = async (
projectId: string,
signal?: AbortSignal,
Expand Down
Loading
Loading