feat(server): enhance workflow and script handling with new features

- Added `workflowLastModifiedAt` function to retrieve the last modified timestamp of workflow YAML files.
- Introduced `encodeBinaryForWire` and `reviveBinaryFromWire` functions for better handling of binary data in JSON.
- Implemented `useDeleteKv` hook for deleting key-value pairs in the web API.
- Enhanced `scriptsPluginFactory` to support dry-run evaluations with JSONata expressions.
- Added `set-dry-run-smoke.js` test to validate dry-run functionality.
- Updated profile configuration to include `overlayFromMerged` for better profile management.
- Introduced try-session management in the web components to handle step execution states.
- Improved various components to support new try-session features and maintain UI consistency.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-22 17:52:17 +07:00
parent 72feb39d56
commit beb7e22652
28 changed files with 1753 additions and 143 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useKvNamespaces() {
@@ -23,3 +23,12 @@ export function useKv(filters = {}) {
});
}
export function useDeleteKv() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ namespace, key }) =>
(await api.delete("/kv", { params: { namespace, key } })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["kv"] }),
});
}
+11 -2
View File
@@ -84,10 +84,19 @@ export function useDeleteScript() {
export function useDryRunScript() {
return useMutation({
mutationFn: async ({ name, content, data, context, config, owner }) =>
mutationFn: async ({
name,
content,
expression,
data,
context,
config,
owner,
}) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content,
...(content != null ? { content } : {}),
...(expression != null ? { expression } : {}),
data,
context,
config,
+1 -1
View File
@@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return (
<div className="overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<div className="nokey overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<Editor
height={height}
language={language}
@@ -28,6 +28,7 @@ import {
parseGraphNodeId,
readGraphLayout,
removeStepEdge,
stepNodeId,
writeGraphLayout,
} from "../../lib/workflow-graph.js";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
@@ -41,6 +42,10 @@ import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
/** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
export function GraphTab(props) {
return (
<ReactFlowProvider>
@@ -61,6 +66,10 @@ function GraphTabInner({
excludeFile,
auths = [],
pages = [],
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const { fitView } = useReactFlow();
const [addStepOpen, setAddStepOpen] = useState(false);
@@ -94,7 +103,7 @@ function GraphTabInner({
}, [doc, disabled, positions]);
useEffect(() => {
const t = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 }));
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t);
}, [owner, file, fitView]);
@@ -218,7 +227,7 @@ function GraphTabInner({
function resetLayout() {
clearGraphLayout(owner, file);
persistPositions({});
requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 }));
requestAnimationFrame(() => fitView(FIT_VIEW));
}
const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null;
@@ -285,6 +294,7 @@ function GraphTabInner({
nodesConnectable={!disabled}
nodesDraggable={!disabled}
connectionRadius={80}
defaultViewport={{ x: 0, y: 0, zoom: INITIAL_ZOOM }}
defaultEdgeOptions={{ type: "smoothstep" }}
proOptions={{ hideAttribution: false }}
>
@@ -309,6 +319,20 @@ function GraphTabInner({
excludeFile={excludeFile}
sortable={false}
defaultExpanded
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === selectedStep.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? selectedStep.uiId : null);
}}
onNavigateTry={(targetUiId) => {
const id = stepNodeId(targetUiId);
setSelectedId(id);
setNodes((prev) =>
prev.map((n) => ({ ...n, selected: n.id === id })),
);
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[selectedStepIndex] = next;
@@ -317,6 +341,7 @@ function GraphTabInner({
onRemove={() => {
patchSteps(steps.filter((_, i) => i !== selectedStepIndex));
setSelectedId(null);
onTryFocus?.(null);
}}
/>
) : selectedTrigger && selectedTriggerIndex >= 0 ? (
@@ -1,19 +1,42 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { LuChevronDown, LuGripVertical, LuMaximize2, LuMinimize2, LuPlay, LuTrash2 } from "react-icons/lu";
import {
LuChevronDown,
LuGripVertical,
LuMaximize2,
LuMinimize2,
LuPlay,
LuTrash2,
} from "react-icons/lu";
import { useDryRunScript, useScript } from "../../api/hooks.js";
import { errorMessage } from "../../api/client.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { JsonTree } from "../JsonViewBlock.jsx";
import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format";
import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
import { needsMode } from "../../lib/workflow-doc.js";
import {
stepPredecessors,
stepSuccessors,
} from "../../lib/workflow-graph.js";
import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
import {
ConfigTooltip,
configValueText,
FieldLabel,
previewConfigValue,
SchemaTooltip,
} from "./FieldHelp.jsx";
import { ScriptIcon } from "../ScriptIcon.jsx";
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
import {
configHasOverlay,
mergeProfileConfig,
overlayFromMerged,
} from "../../lib/profile.js";
export function ScriptCard(props) {
if (props.sortable === false) {
@@ -44,9 +67,21 @@ function ScriptCardView({
excludeFile,
sortable = true,
defaultExpanded = false,
trySession,
onTrySuccess,
tryOpen: tryOpenProp,
onTryOpenChange,
onNavigateTry,
drag,
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = drag ?? {
attributes: {},
listeners: {},
setNodeRef: undefined,
@@ -59,8 +94,15 @@ function ScriptCardView({
transition,
opacity: isDragging ? 0.6 : 1,
};
const [tryOpen, setTryOpen] = useState(false);
const tryControlled = typeof onTryOpenChange === "function";
const [localTryOpen, setLocalTryOpen] = useState(false);
const tryOpen = tryControlled ? Boolean(tryOpenProp) : localTryOpen;
function setTryOpen(open) {
if (tryControlled) onTryOpenChange(open);
else setLocalTryOpen(open);
}
const [expanded, setExpanded] = useState(defaultExpanded);
const [configMode, setConfigMode] = useState("form");
const profile = step.profile && profilesByName?.get(step.profile);
const scriptName = profile?.script || step.script;
const listed = scriptsByName?.get(scriptName);
@@ -81,6 +123,27 @@ function ScriptCardView({
step.kind === "set" ? { expression: step.expression ?? "" } : null;
const missingProfile = Boolean(step.profile) && !profile;
const neighborOpts = useMemo(() => {
const steps = otherSteps ?? [];
const byUi = new Map(steps.map((s, i) => [s.uiId, { step: s, index: i }]));
function labeled(uiIds) {
return uiIds
.map((uiId) => {
const hit = byUi.get(uiId);
if (!hit) return null;
return {
uiId,
label: stepTryLabel(hit.step, hit.index),
};
})
.filter(Boolean);
}
return {
predecessors: labeled(stepPredecessors(steps, step.uiId)),
successors: labeled(stepSuccessors(steps, step.uiId)),
};
}, [otherSteps, step.uiId]);
return (
<article
ref={setNodeRef}
@@ -104,7 +167,11 @@ function ScriptCardView({
</button>
) : null}
{step.kind === "script" && scriptName ? (
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
<ScriptIcon
name={scriptName}
hasIcon={listed?.hasIcon}
className="size-8 shrink-0"
/>
) : null}
<button
type="button"
@@ -112,7 +179,10 @@ function ScriptCardView({
onClick={() => setExpanded((v) => !v)}
>
<div className="flex min-w-0 items-center gap-2">
<h3 className="card-title min-w-0 flex-1 text-base font-mono" title={titleFull}>
<h3
className="card-title min-w-0 flex-1 text-base font-mono"
title={titleFull}
>
<span className="min-w-0 truncate">{baseName}</span>
{!expanded && preview ? (
<span
@@ -134,11 +204,15 @@ function ScriptCardView({
: `profile ${step.profile}`
}
>
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`}
{overridden
? `profile ${step.profile} + overridden`
: `profile ${step.profile}`}
</span>
) : null}
{step.id ? (
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
<span className="badge badge-ghost badge-sm font-mono shrink-0">
{step.id}
</span>
) : null}
</div>
{expanded && step.kind === "script" && missingProfile ? (
@@ -159,10 +233,12 @@ function ScriptCardView({
<SchemaTooltip label="Input" fields={meta?.input} />
) : null}
{!expanded ? (
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} />
<ConfigTooltip
config={step.kind === "set" ? setConfig : (mergedConfig ?? {})}
/>
) : null}
<div className="card-actions shrink-0">
{step.kind === "script" && scriptName ? (
{step.kind === "set" || (step.kind === "script" && scriptName) ? (
<button
type="button"
className="btn btn-ghost btn-xs"
@@ -190,7 +266,9 @@ function ScriptCardView({
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
>
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
<LuChevronDown
className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
</div>
@@ -208,25 +286,64 @@ function ScriptCardView({
className="w-full min-h-24 font-mono text-xs"
value={step.expression ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...step, expression: e.target.value })}
onChange={(e) =>
onChange({ ...step, expression: e.target.value })
}
/>
</div>
) : (
<ConfigFields
script={scriptName}
config={step.config}
inheritedConfig={inheritedConfig}
meta={meta}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
onChange={(config) => onChange({ ...step, config })}
/>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="join">
<button
type="button"
className={`btn btn-xs join-item ${configMode === "form" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("form")}
>
Form
</button>
<button
type="button"
className={`btn btn-xs join-item ${configMode === "json" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("json")}
>
JSON
</button>
</div>
{step.profile ? (
<span className="text-xs opacity-60">
JSON is the step overlay, not the merged runtime config
</span>
) : null}
</div>
{configMode === "form" ? (
<ConfigFields
script={scriptName}
config={step.config}
inheritedConfig={inheritedConfig}
meta={meta}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
onChange={(config) => onChange({ ...step, config })}
/>
) : (
<ConfigJsonEditor
config={step.config}
disabled={disabled}
onChange={(config) => onChange({ ...step, config })}
/>
)}
</div>
)}
<details className="collapse collapse-arrow bg-base-200">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">Advanced</summary>
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
Advanced
</summary>
<div className="collapse-content space-y-2">
<label className="form-control">
<span className="label py-0 text-sm">id</span>
@@ -238,7 +355,9 @@ function ScriptCardView({
placeholder="optional step id"
/>
{duplicateId ? (
<span className="text-error text-xs">Duplicate step id</span>
<span className="text-error text-xs">
Duplicate step id
</span>
) : null}
</label>
<NeedsEditor
@@ -253,11 +372,15 @@ function ScriptCardView({
className="w-full font-mono"
value={step.when ?? ""}
disabled={disabled || Boolean(step.needs)}
onChange={(e) => onChange({ ...step, when: e.target.value })}
onChange={(e) =>
onChange({ ...step, when: e.target.value })
}
placeholder="JSONata; skip if false (linear only)"
/>
{step.needs ? (
<span className="text-xs opacity-60">when is not allowed when needs is set</span>
<span className="text-xs opacity-60">
when is not allowed when needs is set
</span>
) : null}
</label>
</div>
@@ -267,10 +390,27 @@ function ScriptCardView({
</div>
{tryOpen ? (
<ScriptTryDialog
script={scriptName}
kind={step.kind === "set" ? "set" : "script"}
script={step.kind === "set" ? "set" : scriptName}
expression={step.expression ?? ""}
config={mergedConfig}
inheritedConfig={inheritedConfig}
meta={meta}
owner={owner}
disabled={disabled}
step={step}
index={index}
otherSteps={otherSteps}
trySession={trySession}
onTrySuccess={onTrySuccess}
predecessors={neighborOpts.predecessors}
successors={neighborOpts.successors}
onNavigateTry={onNavigateTry}
onApplyConfig={
step.kind === "set"
? (expression) => onChange({ ...step, expression })
: (config) => onChange({ ...step, config })
}
onClose={() => setTryOpen(false)}
/>
) : null}
@@ -278,9 +418,60 @@ function ScriptCardView({
);
}
function parseConfigObject(text) {
const parsed = JSON.parse(text || "{}");
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("config must be a JSON object");
}
return parsed;
}
function configsEqual(a, b) {
try {
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
} catch {
return false;
}
}
function ConfigJsonEditor({ config, disabled, onChange }) {
const [draft, setDraft] = useState(() => prettyJson(config ?? {}) || "{}");
const [parseError, setParseError] = useState(null);
function commit(nextText) {
setDraft(nextText);
try {
const parsed = parseConfigObject(nextText);
setParseError(null);
if (!configsEqual(parsed, config ?? {})) {
onChange(parsed);
}
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="space-y-1">
{parseError ? <p className="text-error text-xs">{parseError}</p> : null}
<div className="h-48">
<CodeEditor
language="json"
value={draft}
onChange={disabled ? () => {} : commit}
readOnly={disabled}
height="100%"
/>
</div>
</div>
);
}
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs);
const ids = otherSteps.filter((s) => s.id && s.uiId !== step.uiId).map((s) => s.id);
const ids = otherSteps
.filter((s) => s.id && s.uiId !== step.uiId)
.map((s) => s.id);
function setMode(next) {
if (next === "none") onChange({ ...step, needs: null });
@@ -303,10 +494,13 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
{mode === "list" ? (
<div className="flex flex-wrap gap-2 pt-1">
{ids.length === 0 ? (
<span className="text-xs opacity-60">Give other steps an id first</span>
<span className="text-xs opacity-60">
Give other steps an id first
</span>
) : (
ids.map((id) => {
const checked = Array.isArray(step.needs) && step.needs.includes(id);
const checked =
Array.isArray(step.needs) && step.needs.includes(id);
return (
<label key={id} className="label cursor-pointer gap-1 py-0">
<input
@@ -315,7 +509,9 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
checked={checked}
disabled={disabled}
onChange={(e) => {
const current = Array.isArray(step.needs) ? [...step.needs] : [];
const current = Array.isArray(step.needs)
? [...step.needs]
: [];
onChange({
...step,
needs: e.target.checked
@@ -367,7 +563,9 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
disabled={disabled}
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
>
{from && !ids.includes(from) ? <option value={from}>{from}</option> : null}
{from && !ids.includes(from) ? (
<option value={from}>{from}</option>
) : null}
{ids.map((id) => (
<option key={id} value={id}>
{id}
@@ -409,63 +607,263 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
);
}
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script);
function TryNavCluster({ direction, neighbors, pick, onPick, onGo, disabled }) {
if (!neighbors?.length) return null;
const isPrev = direction === "prev";
if (neighbors.length === 1) {
const only = neighbors[0];
return (
<button
type="button"
className="btn btn-ghost btn-sm max-w-xs truncate"
disabled={disabled}
title={only.label}
onClick={() => onGo(only.uiId)}
>
{isPrev ? `← Prev: ${only.label}` : `Try next: ${only.label}`}
</button>
);
}
const selected =
neighbors.some((n) => n.uiId === pick) ? pick : neighbors[0].uiId;
return (
<div className="join max-w-md">
<FormSelect
className="join-item select-sm max-w-[14rem] font-mono text-xs"
value={selected}
disabled={disabled}
onChange={(e) => onPick(e.target.value)}
aria-label={isPrev ? "Previous step" : "Next step"}
>
{neighbors.map((n) => (
<option key={n.uiId} value={n.uiId}>
{n.label}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-sm join-item"
disabled={disabled || !selected}
onClick={() => onGo(selected)}
>
{isPrev ? "← Go" : "Go →"}
</button>
</div>
);
}
function ScriptTryDialog({
kind = "script",
script,
expression: initialExpression = "",
config,
inheritedConfig,
meta,
owner,
disabled,
step,
index,
otherSteps,
trySession,
onTrySuccess,
predecessors = [],
successors = [],
onNavigateTry,
onApplyConfig,
onClose,
}) {
const isSet = kind === "set";
const existing = useScript(script, !isSet);
const dryRun = useDryRunScript();
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
const defaultData = useMemo(() => prettyJson(defaults.data ?? {}), [defaults]);
const defaultContext = useMemo(() => prettyJson(defaults.context ?? {}), [defaults]);
const [dataJson, setDataJson] = useState(defaultData);
const [contextJson, setContextJson] = useState(defaultContext);
const seed = useMemo(
() =>
seedTryDialog({
step: step ?? { uiId: script },
index: index ?? 0,
steps: otherSteps ?? [],
session: trySession ?? { byStep: {}, lastTriedUiId: null },
meta: isSet ? null : meta,
}),
[step, index, otherSteps, trySession, meta, isSet, script],
);
const [dataJson, setDataJson] = useState(() => prettyJson(seed.data ?? {}) || "{}");
const [contextJson, setContextJson] = useState(
() => prettyJson(seed.context ?? {}) || "{}",
);
const [configJson, setConfigJson] = useState(
() => prettyJson(config ?? {}) || "{}",
);
const [expression, setExpression] = useState(() => initialExpression ?? "");
const [baselineMerged, setBaselineMerged] = useState(() => ({
...(config ?? {}),
}));
const [baselineExpression, setBaselineExpression] = useState(
() => initialExpression ?? "",
);
const [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false);
const [seedHint] = useState(() => seed.source);
const [sessionResult, setSessionResult] = useState(() => seed.lastResult ?? null);
const [prevPick, setPrevPick] = useState(
() => predecessors[0]?.uiId ?? "",
);
const [nextPick, setNextPick] = useState(
() => successors[0]?.uiId ?? "",
);
useEffect(() => {
setDataJson(defaultData);
setContextJson(defaultContext);
}, [defaultData, defaultContext]);
const resultValue = useMemo(() => {
if (dryRun.data) {
if (dryRun.data.status === "success") {
return {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
};
}
return { error: dryRun.data.error };
}
if (sessionResult) {
return {
output: sessionResult.output,
context: sessionResult.context,
};
}
return undefined;
}, [dryRun.data, sessionResult]);
const resultJson = dryRun.data
? prettyJson(
dryRun.data.status === "success"
? {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
}
: { error: dryRun.data.error },
)
: "";
function parseTryConfig() {
return parseConfigObject(configJson);
}
const configParse = useMemo(() => {
if (isSet) return { ok: true, value: { expression } };
try {
return { ok: true, value: parseConfigObject(configJson) };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}, [configJson, isSet, expression]);
const configDirty = isSet
? expression !== baselineExpression
: configParse.ok && !configsEqual(configParse.value, baselineMerged);
const canApply =
Boolean(onApplyConfig) && !disabled && configParse.ok && configDirty;
function onRun() {
setParseError(null);
let data;
let context;
let runConfig;
try {
data = JSON.parse(dataJson || "null");
context = JSON.parse(contextJson || "{}");
if (!isSet) runConfig = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
if (context != null && (typeof context !== "object" || Array.isArray(context))) {
if (
context != null &&
(typeof context !== "object" || Array.isArray(context))
) {
setParseError("context must be a JSON object");
return;
}
if (isSet) {
if (!expression.trim()) {
setParseError("expression is required");
return;
}
dryRun.mutate(
{
name: "set",
expression,
data,
context: context ?? {},
config: { expression },
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
return;
}
if (!existing.data?.content) {
setParseError("could not load script source");
return;
}
dryRun.mutate({
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: config ?? {},
owner,
});
dryRun.mutate(
{
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: runConfig,
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
}
function onApply() {
setParseError(null);
if (isSet) {
onApplyConfig?.(expression);
setBaselineExpression(expression);
return;
}
let parsed;
try {
parsed = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
const overlay = overlayFromMerged(inheritedConfig, parsed);
onApplyConfig?.(overlay);
setBaselineMerged(parsed);
}
const runDisabled =
dryRun.isPending ||
(!isSet && (existing.isLoading || !configParse.ok)) ||
(isSet && !expression.trim());
return (
<dialog className="modal modal-open">
<div
@@ -477,10 +875,18 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
>
<div className="flex shrink-0 items-start gap-2">
<div className="min-w-0 flex-1">
<h3 className="font-bold font-mono">{script}</h3>
<h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
<p className="text-sm opacity-70">
Dry-run this script with the card config. Does not create an event.
{isSet
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
: "Dry-run this script with editable data, context, and config. Does not create an event. Use Apply to card to write config back to the step."}
</p>
{seedHint ? (
<p className="text-xs opacity-60 mt-1">
Seed: {seedHint}
{isSet ? " · sets do not mutate context" : ""}
</p>
) : null}
</div>
<button
type="button"
@@ -489,12 +895,25 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
aria-label={expanded ? "Exit full screen" : "Full screen"}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />}
{expanded ? (
<LuMinimize2 className="size-4" />
) : (
<LuMaximize2 className="size-4" />
)}
</button>
</div>
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null}
{parseError ? (
<p className="text-error text-sm mt-2 shrink-0">{parseError}</p>
) : null}
{!isSet && !configParse.ok ? (
<p className="text-error text-sm mt-2 shrink-0">
{configParse.error}
</p>
) : null}
{dryRun.isError ? (
<p className="text-error text-sm mt-2 shrink-0">{errorMessage(dryRun.error)}</p>
<p className="text-error text-sm mt-2 shrink-0">
{errorMessage(dryRun.error)}
</p>
) : null}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
@@ -502,47 +921,148 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">data</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
<CodeEditor
language="json"
value={dataJson}
onChange={setDataJson}
height="100%"
/>
</div>
</div>
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">context</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={contextJson} onChange={setContextJson} height="100%" />
<CodeEditor
language="json"
value={contextJson}
onChange={setContextJson}
height="100%"
/>
</div>
</div>
<div className={`md:col-span-2 ${expanded ? "min-h-0 flex-1 h-full" : "h-48"}`}>
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
result (output + context)
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">
{isSet ? (
"expression"
) : (
<>
config
{inheritedConfig ? (
<span className="opacity-50">
{" "}
(merged; Apply writes overlay)
</span>
) : null}
</>
)}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
{isSet ? (
<CodeEditor
language="javascript"
value={expression}
onChange={setExpression}
height="100%"
/>
) : (
<CodeEditor
language="json"
value={configJson}
onChange={setConfigJson}
height="100%"
/>
)}
</div>
</div>
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
result (output + context)
{dryRun.data?.status ? (
<StatusBadge status={dryRun.data.status} />
) : sessionResult && !dryRun.data ? (
<span className="badge badge-ghost badge-xs">session</span>
) : null}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
{resultValue === undefined ? (
<JsonTree
value={{}}
keyName="result"
className="h-full max-h-full"
/>
) : (
<JsonTree
value={resultValue}
keyName="result"
className="h-full max-h-full"
shortenTextAfterLength={undefined}
/>
)}
</div>
</div>
</div>
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0">
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary>
<div className="collapse-content">
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre>
</div>
</details>
{dryRun.data?.logs?.length ? (
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
<LogViewer
logs={dryRun.data.logs}
className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"}
/>
) : null}
<div className="modal-action shrink-0">
<button type="button" className="btn" onClick={onClose}>
Close
</button>
<button
type="button"
className="btn btn-primary"
disabled={dryRun.isPending || existing.isLoading}
onClick={onRun}
>
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
Run
</button>
<div className="modal-action shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<TryNavCluster
direction="prev"
neighbors={predecessors}
pick={prevPick}
onPick={setPrevPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
<TryNavCluster
direction="next"
neighbors={successors}
pick={nextPick}
onPick={setNextPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn" onClick={onClose}>
Close
</button>
<button
type="button"
className="btn"
disabled={!canApply}
onClick={onApply}
title={
disabled
? "Card is read-only"
: isSet
? !configDirty
? "No expression changes to apply"
: "Write expression back to the workflow step"
: !configParse.ok
? "Fix config JSON first"
: !configDirty
? "No config changes to apply"
: "Write overlay back to the workflow step"
}
>
Apply to card
</button>
<button
type="button"
className="btn btn-primary"
disabled={runDisabled}
onClick={onRun}
>
{dryRun.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : null}
Run
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
@@ -28,6 +28,10 @@ export function ScriptsTab({
workflows = [],
owner,
excludeFile,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const [addOpen, setAddOpen] = useState(false);
const sensors = useSensors(
@@ -98,12 +102,24 @@ export function ScriptsTab({
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === step.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? step.uiId : null);
}}
onNavigateTry={(targetUiId) => {
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
patchSteps(copy);
}}
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))}
onRemove={() => {
if (tryFocusUiId === step.uiId) onTryFocus?.(null);
patchSteps(steps.filter((_, i) => i !== index));
}}
/>
))}
</div>
@@ -1,6 +1,11 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
import {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
} from "../../lib/try-session.js";
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
import { FormInput, FormTextarea } from "../FormControls.jsx";
import { GraphTab } from "./GraphTab.jsx";
@@ -23,6 +28,8 @@ export function WorkflowVisualEditor({
const [tab, setTab] = useState("graph");
const [lastEdited, setLastEdited] = useState("yaml");
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
const [trySession, setTrySession] = useState(emptyTrySession);
const [tryFocusUiId, setTryFocusUiId] = useState(/** @type {string | null} */ (null));
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -39,6 +46,38 @@ export function WorkflowVisualEditor({
const scriptCount = displayDoc?.scripts?.length ?? 0;
const triggerCount = displayDoc?.triggers?.length ?? 0;
const unsaved = savedYaml != null && yaml !== savedYaml;
const trySessionCount = Object.keys(trySession.byStep ?? {}).length;
useEffect(() => {
setTrySession((prev) => {
const next = pruneTrySession(prev, displayDoc?.scripts ?? []);
const prevKeys = Object.keys(prev.byStep ?? {});
const nextKeys = Object.keys(next.byStep ?? {});
if (
prevKeys.length === nextKeys.length &&
prevKeys.every((k) => next.byStep[k]) &&
prev.lastTriedUiId === next.lastTriedUiId
) {
return prev;
}
return next;
});
const ids = new Set((displayDoc?.scripts ?? []).map((s) => s.uiId));
setTryFocusUiId((cur) => (cur && !ids.has(cur) ? null : cur));
}, [displayDoc?.scripts]);
function onTrySuccess(uiId, result) {
if (!uiId) return;
setTrySession((prev) => recordTrySuccess(prev, uiId, result));
}
function onTryFocus(uiId) {
setTryFocusUiId(uiId ?? null);
}
function clearTrySession() {
setTrySession(emptyTrySession());
}
function patchDoc(mutator) {
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -108,6 +147,10 @@ export function WorkflowVisualEditor({
excludeFile: file,
auths,
pages,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
};
return (
@@ -138,6 +181,16 @@ export function WorkflowVisualEditor({
}
/>
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
{trySessionCount > 0 ? (
<button
type="button"
className="btn btn-ghost btn-xs"
title="Clear Try session (cached step outputs for seeding)"
onClick={clearTrySession}
>
Clear try session ({trySessionCount})
</button>
) : null}
</div>
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
@@ -192,6 +245,10 @@ export function WorkflowVisualEditor({
workflows={workflows}
owner={owner}
excludeFile={file}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryFocusUiId={tryFocusUiId}
onTryFocus={onTryFocus}
/>
) : null}
{tab === "triggers" ? (
+1 -1
View File
@@ -1 +1 @@
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared";
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
+251
View File
@@ -0,0 +1,251 @@
/**
* Client-side Try session helpers (hybrid seed for ScriptTryDialog).
* Mirrors server chainCtx / mergeStepData without importing server modules.
*/
import { contextFromMeta } from "./script.js";
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js";
/**
* @typedef {{
* data: unknown,
* context: Record<string, unknown>,
* output: unknown,
* resultContext: Record<string, unknown>,
* at: number,
* }} TryStepRecord
*
* @typedef {{
* byStep: Record<string, TryStepRecord>,
* lastTriedUiId: string | null,
* }} TrySession
*
* @typedef {{
* data: unknown,
* context: Record<string, unknown>,
* source: string,
* lastResult?: { output: unknown, context: Record<string, unknown> },
* }} TrySeed
*/
/** @returns {TrySession} */
export function emptyTrySession() {
return { byStep: {}, lastTriedUiId: null };
}
/**
* @param {TrySession} session
* @param {string} uiId
* @param {{ data: unknown, context: unknown, output: unknown, resultContext: unknown }} result
* @returns {TrySession}
*/
export function recordTrySuccess(session, uiId, result) {
const context =
result.context != null &&
typeof result.context === "object" &&
!Array.isArray(result.context)
? /** @type {Record<string, unknown>} */ (result.context)
: {};
const resultContext =
result.resultContext != null &&
typeof result.resultContext === "object" &&
!Array.isArray(result.resultContext)
? /** @type {Record<string, unknown>} */ (result.resultContext)
: {};
return {
byStep: {
...session.byStep,
[uiId]: {
data: result.data,
context,
output: result.output,
resultContext,
at: Date.now(),
},
},
lastTriedUiId: uiId,
};
}
/**
* Drop records for removed steps.
* @param {TrySession} session
* @param {Array<{ uiId: string }>} steps
* @returns {TrySession}
*/
export function pruneTrySession(session, steps) {
const keep = new Set((steps ?? []).map((s) => s.uiId));
/** @type {Record<string, TryStepRecord>} */
const byStep = {};
for (const [id, rec] of Object.entries(session.byStep ?? {})) {
if (keep.has(id)) byStep[id] = rec;
}
const lastTriedUiId =
session.lastTriedUiId && keep.has(session.lastTriedUiId)
? session.lastTriedUiId
: null;
return { byStep, lastTriedUiId };
}
/**
* Human-readable step label for seed hints.
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string }} step
* @param {number} [index]
*/
export function stepTryLabel(step, index) {
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
const name =
step.kind === "set"
? "set"
: step.profile || step.script || "step";
if (id) return `step ${n} / ${id} (${name})`;
return `step ${n} / ${name}`;
}
/**
* Build data from needs the way the runner does (mergeStepData).
* @param {unknown} needs
* @param {Map<string, TryStepRecord>} byId — keyed by step.id
* @returns {{ ok: true, data: unknown, labels: string[] } | { ok: false }}
*/
function mergeNeedsFromTries(needs, byId) {
const mode = needsMode(needs);
if (mode === "none" || isEmptyNeeds(needs)) return { ok: false };
if (mode === "list") {
const list = /** @type {string[]} */ (needs);
if (list.length === 1) {
const rec = byId.get(list[0]);
if (!rec) return { ok: false };
return { ok: true, data: rec.output ?? null, labels: [list[0]] };
}
/** @type {Record<string, unknown>} */
const data = {};
const labels = [];
for (const from of list) {
const rec = byId.get(from);
if (!rec) return { ok: false };
data[from] = rec.output ?? null;
labels.push(from);
}
return { ok: true, data, labels };
}
// map: { alias: fromId }
const map = /** @type {Record<string, string>} */ (needs);
/** @type {Record<string, unknown>} */
const data = {};
const labels = [];
for (const [alias, from] of Object.entries(map)) {
const rec = byId.get(from);
if (!rec) return { ok: false };
data[alias] = rec.output ?? null;
labels.push(from);
}
return { ok: true, data, labels };
}
/**
* Hybrid seed for opening Try on a step.
*
* @param {{
* step: { uiId: string, kind?: string, script?: string, profile?: string, id?: string, needs?: unknown },
* index: number,
* steps: Array<{ uiId: string, kind?: string, script?: string, profile?: string, id?: string, needs?: unknown }>,
* session: TrySession,
* meta?: unknown,
* }} opts
* @returns {TrySeed}
*/
export function seedTryDialog({ step, index, steps, session, meta }) {
const byStep = session?.byStep ?? {};
const self = byStep[step.uiId];
if (self) {
return {
data: self.data,
context: self.context ?? {},
source: "restored from last try on this step",
lastResult: {
output: self.output,
context: self.resultContext ?? {},
},
};
}
const dag = isDagDoc({ scripts: steps });
if (!dag && index > 0) {
const prev = steps[index - 1];
const prevRec = prev ? byStep[prev.uiId] : null;
if (prevRec) {
return {
data: prevRec.output ?? null,
context: prevRec.resultContext ?? {},
source: `from ${stepTryLabel(prev, index - 1)}`,
};
}
}
if (!isEmptyNeeds(step.needs)) {
/** @type {Map<string, TryStepRecord>} */
const byId = new Map();
/** @type {Map<string, { step: typeof steps[0], index: number }>} */
const stepById = new Map();
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (typeof s.id === "string" && s.id.trim()) {
stepById.set(s.id, { step: s, index: i });
const rec = byStep[s.uiId];
if (rec) byId.set(s.id, rec);
}
}
const merged = mergeNeedsFromTries(step.needs, byId);
if (merged.ok) {
// Context: use the most recently tried upstream among needs (shared clipboard).
let context = {};
let newestAt = -1;
const labelParts = [];
for (const id of merged.labels) {
const info = stepById.get(id);
const rec = byId.get(id);
if (info) labelParts.push(stepTryLabel(info.step, info.index));
if (rec && rec.at > newestAt) {
newestAt = rec.at;
context = rec.resultContext ?? {};
}
}
return {
data: merged.data,
context,
source: `from needs (${labelParts.join(", ")})`,
};
}
}
const lastId = session?.lastTriedUiId;
if (lastId && byStep[lastId]) {
const lastRec = byStep[lastId];
const lastIndex = steps.findIndex((s) => s.uiId === lastId);
const lastStep = lastIndex >= 0 ? steps[lastIndex] : null;
return {
data: lastRec.output ?? null,
context: lastRec.resultContext ?? {},
source: lastStep
? `from last try (${stepTryLabel(lastStep, lastIndex)})`
: "from last try",
};
}
const fromMeta = contextFromMeta(meta);
return {
data: fromMeta.data ?? {},
context:
fromMeta.context != null &&
typeof fromMeta.context === "object" &&
!Array.isArray(fromMeta.context)
? fromMeta.context
: {},
source: meta?.example ? "from script example" : "empty defaults",
};
}
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
seedTryDialog,
} from "./try-session.js";
function step(uiId, extra = {}) {
return {
uiId,
kind: extra.kind ?? "script",
script: extra.script ?? uiId,
profile: "",
config: {},
id: extra.id ?? "",
when: "",
needs: extra.needs ?? null,
expression: extra.expression,
};
}
test("empty defaults when no session", () => {
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a")],
session: emptyTrySession(),
meta: null,
});
assert.deepEqual(seed.data, {});
assert.deepEqual(seed.context, {});
assert.equal(seed.source, "empty defaults");
});
test("restores self when already tried", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: { in: 1 },
context: { c: 1 },
output: { out: 1 },
resultContext: { c: 2 },
});
// Later try on b should not overwrite restore of a
session = recordTrySuccess(session, "b", {
data: { in: 9 },
context: {},
output: { out: 9 },
resultContext: {},
});
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a"), step("b")],
session,
meta: null,
});
assert.deepEqual(seed.data, { in: 1 });
assert.deepEqual(seed.context, { c: 1 });
assert.match(seed.source, /restored/);
assert.deepEqual(seed.lastResult?.output, { out: 1 });
});
test("linear previous step seeds next", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { fromA: true },
resultContext: { clip: 1 },
});
const seed = seedTryDialog({
step: step("b"),
index: 1,
steps: [step("a"), step("b")],
session,
meta: null,
});
assert.deepEqual(seed.data, { fromA: true });
assert.deepEqual(seed.context, { clip: 1 });
assert.match(seed.source, /from step 1/);
});
test("needs merge when all upstream tried", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { a: 1 },
resultContext: { x: 1 },
});
session = recordTrySuccess(session, "b", {
data: {},
context: {},
output: { b: 2 },
resultContext: { x: 2 },
});
const seed = seedTryDialog({
step: step("c", { id: "c", needs: { left: "a", right: "b" } }),
index: 2,
steps: [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: { left: "a", right: "b" } }),
],
session,
meta: null,
});
assert.deepEqual(seed.data, { left: { a: 1 }, right: { b: 2 } });
assert.match(seed.source, /from needs/);
});
test("needs skips when upstream missing then falls back to last try", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { onlyA: true },
resultContext: {},
});
const seed = seedTryDialog({
step: step("c", { id: "c", needs: ["a", "b"] }),
index: 2,
steps: [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: ["a", "b"] }),
],
session,
meta: null,
});
assert.deepEqual(seed.data, { onlyA: true });
assert.match(seed.source, /from last try/);
});
test("script example when nothing tried", () => {
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a")],
session: emptyTrySession(),
meta: { example: { data: { hello: 1 }, context: { k: 2 } } },
});
assert.deepEqual(seed.data, { hello: 1 });
assert.deepEqual(seed.context, { k: 2 });
assert.equal(seed.source, "from script example");
});
test("pruneTrySession drops removed steps", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: 1,
resultContext: {},
});
session = recordTrySuccess(session, "b", {
data: {},
context: {},
output: 2,
resultContext: {},
});
const pruned = pruneTrySession(session, [step("a")]);
assert.ok(pruned.byStep.a);
assert.equal(pruned.byStep.b, undefined);
assert.equal(pruned.lastTriedUiId, null);
});
+16
View File
@@ -124,6 +124,22 @@ function adjacencyFromScripts(scripts) {
return children;
}
/** Outgoing neighbors (linear next, or DAG children). */
export function stepSuccessors(scripts, uiId) {
return adjacencyFromScripts(scripts ?? []).get(uiId) ?? [];
}
/** Incoming neighbors (linear previous, or DAG parents). */
export function stepPredecessors(scripts, uiId) {
const children = adjacencyFromScripts(scripts ?? []);
/** @type {string[]} */
const out = [];
for (const [from, tos] of children) {
if (tos.includes(uiId)) out.push(from);
}
return out;
}
export function wouldCreateCycle(scripts, fromUiId, toUiId) {
if (fromUiId === toUiId) return true;
const children = adjacencyFromScripts(scripts);
@@ -6,6 +6,8 @@ import {
canConnectSteps,
enteringDagWouldStripWhen,
removeStepEdge,
stepPredecessors,
stepSuccessors,
wouldCreateCycle,
} from "./workflow-graph.js";
@@ -87,3 +89,27 @@ test("removeStepEdge drops a list dependency", () => {
assert.equal(scripts[1].needs, null);
assert.equal(isDagDoc({ scripts }), false);
});
test("stepSuccessors / stepPredecessors linear", () => {
const scripts = [step("a"), step("b"), step("c")];
assert.deepEqual(stepSuccessors(scripts, "a"), ["b"]);
assert.deepEqual(stepSuccessors(scripts, "b"), ["c"]);
assert.deepEqual(stepSuccessors(scripts, "c"), []);
assert.deepEqual(stepPredecessors(scripts, "a"), []);
assert.deepEqual(stepPredecessors(scripts, "b"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "c"), ["b"]);
});
test("stepSuccessors / stepPredecessors DAG multi", () => {
const scripts = [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: ["a", "b"] }),
step("d", { id: "d", needs: ["a"] }),
];
assert.deepEqual(stepSuccessors(scripts, "a").sort(), ["c", "d"]);
assert.deepEqual(stepSuccessors(scripts, "b"), ["c"]);
assert.deepEqual(stepPredecessors(scripts, "c").sort(), ["a", "b"]);
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "a"), []);
});
+45 -1
View File
@@ -1,6 +1,9 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useKv, useKvNamespaces } from "../api/hooks.js";
import { LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useDeleteKv, useKv, useKvNamespaces } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { formatTime } from "../lib/format";
@@ -19,8 +22,10 @@ export function KvPage() {
const q = params.get("q") || "";
const offset = Math.max(Number(params.get("offset")) || 0, 0);
const [expanded, setExpanded] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const { data: namespaces = [] } = useKvNamespaces();
const del = useDeleteKv();
const { data, isLoading } = useKv({
namespace: namespace || undefined,
q: q || undefined,
@@ -83,6 +88,7 @@ export function KvPage() {
<th>Value</th>
<th>Updated</th>
<th>Expires</th>
<th />
</tr>
</thead>
<tbody>
@@ -119,6 +125,21 @@ export function KvPage() {
<td className="whitespace-nowrap align-top">
{item.expiresAt ? formatTime(item.expiresAt) : "—"}
</td>
<td className="text-right whitespace-nowrap align-top">
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
aria-label="Delete"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(item);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
);
})}
@@ -148,6 +169,29 @@ export function KvPage() {
</div>
</>
)}
<ConfirmDialog
open={Boolean(confirmDelete)}
title={
confirmDelete ? `Delete ${confirmDelete.namespace}/${confirmDelete.key}?` : ""
}
message="This cannot be undone. Scripts that read this key will get null."
error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending}
onCancel={() => setConfirmDelete(null)}
onConfirm={() =>
del.mutate(
{ namespace: confirmDelete.namespace, key: confirmDelete.key },
{
onSuccess: () => {
const id = rowId(confirmDelete);
setConfirmDelete(null);
setExpanded((current) => (current === id ? null : current));
},
},
)
}
/>
</div>
);
}
@@ -142,11 +142,6 @@ export function WorkflowNewPage() {
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<p className="text-sm opacity-70 shrink-0">
A UUID filename is assigned on save (for example{" "}
<span className="font-mono">a1b2c3d4-….yaml</span>). Edit the{" "}
<span className="font-mono">name:</span> field for the display name.
</p>
<WorkflowVisualEditor
yaml={content}
onYamlChange={setContent}
+106 -6
View File
@@ -14,11 +14,78 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format";
const SORT_COLUMNS = [
{ key: "name", label: "Name", defaultOrder: "asc" },
{ key: "owner", label: "Owner", defaultOrder: "asc" },
{ key: "status", label: "Status", defaultOrder: "asc" },
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
{ key: "invocationCount", label: "Runs", defaultOrder: "desc" },
];
function SortHeader({ column, label, sort, order, onSort }) {
const active = sort === column;
return (
<th>
<button
type="button"
className={`font-semibold hover:underline ${active ? "" : "opacity-80"}`}
onClick={() => onSort(column)}
>
{label}
{active ? (order === "asc" ? " ↑" : " ↓") : ""}
</button>
</th>
);
}
function statusSortKey(w) {
if (w.loadError) return "0-broken";
if (!w.enabled) return "1-disabled";
if (w.lastStatus === "failed") return "2-failed";
if (w.lastStatus === "queued") return "3-queued";
if (w.lastStatus === "running") return "4-running";
if (w.lastStatus === "success") return "5-working";
return "6-never";
}
function compareName(a, b) {
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
sensitivity: "base",
});
if (byName !== 0) return byName;
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
}
function compareWorkflows(a, b, sort) {
if (sort === "name") return compareName(a, b);
if (sort === "owner") {
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
sensitivity: "base",
});
return byOwner !== 0 ? byOwner : compareName(a, b);
}
if (sort === "status") {
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
return byStatus !== 0 ? byStatus : compareName(a, b);
}
if (sort === "invocationCount") {
const byCount = (Number(a.invocationCount) || 0) - (Number(b.invocationCount) || 0);
return byCount !== 0 ? byCount : compareName(a, b);
}
const aTime = a[sort] ?? "";
const bTime = b[sort] ?? "";
const byTime = String(aTime).localeCompare(String(bTime));
return byTime !== 0 ? byTime : compareName(a, b);
}
export function WorkflowsPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const editParam = params.get("edit");
const { data: workflows = [], isLoading } = useWorkflows();
const [sort, setSortColumn] = useState("name");
const [order, setOrder] = useState("asc");
const [confirmDelete, setConfirmDelete] = useState(null);
const [duplicateSource, setDuplicateSource] = useState(null);
const [runError, setRunError] = useState(null);
@@ -68,6 +135,19 @@ export function WorkflowsPage() {
? `${setEnabled.variables.owner}/${setEnabled.variables.file}`
: null;
const dir = order === "desc" ? -1 : 1;
const sortedWorkflows = [...workflows].sort((a, b) => dir * compareWorkflows(a, b, sort));
function setSort(column) {
const spec = SORT_COLUMNS.find((c) => c.key === column);
if (sort === column) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setSortColumn(column);
setOrder(spec?.defaultOrder ?? "asc");
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
@@ -115,17 +195,36 @@ export function WorkflowsPage() {
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Status</th>
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
<SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
<SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
<th>Triggers</th>
<th>Last run</th>
<th>Runs</th>
<SortHeader
column="lastModifiedAt"
label="Last modified"
sort={sort}
order={order}
onSort={setSort}
/>
<SortHeader
column="lastInvokedAt"
label="Last run"
sort={sort}
order={order}
onSort={setSort}
/>
<SortHeader
column="invocationCount"
label="Runs"
sort={sort}
order={order}
onSort={setSort}
/>
<th />
</tr>
</thead>
<tbody>
{workflows.map((w) => (
{sortedWorkflows.map((w) => (
<tr key={w.key} className="hover">
<td>
<Link
@@ -152,6 +251,7 @@ export function WorkflowsPage() {
<td>
<TriggerList triggers={w.triggers} />
</td>
<td className="whitespace-nowrap">{formatTime(w.lastModifiedAt)}</td>
<td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td>
<td>{w.invocationCount}</td>
<td className="text-right whitespace-nowrap">