feat(scripts): enhance script return structure and context handling

- Introduced a new contract for script returns, requiring an object with `output`, `context`, and `skipRemaining` fields.
- Refactored existing scripts to align with the new return structure, ensuring compatibility with the updated context management.
- Added utility functions for normalizing context and step results, improving the handling of script execution context.
- Updated various scripts (e.g., fetch-binary, fetch-html, fetch-http) to return structured output and context, enhancing data flow and usability.
- Improved error handling for script returns, ensuring clearer feedback when invalid structures are returned.

This update significantly enhances the flexibility and clarity of script interactions within workflows.
This commit is contained in:
2026-08-15 22:32:42 +07:00
parent e8e1cf383d
commit 43e9370638
36 changed files with 766 additions and 337 deletions
+2 -1
View File
@@ -107,11 +107,12 @@ export function useDeleteScript() {
export function useDryRunScript() {
return useMutation({
mutationFn: async ({ name, content, data, config, owner }) =>
mutationFn: async ({ name, content, data, context, config, owner }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content,
data,
context,
config,
owner,
})
@@ -1,4 +1,4 @@
import { scriptTags } from "../lib/script.js";
import { scriptTags, scriptReadsCtx } from "../lib/script.js";
import { TagBadge } from "./TagBadge.jsx";
function FieldTable({ title, fields }) {
@@ -64,17 +64,21 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
return (
<div className={`space-y-3 ${className}`}>
{meta.description ? <p className="text-sm">{meta.description}</p> : null}
{tags.length ? (
{(tags.length || scriptReadsCtx(meta)) ? (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<TagBadge key={tag} tag={tag} />
))}
{scriptReadsCtx(meta) ? (
<span className="badge badge-outline badge-sm font-mono">reads: ctx</span>
) : null}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<FieldTable title="Config" fields={meta.config} />
<FieldTable title="Input (data)" fields={meta.input} />
<FieldTable title="Output" fields={meta.output} />
<FieldTable title="Context" fields={meta.context} />
</div>
</div>
);
@@ -49,7 +49,9 @@ export function AddScriptDialog({ open, onClose, onPick }) {
}}
>
<span className="font-semibold">Set (JSONata)</span>
<span className="text-xs opacity-70">Assign a JSONata result to a variable</span>
<span className="text-xs opacity-70">
JSONata over ctx; result becomes the next step’s data
</span>
</button>
</li>
{isLoading ? (
@@ -42,10 +42,10 @@ export function ScriptCard({
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(step.config, meta?.previewConfigKey);
const previewFull = configValueText(step.config, meta?.previewConfigKey);
const baseName = step.kind === "set" ? `set:${step.as || "…"}` : step.script || "untitled";
const baseName = step.kind === "set" ? "set" : step.script || "untitled";
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig =
step.kind === "set" ? { as: step.as ?? "", expression: step.expression ?? "" } : null;
step.kind === "set" ? { expression: step.expression ?? "" } : null;
return (
<article
@@ -95,7 +95,9 @@ export function ScriptCard({
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
) : null}
{expanded && step.kind === "set" ? (
<p className="text-sm opacity-70 mt-1">Assign a JSONata result onto the context</p>
<p className="text-sm opacity-70 mt-1">
JSONata over ctx; result becomes the next step’s data
</p>
) : null}
</button>
{step.kind === "script" ? (
@@ -142,14 +144,11 @@ export function ScriptCard({
<>
{step.kind === "set" ? (
<div className="space-y-2">
<FieldLabel name="as" required description="Context field to write (not data or config)" />
<input
className="input input-sm w-full font-mono"
value={step.as ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...step, as: e.target.value })}
<FieldLabel
name="expression"
required
description="JSONata evaluated against ctx (data, context, config). Result becomes the next step's data."
/>
<FieldLabel name="expression" required description="JSONata evaluated against ctx" />
<textarea
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
value={step.expression ?? ""}
@@ -186,14 +185,12 @@ export function ScriptCard({
<span className="text-error text-xs">Duplicate step id</span>
) : null}
</label>
{step.kind === "set" ? null : (
<NeedsEditor
step={step}
otherSteps={otherSteps}
disabled={disabled}
onChange={onChange}
/>
)}
<NeedsEditor
step={step}
otherSteps={otherSteps}
disabled={disabled}
onChange={onChange}
/>
<label className="form-control">
<span className="label py-0 text-sm">when</span>
<input
@@ -244,7 +241,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
disabled={disabled}
onChange={(e) => setMode(e.target.value)}
>
<option value="none">None (use previous step data)</option>
<option value="none">None (trigger data / previous output)</option>
<option value="list">List of step ids</option>
<option value="map">Map alias → step id</option>
</select>
@@ -366,29 +363,46 @@ function needsMode(needs) {
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script);
const dryRun = useDryRunScript();
const defaultData = useMemo(() => prettyJson(contextFromMeta(meta).data ?? {}), [meta]);
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 [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setDataJson(defaultData);
}, [defaultData]);
setContextJson(defaultContext);
}, [defaultData, defaultContext]);
const outputJson = dryRun.data
? prettyJson(dryRun.data.status === "success" ? dryRun.data.output : { error: dryRun.data.error })
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 onRun() {
setParseError(null);
let data;
let context;
try {
const parsed = JSON.parse(dataJson || "null");
data = parsed;
data = JSON.parse(dataJson || "null");
context = JSON.parse(contextJson || "{}");
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
if (context != null && (typeof context !== "object" || Array.isArray(context))) {
setParseError("context must be a JSON object");
return;
}
if (!existing.data?.content) {
setParseError("could not load script source");
return;
@@ -397,6 +411,7 @@ export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: config ?? {},
owner,
});
@@ -442,12 +457,18 @@ export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
</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%" />
</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">
output
result (output + context)
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={outputJson} onChange={() => {}} readOnly height="100%" />
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
</div>
</div>
</div>
@@ -62,7 +62,8 @@ export function ScriptsTab({
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto">
<div className="flex shrink-0 items-center justify-between">
<p className="text-sm opacity-70">
Steps run top to bottom (or by needs in DAG mode). Drag the handle to reorder.
Steps run top to bottom. In DAG mode, <span className="font-mono">needs</span> selects
upstream outputs as this step’s data; context is shared per wave.
</p>
<button
type="button"
+10 -1
View File
@@ -1,5 +1,5 @@
export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
return ctx;
return { output: ctx.data ?? null, context: ctx.context ?? {} };
}
main.meta = {
@@ -9,8 +9,10 @@ main.meta = {
config: {},
input: {},
output: {},
context: {},
example: {
data: {},
context: {},
config: {},
},
};
@@ -20,6 +22,7 @@ export default main;
export const DEFAULT_INPUT_CONTEXT = `{
"data": {},
"context": {},
"config": {}
}
`;
@@ -35,6 +38,10 @@ export function scriptTags(meta) {
return meta.tags.map((t) => String(t).trim()).filter(Boolean);
}
export function scriptReadsCtx(meta) {
return meta?.reads === "ctx";
}
const TAG_PALETTE = [
{ bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" },
{ bg: "color-mix(in oklab, #059669 22%, transparent)", text: "#059669" },
@@ -94,11 +101,13 @@ export function contextFromMeta(meta) {
if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
return {
data: "data" in meta.example ? meta.example.data : {},
context: "context" in meta.example ? meta.example.context ?? {} : {},
config: "config" in meta.example ? meta.example.config ?? {} : {},
};
}
return {
data: defaultsFromFields(meta?.input),
context: defaultsFromFields(meta?.context),
config: defaultsFromFields(meta?.config),
};
}
+2 -4
View File
@@ -96,7 +96,6 @@ export function newSetStep() {
uiId: nextUiId("step"),
kind: "set",
script: "set",
as: "value",
expression: "",
id: "",
when: "",
@@ -187,11 +186,10 @@ function normalizeStep(step) {
uiId,
kind: "set",
script: "set",
as: typeof spec.as === "string" ? spec.as : "",
expression: typeof spec.expression === "string" ? spec.expression : "",
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
needs: null,
needs: step.needs ?? null,
};
}
const known = new Set(["script", "config", "id", "when", "needs", "set"]);
@@ -254,9 +252,9 @@ function dumpStep(step) {
if (step.id) out.id = step.id;
out.set = {
expression: step.expression ?? "",
as: step.as ?? "",
};
if (step.when) out.when = step.when;
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
return out;
}
/** @type {Record<string, unknown>} */
+3 -6
View File
@@ -1,15 +1,13 @@
function parseStep(step) {
if (typeof step === "string") {
return { kind: "script", script: step, id: null, needs: null, when: null, as: null };
return { kind: "script", script: step, id: null, needs: null, when: null };
}
if (step?.set) {
const as = typeof step.set?.as === "string" && step.set.as ? step.set.as : "set";
return {
kind: "set",
script: null,
as,
id: typeof step.id === "string" && step.id ? step.id : null,
needs: null,
needs: step.needs ?? null,
when: typeof step.when === "string" && step.when ? step.when : null,
};
}
@@ -17,7 +15,6 @@ function parseStep(step) {
return {
kind: "script",
script: step.script,
as: null,
id: typeof step.id === "string" && step.id ? step.id : null,
needs: step.needs ?? null,
when: typeof step.when === "string" && step.when ? step.when : null,
@@ -55,7 +52,7 @@ function triggerLabel(t) {
function stepLabel(s) {
if (s.kind === "set") {
const base = s.id ? `${s.id}: set ${s.as}` : `set ${s.as}`;
const base = s.id ? `${s.id}: set` : "set";
return s.when ? `${base} when: ${s.when}` : base;
}
const base = s.id ? `${s.id}: ${s.script}` : s.script;
+101 -40
View File
@@ -1,14 +1,13 @@
import { useMemo, useState } from "react";
import { useMemo, useState, Fragment } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { LuArrowLeft, LuFilter } from "react-icons/lu";
import { useRun } from "../api/hooks.js";
import { LogViewer } from "../components/LogViewer.jsx";
import { formatTime, StatusBadge } from "../lib/format.jsx";
import { prettyJson } from "../lib/script.js";
function stepLabel(s) {
if (s.script === "set") {
return s.config?.as ? `set:${s.config.as}` : "set";
}
if (s.script === "set") return "set";
return s.script;
}
@@ -20,11 +19,39 @@ function isEditableScript(s) {
return Boolean(s.script) && s.script !== "set";
}
function JsonBlock({ title, value }) {
if (value == null || value === "") return null;
const text = prettyJson(value);
if (!text) return null;
return (
<details className="collapse collapse-arrow border border-base-300 bg-base-100">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
<div className="collapse-content">
<pre className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 font-mono text-xs">
{text}
</pre>
</div>
</details>
);
}
function envelopeParts(raw) {
if (raw != null && typeof raw === "object" && !Array.isArray(raw) && ("output" in raw || "context" in raw)) {
return {
output: "output" in raw ? raw.output : undefined,
context: "context" in raw ? raw.context : undefined,
skipRemaining: raw.skipRemaining === true,
};
}
return { output: raw, context: undefined, skipRemaining: false };
}
export function EventDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { data: run, isLoading, error } = useRun(id);
const [stepFilters, setStepFilters] = useState([]);
const [openStep, setOpenStep] = useState(null);
function goBack() {
const idx = window.history.state?.idx;
@@ -60,6 +87,8 @@ export function EventDetailPage() {
if (isLoading) return <span className="loading loading-spinner loading-lg" />;
if (error || !run) return <p className="text-error">Event not found</p>;
const runParts = envelopeParts(run.output);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -98,6 +127,15 @@ export function EventDetailPage() {
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<JsonBlock title="Run input (data)" value={run.input} />
<JsonBlock title="Run output" value={runParts.output} />
<JsonBlock title="Run context" value={runParts.context} />
</div>
{runParts.skipRemaining ? (
<p className="text-sm opacity-70">This run stopped early (<span className="font-mono">skipRemaining</span>).</p>
) : null}
<section>
<h2 className="font-semibold mb-2">Steps</h2>
<div className="overflow-x-auto">
@@ -113,42 +151,65 @@ export function EventDetailPage() {
</tr>
</thead>
<tbody>
{steps.map((s) => (
<tr key={s.id}>
<td>{s.step_index}</td>
<td className="font-mono">
{isEditableScript(s) ? (
<Link className="link" to={`/scripts/${encodeURIComponent(s.script)}/edit`}>
{stepLabel(s)}
</Link>
) : (
stepLabel(s)
)}
</td>
<td>
<StatusBadge status={s.status} />
</td>
<td>{s.duration_ms != null ? `${s.duration_ms}ms` : "—"}</td>
<td
className={
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
}
>
{s.error || ""}
</td>
<td className="text-right">
<button
type="button"
className={`btn btn-ghost btn-xs btn-square ${stepFilters.includes(s.id) ? "btn-active" : ""}`}
aria-label={`Filter logs for ${filterLabel(s)}`}
aria-pressed={stepFilters.includes(s.id)}
onClick={() => toggleStepFilter(s.id)}
>
<LuFilter className="size-3.5" />
</button>
</td>
</tr>
))}
{steps.map((s) => {
const parts = envelopeParts(s.output);
const open = openStep === s.id;
return (
<Fragment key={s.id}>
<tr>
<td>{s.step_index}</td>
<td className="font-mono">
{isEditableScript(s) ? (
<Link className="link" to={`/scripts/${encodeURIComponent(s.script)}/edit`}>
{stepLabel(s)}
</Link>
) : (
stepLabel(s)
)}
</td>
<td>
<StatusBadge status={s.status} />
</td>
<td>{s.duration_ms != null ? `${s.duration_ms}ms` : "—"}</td>
<td
className={
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
}
>
{s.error || (parts.skipRemaining ? "skipRemaining" : "")}
</td>
<td className="text-right">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setOpenStep(open ? null : s.id)}
>
I/O
</button>
<button
type="button"
className={`btn btn-ghost btn-xs btn-square ${stepFilters.includes(s.id) ? "btn-active" : ""}`}
aria-label={`Filter logs for ${filterLabel(s)}`}
aria-pressed={stepFilters.includes(s.id)}
onClick={() => toggleStepFilter(s.id)}
>
<LuFilter className="size-3.5" />
</button>
</td>
</tr>
{open ? (
<tr>
<td colSpan={6} className="bg-base-200">
<div className="grid gap-2 p-2 sm:grid-cols-2">
<JsonBlock title="output" value={parts.output} />
<JsonBlock title="context" value={parts.context} />
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
+7 -3
View File
@@ -76,7 +76,9 @@ export function ScriptDryRunPage() {
setRunStatus(lastRun.status);
setLogs(lastRun.logs ?? []);
if (lastRun.status === "success") {
setOutputJson(prettyJson(lastRun.output));
const envelope = { output: lastRun.output, context: lastRun.context };
if (lastRun.skipRemaining) envelope.skipRemaining = true;
setOutputJson(prettyJson(envelope));
} else {
setOutputJson(prettyJson({ error: lastRun.error ?? "run failed" }));
}
@@ -97,10 +99,11 @@ export function ScriptDryRunPage() {
throw new Error(err instanceof Error ? err.message : "invalid JSON");
}
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error('input context must be a JSON object with "data" and/or "config"');
throw new Error('input must be a JSON object with "data", "context", and/or "config"');
}
return {
data: "data" in parsed ? parsed.data : null,
context: "context" in parsed ? parsed.context : {},
config: "config" in parsed ? parsed.config : null,
};
}
@@ -119,6 +122,7 @@ export function ScriptDryRunPage() {
name,
content,
data: ctx.data,
context: ctx.context,
config: ctx.config,
owner,
});
@@ -229,7 +233,7 @@ export function ScriptDryRunPage() {
</section>
<section className="flex min-h-0 flex-col gap-1">
<h2 className="shrink-0 text-sm font-semibold opacity-70">Output</h2>
<h2 className="shrink-0 text-sm font-semibold opacity-70">Result (output + context)</h2>
<div className="min-h-0 flex-1">
<CodeEditor
language="json"