mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: enhance agent execution flow with multi-execution view and improved error handling
- Added multi-execution view to display multiple execution statuses. - Improved error handling in agent triggering to surface failures. - Updated execution view to support live updates via SSE and added a log panel for node logs. - Enhanced pipeline canvas to display node statuses during execution. - Introduced new fields in the build node form for Docker configurations. - Updated API to support execution streaming and enhanced node catalog descriptions.
This commit is contained in:
@@ -1,13 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { lookup } from "@/lib/node-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowNodeData } from "@/lib/pipeline-graph";
|
||||
|
||||
export type NodeRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "paused";
|
||||
|
||||
export function FlowNode({ data, selected }: NodeProps) {
|
||||
const pn = (data as FlowNodeData).pipelineNode;
|
||||
const fd = data as FlowNodeData & { runStatus?: NodeRunStatus };
|
||||
const pn = fd.pipelineNode;
|
||||
const status = fd.runStatus;
|
||||
const entry = lookup(pn.type);
|
||||
const Icon = entry?.icon ?? AlertTriangle;
|
||||
const outputs = entry?.outputs ?? 1;
|
||||
@@ -18,7 +33,11 @@ export function FlowNode({ data, selected }: NodeProps) {
|
||||
className={cn(
|
||||
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
|
||||
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
|
||||
!entry && "border-destructive/60"
|
||||
!entry && "border-destructive/60",
|
||||
status === "running" && "ring-2 ring-sky-500 animate-pulse",
|
||||
status === "success" && "ring-2 ring-emerald-500",
|
||||
status === "failed" && "ring-2 ring-rose-500",
|
||||
status === "paused" && "ring-2 ring-amber-500"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -31,7 +50,10 @@ export function FlowNode({ data, selected }: NodeProps) {
|
||||
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="truncate text-sm font-medium">{pn.name}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{pn.name}</span>
|
||||
<StatusIcon status={status} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
|
||||
</div>
|
||||
|
||||
@@ -62,3 +84,16 @@ export function FlowNode({ data, selected }: NodeProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status?: NodeRunStatus }) {
|
||||
if (!status || status === "pending") return null;
|
||||
if (status === "running")
|
||||
return <Loader2 className="size-3.5 animate-spin text-sky-500" />;
|
||||
if (status === "success")
|
||||
return <CheckCircle2 className="size-3.5 text-emerald-500" />;
|
||||
if (status === "failed")
|
||||
return <XCircle className="size-3.5 text-rose-500" />;
|
||||
if (status === "paused")
|
||||
return <PauseCircle className="size-3.5 text-amber-500" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,14 @@ function TriggerForm({ node, onChange }: NodeFormProps) {
|
||||
}
|
||||
|
||||
function BuildForm({ node, onChange }: NodeFormProps) {
|
||||
const mode = getString(node, "mode", "docker");
|
||||
const dockerfile = getString(node, "dockerfile", "Dockerfile");
|
||||
const ctx = getString(node, "context", ".");
|
||||
const imageName = getString(node, "imageName", "");
|
||||
const registry = getString(node, "registry", "registry:5000");
|
||||
const platform = getString(node, "platform", "linux/amd64");
|
||||
const buildArgs = getString(node, "buildArgs", "");
|
||||
|
||||
const command = getString(
|
||||
node,
|
||||
"command",
|
||||
@@ -129,48 +137,164 @@ function BuildForm({ node, onChange }: NodeFormProps) {
|
||||
);
|
||||
const workdir = getString(node, "workdir", ".");
|
||||
const timeout = getNumber(node, "timeoutSeconds", 600);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="command">Command</Label>
|
||||
<Textarea
|
||||
id="command"
|
||||
rows={3}
|
||||
value={command}
|
||||
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label>Mode</Label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="docker">docker</option>
|
||||
<option value="shell">shell</option>
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runs in a clone of the agent repo. Available env:{" "}
|
||||
<code>$AGENT_NAME</code>, <code>$REPO_URL</code>,{" "}
|
||||
<code>$COMMIT_SHA</code>, <code>$REF</code>.
|
||||
<code>docker</code> = real OCI image build & push via BuildKit.{" "}
|
||||
<code>shell</code> = run any command (escape hatch).
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="workdir">Workdir</Label>
|
||||
<Input
|
||||
id="workdir"
|
||||
value={workdir}
|
||||
onChange={(e) => onChange(setParam(node, "workdir", e.target.value))}
|
||||
placeholder="."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="timeout">Timeout (s)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={timeout}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "docker" ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dockerfile">Dockerfile</Label>
|
||||
<Input
|
||||
id="dockerfile"
|
||||
value={dockerfile}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "dockerfile", e.target.value))
|
||||
}
|
||||
placeholder="Dockerfile"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Path relative to the repo root.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="b-ctx">Build context</Label>
|
||||
<Input
|
||||
id="b-ctx"
|
||||
value={ctx}
|
||||
onChange={(e) => onChange(setParam(node, "context", e.target.value))}
|
||||
placeholder="."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="b-image">Image name (optional)</Label>
|
||||
<Input
|
||||
id="b-image"
|
||||
value={imageName}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "imageName", e.target.value))
|
||||
}
|
||||
placeholder="my-org/my-agent"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Defaults to <code><owner>/<repo></code> from the
|
||||
agent’s connection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="b-reg">Registry</Label>
|
||||
<Input
|
||||
id="b-reg"
|
||||
value={registry}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "registry", e.target.value))
|
||||
}
|
||||
placeholder="ghcr.io"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
In docker-compose, <code>registry:5000</code> is the bundled
|
||||
local registry (host port 5050 for <code>docker pull</code>).
|
||||
For <code>ghcr.io</code>, the agent’s PAT must have{" "}
|
||||
<code>write:packages</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="b-plat">Target platform</Label>
|
||||
<Input
|
||||
id="b-plat"
|
||||
value={platform}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "platform", e.target.value))
|
||||
}
|
||||
placeholder="linux/amd64"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="b-args">Build args (optional)</Label>
|
||||
<Input
|
||||
id="b-args"
|
||||
value={buildArgs}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "buildArgs", e.target.value))
|
||||
}
|
||||
placeholder="NODE_ENV=production, FOO=bar"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<code>key=value</code>, comma-separated.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="command">Command</Label>
|
||||
<Textarea
|
||||
id="command"
|
||||
rows={3}
|
||||
value={command}
|
||||
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runs in a clone of the agent repo. Available env:{" "}
|
||||
<code>$AGENT_NAME</code>, <code>$REPO_URL</code>,{" "}
|
||||
<code>$COMMIT_SHA</code>, <code>$REF</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="workdir">Workdir</Label>
|
||||
<Input
|
||||
id="workdir"
|
||||
value={workdir}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "workdir", e.target.value))
|
||||
}
|
||||
placeholder="."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={timeout}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,9 @@ interface PipelineCanvasProps {
|
||||
* the legacy fixed height. Use when the page wraps the canvas in its own
|
||||
* layout (e.g. flow editor pages). */
|
||||
fullBleed?: boolean;
|
||||
/** Per-node run status overlay. Keys are node names. Drives the colored
|
||||
* ring + status icon on each FlowNode. Used by the live execution view. */
|
||||
nodeStatuses?: Record<string, "pending" | "running" | "success" | "failed" | "paused">;
|
||||
}
|
||||
|
||||
// Public component: keys on pipelineId so a fresh inner instance mounts when
|
||||
@@ -65,6 +68,7 @@ function CanvasInner({
|
||||
onChange,
|
||||
readOnly,
|
||||
fullBleed,
|
||||
nodeStatuses,
|
||||
}: PipelineCanvasProps) {
|
||||
// Compute initial RF state once. The canvas owns it from here on.
|
||||
const initial = useMemo(() => toReactFlow(initialValue ?? null), []);
|
||||
@@ -229,7 +233,17 @@ function CanvasInner({
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={
|
||||
nodeStatuses
|
||||
? nodes.map((n) => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
runStatus: nodeStatuses[n.id] ?? "pending",
|
||||
},
|
||||
}))
|
||||
: nodes
|
||||
}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
|
||||
Reference in New Issue
Block a user