feat(config-refs): introduce config reference parsing and resolution
- Added a new module for parsing and resolving configuration references, supporting `$SECRET_`, `$KV_`, and `$CONTEXT_` prefixes. - Implemented functions to handle the resolution of these references within workflow contexts, enhancing the flexibility of configuration management. - Updated existing workflows and scripts to utilize the new config reference resolution, improving data handling and context management. - Introduced a smoke test to validate the functionality of the config reference system, ensuring robustness and reliability. This update significantly enhances the ability to manage dynamic configurations within workflows, improving overall usability and maintainability.
This commit is contained in:
@@ -5,3 +5,8 @@ logs/
|
||||
*.db-*
|
||||
.DS_Store
|
||||
packages/web/dist
|
||||
|
||||
# Personal/local scripts and workflows (not for the repo)
|
||||
packages/server/scripts/dev-*
|
||||
packages/server/workflows/**/dev-*
|
||||
debug-*.js
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { coerceCredentialString } from "./http-trigger-auth.js";
|
||||
import { kvGet } from "./kv-store.js";
|
||||
import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
|
||||
import { isSecret } from "./secret-value.js";
|
||||
|
||||
/** Longest prefix first so `$CONTEXT_` is not confused with `$KV_`. */
|
||||
const PREFIXES = [
|
||||
{ kind: "context", prefix: "$CONTEXT_" },
|
||||
{ kind: "secret", prefix: "$SECRET_" },
|
||||
{ kind: "kv", prefix: "$KV_" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {{ kind: "secret" | "kv" | "context", name: string, raw: string }} ConfigRef
|
||||
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a whole-value config placeholder. Returns null for literals.
|
||||
* @param {unknown} value
|
||||
* @returns {ConfigRef | null}
|
||||
*/
|
||||
export function parseConfigRef(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
for (const { kind, prefix } of PREFIXES) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return { kind, name: trimmed.slice(prefix.length), raw: trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$KV_` / `$CONTEXT_`
|
||||
* strings. Does not walk trigger data.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @param {WeakSet<object>} [seen]
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
|
||||
if (typeof value === "string") {
|
||||
return resolveStringRef(value, ctx);
|
||||
}
|
||||
if (value == null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const out = [];
|
||||
for (const item of value) {
|
||||
out.push(await resolveConfigRefs(item, ctx, seen));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
out[key] = await resolveConfigRefs(child, ctx, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveStringRef(value, ctx) {
|
||||
const ref = parseConfigRef(value);
|
||||
if (!ref) return value;
|
||||
|
||||
if (ref.kind === "secret") {
|
||||
return resolveSecretRef(ref, ctx);
|
||||
}
|
||||
if (ref.kind === "kv") {
|
||||
return resolveKvRef(ref, ctx);
|
||||
}
|
||||
return resolveContextRef(ref, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveSecretRef(ref, ctx) {
|
||||
try {
|
||||
assertSecretName(ref.name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid secret name`);
|
||||
}
|
||||
const plaintext = await getSecretPlaintext(ctx.owner, ref.name);
|
||||
if (plaintext == null) {
|
||||
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`);
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveKvRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty KV key`);
|
||||
}
|
||||
const raw = await kvGet(ctx.workflowKey, ref.name);
|
||||
if (raw == null) {
|
||||
throw new Error(`config ref ${ref.raw}: KV "${ref.name}" not found`);
|
||||
}
|
||||
const coerced = coerceCredentialString(raw);
|
||||
if (coerced == null) {
|
||||
throw new Error(`config ref ${ref.raw}: KV "${ref.name}" is not a scalar`);
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveContextRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty context key`);
|
||||
}
|
||||
const bag =
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? /** @type {Record<string, unknown>} */ (ctx.context)
|
||||
: {};
|
||||
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
|
||||
}
|
||||
const raw = bag[ref.name];
|
||||
if (isSecret(raw)) {
|
||||
return raw.reveal();
|
||||
}
|
||||
const coerced = coerceCredentialString(raw);
|
||||
if (coerced == null) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`);
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
+20
-11
@@ -30,6 +30,7 @@ import {
|
||||
sendHttpPageOrJson,
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
|
||||
/**
|
||||
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
|
||||
@@ -137,9 +138,12 @@ export function createRegistry(server) {
|
||||
|
||||
const onDisk = fsStore.listOwnerYamlFiles(owner);
|
||||
for (const file of onDisk) {
|
||||
if (!workflowFiles.includes(file)) {
|
||||
log.warn(`Workflow file not in registers.yaml: ${owner}/${file}`);
|
||||
if (workflowFiles.includes(file)) continue;
|
||||
if (file.startsWith("dev-")) {
|
||||
workflowFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
log.warn(`Workflow file not in registers.yaml: ${owner}/${file}`);
|
||||
}
|
||||
|
||||
for (const file of workflowFiles) {
|
||||
@@ -572,21 +576,26 @@ export function createRegistry(server) {
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const config = parsed.config;
|
||||
const unresolvedConfig = parsed.config;
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
index,
|
||||
script,
|
||||
config: unresolvedConfig,
|
||||
});
|
||||
const stepLog = runLog.child({ stepId: step.id, script });
|
||||
try {
|
||||
const config = await resolveConfigRefs(unresolvedConfig, {
|
||||
owner,
|
||||
workflowKey: key,
|
||||
context: incomingContext,
|
||||
});
|
||||
const stepCtx = {
|
||||
data: ctx.data,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
index,
|
||||
script,
|
||||
config,
|
||||
});
|
||||
const stepLog = runLog.child({ stepId: step.id, script });
|
||||
try {
|
||||
if (parsed.when) {
|
||||
const whenResult = await evaluateJsonata(parsed.when, stepCtx);
|
||||
if (!isJsonataTruthy(whenResult)) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||
import { normalizeStepResult } from "../../step-result.js";
|
||||
import { resolveConfigRefs } from "../../config-refs.js";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
@@ -123,16 +124,21 @@ export default function scriptsPluginFactory(registry) {
|
||||
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
|
||||
? body.context
|
||||
: {};
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
context: incomingContext,
|
||||
config: body.config ?? null,
|
||||
};
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(body.config ?? null, {
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
});
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
||||
log,
|
||||
workflowName: "dry-run",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { migrate, db } from "../db.js";
|
||||
import { kvSet, kvDelete } from "../kv-store.js";
|
||||
import { upsertSecret, deleteSecret } from "../secrets-store.js";
|
||||
import { Secret } from "../secret-value.js";
|
||||
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function assertRejects(fn, match) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (match && !message.includes(match)) {
|
||||
throw new Error(`rejected with "${message}", expected to include "${match}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected to reject (${match ?? "any error"})`);
|
||||
}
|
||||
|
||||
const owner = "default";
|
||||
const workflowKey = "default/config-refs-smoke.yaml";
|
||||
const ctx = { owner, workflowKey, context: {} };
|
||||
|
||||
function assertParse(value, expected) {
|
||||
const got = parseConfigRef(value);
|
||||
if (expected == null) {
|
||||
assert(got == null, `expected null parse for ${JSON.stringify(value)}, got ${JSON.stringify(got)}`);
|
||||
return;
|
||||
}
|
||||
assert(got != null, `expected parse for ${JSON.stringify(value)}`);
|
||||
assert(got.kind === expected.kind, `kind ${got.kind} !== ${expected.kind}`);
|
||||
assert(got.name === expected.name, `name ${JSON.stringify(got.name)} !== ${JSON.stringify(expected.name)}`);
|
||||
}
|
||||
|
||||
assertParse("password123", null);
|
||||
assertParse("$FOO_bar", null);
|
||||
assertParse("$SECRET", null);
|
||||
assertParse(" password123 ", null);
|
||||
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
|
||||
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
|
||||
assertParse("Bearer $SECRET_x", null);
|
||||
assertParse("$KV_modem password", { kind: "kv", name: "modem password" });
|
||||
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
|
||||
assertParse("$SECRET_", { kind: "secret", name: "" });
|
||||
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
|
||||
|
||||
{
|
||||
const literal = await resolveConfigRefs("password123", ctx);
|
||||
assert(literal === "password123", "literal passthrough");
|
||||
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
|
||||
assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
|
||||
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
|
||||
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
|
||||
const number = await resolveConfigRefs(42, ctx);
|
||||
assert(number === 42, "number passthrough");
|
||||
}
|
||||
|
||||
const secret = await upsertSecret({
|
||||
owner,
|
||||
name: "config_refs_smoke_token",
|
||||
value: "s3cret-ok",
|
||||
});
|
||||
await kvSet(workflowKey, "modem_password", "kv-pass-ok");
|
||||
await kvSet(workflowKey, "modem password", "kv-spaced-ok");
|
||||
await kvSet(workflowKey, "obj-key", { nested: true });
|
||||
|
||||
try {
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve trimmed");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$KV_modem_password", ctx);
|
||||
assert(resolved === "kv-pass-ok", "kv resolve");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$KV_modem password", ctx);
|
||||
assert(resolved === "kv-spaced-ok", "kv spaced key");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_token", {
|
||||
...ctx,
|
||||
context: { token: "ctx-token-ok" },
|
||||
});
|
||||
assert(resolved === "ctx-token-ok", "context string");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_n", {
|
||||
...ctx,
|
||||
context: { n: 7 },
|
||||
});
|
||||
assert(resolved === "7", "context number stringify");
|
||||
}
|
||||
{
|
||||
const wrapped = new Secret("wrapped-secret-ok");
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_tok", {
|
||||
...ctx,
|
||||
context: { tok: wrapped },
|
||||
});
|
||||
assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
|
||||
}
|
||||
|
||||
{
|
||||
const nested = await resolveConfigRefs(
|
||||
{
|
||||
url: "http://example.test",
|
||||
password: "$SECRET_config_refs_smoke_token",
|
||||
headers: { Authorization: "$KV_modem_password" },
|
||||
extra: ["$CONTEXT_token", "plain"],
|
||||
},
|
||||
{ ...ctx, context: { token: "ctx-token-ok" } },
|
||||
);
|
||||
assert(nested.url === "http://example.test", "nested literal");
|
||||
assert(nested.password === "s3cret-ok", "nested secret");
|
||||
assert(nested.headers.Authorization === "kv-pass-ok", "nested kv");
|
||||
assert(nested.extra[0] === "ctx-token-ok", "nested array context");
|
||||
assert(nested.extra[1] === "plain", "nested array literal");
|
||||
}
|
||||
|
||||
const data = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const config = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const resolvedConfig = await resolveConfigRefs(config, ctx);
|
||||
assert(resolvedConfig.password === "s3cret-ok", "config resolved");
|
||||
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked");
|
||||
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated");
|
||||
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx),
|
||||
'secret "does_not_exist_xyz" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_not valid", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$KV_missing-key", ctx),
|
||||
'KV "missing-key" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$KV_obj-key", ctx),
|
||||
'KV "obj-key" is not a scalar',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$KV_", ctx),
|
||||
"empty KV key",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_missing", ctx),
|
||||
'context "missing" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
resolveConfigRefs("$CONTEXT_obj", {
|
||||
...ctx,
|
||||
context: { obj: { a: 1 } },
|
||||
}),
|
||||
'context "obj" is not a scalar',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_", ctx),
|
||||
"empty context key",
|
||||
);
|
||||
} finally {
|
||||
await deleteSecret(secret.id);
|
||||
await kvDelete(workflowKey, "modem_password");
|
||||
await kvDelete(workflowKey, "modem password");
|
||||
await kvDelete(workflowKey, "obj-key");
|
||||
}
|
||||
|
||||
console.log("config-refs smoke test passed");
|
||||
await db.destroy();
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js";
|
||||
import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
const EMPTY_WORKFLOWS = [];
|
||||
|
||||
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
|
||||
const { notify } = useNotifications();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows();
|
||||
const duplicate = useDuplicateWorkflow();
|
||||
@@ -40,6 +42,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
notify.success(`Duplicated to ${data.owner}/${data.file}`);
|
||||
onDuplicated?.(data);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,33 @@ import { FieldLabel } from "./FieldHelp.jsx";
|
||||
|
||||
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||
|
||||
const CONFIG_REF_PREFIXES = [
|
||||
{ prefix: "$SECRET_", label: "secret" },
|
||||
{ prefix: "$CONTEXT_", label: "context" },
|
||||
{ prefix: "$KV_", label: "KV" },
|
||||
];
|
||||
|
||||
function describeConfigRef(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
||||
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
||||
return { label, name: trimmed.slice(prefix.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ConfigRefHint({ value }) {
|
||||
const ref = describeConfigRef(value);
|
||||
if (!ref) return null;
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldSpec(meta, key) {
|
||||
const spec = meta?.config?.[key];
|
||||
if (spec && typeof spec === "object") return spec;
|
||||
@@ -261,11 +288,14 @@ function ObjectFields({ value, onChange }) {
|
||||
onChange={(nv) => setVal(k, nv)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<EditableText
|
||||
value={v == null ? "" : String(v)}
|
||||
onCommit={(nv) => setVal(k, nv)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<ConfigRefHint value={v} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -395,6 +425,7 @@ export function ConfigFields({
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -430,6 +461,7 @@ export function ConfigFields({
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
@@ -6,25 +6,68 @@ import { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import {
|
||||
overlayWorkflowTestData,
|
||||
readWorkflowTestData,
|
||||
writeWorkflowTestData,
|
||||
} from "../../lib/workflow-test-storage.js";
|
||||
import { SchemaTooltip } from "./FieldHelp.jsx";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
function initialDataJson(owner, file, defaultData) {
|
||||
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
|
||||
}
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
owner,
|
||||
file,
|
||||
defaultData,
|
||||
inputFields,
|
||||
disabled,
|
||||
disabledReason,
|
||||
open,
|
||||
onClose,
|
||||
}) {
|
||||
const run = useRunWorkflow();
|
||||
const [dataJson, setDataJson] = useState(() => prettyJson(defaultData ?? {}));
|
||||
const [dataJson, setDataJson] = useState(() => initialDataJson(owner, file, defaultData));
|
||||
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [last, setLast] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const saveTimer = useRef(null);
|
||||
|
||||
const seedJson = prettyJson(
|
||||
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDataJson(prettyJson(defaultData ?? {}));
|
||||
}, [defaultData]);
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputTouched) return;
|
||||
setDataJson(seedJson);
|
||||
}, [seedJson, inputTouched]);
|
||||
|
||||
function persist(data) {
|
||||
writeWorkflowTestData(owner, file, data);
|
||||
}
|
||||
|
||||
function onDataChange(value) {
|
||||
setDataJson(value);
|
||||
setInputTouched(true);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
try {
|
||||
persist(JSON.parse(value || "null"));
|
||||
} catch {
|
||||
// skip invalid JSON
|
||||
}
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onRun() {
|
||||
setParseError(null);
|
||||
@@ -35,6 +78,7 @@ export function WorkflowTestPanel({
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
persist(data);
|
||||
run.mutate(
|
||||
{ owner, file, data },
|
||||
{
|
||||
@@ -64,6 +108,15 @@ export function WorkflowTestPanel({
|
||||
: "";
|
||||
|
||||
function close() {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
try {
|
||||
persist(JSON.parse(dataJson || "null"));
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
setExpanded(false);
|
||||
onClose?.();
|
||||
}
|
||||
@@ -103,9 +156,12 @@ export function WorkflowTestPanel({
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
|
||||
>
|
||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
<p className="text-xs opacity-60 mb-1">data</p>
|
||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||
data
|
||||
<SchemaTooltip label="Input" fields={inputFields} />
|
||||
</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
||||
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
||||
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
||||
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||
import { ScriptsTab } from "./ScriptsTab.jsx";
|
||||
@@ -80,7 +81,27 @@ export function WorkflowVisualEditor({
|
||||
setTab(next);
|
||||
}
|
||||
|
||||
const defaultData = displayDoc?.extra?.data ?? {};
|
||||
const scriptsByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const s of scripts) {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
if (name) map.set(name, s);
|
||||
}
|
||||
return map;
|
||||
}, [scripts]);
|
||||
|
||||
const inputMeta = useMemo(
|
||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
|
||||
[displayDoc?.scripts, scriptsByName],
|
||||
);
|
||||
|
||||
const defaultData = useMemo(() => {
|
||||
const yamlData = displayDoc?.extra?.data;
|
||||
return dataFromInputMeta(inputMeta, yamlData);
|
||||
}, [displayDoc?.extra?.data, inputMeta]);
|
||||
|
||||
const inputFields = inputMeta?.input;
|
||||
|
||||
let testDisabledReason = null;
|
||||
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
||||
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
|
||||
@@ -181,9 +202,11 @@ export function WorkflowVisualEditor({
|
||||
|
||||
{showTest && owner && file ? (
|
||||
<WorkflowTestPanel
|
||||
key={`${owner}/${file}`}
|
||||
owner={owner}
|
||||
file={file}
|
||||
defaultData={defaultData}
|
||||
inputFields={inputFields}
|
||||
disabled={Boolean(testDisabledReason)}
|
||||
disabledReason={testDisabledReason}
|
||||
open={testOpen}
|
||||
|
||||
@@ -78,8 +78,12 @@ export function prettyJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function defaultsFromFields(fields) {
|
||||
if (fields == null || typeof fields !== "object" || Array.isArray(fields)) return {};
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
@@ -90,6 +94,57 @@ function defaultsFromFields(fields) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function placeholderForField(spec) {
|
||||
const type = spec?.type;
|
||||
if (type === "object") return {};
|
||||
if (type === "array") return [];
|
||||
if (type === "number" || type === "integer") return 0;
|
||||
if (type === "boolean") return false;
|
||||
if (type === "string") return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
function seedFromInputFields(fields) {
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
const field = spec && typeof spec === "object" ? spec : {};
|
||||
if ("default" in field) out[key] = field.default;
|
||||
else if (field.required) out[key] = placeholderForField(field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inputHasFields(meta) {
|
||||
return isPlainObject(meta?.input) && Object.keys(meta.input).length > 0;
|
||||
}
|
||||
|
||||
/** First script step with a non-empty `meta.input`; else first script meta. */
|
||||
export function firstInputMeta(steps, scriptsByName) {
|
||||
let fallback = null;
|
||||
for (const step of steps ?? []) {
|
||||
if (step?.kind === "set") continue;
|
||||
const name = typeof step === "string" ? step : step?.script;
|
||||
if (!name) continue;
|
||||
const listed = scriptsByName?.get(name);
|
||||
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
|
||||
if (!fallback) fallback = meta;
|
||||
if (inputHasFields(meta)) return meta;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Seed workflow test `data` from required/default fields, example, then YAML `data`. */
|
||||
export function dataFromInputMeta(meta, yamlData) {
|
||||
const fromExample = contextFromMeta(meta).data;
|
||||
return {
|
||||
...seedFromInputFields(meta?.input),
|
||||
...(isPlainObject(fromExample) ? fromExample : {}),
|
||||
...(isPlainObject(yamlData) ? yamlData : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigFromMeta(meta) {
|
||||
if (meta?.example?.config && typeof meta.example.config === "object" && !Array.isArray(meta.example.config)) {
|
||||
return { ...meta.example.config };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const PREFIX = "scrunner.workflowTestData:";
|
||||
|
||||
export function workflowTestStorageKey(owner, file) {
|
||||
return `${PREFIX}${owner}/${file}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Overlay saved test JSON on the schema/YAML seed. Stored object keys win. */
|
||||
export function overlayWorkflowTestData(seed, stored) {
|
||||
const base = isPlainObject(seed) ? seed : {};
|
||||
if (stored == null) return base;
|
||||
if (isPlainObject(stored)) return { ...base, ...stored };
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function readWorkflowTestData(owner, file) {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(workflowTestStorageKey(owner, file));
|
||||
if (raw == null || raw === "") return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowTestData(owner, file, data) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(workflowTestStorageKey(owner, file), JSON.stringify(data));
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "./theme.jsx";
|
||||
import { NotificationProvider } from "./notifications.jsx";
|
||||
import { App } from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
@@ -19,9 +20,11 @@ createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<NotificationProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</NotificationProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
|
||||
const NotificationContext = createContext(null);
|
||||
|
||||
const TYPES = new Set(["info", "success", "warning", "error"]);
|
||||
const ALERT_CLASS = {
|
||||
info: "alert-info",
|
||||
success: "alert-success",
|
||||
warning: "alert-warning",
|
||||
error: "alert-error",
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
let seq = 0;
|
||||
function nextId() {
|
||||
seq += 1;
|
||||
return `toast-${seq}`;
|
||||
}
|
||||
|
||||
function isSticky(duration) {
|
||||
return duration === 0 || duration === Infinity;
|
||||
}
|
||||
|
||||
function parseNotify(input) {
|
||||
const raw = typeof input === "string" ? { message: input } : (input ?? {});
|
||||
const type = TYPES.has(raw.type) ? raw.type : "info";
|
||||
const message = String(raw.message ?? "");
|
||||
const duration = raw.duration ?? DEFAULT_DURATION;
|
||||
return { type, message, duration };
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const timers = useRef(new Map());
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const dismissRef = useRef(dismiss);
|
||||
dismissRef.current = dismiss;
|
||||
|
||||
const notify = useMemo(() => {
|
||||
function notifyFn(input) {
|
||||
const parsed = parseNotify(input);
|
||||
if (!parsed.message) return null;
|
||||
|
||||
const id = nextId();
|
||||
const item = { id, ...parsed };
|
||||
|
||||
setItems((prev) => {
|
||||
let kept = prev;
|
||||
if (prev.length >= MAX_ITEMS) {
|
||||
const overflow = prev.slice(0, prev.length - MAX_ITEMS + 1);
|
||||
for (const old of overflow) {
|
||||
const timer = timers.current.get(old.id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(old.id);
|
||||
}
|
||||
}
|
||||
kept = prev.slice(prev.length - MAX_ITEMS + 1);
|
||||
}
|
||||
return [...kept, item];
|
||||
});
|
||||
|
||||
if (!isSticky(parsed.duration)) {
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismissRef.current(id), parsed.duration),
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
notifyFn.success = (message, opts) => notifyFn({ type: "success", message, ...opts });
|
||||
notifyFn.error = (message, opts) => notifyFn({ type: "error", message, ...opts });
|
||||
notifyFn.warning = (message, opts) => notifyFn({ type: "warning", message, ...opts });
|
||||
notifyFn.info = (message, opts) => notifyFn({ type: "info", message, ...opts });
|
||||
return notifyFn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = timers.current;
|
||||
return () => {
|
||||
for (const timer of map.values()) clearTimeout(timer);
|
||||
map.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ items, notify, dismiss }), [items, notify, dismiss]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastHost items={items} onDismiss={dismiss} />
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastHost({ items, onDismiss }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="toast toast-end toast-bottom z-[100]">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role={item.type === "warning" || item.type === "error" ? "alert" : "status"}
|
||||
className={`alert ${ALERT_CLASS[item.type] ?? "alert-info"}`}
|
||||
>
|
||||
<span>{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => onDismiss(item.id)}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNotifications must be used within NotificationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
contextFromMeta,
|
||||
prettyJson,
|
||||
} from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptDryRunPage() {
|
||||
const { notify } = useNotifications();
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const location = useLocation();
|
||||
@@ -129,7 +131,10 @@ export function ScriptDryRunPage() {
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -194,9 +199,6 @@ export function ScriptDryRunPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col gap-1">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptNewPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -88,6 +89,7 @@ export function ScriptEditPage() {
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const existing = useScript(name);
|
||||
const save = useSaveScript();
|
||||
const [content, setContent] = useState("");
|
||||
@@ -103,7 +105,10 @@ export function ScriptEditPage() {
|
||||
|
||||
function onSave(e) {
|
||||
e.preventDefault();
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
function openDryRun() {
|
||||
@@ -172,9 +177,6 @@ export function ScriptEditPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.j
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
|
||||
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
function WorkflowEditorLayout({
|
||||
title,
|
||||
@@ -19,7 +20,6 @@ function WorkflowEditorLayout({
|
||||
savePending,
|
||||
saveDisabled,
|
||||
saveError,
|
||||
saveSuccess,
|
||||
onSave,
|
||||
onTest,
|
||||
onDuplicate,
|
||||
@@ -81,7 +81,6 @@ function WorkflowEditorLayout({
|
||||
{children}
|
||||
{saveError ? <p className="text-error text-sm shrink-0">{saveError}</p> : null}
|
||||
{enableError ? <p className="text-error text-sm shrink-0">{enableError}</p> : null}
|
||||
{saveSuccess ? <p className="text-success text-sm shrink-0">Workflow saved</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,6 +153,7 @@ export function WorkflowNewPage() {
|
||||
|
||||
export function WorkflowEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const { owner: rawOwner, file: rawFile } = useParams();
|
||||
const owner = decodeURIComponent(rawOwner ?? "");
|
||||
const file = decodeURIComponent(rawFile ?? "");
|
||||
@@ -190,7 +190,10 @@ export function WorkflowEditPage() {
|
||||
save.mutate(
|
||||
{ owner, file, content },
|
||||
{
|
||||
onSuccess: () => setSavedYaml(content),
|
||||
onSuccess: () => {
|
||||
setSavedYaml(content);
|
||||
notify.success("Workflow saved");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -227,7 +230,6 @@ export function WorkflowEditPage() {
|
||||
savePending={save.isPending}
|
||||
saveDisabled={!contentReady}
|
||||
saveError={save.isError ? errorMessage(save.error) : null}
|
||||
saveSuccess={save.isSuccess}
|
||||
onSave={onSave}
|
||||
onTest={() => setTestOpen(true)}
|
||||
onDuplicate={() => setDuplicateOpen(true)}
|
||||
|
||||
Reference in New Issue
Block a user