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:
2026-08-16 21:36:15 +07:00
parent cf4b342e04
commit fd0865063b
16 changed files with 752 additions and 42 deletions
@@ -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)}
/>
) : (
<EditableText
value={v == null ? "" : String(v)}
onCommit={(nv) => setVal(k, nv)}
className="font-mono text-xs"
/>
<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}