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
+25
View File
@@ -21,6 +21,31 @@ pnpm dev
The first account created becomes **admin**. Later accounts are created from Users. The first account created becomes **admin**. Later accounts are created from Users.
## Script contract
Each script is `async function main(ctx)` and **must** return:
```js
{ output, context?, skipRemaining? }
```
| Field | Meaning |
|---|---|
| `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object, default `{}`) |
| `ctx.config` | This step’s YAML config |
| `output` | Becomes the **next** step’s `data` |
| `context` | Next snapshot of the bag. Omitted → keep incoming |
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` |
Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned.
YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same.
DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run.
Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / `meta.output` / `meta.context` describe `data`, the return pipe, and clipboard keys.
## Scripts ## Scripts
| Command | Description | | Command | Description |
+129 -46
View File
@@ -15,6 +15,13 @@ import {
namespacedPath, namespacedPath,
parseScriptStep, parseScriptStep,
} from "./workflow-parse.js"; } from "./workflow-parse.js";
import {
chainCtx,
mergeContextWave,
normalizeContext,
normalizeStepResult,
storedEnvelope,
} from "./step-result.js";
import * as fsStore from "./fs-store.js"; import * as fsStore from "./fs-store.js";
import { import {
checkHttpAuth, checkHttpAuth,
@@ -327,24 +334,15 @@ export function createRegistry(server) {
); );
} }
function isSkipRemaining(result) {
return (
result != null &&
typeof result === "object" &&
!Array.isArray(result) &&
/** @type {{ skipRemaining?: unknown }} */ (result).skipRemaining === true
);
}
/** /**
* @param {import("./workflow-parse.js").CompiledStep} parsed * @param {import("./workflow-parse.js").CompiledStep} parsed
* @param {number} index * @param {number} index
* @param {unknown} ctx * @param {import("./step-result.js").StepResult} last
* @param {string} runId * @param {string} runId
* @param {import("pino").Logger} runLog * @param {import("pino").Logger} runLog
* @param {string} reason * @param {string} reason
*/ */
async function markStepSkipped(parsed, index, ctx, runId, runLog, reason) { async function markStepSkipped(parsed, index, last, runId, runLog, reason) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const step = await store.startStep({ const step = await store.startStep({
runId, runId,
@@ -354,12 +352,12 @@ export function createRegistry(server) {
}); });
const stepLog = runLog.child({ stepId: step.id, script }); const stepLog = runLog.child({ stepId: step.id, script });
stepLog.debug({ reason }, "step skipped"); stepLog.debug({ reason }, "step skipped");
await store.finishStep(step.id, "skipped", ctx, reason); await store.finishStep(step.id, "skipped", storedEnvelope(last), reason);
} }
/** /**
* @param {import("./workflow-parse.js").CompiledScripts} compiled * @param {import("./workflow-parse.js").CompiledScripts} compiled
* @param {{ data?: unknown }} ctx * @param {{ data?: unknown, context?: Record<string, unknown> }} ctx
* @param {string} runId * @param {string} runId
* @param {import("pino").Logger} runLog * @param {import("pino").Logger} runLog
* @param {string} key * @param {string} key
@@ -367,11 +365,17 @@ export function createRegistry(server) {
* @param {number} depth * @param {number} depth
*/ */
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) { async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) {
let next = ctx; /** @type {import("./step-result.js").StepResult} */
let last = {
output: ctx.data,
context: normalizeContext(ctx.context),
skipRemaining: false,
};
let next = { data: ctx.data, context: last.context };
for (let i = 0; i < compiled.order.length; i++) { for (let i = 0; i < compiled.order.length; i++) {
const index = compiled.order[i]; const index = compiled.order[i];
const parsed = compiled.steps[index]; const parsed = compiled.steps[index];
next = await runCompiledStep( last = await runCompiledStep(
parsed, parsed,
next, next,
index, index,
@@ -381,13 +385,14 @@ export function createRegistry(server) {
owner, owner,
depth, depth,
); );
if (isSkipRemaining(next)) { next = chainCtx(last);
if (last.skipRemaining) {
for (let j = i + 1; j < compiled.order.length; j++) { for (let j = i + 1; j < compiled.order.length; j++) {
const laterIndex = compiled.order[j]; const laterIndex = compiled.order[j];
await markStepSkipped( await markStepSkipped(
compiled.steps[laterIndex], compiled.steps[laterIndex],
laterIndex, laterIndex,
next, last,
runId, runId,
runLog, runLog,
"skipRemaining", "skipRemaining",
@@ -396,12 +401,12 @@ export function createRegistry(server) {
break; break;
} }
} }
return next; return last;
} }
/** /**
* @param {import("./workflow-parse.js").CompiledScripts} compiled * @param {import("./workflow-parse.js").CompiledScripts} compiled
* @param {{ data?: unknown }} ctx * @param {{ data?: unknown, context?: Record<string, unknown> }} ctx
* @param {string} runId * @param {string} runId
* @param {import("pino").Logger} runLog * @param {import("pino").Logger} runLog
* @param {string} key * @param {string} key
@@ -412,15 +417,64 @@ export function createRegistry(server) {
const triggerData = ctx.data; const triggerData = ctx.data;
/** @type {Map<string, unknown>} */ /** @type {Map<string, unknown>} */
const outputsById = new Map(); const outputsById = new Map();
let last = ctx; /** @type {Map<string, number>} */
const idToIndex = new Map();
for (const step of compiled.steps) {
if (step.id) idToIndex.set(step.id, step.index);
}
for (const [orderIndex, stepIndex] of compiled.order.entries()) { const remaining = new Set(compiled.steps.map((s) => s.index));
const parsed = compiled.steps[stepIndex]; const completed = new Set();
let context = normalizeContext(ctx.context);
/** @type {import("./step-result.js").StepResult} */
let last = {
output: ctx.data,
context,
skipRemaining: false,
};
let execIndex = 0;
/**
* @param {import("./workflow-parse.js").CompiledStep} step
*/
function needsMet(step) {
if (step.needsKind === "none" || step.needs.length === 0) return true;
return step.needs.every((edge) => completed.has(idToIndex.get(edge.from)));
}
while (remaining.size) {
const wave = compiled.steps
.filter((s) => remaining.has(s.index) && needsMet(s))
.sort((a, b) => a.index - b.index);
if (wave.length === 0) {
throw new Error("Workflow has a cycle");
}
const snapshot = { ...context };
/** @type {Array<{ id: string, context: unknown }>} */
const patches = [];
let skipRest = false;
for (const parsed of wave) {
remaining.delete(parsed.index);
const index = execIndex;
execIndex += 1;
if (skipRest) {
await markStepSkipped(
parsed,
index,
last,
runId,
runLog,
"skipRemaining",
);
continue;
}
const data = mergeStepData(parsed, outputsById, triggerData); const data = mergeStepData(parsed, outputsById, triggerData);
last = await runCompiledStep( last = await runCompiledStep(
parsed, parsed,
{ ...ctx, data }, { data, context: { ...snapshot } },
orderIndex, index,
runId, runId,
runLog, runLog,
key, key,
@@ -428,14 +482,26 @@ export function createRegistry(server) {
depth, depth,
); );
if (parsed.id) { if (parsed.id) {
outputsById.set(parsed.id, last); outputsById.set(parsed.id, last.output);
} }
if (isSkipRemaining(last)) { patches.push({
for (let j = orderIndex + 1; j < compiled.order.length; j++) { id: parsed.id ?? String(parsed.index),
const laterParsed = compiled.steps[compiled.order[j]]; context: last.context,
});
if (last.skipRemaining) skipRest = true;
}
context = mergeContextWave(snapshot, patches);
last = { ...last, context };
if (skipRest) {
for (const later of compiled.steps.filter((s) => remaining.has(s.index))) {
remaining.delete(later.index);
const index = execIndex;
execIndex += 1;
await markStepSkipped( await markStepSkipped(
laterParsed, later,
j, index,
last, last,
runId, runId,
runLog, runLog,
@@ -444,7 +510,10 @@ export function createRegistry(server) {
} }
break; break;
} }
for (const parsed of wave) completed.add(parsed.index);
} }
return last; return last;
} }
@@ -483,13 +552,14 @@ export function createRegistry(server) {
/** /**
* @param {import("./workflow-parse.js").CompiledStep} parsed * @param {import("./workflow-parse.js").CompiledStep} parsed
* @param {{ data?: unknown, config?: unknown }} ctx * @param {{ data?: unknown, context?: unknown, config?: unknown }} ctx
* @param {number} index * @param {number} index
* @param {string} runId * @param {string} runId
* @param {import("pino").Logger} runLog * @param {import("pino").Logger} runLog
* @param {string} key * @param {string} key
* @param {string} owner * @param {string} owner
* @param {number} depth * @param {number} depth
* @returns {Promise<import("./step-result.js").StepResult>}
*/ */
async function runCompiledStep( async function runCompiledStep(
parsed, parsed,
@@ -503,6 +573,12 @@ export function createRegistry(server) {
) { ) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const config = parsed.config; const config = parsed.config;
const incomingContext = normalizeContext(ctx.context);
const stepCtx = {
data: ctx.data,
context: incomingContext,
config,
};
const step = await store.startStep({ const step = await store.startStep({
runId, runId,
index, index,
@@ -512,29 +588,36 @@ export function createRegistry(server) {
const stepLog = runLog.child({ stepId: step.id, script }); const stepLog = runLog.child({ stepId: step.id, script });
try { try {
if (parsed.when) { if (parsed.when) {
const whenResult = await evaluateJsonata(parsed.when, ctx); const whenResult = await evaluateJsonata(parsed.when, stepCtx);
if (!isJsonataTruthy(whenResult)) { if (!isJsonataTruthy(whenResult)) {
stepLog.debug({ when: parsed.when }, "step skipped"); stepLog.debug({ when: parsed.when }, "step skipped");
await store.finishStep(step.id, "skipped", ctx, "when condition"); const skipped = {
return ctx; output: stepCtx.data,
context: incomingContext,
skipRemaining: false,
};
await store.finishStep(step.id, "skipped", storedEnvelope(skipped), "when condition");
return skipped;
} }
} }
if (parsed.kind === "set") { if (parsed.kind === "set") {
if (ctx == null || typeof ctx !== "object" || Array.isArray(ctx)) { const value = await evaluateJsonata(parsed.expression, stepCtx);
throw new Error("set requires an object context"); const result = {
} output: value,
const value = await evaluateJsonata(parsed.expression, ctx); context: incomingContext,
const result = { ...ctx, [parsed.as]: value }; skipRemaining: false,
await store.finishStep(step.id, "success", result); };
await store.finishStep(step.id, "success", storedEnvelope(result));
return result; return result;
} }
const result = await runScript(script, { ...ctx, config }, { const raw = await runScript(script, stepCtx, {
log: stepLog, log: stepLog,
workflowName: key, workflowName: key,
owner, owner,
$workflows: createWorkflowsApi(owner, key, runId, depth), $workflows: createWorkflowsApi(owner, key, runId, depth),
}); });
await store.finishStep(step.id, "success", result); const result = normalizeStepResult(raw, incomingContext, script);
await store.finishStep(step.id, "success", storedEnvelope(result));
return result; return result;
} catch (err) { } catch (err) {
await store.finishStep(step.id, "failed", null, err); await store.finishStep(step.id, "failed", null, err);
@@ -544,7 +627,7 @@ export function createRegistry(server) {
/** /**
* @param {string} key * @param {string} key
* @param {{ data?: unknown }} context * @param {{ data?: unknown, context?: unknown }} context
* @param {{ type: string, detail?: string | null }} trigger * @param {{ type: string, detail?: string | null }} trigger
* @param {{ * @param {{
* parentRunId?: string | null, * parentRunId?: string | null,
@@ -580,8 +663,8 @@ export function createRegistry(server) {
runLog.debug(detach ? "running workflow (detached)" : "running workflow"); runLog.debug(detach ? "running workflow (detached)" : "running workflow");
const initialCtx = { const initialCtx = {
...context,
data: context.data ?? workflow.data ?? null, data: context.data ?? workflow.data ?? null,
context: normalizeContext(context.context),
}; };
const execute = async () => { const execute = async () => {
@@ -609,8 +692,8 @@ export function createRegistry(server) {
depth, depth,
); );
} }
await store.finishRun(run.id, "success", ctx); await store.finishRun(run.id, "success", storedEnvelope(ctx));
return { runId: run.id, status: "success", result: ctx }; return { runId: run.id, status: "success", result: storedEnvelope(ctx) };
} catch (err) { } catch (err) {
runLog.error({ err }, "workflow failed"); runLog.error({ err }, "workflow failed");
await store.finishRun(run.id, "failed", null, err); await store.finishRun(run.id, "failed", null, err);
+28 -12
View File
@@ -1,7 +1,15 @@
function ensureDataObject(ctx) { function passContext(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
ctx.data = {}; return { ...ctx.context };
} }
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
} }
function filenameFromUrl(url) { function filenameFromUrl(url) {
@@ -38,8 +46,6 @@ function resolveUrl(ctx) {
} }
async function fetchBinary(ctx) { async function fetchBinary(ctx) {
ensureDataObject(ctx);
const url = resolveUrl(ctx); const url = resolveUrl(ctx);
if (!url) { if (!url) {
throw new Error( throw new Error(
@@ -55,27 +61,32 @@ async function fetchBinary(ctx) {
const response = await $axios.get(url, { responseType: "arraybuffer" }); const response = await $axios.get(url, { responseType: "arraybuffer" });
const file = Buffer.from(response.data ?? []); const file = Buffer.from(response.data ?? []);
const contentType = String(response.headers?.["content-type"] ?? "application/octet-stream"); const contentType = String(response.headers?.["content-type"] ?? "application/octet-stream");
const filename = ctx.config?.filename || ctx.data.filename || filenameFromUrl(url); const filename = ctx.config?.filename || ctx.data?.filename || filenameFromUrl(url);
ctx.data[outputVar] = file; const extra = {
ctx.data.filename = filename; [outputVar]: file,
ctx.data.contentType = contentType; filename,
contentType,
};
log.info( log.info(
{ outputVar, filename, contentType, length: file.length }, { outputVar, filename, contentType, length: file.length },
"fetch-binary: saved", "fetch-binary: saved",
); );
return ctx; return {
output: { ...mergeData(ctx.data), ...extra },
context: { ...passContext(ctx), ...extra },
};
} }
fetchBinary.meta = { fetchBinary.meta = {
description: "Download a binary URL into ctx.data as a Buffer", description: "Download a binary URL and add the Buffer to output (keeps previous data fields)",
previewConfigKey: "url", previewConfigKey: "url",
config: { config: {
url: { type: "string", required: false, description: "Direct download URL" }, url: { type: "string", required: false, description: "Direct download URL" },
urlVar: { type: "string", required: false, description: "Key in ctx.data that holds the URL" }, urlVar: { type: "string", required: false, description: "Key in ctx.data that holds the URL" },
outputVar: { type: "string", default: "file", description: "ctx.data key for the Buffer" }, outputVar: { type: "string", default: "file", description: "output key for the Buffer" },
filename: { type: "string", required: false, description: "Override saved filename" }, filename: { type: "string", required: false, description: "Override saved filename" },
}, },
input: { input: {
@@ -88,6 +99,11 @@ fetchBinary.meta = {
filename: { type: "string" }, filename: { type: "string" },
contentType: { type: "string" }, contentType: { type: "string" },
}, },
context: {
file: { type: "buffer", description: "Downloaded bytes (or ctx.config.outputVar)" },
filename: { type: "string" },
contentType: { type: "string" },
},
example: { example: {
data: { attach: "https://example.com/image.png" }, data: { attach: "https://example.com/image.png" },
config: { outputVar: "file" }, config: { outputVar: "file" },
+17 -15
View File
@@ -1,10 +1,11 @@
import { parse } from "node-html-parser"; import { parse } from "node-html-parser";
import jsonata from "jsonata"; import jsonata from "jsonata";
function ensureDataObject(ctx) { function passContext(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
ctx.data = {}; return { ...ctx.context };
} }
return {};
} }
function serializeElement(el) { function serializeElement(el) {
@@ -43,8 +44,6 @@ async function fetchHtml(ctx) {
throw new Error("fetch-html: ctx.config.outputVar is required when selector or jsonata is set"); throw new Error("fetch-html: ctx.config.outputVar is required when selector or jsonata is set");
} }
ensureDataObject(ctx);
log.info({ url }, "fetch-html: fetching page"); log.info({ url }, "fetch-html: fetching page");
const response = await $axios.get(url); const response = await $axios.get(url);
log.info( log.info(
@@ -52,19 +51,20 @@ async function fetchHtml(ctx) {
"fetch-html: fetch complete", "fetch-html: fetch complete",
); );
ctx.data.httpResponse = String(response.data ?? ""); /** @type {Record<string, unknown>} */
const output = { httpResponse: String(response.data ?? "") };
log.info( log.info(
{ length: ctx.data.httpResponse.length }, { length: String(output.httpResponse).length },
"fetch-html: saved httpResponse", "fetch-html: saved httpResponse",
); );
if (selector) { if (selector) {
log.info({ selector, outputVar }, "fetch-html: selecting elements"); log.info({ selector, outputVar }, "fetch-html: selecting elements");
const root = parse(ctx.data.httpResponse); const root = parse(String(output.httpResponse));
const elements = root.querySelectorAll(selector); const elements = root.querySelectorAll(selector);
ctx.data[outputVar] = elements.map(serializeElement); output[outputVar] = elements.map(serializeElement);
log.info( log.info(
{ outputVar, count: ctx.data[outputVar].length }, { outputVar, count: output[outputVar].length },
"fetch-html: saved selector matches", "fetch-html: saved selector matches",
); );
} }
@@ -72,16 +72,15 @@ async function fetchHtml(ctx) {
if (jsonataExpr) { if (jsonataExpr) {
log.info({ outputVar, jsonata: jsonataExpr }, "fetch-html: evaluating jsonata"); log.info({ outputVar, jsonata: jsonataExpr }, "fetch-html: evaluating jsonata");
const expression = jsonata(jsonataExpr); const expression = jsonata(jsonataExpr);
const input = ctx.data[outputVar]; const result = await expression.evaluate(output[outputVar]);
const result = await expression.evaluate(input); output[outputVar] = result;
ctx.data[outputVar] = result;
log.info( log.info(
{ outputVar, result: previewValue(result) }, { outputVar, result: previewValue(result) },
"fetch-html: saved jsonata result", "fetch-html: saved jsonata result",
); );
} }
return ctx; return { output, context: { ...passContext(ctx), ...output } };
} }
fetchHtml.meta = { fetchHtml.meta = {
@@ -100,7 +99,10 @@ fetchHtml.meta = {
}, },
input: {}, input: {},
output: { output: {
httpResponse: { type: "string", description: "Raw HTML" }, httpResponse: { type: "string", description: "Raw HTML (overwritten when outputVar is httpResponse)" },
},
context: {
httpResponse: { type: "any", description: "Same keys as output" },
}, },
example: { example: {
data: {}, data: {},
+14 -10
View File
@@ -1,9 +1,10 @@
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
function ensureDataObject(ctx) { function passContext(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
ctx.data = {}; return { ...ctx.context };
} }
return {};
} }
function isPlainObject(value) { function isPlainObject(value) {
@@ -40,7 +41,6 @@ function normalizeHeaders(value, label) {
headers[key] = String(val); headers[key] = String(val);
continue; continue;
} }
// Secret wrappers are unwrapped by $axios; do not String() them.
if (typeof val === "object") { if (typeof val === "object") {
headers[key] = val; headers[key] = val;
continue; continue;
@@ -88,8 +88,6 @@ async function fetchHttp(ctx) {
const headers = resolveHeaders(ctx); const headers = resolveHeaders(ctx);
const body = resolveBody(ctx); const body = resolveBody(ctx);
ensureDataObject(ctx);
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const request = { url, method, headers }; const request = { url, method, headers };
if (body.present && method !== "HEAD") { if (body.present && method !== "HEAD") {
@@ -103,14 +101,16 @@ async function fetchHttp(ctx) {
"fetch-http: fetch complete", "fetch-http: fetch complete",
); );
ctx.data.httpResponse = response.data; const output = {
ctx.data.httpStatus = response.status; httpResponse: response.data,
httpStatus: response.status,
};
log.info( log.info(
{ status: ctx.data.httpStatus, length: responseSize(ctx.data.httpResponse) }, { status: output.httpStatus, length: responseSize(output.httpResponse) },
"fetch-http: saved httpResponse", "fetch-http: saved httpResponse",
); );
return ctx; return { output, context: { ...passContext(ctx), ...output } };
} }
fetchHttp.meta = { fetchHttp.meta = {
@@ -154,6 +154,10 @@ fetchHttp.meta = {
httpResponse: { type: "any", description: "Response body as returned by the server" }, httpResponse: { type: "any", description: "Response body as returned by the server" },
httpStatus: { type: "number", description: "HTTP status code" }, httpStatus: { type: "number", description: "HTTP status code" },
}, },
context: {
httpResponse: { type: "any", description: "Same as output.httpResponse" },
httpStatus: { type: "number", description: "Same as output.httpStatus" },
},
example: { example: {
data: {}, data: {},
config: { config: {
+12 -9
View File
@@ -1,10 +1,11 @@
import rssParser from "rss-parser"; import rssParser from "rss-parser";
import jsonata from "jsonata"; import jsonata from "jsonata";
function ensureDataObject(ctx) { function passContext(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
ctx.data = {}; return { ...ctx.context };
} }
return {};
} }
function previewValue(value) { function previewValue(value) {
@@ -28,8 +29,6 @@ async function fetchRssFeed(ctx) {
throw new Error("fetch-rss-feed: ctx.config.outputVar is required when jsonata is set"); throw new Error("fetch-rss-feed: ctx.config.outputVar is required when jsonata is set");
} }
ensureDataObject(ctx);
log.info({ url }, "fetch-rss-feed: fetching feed"); log.info({ url }, "fetch-rss-feed: fetching feed");
const parser = new rssParser({ const parser = new rssParser({
customFields: { customFields: {
@@ -46,7 +45,8 @@ async function fetchRssFeed(ctx) {
"fetch-rss-feed: fetch complete", "fetch-rss-feed: fetch complete",
); );
ctx.data.rssFeed = feed; /** @type {Record<string, unknown>} */
const output = { rssFeed: feed };
log.info( log.info(
{ title: feed.title, itemCount }, { title: feed.title, itemCount },
"fetch-rss-feed: saved rssFeed", "fetch-rss-feed: saved rssFeed",
@@ -56,14 +56,14 @@ async function fetchRssFeed(ctx) {
log.info({ outputVar, jsonata: jsonataExpr }, "fetch-rss-feed: evaluating jsonata"); log.info({ outputVar, jsonata: jsonataExpr }, "fetch-rss-feed: evaluating jsonata");
const expression = jsonata(jsonataExpr); const expression = jsonata(jsonataExpr);
const result = await expression.evaluate(feed); const result = await expression.evaluate(feed);
ctx.data[outputVar] = result; output[outputVar] = result;
log.info( log.info(
{ outputVar, result: previewValue(result) }, { outputVar, result: previewValue(result) },
"fetch-rss-feed: saved jsonata result", "fetch-rss-feed: saved jsonata result",
); );
} }
return ctx; return { output, context: { ...passContext(ctx), ...output } };
} }
fetchRssFeed.meta = { fetchRssFeed.meta = {
@@ -75,7 +75,7 @@ fetchRssFeed.meta = {
outputVar: { outputVar: {
type: "string", type: "string",
required: false, required: false,
description: "Required when jsonata is set; destination on ctx.data", description: "Required when jsonata is set; key on output and context",
}, },
jsonata: { jsonata: {
type: "string", type: "string",
@@ -89,6 +89,9 @@ fetchRssFeed.meta = {
output: { output: {
rssFeed: { type: "object", description: "Parsed RSS/Atom feed from rss-parser" }, rssFeed: { type: "object", description: "Parsed RSS/Atom feed from rss-parser" },
}, },
context: {
rssFeed: { type: "object", description: "Same as output.rssFeed (plus outputVar when set)" },
},
example: { example: {
data: {}, data: {},
config: { config: {
+35 -13
View File
@@ -1,9 +1,17 @@
import jsonata from "jsonata"; import jsonata from "jsonata";
function ensureDataObject(ctx) { function passContext(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
ctx.data = {}; return { ...ctx.context };
} }
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
} }
async function fingerprint(ctx) { async function fingerprint(ctx) {
@@ -28,13 +36,14 @@ async function fingerprint(ctx) {
maxAge: ctx.config?.maxAge, maxAge: ctx.config?.maxAge,
}); });
ensureDataObject(ctx); const extra = {
ctx.data.fingerprint = result.hash; fingerprint: result.hash,
ctx.data.fingerprintChanged = result.changed; fingerprintChanged: result.changed,
ctx.data.fingerprintPrevious = result.previous; fingerprintPrevious: result.previous,
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt; fingerprintAt: result.changed ? result.at : result.previousAt,
ctx.data.fingerprintAge = result.ageMs; fingerprintAge: result.ageMs,
ctx.data.fingerprintExpired = result.expired; fingerprintExpired: result.expired,
};
log.info( log.info(
{ {
@@ -46,17 +55,22 @@ async function fingerprint(ctx) {
"fingerprint: result", "fingerprint: result",
); );
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
const envelope = {
output: { ...mergeData(ctx.data), ...extra },
context: { ...passContext(ctx), ...extra },
};
if (!result.changed && skipRemaining) { if (!result.changed && skipRemaining) {
ctx.skipRemaining = true; envelope.skipRemaining = true;
} }
return envelope;
return ctx;
} }
fingerprint.meta = { fingerprint.meta = {
description: description:
"Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged", "Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged",
previewConfigKey: "key", previewConfigKey: "key",
reads: "ctx",
config: { config: {
key: { key: {
type: "string", type: "string",
@@ -88,6 +102,14 @@ fingerprint.meta = {
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" }, fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
fingerprintExpired: { type: "boolean" }, fingerprintExpired: { type: "boolean" },
}, },
context: {
fingerprint: { type: "string" },
fingerprintChanged: { type: "boolean" },
fingerprintPrevious: { type: "string", required: false },
fingerprintAt: { type: "string", required: false },
fingerprintAge: { type: "number", required: false },
fingerprintExpired: { type: "boolean" },
},
example: { example: {
data: { item: { guid: "https://example.com/post-1" } }, data: { item: { guid: "https://example.com/post-1" } },
config: { config: {
+16 -8
View File
@@ -1,22 +1,30 @@
// this script will get current time function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function getCurrentTime() { function getCurrentTime(ctx) {
return { const output = {
data: {
datetime: new Date().toISOString(), datetime: new Date().toISOString(),
processId: "1234" processId: "1234",
} };
} return { output, context: { ...passContext(ctx), ...output } };
} }
getCurrentTime.meta = { getCurrentTime.meta = {
description: "Return the current time as ctx.data.datetime", description: "Return the current time as output.datetime",
config: {}, config: {},
input: {}, input: {},
output: { output: {
datetime: { type: "string", description: "ISO timestamp" }, datetime: { type: "string", description: "ISO timestamp" },
processId: { type: "string" }, processId: { type: "string" },
}, },
context: {
datetime: { type: "string", description: "ISO timestamp" },
processId: { type: "string" },
},
example: { example: {
data: {}, data: {},
config: {}, config: {},
+23 -16
View File
@@ -1,3 +1,17 @@
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
async function getSecret(ctx) { async function getSecret(ctx) {
const name = ctx.config?.name; const name = ctx.config?.name;
if (typeof name !== "string" || name.length === 0) { if (typeof name !== "string" || name.length === 0) {
@@ -9,19 +23,16 @@ async function getSecret(ctx) {
: name; : name;
const value = await $secrets.get(name); const value = await $secrets.get(name);
const base = const extra = { [as]: value };
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {}; return {
const data = output: { ...mergeData(ctx.data), ...extra },
base.data != null && typeof base.data === "object" && !Array.isArray(base.data) context: { ...passContext(ctx), ...extra },
? { ...base.data } };
: {};
data[as] = value;
return { ...base, data };
} }
getSecret.meta = { getSecret.meta = {
description: description:
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.", "Load a named secret for this workflow owner onto output and context. The value is wrapped and redacted in logs.",
previewConfigKey: "name", previewConfigKey: "name",
config: { config: {
name: { name: {
@@ -32,16 +43,12 @@ getSecret.meta = {
as: { as: {
type: "string", type: "string",
required: false, required: false,
description: "ctx.data field to write (defaults to name)", description: "Field name on output and context (defaults to name)",
}, },
}, },
input: {}, input: {},
output: { output: {},
data: { context: {},
type: "object",
description: "Previous ctx.data plus the retrieved Secret at [as]",
},
},
example: { example: {
data: {}, data: {},
config: { name: "ntfy_token", as: "ntfyToken" }, config: { name: "ntfy_token", as: "ntfyToken" },
+14 -6
View File
@@ -1,26 +1,34 @@
import jsonata from "jsonata"; import jsonata from "jsonata";
async function jsonataFn(ctx) { async function jsonataFn(ctx) {
log.info({ctx}, "jsonata: context") log.info({ ctx }, "jsonata: context");
log.info("jsonata: evaluating expression %s", ctx.config.expression); log.info("jsonata: evaluating expression %s", ctx.config.expression);
const expression = jsonata(ctx.config.expression); const expression = jsonata(ctx.config.expression);
const result = await expression.evaluate(ctx.data); const result = await expression.evaluate(ctx);
log.info({ result }, "jsonata: expression result"); log.info({ result }, "jsonata: expression result");
return result; return {
output: result,
context:
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
? ctx.context
: {},
};
} }
jsonataFn.meta = { jsonataFn.meta = {
description: "Evaluate a JSONata expression against ctx.data and return the result as the next context", description:
"Evaluate a JSONata expression against the full ctx (data, context, config); the result is the next data",
previewConfigKey: "expression", previewConfigKey: "expression",
reads: "ctx",
config: { config: {
expression: { type: "string", required: true, description: "JSONata expression" }, expression: { type: "string", required: true, description: "JSONata expression against ctx" },
}, },
input: {}, input: {},
output: {}, output: {},
example: { example: {
data: { title: "Hello", url: "https://example.com" }, data: { title: "Hello", url: "https://example.com" },
config: { config: {
expression: '{"data": {"title": title, "message": title, "attach": url}}', expression: '{"title": data.title, "message": data.title, "attach": data.url}',
}, },
}, },
}; };
+14 -3
View File
@@ -1,5 +1,12 @@
import jsonata from "jsonata"; import jsonata from "jsonata";
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function ntfyHeaders(ctx) { function ntfyHeaders(ctx) {
const headers = {}; const headers = {};
@@ -72,11 +79,14 @@ async function ntfy(ctx) {
"ntfy: skipped, fingerprint unchanged", "ntfy: skipped, fingerprint unchanged",
); );
return { return {
output: {
sent: "false", sent: "false",
skipped: true, skipped: true,
fingerprint: checked.hash, fingerprint: checked.hash,
fingerprintAt: checked.previousAt, fingerprintAt: checked.previousAt,
fingerprintAge: checked.ageMs, fingerprintAge: checked.ageMs,
},
context: passContext(ctx),
}; };
} }
fp = checked; fp = checked;
@@ -126,7 +136,7 @@ async function ntfy(ctx) {
sent.fingerprint = stored.hash; sent.fingerprint = stored.hash;
sent.fingerprintAt = stored.at; sent.fingerprintAt = stored.at;
} }
return sent; return { output: sent, context: passContext(ctx) };
} }
ntfy.meta = { ntfy.meta = {
@@ -147,7 +157,7 @@ ntfy.meta = {
fingerprintJsonata: { fingerprintJsonata: {
type: "string", type: "string",
required: false, required: false,
description: "JSONata against ctx; default hashes title, message, attach, filename, contentType", description: "JSONata against full ctx; default hashes data title, message, attach, filename, contentType",
}, },
fingerprintMaxAge: { fingerprintMaxAge: {
type: "string", type: "string",
@@ -163,8 +173,9 @@ ntfy.meta = {
filename: { type: "string", required: false }, filename: { type: "string", required: false },
contentType: { type: "string", required: false }, contentType: { type: "string", required: false },
}, },
reads: "ctx",
output: { output: {
sent: { type: "string", description: "Replaces the workflow context with { sent: \"true\" }" }, sent: { type: "string", description: '"true" when sent, "false" when fingerprint skipped' },
}, },
example: { example: {
data: { title: "Hello", message: "Hello from jerapah-flow" }, data: { title: "Hello", message: "Hello from jerapah-flow" },
@@ -70,9 +70,15 @@ async function renderTemplate(ctx) {
const text = htmlToText(html); const text = htmlToText(html);
return { return {
output: {
html, html,
text, text,
template: templateName, template: templateName,
},
context:
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
? ctx.context
: {},
}; };
} }
+6
View File
@@ -218,6 +218,7 @@ async function sendEmail(ctx) {
log.info({ messageId: info.messageId }, "send-email: message sent"); log.info({ messageId: info.messageId }, "send-email: message sent");
return { return {
output: {
sent: true, sent: true,
messageId: info.messageId ?? null, messageId: info.messageId ?? null,
from, from,
@@ -225,6 +226,11 @@ async function sendEmail(ctx) {
cc: cc ?? null, cc: cc ?? null,
bcc: bcc ?? null, bcc: bcc ?? null,
subject, subject,
},
context:
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
? ctx.context
: {},
}; };
} }
+18 -17
View File
@@ -1,5 +1,12 @@
import jsonata from "jsonata"; import jsonata from "jsonata";
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
async function triggerWorkflow(ctx) { async function triggerWorkflow(ctx) {
const name = ctx.config?.name; const name = ctx.config?.name;
if (typeof name !== "string" || name.length === 0) { if (typeof name !== "string" || name.length === 0) {
@@ -9,27 +16,23 @@ async function triggerWorkflow(ctx) {
let data = ctx.data; let data = ctx.data;
const expression = ctx.config?.expression; const expression = ctx.config?.expression;
if (typeof expression === "string" && expression.length > 0) { if (typeof expression === "string" && expression.length > 0) {
const result = jsonata(expression).evaluate(ctx.data); const result = jsonata(expression).evaluate(ctx);
data = await result; data = await result;
} }
const started = await $workflows.trigger(name, data); const started = await $workflows.trigger(name, data);
const triggered = { name, runId: started?.runId ?? null }; return {
output: { name, runId: started?.runId ?? null },
const base = context: passContext(ctx),
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {}; };
if (base.data != null && typeof base.data === "object" && !Array.isArray(base.data)) {
return { ...base, data: { ...base.data, triggered } };
}
return { ...base, triggered };
} }
triggerWorkflow.meta = { triggerWorkflow.meta = {
description: description:
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.", "Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape the destination input with JSONata against full ctx.",
previewConfigKey: "name", previewConfigKey: "name",
tags: ["trigger"], tags: ["trigger"],
reads: "ctx",
config: { config: {
name: { name: {
type: "string", type: "string",
@@ -40,15 +43,13 @@ triggerWorkflow.meta = {
type: "string", type: "string",
required: false, required: false,
description: description:
"Optional JSONata expression evaluated against ctx.data; result becomes the destination run input", "Optional JSONata expression evaluated against ctx; result becomes the destination run input",
}, },
}, },
input: {}, input: {},
output: { output: {
triggered: { name: { type: "string", description: "Destination workflow name" },
type: "object", runId: { type: "string", description: "Started run id (null if the destination failed to start)" },
description: "Record of the kicked-off run ({ name, runId }); under data when data is an object",
},
}, },
example: { example: {
data: { data: {
@@ -58,7 +59,7 @@ triggerWorkflow.meta = {
config: { config: {
name: "notify-comic", name: "notify-comic",
expression: expression:
'{ "title": title, "message": title, "attach": url }', '{ "title": data.title, "message": data.title, "attach": data.url }',
}, },
}, },
}; };
+14 -3
View File
@@ -6,6 +6,7 @@ import {
} from "../../script-sandbox.js"; } from "../../script-sandbox.js";
import * as fsStore from "../../fs-store.js"; import * as fsStore from "../../fs-store.js";
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js"; import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
import { normalizeStepResult } from "../../step-result.js";
/** /**
* @param {{ referencedScripts: () => Set<string> }} registry * @param {{ referencedScripts: () => Set<string> }} registry
@@ -102,7 +103,7 @@ export default function scriptsPluginFactory(registry) {
return reply.code(err.statusCode ?? 400).send({ error: err.message }); return reply.code(err.statusCode ?? 400).send({ error: err.message });
} }
const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ ( const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {} req.body ?? {}
); );
if (typeof body.content !== "string") { if (typeof body.content !== "string") {
@@ -118,8 +119,13 @@ export default function scriptsPluginFactory(registry) {
} }
} }
const incomingContext =
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
? body.context
: {};
const ctx = { const ctx = {
data: body.data ?? null, data: body.data ?? null,
context: incomingContext,
config: body.config ?? null, config: body.config ?? null,
}; };
@@ -132,10 +138,13 @@ export default function scriptsPluginFactory(registry) {
workflowName: "dry-run", workflowName: "dry-run",
owner, owner,
}); });
const output = await fn(ctx); const raw = await fn(ctx);
const result = normalizeStepResult(raw, incomingContext, name);
return { return {
status: "success", status: "success",
output: safeSerialize(output), output: safeSerialize(result.output),
context: safeSerialize(result.context),
skipRemaining: result.skipRemaining,
error: null, error: null,
logs, logs,
durationMs: Date.now() - started, durationMs: Date.now() - started,
@@ -147,6 +156,8 @@ export default function scriptsPluginFactory(registry) {
return { return {
status: "failed", status: "failed",
output: null, output: null,
context: null,
skipRemaining: false,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
logs, logs,
durationMs: Date.now() - started, durationMs: Date.now() - started,
+1 -1
View File
@@ -32,7 +32,7 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) { for (const raw of workflow.scripts ?? []) {
try { try {
const parsed = parseScriptStep(raw); const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? `set:${parsed.as}` : parsed.script); names.push(parsed.kind === "set" ? "set" : parsed.script);
} catch { } catch {
names.push(null); names.push(null);
} }
+141
View File
@@ -0,0 +1,141 @@
/**
* Step return contract: `{ output, context?, skipRemaining? }`.
*/
/**
* @param {unknown} value
*/
export function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* @param {unknown} value
* @returns {Record<string, unknown>}
*/
export function normalizeContext(value) {
return isPlainObject(value) ? /** @type {Record<string, unknown>} */ (value) : {};
}
/**
* @typedef {{
* output: unknown,
* context: Record<string, unknown>,
* skipRemaining: boolean,
* }} StepResult
*/
/**
* @param {unknown} raw
* @param {unknown} incomingContext
* @param {string} [label]
* @returns {StepResult}
*/
export function normalizeStepResult(raw, incomingContext, label = "script") {
const incoming = normalizeContext(incomingContext);
if (!isPlainObject(raw)) {
const got = raw == null ? String(raw) : typeof raw;
throw new Error(`${label} must return { output, context }. Got ${got}`);
}
if (!("output" in raw) && ("data" in raw || "config" in raw)) {
throw new Error(
`${label} must return { output, context }. Returning the full ctx is no longer valid.`,
);
}
let context = incoming;
if ("context" in raw) {
if (raw.context == null) {
context = incoming;
} else if (!isPlainObject(raw.context)) {
throw new Error(`${label} returned a non-object context`);
} else {
context = /** @type {Record<string, unknown>} */ (raw.context);
}
}
return {
output: "output" in raw ? raw.output : null,
context,
skipRemaining: raw.skipRemaining === true,
};
}
/**
* Persistable envelope (omit skipRemaining unless set).
* @param {StepResult} result
*/
export function storedEnvelope(result) {
/** @type {{ output: unknown, context: Record<string, unknown>, skipRemaining?: true }} */
const out = { output: result.output, context: result.context };
if (result.skipRemaining) out.skipRemaining = true;
return out;
}
/**
* Next step ctx (without config).
* @param {StepResult} result
*/
export function chainCtx(result) {
return { data: result.output, context: result.context };
}
/**
* Shallow-merge sibling context diffs vs a shared snapshot.
* Fails if two siblings change the same key.
*
* @param {unknown} snapshot
* @param {Array<{ id: string, context: unknown }>} patches
* @returns {Record<string, unknown>}
*/
export function mergeContextWave(snapshot, patches) {
const base = normalizeContext(snapshot);
/** @type {Map<string, string>} */
const writers = new Map();
/** @type {Map<string, { deleted: true } | { value: unknown }>} */
const changes = new Map();
for (const patch of patches) {
const who = patch.id;
const next = normalizeContext(patch.context);
const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
for (const key of keys) {
const inBase = Object.prototype.hasOwnProperty.call(base, key);
const inNext = Object.prototype.hasOwnProperty.call(next, key);
if (inBase && inNext && Object.is(base[key], next[key])) continue;
if (!inBase && !inNext) continue;
if (inBase && !inNext) {
rememberChange(writers, changes, key, who, { deleted: true });
continue;
}
if (!inBase || !Object.is(base[key], next[key])) {
rememberChange(writers, changes, key, who, { value: next[key] });
}
}
}
const merged = { ...base };
for (const [key, spec] of changes) {
if ("deleted" in spec) delete merged[key];
else merged[key] = spec.value;
}
return merged;
}
/**
* @param {Map<string, string>} writers
* @param {Map<string, { deleted: true } | { value: unknown }>} changes
* @param {string} key
* @param {string} who
* @param {{ deleted: true } | { value: unknown }} spec
*/
function rememberChange(writers, changes, key, who, spec) {
const previous = writers.get(key);
if (previous != null && previous !== who) {
throw new Error(
`DAG context key conflict: "${key}" written by "${previous}" and "${who}"`,
);
}
writers.set(key, who);
changes.set(key, spec);
}
+1 -1
View File
@@ -92,7 +92,7 @@ export default async function (ctx) {
const scriptOut = await runScriptSource( const scriptOut = await runScriptSource(
"fingerprint-smoke.js", "fingerprint-smoke.js",
script, script,
{ data: { n: 1 } }, { data: { n: 1 }, context: {} },
{ log, workflowName: ns }, { log, workflowName: ns },
); );
if (!scriptOut.changed) throw new Error("sandbox $fingerprint.claim should be new"); if (!scriptOut.changed) throw new Error("sandbox $fingerprint.claim should be new");
+10 -31
View File
@@ -1,8 +1,5 @@
import jsonata from "jsonata"; import jsonata from "jsonata";
const AS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
const RESERVED_AS = new Set(["data", "config"]);
export const SET_STEP_SCRIPT = "set"; export const SET_STEP_SCRIPT = "set";
/** /**
@@ -12,7 +9,6 @@ export const SET_STEP_SCRIPT = "set";
* script: string, * script: string,
* config: unknown | null, * config: unknown | null,
* expression?: undefined, * expression?: undefined,
* as?: undefined,
* id: string | null, * id: string | null,
* needsKind: "none" | "list" | "map", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
@@ -21,11 +17,10 @@ export const SET_STEP_SCRIPT = "set";
* @typedef {{ * @typedef {{
* kind: "set", * kind: "set",
* script: typeof SET_STEP_SCRIPT, * script: typeof SET_STEP_SCRIPT,
* config: { expression: string, as: string }, * config: { expression: string },
* expression: string, * expression: string,
* as: string,
* id: string | null, * id: string | null,
* needsKind: "none", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
* when: string | null, * when: string | null,
* }} ParsedSetStep * }} ParsedSetStep
@@ -163,9 +158,6 @@ export function compileWorkflowScripts(scripts) {
if (dagMode) { if (dagMode) {
for (const step of steps) { for (const step of steps) {
const who = step.id ?? String(step.index); const who = step.id ?? String(step.index);
if (step.kind === "set") {
throw new Error(`set steps are not allowed in DAG workflows (step ${who})`);
}
if (step.when) { if (step.when) {
throw new Error(`when is not allowed in DAG workflows (step ${who})`); throw new Error(`when is not allowed in DAG workflows (step ${who})`);
} }
@@ -227,13 +219,10 @@ export function compileWorkflowScripts(scripts) {
} }
/** /**
* Prefer a script return's `.data`; otherwise treat the whole return as data. * Upstream DAG output stored in outputsById (already the pipe value).
* @param {unknown} result * @param {unknown} result
*/ */
export function extractStepData(result) { export function extractStepOutput(result) {
if (result && typeof result === "object" && !Array.isArray(result) && "data" in result) {
return result.data;
}
return result ?? null; return result ?? null;
} }
@@ -248,12 +237,12 @@ export function mergeStepData(step, outputsById, triggerData) {
return triggerData; return triggerData;
} }
if (step.needsKind === "list" && step.needs.length === 1) { if (step.needsKind === "list" && step.needs.length === 1) {
return extractStepData(outputsById.get(step.needs[0].from)); return extractStepOutput(outputsById.get(step.needs[0].from));
} }
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const data = {}; const data = {};
for (const { alias, from } of step.needs) { for (const { alias, from } of step.needs) {
data[alias] = extractStepData(outputsById.get(from)); data[alias] = extractStepOutput(outputsById.get(from));
} }
return data; return data;
} }
@@ -263,34 +252,24 @@ export function mergeStepData(step, outputsById, triggerData) {
* @returns {ParsedSetStep} * @returns {ParsedSetStep}
*/ */
function parseSetStep(step) { function parseSetStep(step) {
if (step.needs != null) {
throw new Error("needs is not allowed on set steps");
}
const spec = step.set; const spec = step.set;
if (spec == null || typeof spec !== "object" || Array.isArray(spec)) { if (spec == null || typeof spec !== "object" || Array.isArray(spec)) {
throw new Error(`Invalid set step: ${JSON.stringify(step)}`); throw new Error(`Invalid set step: ${JSON.stringify(step)}`);
} }
const expression = spec.expression; const expression = spec.expression;
const as = spec.as;
if (typeof expression !== "string" || !expression.trim()) { if (typeof expression !== "string" || !expression.trim()) {
throw new Error("set.expression is required"); throw new Error("set.expression is required");
} }
if (typeof as !== "string" || !AS_IDENT.test(as)) {
throw new Error(`Invalid set.as: ${JSON.stringify(as)}`);
}
if (RESERVED_AS.has(as)) {
throw new Error(`set.as "${as}" is reserved`);
}
compileJsonata(expression, "set.expression"); compileJsonata(expression, "set.expression");
const { needsKind, needs } = parseNeeds(step.needs);
return { return {
kind: "set", kind: "set",
script: SET_STEP_SCRIPT, script: SET_STEP_SCRIPT,
config: { expression, as }, config: { expression },
expression, expression,
as,
id: parseOptionalId(step.id), id: parseOptionalId(step.id),
needsKind: "none", needsKind,
needs: [], needs,
when: parseWhen(step.when), when: parseWhen(step.when),
}; };
} }
@@ -10,7 +10,11 @@ scripts:
- script: jsonata.js - script: jsonata.js
config: config:
expression: | expression: |
{"data": {"title": httpResponse.title, "message": httpResponse.title, "attach": httpResponse.url}} {
"title": data.httpResponse.title,
"message": data.httpResponse.title,
"attach": data.httpResponse.url
}
- fetch-binary.js - fetch-binary.js
- script: ntfy.js - script: ntfy.js
config: config:
@@ -13,11 +13,9 @@ scripts:
config: config:
expression: | expression: |
{ {
"data": { "title": data.item.title,
"title": item.title, "message": data.item.contentSnippet & "\n" & data.item.link,
"message": item.contentSnippet & "\n" & item.link, "attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
"attach": item.mediaContent.url ? item.mediaContent.url : (item.mediaContent.$ ? item.mediaContent.$.url : undefined)
}
} }
- script: ntfy.js - script: ntfy.js
config: config:
@@ -14,18 +14,16 @@ scripts:
config: config:
expression: | expression: |
{ {
"data": { "to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
"to": $exists(to) ? to : "nasyarobby@gmail.com", "from": data.from,
"from": from, "subject": data.subject,
"subject": subject, "text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
"text": $exists(text) ? text : ($exists(body) ? body : message), "html": data.html,
"html": html, "cc": data.cc,
"cc": cc, "bcc": data.bcc,
"bcc": bcc, "replyTo": data.replyTo,
"replyTo": replyTo, "priority": data.priority,
"priority": priority, "headers": data.headers
"headers": headers
}
} }
- script: send-email.js - script: send-email.js
config: config:
+1 -1
View File
@@ -3,7 +3,7 @@ scripts:
- get-current-time.js - get-current-time.js
- script: jsonata.js - script: jsonata.js
config: config:
expression: '{"data": {"message": datetime & " " & processId}}' expression: '{"message": data.datetime & " " & data.processId}'
- ntfy.js - ntfy.js
triggers: triggers:
- type: HTTP - type: HTTP
@@ -19,11 +19,11 @@ scripts:
needs: [time, comic] needs: [time, comic]
config: config:
expression: | expression: |
{"data": { {
"title": comic.httpResponse.title, "title": data.comic.httpResponse.title,
"message": time.datetime & " " & comic.httpResponse.title, "message": data.time.datetime & " " & data.comic.httpResponse.title,
"attach": comic.httpResponse.url "attach": data.comic.httpResponse.url
}} }
- id: notify - id: notify
script: ntfy.js script: ntfy.js
@@ -2,10 +2,10 @@ name: time to ntfy example
description: | description: |
this workflow will send a message to ntfy with the current time this workflow will send a message to ntfy with the current time
scripts: scripts:
- get-current-time.js - script: get-current-time.js
- script: ntfy.js - script: ntfy.js
config: config:
url: https://ntfy.sh/scrunner url: https://n.0dev.web.id/system
triggers: triggers:
- type: HTTP - type: HTTP
method: POST method: POST
+2 -4
View File
@@ -16,10 +16,8 @@ scripts:
config: config:
expression: | expression: |
{ {
"data": { "title": data.httpResponse.customer_name,
"title": httpResponse.customer_name, "message": data.httpResponse.customer_name & " : " & data.httpResponse.status
"message": httpResponse.customer_name & " : " & httpResponse.status
}
} }
- script: ntfy.js - script: ntfy.js
config: config:
+2 -1
View File
@@ -107,11 +107,12 @@ export function useDeleteScript() {
export function useDryRunScript() { export function useDryRunScript() {
return useMutation({ 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`, { await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content, content,
data, data,
context,
config, config,
owner, owner,
}) })
@@ -1,4 +1,4 @@
import { scriptTags } from "../lib/script.js"; import { scriptTags, scriptReadsCtx } from "../lib/script.js";
import { TagBadge } from "./TagBadge.jsx"; import { TagBadge } from "./TagBadge.jsx";
function FieldTable({ title, fields }) { function FieldTable({ title, fields }) {
@@ -64,17 +64,21 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
return ( return (
<div className={`space-y-3 ${className}`}> <div className={`space-y-3 ${className}`}>
{meta.description ? <p className="text-sm">{meta.description}</p> : null} {meta.description ? <p className="text-sm">{meta.description}</p> : null}
{tags.length ? ( {(tags.length || scriptReadsCtx(meta)) ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{tags.map((tag) => ( {tags.map((tag) => (
<TagBadge key={tag} tag={tag} /> <TagBadge key={tag} tag={tag} />
))} ))}
{scriptReadsCtx(meta) ? (
<span className="badge badge-outline badge-sm font-mono">reads: ctx</span>
) : null}
</div> </div>
) : null} ) : 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="Config" fields={meta.config} />
<FieldTable title="Input (data)" fields={meta.input} /> <FieldTable title="Input (data)" fields={meta.input} />
<FieldTable title="Output" fields={meta.output} /> <FieldTable title="Output" fields={meta.output} />
<FieldTable title="Context" fields={meta.context} />
</div> </div>
</div> </div>
); );
@@ -49,7 +49,9 @@ export function AddScriptDialog({ open, onClose, onPick }) {
}} }}
> >
<span className="font-semibold">Set (JSONata)</span> <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> </button>
</li> </li>
{isLoading ? ( {isLoading ? (
@@ -42,10 +42,10 @@ export function ScriptCard({
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId); step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(step.config, meta?.previewConfigKey); const preview = previewConfigValue(step.config, meta?.previewConfigKey);
const previewFull = configValueText(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 titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig = const setConfig =
step.kind === "set" ? { as: step.as ?? "", expression: step.expression ?? "" } : null; step.kind === "set" ? { expression: step.expression ?? "" } : null;
return ( return (
<article <article
@@ -95,7 +95,9 @@ export function ScriptCard({
<p className="text-sm opacity-70 mt-1">{meta.description}</p> <p className="text-sm opacity-70 mt-1">{meta.description}</p>
) : null} ) : null}
{expanded && step.kind === "set" ? ( {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} ) : null}
</button> </button>
{step.kind === "script" ? ( {step.kind === "script" ? (
@@ -142,14 +144,11 @@ export function ScriptCard({
<> <>
{step.kind === "set" ? ( {step.kind === "set" ? (
<div className="space-y-2"> <div className="space-y-2">
<FieldLabel name="as" required description="Context field to write (not data or config)" /> <FieldLabel
<input name="expression"
className="input input-sm w-full font-mono" required
value={step.as ?? ""} description="JSONata evaluated against ctx (data, context, config). Result becomes the next step's data."
disabled={disabled}
onChange={(e) => onChange({ ...step, as: e.target.value })}
/> />
<FieldLabel name="expression" required description="JSONata evaluated against ctx" />
<textarea <textarea
className="textarea textarea-sm w-full min-h-24 font-mono text-xs" className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
value={step.expression ?? ""} value={step.expression ?? ""}
@@ -186,14 +185,12 @@ export function ScriptCard({
<span className="text-error text-xs">Duplicate step id</span> <span className="text-error text-xs">Duplicate step id</span>
) : null} ) : null}
</label> </label>
{step.kind === "set" ? null : (
<NeedsEditor <NeedsEditor
step={step} step={step}
otherSteps={otherSteps} otherSteps={otherSteps}
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
/> />
)}
<label className="form-control"> <label className="form-control">
<span className="label py-0 text-sm">when</span> <span className="label py-0 text-sm">when</span>
<input <input
@@ -244,7 +241,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
disabled={disabled} disabled={disabled}
onChange={(e) => setMode(e.target.value)} 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="list">List of step ids</option>
<option value="map">Map alias → step id</option> <option value="map">Map alias → step id</option>
</select> </select>
@@ -366,29 +363,46 @@ function needsMode(needs) {
export function ScriptTryDialog({ script, config, meta, owner, onClose }) { export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script); const existing = useScript(script);
const dryRun = useDryRunScript(); 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 [dataJson, setDataJson] = useState(defaultData);
const [contextJson, setContextJson] = useState(defaultContext);
const [parseError, setParseError] = useState(null); const [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
useEffect(() => { useEffect(() => {
setDataJson(defaultData); setDataJson(defaultData);
}, [defaultData]); setContextJson(defaultContext);
}, [defaultData, defaultContext]);
const outputJson = dryRun.data const resultJson = dryRun.data
? prettyJson(dryRun.data.status === "success" ? dryRun.data.output : { error: dryRun.data.error }) ? prettyJson(
dryRun.data.status === "success"
? {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
}
: { error: dryRun.data.error },
)
: ""; : "";
function onRun() { function onRun() {
setParseError(null); setParseError(null);
let data; let data;
let context;
try { try {
const parsed = JSON.parse(dataJson || "null"); data = JSON.parse(dataJson || "null");
data = parsed; context = JSON.parse(contextJson || "{}");
} catch (err) { } catch (err) {
setParseError(err instanceof Error ? err.message : String(err)); setParseError(err instanceof Error ? err.message : String(err));
return; return;
} }
if (context != null && (typeof context !== "object" || Array.isArray(context))) {
setParseError("context must be a JSON object");
return;
}
if (!existing.data?.content) { if (!existing.data?.content) {
setParseError("could not load script source"); setParseError("could not load script source");
return; return;
@@ -397,6 +411,7 @@ export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
name: script, name: script,
content: existing.data.content, content: existing.data.content,
data, data,
context: context ?? {},
config: config ?? {}, config: config ?? {},
owner, owner,
}); });
@@ -442,12 +457,18 @@ export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
</div> </div>
</div> </div>
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}> <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"> <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} {dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
</p> </p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}> <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> </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 min-h-0 flex-1 flex-col gap-3 overflow-auto">
<div className="flex shrink-0 items-center justify-between"> <div className="flex shrink-0 items-center justify-between">
<p className="text-sm opacity-70"> <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> </p>
<button <button
type="button" type="button"
+10 -1
View File
@@ -1,5 +1,5 @@
export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) { export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
return ctx; return { output: ctx.data ?? null, context: ctx.context ?? {} };
} }
main.meta = { main.meta = {
@@ -9,8 +9,10 @@ main.meta = {
config: {}, config: {},
input: {}, input: {},
output: {}, output: {},
context: {},
example: { example: {
data: {}, data: {},
context: {},
config: {}, config: {},
}, },
}; };
@@ -20,6 +22,7 @@ export default main;
export const DEFAULT_INPUT_CONTEXT = `{ export const DEFAULT_INPUT_CONTEXT = `{
"data": {}, "data": {},
"context": {},
"config": {} "config": {}
} }
`; `;
@@ -35,6 +38,10 @@ export function scriptTags(meta) {
return meta.tags.map((t) => String(t).trim()).filter(Boolean); return meta.tags.map((t) => String(t).trim()).filter(Boolean);
} }
export function scriptReadsCtx(meta) {
return meta?.reads === "ctx";
}
const TAG_PALETTE = [ const TAG_PALETTE = [
{ bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" }, { bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" },
{ bg: "color-mix(in oklab, #059669 22%, transparent)", text: "#059669" }, { 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)) { if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
return { return {
data: "data" in meta.example ? meta.example.data : {}, data: "data" in meta.example ? meta.example.data : {},
context: "context" in meta.example ? meta.example.context ?? {} : {},
config: "config" in meta.example ? meta.example.config ?? {} : {}, config: "config" in meta.example ? meta.example.config ?? {} : {},
}; };
} }
return { return {
data: defaultsFromFields(meta?.input), data: defaultsFromFields(meta?.input),
context: defaultsFromFields(meta?.context),
config: defaultsFromFields(meta?.config), config: defaultsFromFields(meta?.config),
}; };
} }
+2 -4
View File
@@ -96,7 +96,6 @@ export function newSetStep() {
uiId: nextUiId("step"), uiId: nextUiId("step"),
kind: "set", kind: "set",
script: "set", script: "set",
as: "value",
expression: "", expression: "",
id: "", id: "",
when: "", when: "",
@@ -187,11 +186,10 @@ function normalizeStep(step) {
uiId, uiId,
kind: "set", kind: "set",
script: "set", script: "set",
as: typeof spec.as === "string" ? spec.as : "",
expression: typeof spec.expression === "string" ? spec.expression : "", expression: typeof spec.expression === "string" ? spec.expression : "",
id: typeof step.id === "string" ? step.id : "", id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "", when: typeof step.when === "string" ? step.when : "",
needs: null, needs: step.needs ?? null,
}; };
} }
const known = new Set(["script", "config", "id", "when", "needs", "set"]); const known = new Set(["script", "config", "id", "when", "needs", "set"]);
@@ -254,9 +252,9 @@ function dumpStep(step) {
if (step.id) out.id = step.id; if (step.id) out.id = step.id;
out.set = { out.set = {
expression: step.expression ?? "", expression: step.expression ?? "",
as: step.as ?? "",
}; };
if (step.when) out.when = step.when; if (step.when) out.when = step.when;
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
return out; return out;
} }
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
+3 -6
View File
@@ -1,15 +1,13 @@
function parseStep(step) { function parseStep(step) {
if (typeof step === "string") { 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) { if (step?.set) {
const as = typeof step.set?.as === "string" && step.set.as ? step.set.as : "set";
return { return {
kind: "set", kind: "set",
script: null, script: null,
as,
id: typeof step.id === "string" && step.id ? step.id : null, 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, when: typeof step.when === "string" && step.when ? step.when : null,
}; };
} }
@@ -17,7 +15,6 @@ function parseStep(step) {
return { return {
kind: "script", kind: "script",
script: step.script, script: step.script,
as: null,
id: typeof step.id === "string" && step.id ? step.id : null, id: typeof step.id === "string" && step.id ? step.id : null,
needs: step.needs ?? null, needs: step.needs ?? null,
when: typeof step.when === "string" && step.when ? step.when : null, when: typeof step.when === "string" && step.when ? step.when : null,
@@ -55,7 +52,7 @@ function triggerLabel(t) {
function stepLabel(s) { function stepLabel(s) {
if (s.kind === "set") { 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; return s.when ? `${base} when: ${s.when}` : base;
} }
const base = s.id ? `${s.id}: ${s.script}` : s.script; const base = s.id ? `${s.id}: ${s.script}` : s.script;
+69 -8
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 { Link, useNavigate, useParams } from "react-router-dom";
import { LuArrowLeft, LuFilter } from "react-icons/lu"; import { LuArrowLeft, LuFilter } from "react-icons/lu";
import { useRun } from "../api/hooks.js"; import { useRun } from "../api/hooks.js";
import { LogViewer } from "../components/LogViewer.jsx"; import { LogViewer } from "../components/LogViewer.jsx";
import { formatTime, StatusBadge } from "../lib/format.jsx"; import { formatTime, StatusBadge } from "../lib/format.jsx";
import { prettyJson } from "../lib/script.js";
function stepLabel(s) { function stepLabel(s) {
if (s.script === "set") { if (s.script === "set") return "set";
return s.config?.as ? `set:${s.config.as}` : "set";
}
return s.script; return s.script;
} }
@@ -20,11 +19,39 @@ function isEditableScript(s) {
return Boolean(s.script) && s.script !== "set"; 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() { export function EventDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: run, isLoading, error } = useRun(id); const { data: run, isLoading, error } = useRun(id);
const [stepFilters, setStepFilters] = useState([]); const [stepFilters, setStepFilters] = useState([]);
const [openStep, setOpenStep] = useState(null);
function goBack() { function goBack() {
const idx = window.history.state?.idx; const idx = window.history.state?.idx;
@@ -60,6 +87,8 @@ export function EventDetailPage() {
if (isLoading) return <span className="loading loading-spinner loading-lg" />; if (isLoading) return <span className="loading loading-spinner loading-lg" />;
if (error || !run) return <p className="text-error">Event not found</p>; if (error || !run) return <p className="text-error">Event not found</p>;
const runParts = envelopeParts(run.output);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -98,6 +127,15 @@ export function EventDetailPage() {
</div> </div>
) : null} ) : 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> <section>
<h2 className="font-semibold mb-2">Steps</h2> <h2 className="font-semibold mb-2">Steps</h2>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -113,8 +151,12 @@ export function EventDetailPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{steps.map((s) => ( {steps.map((s) => {
<tr key={s.id}> const parts = envelopeParts(s.output);
const open = openStep === s.id;
return (
<Fragment key={s.id}>
<tr>
<td>{s.step_index}</td> <td>{s.step_index}</td>
<td className="font-mono"> <td className="font-mono">
{isEditableScript(s) ? ( {isEditableScript(s) ? (
@@ -134,9 +176,16 @@ export function EventDetailPage() {
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs" s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
} }
> >
{s.error || ""} {s.error || (parts.skipRemaining ? "skipRemaining" : "")}
</td> </td>
<td className="text-right"> <td className="text-right">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setOpenStep(open ? null : s.id)}
>
I/O
</button>
<button <button
type="button" type="button"
className={`btn btn-ghost btn-xs btn-square ${stepFilters.includes(s.id) ? "btn-active" : ""}`} className={`btn btn-ghost btn-xs btn-square ${stepFilters.includes(s.id) ? "btn-active" : ""}`}
@@ -148,7 +197,19 @@ export function EventDetailPage() {
</button> </button>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
+7 -3
View File
@@ -76,7 +76,9 @@ export function ScriptDryRunPage() {
setRunStatus(lastRun.status); setRunStatus(lastRun.status);
setLogs(lastRun.logs ?? []); setLogs(lastRun.logs ?? []);
if (lastRun.status === "success") { 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 { } else {
setOutputJson(prettyJson({ error: lastRun.error ?? "run failed" })); setOutputJson(prettyJson({ error: lastRun.error ?? "run failed" }));
} }
@@ -97,10 +99,11 @@ export function ScriptDryRunPage() {
throw new Error(err instanceof Error ? err.message : "invalid JSON"); throw new Error(err instanceof Error ? err.message : "invalid JSON");
} }
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { 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 { return {
data: "data" in parsed ? parsed.data : null, data: "data" in parsed ? parsed.data : null,
context: "context" in parsed ? parsed.context : {},
config: "config" in parsed ? parsed.config : null, config: "config" in parsed ? parsed.config : null,
}; };
} }
@@ -119,6 +122,7 @@ export function ScriptDryRunPage() {
name, name,
content, content,
data: ctx.data, data: ctx.data,
context: ctx.context,
config: ctx.config, config: ctx.config,
owner, owner,
}); });
@@ -229,7 +233,7 @@ export function ScriptDryRunPage() {
</section> </section>
<section className="flex min-h-0 flex-col gap-1"> <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"> <div className="min-h-0 flex-1">
<CodeEditor <CodeEditor
language="json" language="json"