feat(server): enhance script execution and workflow management
- Introduced compileWorkflowScripts function to parse and validate workflow scripts, supporting both linear and DAG execution modes. - Added runLinearSteps and runDagSteps functions for executing compiled workflows based on their structure. - Updated createRegistry function to utilize the new workflow compilation and execution logic. - Enhanced script sandbox with meta extraction and instantiation capabilities for better script management. - Improved API endpoints to return script metadata and handle errors more effectively. - Removed deprecated scripts and updated workflows to reflect new structure and functionality.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
function FieldTable({ title, fields }) {
|
||||
const entries = Object.entries(fields ?? {});
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide opacity-60">{title}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([name, spec]) => {
|
||||
const field = spec && typeof spec === "object" ? spec : { description: String(spec) };
|
||||
const notes = [
|
||||
field.required ? "required" : null,
|
||||
field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null,
|
||||
field.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className="font-mono">{name}</td>
|
||||
<td className="opacity-70">{field.type ?? ""}</td>
|
||||
<td className="opacity-80">{notes}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScriptMetaPanel({ meta, metaError, className = "" }) {
|
||||
if (metaError) {
|
||||
return <p className={`text-error text-sm ${className}`}>Meta: {metaError}</p>;
|
||||
}
|
||||
if (!meta) {
|
||||
return (
|
||||
<p className={`text-sm opacity-50 ${className}`}>
|
||||
No script.meta on the default export
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{meta.description ? <p className="text-sm">{meta.description}</p> : null}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<FieldTable title="Config" fields={meta.config} />
|
||||
<FieldTable title="Input (data)" fields={meta.input} />
|
||||
<FieldTable title="Output" fields={meta.output} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
export const NEW_SCRIPT_TEMPLATE = `export default async function main(ctx) {
|
||||
export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
main.meta = {
|
||||
description: "",
|
||||
config: {},
|
||||
input: {},
|
||||
output: {},
|
||||
example: {
|
||||
data: {},
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default main;
|
||||
`;
|
||||
|
||||
export const DEFAULT_INPUT_CONTEXT = `{
|
||||
@@ -23,3 +36,28 @@ export function prettyJson(value) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultsFromFields(fields) {
|
||||
if (fields == null || typeof fields !== "object" || Array.isArray(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
if (spec && typeof spec === "object" && "default" in spec) {
|
||||
out[key] = spec.default;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function contextFromMeta(meta) {
|
||||
if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
|
||||
return {
|
||||
data: "data" in meta.example ? meta.example.data : {},
|
||||
config: "config" in meta.example ? meta.example.config ?? {} : {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: defaultsFromFields(meta?.input),
|
||||
config: defaultsFromFields(meta?.config),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,11 +8,30 @@ function sanitize(label) {
|
||||
}
|
||||
|
||||
function parseStep(step) {
|
||||
if (typeof step === "string") return step;
|
||||
if (step?.script) return step.script;
|
||||
if (typeof step === "string") {
|
||||
return { script: step, id: null, needs: null };
|
||||
}
|
||||
if (step?.script) {
|
||||
return {
|
||||
script: step.script,
|
||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||
needs: step.needs ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parentIdsFromNeeds(needs) {
|
||||
if (needs == null) return null;
|
||||
if (Array.isArray(needs)) {
|
||||
return needs.filter((x) => typeof x === "string" && x.length > 0);
|
||||
}
|
||||
if (typeof needs === "object") {
|
||||
return Object.values(needs).filter((x) => typeof x === "string" && x.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function workflowToArchitecture(parsed) {
|
||||
/** @type {Record<string, string>} */
|
||||
const scriptIds = {};
|
||||
@@ -38,27 +57,61 @@ export function workflowToArchitecture(parsed) {
|
||||
});
|
||||
|
||||
const scripts = Array.isArray(parsed.scripts) ? parsed.scripts : [];
|
||||
let prev = null;
|
||||
let firstScript = null;
|
||||
const parsedSteps = [];
|
||||
scripts.forEach((step, i) => {
|
||||
const script = parseStep(step);
|
||||
if (!script) return;
|
||||
const id = `s${i}`;
|
||||
scriptIds[id] = script;
|
||||
lines.push(` service ${id}(server)[${sanitize(script)}] in ${groupId}`);
|
||||
if (!firstScript) firstScript = id;
|
||||
if (prev) lines.push(` ${prev}:R -- L:${id}`);
|
||||
prev = id;
|
||||
const parsedStep = parseStep(step);
|
||||
if (!parsedStep) return;
|
||||
parsedSteps.push({ ...parsedStep, mermaidId: `s${i}` });
|
||||
});
|
||||
|
||||
// All triggers point at the first script (not chained to each other).
|
||||
if (firstScript) {
|
||||
const dagMode = parsedSteps.some((s) => s.needs != null);
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const idToMermaid = {};
|
||||
for (const s of parsedSteps) {
|
||||
if (s.id) idToMermaid[s.id] = s.mermaidId;
|
||||
}
|
||||
|
||||
for (const s of parsedSteps) {
|
||||
scriptIds[s.mermaidId] = s.script;
|
||||
lines.push(
|
||||
` service ${s.mermaidId}(server)[${sanitize(s.id || s.script)}] in ${groupId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dagMode) {
|
||||
const roots = [];
|
||||
for (const s of parsedSteps) {
|
||||
const parents = parentIdsFromNeeds(s.needs);
|
||||
if (!parents || parents.length === 0) {
|
||||
roots.push(s.mermaidId);
|
||||
continue;
|
||||
}
|
||||
for (const pid of parents) {
|
||||
const from = idToMermaid[pid];
|
||||
if (from) lines.push(` ${from}:R -- L:${s.mermaidId}`);
|
||||
}
|
||||
}
|
||||
for (const tid of triggerIds) {
|
||||
lines.push(` ${tid}:R -- L:${firstScript}`);
|
||||
for (const rid of roots) {
|
||||
lines.push(` ${tid}:R -- L:${rid}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let prev = null;
|
||||
let firstScript = null;
|
||||
for (const s of parsedSteps) {
|
||||
if (!firstScript) firstScript = s.mermaidId;
|
||||
if (prev) lines.push(` ${prev}:R -- L:${s.mermaidId}`);
|
||||
prev = s.mermaidId;
|
||||
}
|
||||
if (firstScript) {
|
||||
for (const tid of triggerIds) {
|
||||
lines.push(` ${tid}:R -- L:${firstScript}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep multiple triggers in one column so they don't overlap.
|
||||
if (triggerIds.length >= 2) {
|
||||
lines.push(` align column ${triggerIds.join(" ")}`);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
} from "../api/hooks.js";
|
||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { StatusBadge } from "../lib/format.jsx";
|
||||
import {
|
||||
DEFAULT_INPUT_CONTEXT,
|
||||
NEW_SCRIPT_TEMPLATE,
|
||||
normalizeScriptName,
|
||||
contextFromMeta,
|
||||
prettyJson,
|
||||
} from "../lib/script.js";
|
||||
|
||||
@@ -29,6 +30,7 @@ export function ScriptDryRunPage() {
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
|
||||
const [inputTouched, setInputTouched] = useState(false);
|
||||
const [outputJson, setOutputJson] = useState("");
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [runStatus, setRunStatus] = useState(null);
|
||||
@@ -56,6 +58,14 @@ export function ScriptDryRunPage() {
|
||||
const backHref = `/scripts/${encodeURIComponent(name)}/edit`;
|
||||
|
||||
const lastRun = dryRun.data;
|
||||
const meta = lastRun?.meta ?? existing.data?.meta ?? null;
|
||||
const metaError = lastRun?.metaError ?? existing.data?.metaError ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (inputTouched) return;
|
||||
if (!existing.data?.meta) return;
|
||||
setInputJson(prettyJson(contextFromMeta(existing.data.meta)));
|
||||
}, [existing.data?.meta, inputTouched]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastRun) return;
|
||||
@@ -170,7 +180,10 @@ export function ScriptDryRunPage() {
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={inputJson}
|
||||
onChange={setInputJson}
|
||||
onChange={(value) => {
|
||||
setInputTouched(true);
|
||||
setInputJson(value);
|
||||
}}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
@@ -208,6 +221,15 @@ export function ScriptDryRunPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details className="collapse collapse-arrow shrink-0 border border-base-300 bg-base-100">
|
||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
|
||||
Input / output
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
<ScriptMetaPanel meta={meta} metaError={metaError} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<LogViewer logs={logs} className="h-48 shrink-0 lg:h-56" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useSaveScript, useScript } from "../api/hooks.js";
|
||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
||||
|
||||
export function ScriptNewPage() {
|
||||
@@ -157,6 +158,15 @@ export function ScriptEditPage() {
|
||||
<CodeEditor language="javascript" value={content} onChange={setContent} height="100%" />
|
||||
</form>
|
||||
|
||||
<details className="collapse collapse-arrow shrink-0 border border-base-300 bg-base-100">
|
||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
|
||||
Input / output
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
<ScriptMetaPanel meta={existing.data?.meta} metaError={existing.data?.metaError} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
|
||||
@@ -34,30 +34,42 @@ export function ScriptsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th className="w-28" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scripts.map((s) => (
|
||||
<tr key={s} className="hover">
|
||||
{scripts.map((s) => {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
const description = typeof s === "string" ? "" : s.meta?.description;
|
||||
const metaError = typeof s === "string" ? null : s.metaError;
|
||||
return (
|
||||
<tr key={name} className="hover">
|
||||
<td className="font-mono">
|
||||
<Link className="link" to={`/scripts/${encodeURIComponent(s)}/edit`}>
|
||||
{s}
|
||||
<Link className="link" to={`/scripts/${encodeURIComponent(name)}/edit`}>
|
||||
{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="max-w-xl truncate text-sm opacity-80">
|
||||
{metaError ? (
|
||||
<span className="text-error">{metaError}</span>
|
||||
) : (
|
||||
description || "—"
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Dry run"
|
||||
onClick={() =>
|
||||
navigate(`/scripts/${encodeURIComponent(s)}/dry-run`)
|
||||
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
|
||||
}
|
||||
>
|
||||
<LuPlay className="size-4" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(s)}/edit`}
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
>
|
||||
@@ -67,13 +79,14 @@ export function ScriptsPage() {
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDelete(s)}
|
||||
onClick={() => setConfirmDelete(name)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user