feat(server): enhance workflow and script handling with new features

- Added `workflowLastModifiedAt` function to retrieve the last modified timestamp of workflow YAML files.
- Introduced `encodeBinaryForWire` and `reviveBinaryFromWire` functions for better handling of binary data in JSON.
- Implemented `useDeleteKv` hook for deleting key-value pairs in the web API.
- Enhanced `scriptsPluginFactory` to support dry-run evaluations with JSONata expressions.
- Added `set-dry-run-smoke.js` test to validate dry-run functionality.
- Updated profile configuration to include `overlayFromMerged` for better profile management.
- Introduced try-session management in the web components to handle step execution states.
- Improved various components to support new try-session features and maintain UI consistency.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-22 17:52:17 +07:00
parent 72feb39d56
commit beb7e22652
28 changed files with 1753 additions and 143 deletions
+25
View File
@@ -110,6 +110,31 @@ export function readWorkflowYaml(owner, file) {
return fs.readFileSync(filePath, "utf8"); return fs.readFileSync(filePath, "utf8");
} }
/**
* Last content change time for a workflow YAML file.
* Uses birthtime (creation) when the file has not been modified since it was created.
* @returns {string | null} ISO timestamp
*/
export function workflowLastModifiedAt(owner, file) {
try {
assertOwner(owner);
assertWorkflowFile(file);
} catch {
return null;
}
const filePath = path.join(WORKFLOWS_DIR, owner, file);
try {
const st = fs.statSync(filePath);
const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null;
const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000);
const ms = unmodified ? birthMs : (mtimeMs ?? birthMs);
return ms != null ? new Date(ms).toISOString() : null;
} catch {
return null;
}
}
export function writeWorkflowYaml(owner, file, content) { export function writeWorkflowYaml(owner, file, content) {
assertOwner(owner); assertOwner(owner);
assertWorkflowFile(file); assertWorkflowFile(file);
+88 -5
View File
@@ -11,16 +11,21 @@ export function isBinary(value) {
); );
} }
/**
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function toBuffer(value) {
if (Buffer.isBuffer(value)) return value;
if (value instanceof ArrayBuffer) return Buffer.from(value);
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
/** /**
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number). * Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value * @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/ */
export function summarizeBinary(value) { export function summarizeBinary(value) {
const buf = Buffer.isBuffer(value) const buf = toBuffer(value);
? value
: value instanceof ArrayBuffer
? Buffer.from(value)
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES); const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return { return {
type: "Buffer", type: "Buffer",
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
}; };
} }
/**
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function encodeBinary(value) {
const buf = toBuffer(value);
return {
type: "Buffer",
encoding: "base64",
data: buf.toString("base64"),
length: buf.length,
};
}
/**
* @param {unknown} value
*/
export function isWireBuffer(value) {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
if (obj.type !== "Buffer") return false;
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
return Array.isArray(obj.data);
}
/**
* Replace live Buffers with reconstructable JSON (for dry-run responses).
* Display still uses summarizeBinary / safeSerialize.
* @param {unknown} value
*/
export function encodeBinaryForWire(value) {
const seen = new WeakSet();
/** @param {unknown} v */
function walk(v) {
if (isBinary(v)) return encodeBinary(v);
if (typeof v === "bigint") return v.toString();
if (v == null || typeof v !== "object") return v;
if (seen.has(v)) return "[Circular]";
seen.add(v);
if (Array.isArray(v)) return v.map(walk);
/** @type {Record<string, unknown>} */
const out = {};
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
return out;
}
return walk(value);
}
/**
* Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`.
* Preview-only summaries (`preview` / `truncated`, no payload) are left as-is.
* @param {unknown} value
*/
export function reviveBinaryFromWire(value) {
const seen = new WeakSet();
/** @param {unknown} v */
function walk(v) {
if (v == null || typeof v !== "object") return v;
if (isWireBuffer(v)) {
const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v);
if (obj.encoding === "base64" && typeof obj.data === "string") {
return Buffer.from(obj.data, "base64");
}
return Buffer.from(/** @type {number[]} */ (obj.data));
}
if (seen.has(v)) return v;
seen.add(v);
if (Array.isArray(v)) {
for (let i = 0; i < v.length; i++) v[i] = walk(v[i]);
return v;
}
const obj = /** @type {Record<string, unknown>} */ (v);
for (const k of Object.keys(obj)) obj[k] = walk(obj[k]);
return obj;
}
return walk(value);
}
/** /**
* JSON.stringify replacer. Must be a real function so `this` is the holder: * JSON.stringify replacer. Must be a real function so `this` is the holder:
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer. * Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
+2 -1
View File
@@ -14,7 +14,8 @@
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "node test/workflow-history-smoke.js", "test:workflow-history": "node test/workflow-history-smoke.js",
"test:profiles": "node test/profiles-smoke.js" "test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js"
}, },
"dependencies": { "dependencies": {
"@jerapah-flow/shared": "workspace:*", "@jerapah-flow/shared": "workspace:*",
+1 -1
View File
@@ -1 +1 @@
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared"; export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
+14 -1
View File
@@ -1,4 +1,4 @@
import { kvNamespaces, kvQuery } from "../../kv-store.js"; import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
/** /**
* @param {import("fastify").FastifyInstance} fastify * @param {import("fastify").FastifyInstance} fastify
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
offset: Number.isFinite(offset) ? offset : undefined, offset: Number.isFinite(offset) ? offset : undefined,
}); });
}); });
fastify.delete("/kv", async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
try {
const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? ""));
if (!deleted) {
return reply.code(404).send({ error: "kv entry not found" });
}
return { ok: true };
} catch (err) {
return reply.code(400).send({ error: err.message });
}
});
} }
+91 -10
View File
@@ -21,11 +21,16 @@ import {
installPluginFromZipBuffer, installPluginFromZipBuffer,
} from "../../plugin-install.js"; } from "../../plugin-install.js";
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js"; import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
import {
encodeBinaryForWire,
reviveBinaryFromWire,
} from "../../json-preview.js";
import { normalizeStepResult } from "../../step-result.js"; import { normalizeStepResult } from "../../step-result.js";
import { resolveConfigRefs } from "../../config-refs.js"; import { resolveConfigRefs } from "../../config-refs.js";
import { getAppVersion } from "../../app-version.js"; import { getAppVersion } from "../../app-version.js";
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js"; import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
import { pluginScriptRef } from "../../plugin-manifest.js"; import { pluginScriptRef } from "../../plugin-manifest.js";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
/** /**
* @param {{ referencedScripts: () => Set<string> }} registry * @param {{ referencedScripts: () => Set<string> }} registry
@@ -249,12 +254,9 @@ export default function scriptsPluginFactory(registry) {
const rawName = decodeURIComponent( const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name, /** @type {{ name: string }} */ (req.params).name,
); );
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ ( const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {} req.body ?? {}
); );
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let owner = "default"; let owner = "default";
if (body.owner != null && body.owner !== "") { if (body.owner != null && body.owner !== "") {
@@ -265,12 +267,92 @@ export default function scriptsPluginFactory(registry) {
} }
} }
const incomingContext = const incomingContext = reviveBinaryFromWire(
body.context != null && body.context != null &&
typeof body.context === "object" && typeof body.context === "object" &&
!Array.isArray(body.context) !Array.isArray(body.context)
? body.context ? body.context
: {}; : {},
);
const incomingData = reviveBinaryFromWire(body.data ?? null);
const { log, logs } = createDryRunLogger();
const started = Date.now();
// Set steps are inline JSONata (no script file). Match registry runCompiledStep.
if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) {
const configObj =
body.config != null &&
typeof body.config === "object" &&
!Array.isArray(body.config)
? /** @type {Record<string, unknown>} */ (body.config)
: null;
const expression =
typeof body.expression === "string"
? body.expression
: typeof configObj?.expression === "string"
? configObj.expression
: null;
if (expression == null || !expression.trim()) {
return reply.code(400).send({ error: "expression is required" });
}
try {
const config = await resolveConfigRefs(
{ ...(configObj ?? {}), expression },
{
owner,
workflowKey: "dry-run",
context: incomingContext,
},
);
const ctx = {
data: incomingData,
context: incomingContext,
config,
};
const value = await evaluateJsonata(expression, ctx);
const result = normalizeStepResult(
{
output: value,
context: incomingContext,
skipRemaining: false,
},
incomingContext,
SET_STEP_SCRIPT,
);
log.info({ expression }, "set: dry-run evaluated");
return {
status: "success",
output: safeSerialize(result.output),
context: safeSerialize(result.context),
wireOutput: encodeBinaryForWire(result.output),
wireContext: encodeBinaryForWire(result.context),
skipRemaining: result.skipRemaining,
error: null,
logs,
durationMs: Date.now() - started,
meta: null,
metaError: null,
};
} catch (err) {
return {
status: "failed",
output: null,
context: null,
skipRemaining: false,
error: err instanceof Error ? err.message : String(err),
logs,
durationMs: Date.now() - started,
meta: null,
metaError: null,
};
}
}
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
const resolved = resolveScriptRef(rawName); const resolved = resolveScriptRef(rawName);
const pluginDir = const pluginDir =
@@ -278,9 +360,6 @@ export default function scriptsPluginFactory(registry) {
? resolved.pluginDir ?? null ? resolved.pluginDir ?? null
: null; : null;
const { log, logs } = createDryRunLogger();
const started = Date.now();
try { try {
const config = await resolveConfigRefs(body.config ?? null, { const config = await resolveConfigRefs(body.config ?? null, {
owner, owner,
@@ -288,7 +367,7 @@ export default function scriptsPluginFactory(registry) {
context: incomingContext, context: incomingContext,
}); });
const ctx = { const ctx = {
data: body.data ?? null, data: incomingData,
context: incomingContext, context: incomingContext,
config, config,
}; };
@@ -312,6 +391,8 @@ export default function scriptsPluginFactory(registry) {
status: "success", status: "success",
output: safeSerialize(result.output), output: safeSerialize(result.output),
context: safeSerialize(result.context), context: safeSerialize(result.context),
wireOutput: encodeBinaryForWire(result.output),
wireContext: encodeBinaryForWire(result.context),
skipRemaining: result.skipRemaining, skipRemaining: result.skipRemaining,
error: null, error: null,
logs, logs,
+8
View File
@@ -366,6 +366,7 @@ export default function workflowsPluginFactory(registry) {
enabled: parsed ? parsed.enabled !== false : false, enabled: parsed ? parsed.enabled !== false : false,
registered: registered.includes(file), registered: registered.includes(file),
loadError: loadError ?? (parsed ? null : "unreadable"), loadError: loadError ?? (parsed ? null : "unreadable"),
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
lastInvokedAt: st.lastInvokedAt, lastInvokedAt: st.lastInvokedAt,
lastStatus: st.lastStatus ?? null, lastStatus: st.lastStatus ?? null,
invocationCount: st.invocationCount, invocationCount: st.invocationCount,
@@ -374,6 +375,13 @@ export default function workflowsPluginFactory(registry) {
}); });
} }
} }
items.sort((a, b) => {
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
sensitivity: "base",
});
if (byName !== 0) return byName;
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
});
return { workflows: items }; return { workflows: items };
}); });
+20 -1
View File
@@ -1,4 +1,9 @@
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js"; import {
encodeBinaryForWire,
jsonPreviewReplacer,
reviveBinaryFromWire,
summarizeBinary,
} from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js"; import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js"; import { safeSerialize } from "../src/api/dry-run-logger.js";
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`); throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
} }
const wired = encodeBinaryForWire({ file: png, n: 1n });
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
}
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
throw new Error("reviveBinaryFromWire failed to restore bytes");
}
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
const left = reviveBinaryFromWire(previewOnly);
if (Buffer.isBuffer(left)) {
throw new Error("preview-only summary should not revive");
}
console.log("json-preview-smoke: ok"); console.log("json-preview-smoke: ok");
+81
View File
@@ -0,0 +1,81 @@
/**
* Smoke: set dry-run path (evaluateJsonata + envelope), mirrors
* POST /scripts/set/dry-run in src/api/scripts.js.
*/
import assert from "node:assert/strict";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../workflow-parse.js";
import { normalizeStepResult } from "../step-result.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
assert.equal(SET_STEP_SCRIPT, "set");
async function dryRunSet({ expression, data, context = {} }) {
if (typeof expression !== "string" || !expression.trim()) {
throw new Error("expression is required");
}
const incomingContext =
context != null && typeof context === "object" && !Array.isArray(context)
? context
: {};
const config = { expression };
const ctx = { data: data ?? null, context: incomingContext, config };
const value = await evaluateJsonata(expression, ctx);
const result = normalizeStepResult(
{ output: value, context: incomingContext, skipRemaining: false },
incomingContext,
SET_STEP_SCRIPT,
);
return {
status: "success",
output: safeSerialize(result.output),
context: safeSerialize(result.context),
skipRemaining: result.skipRemaining,
};
}
{
const res = await dryRunSet({
expression: '{"title": data.title, "ok": true}',
data: { title: "Hello" },
context: { runId: "dry" },
});
assert.equal(res.status, "success");
assert.deepEqual(res.output, { title: "Hello", ok: true });
assert.deepEqual(res.context, { runId: "dry" });
assert.equal(res.skipRemaining, false);
}
{
const res = await dryRunSet({
expression: "data.count + 1",
data: { count: 41 },
context: { token: "abc" },
});
assert.equal(res.output, 42);
// Sets never mutate context
assert.deepEqual(res.context, { token: "abc" });
}
{
let hit = false;
try {
await dryRunSet({ expression: " ", data: {} });
} catch (err) {
hit = true;
assert.match(String(err.message), /expression is required/);
}
assert.equal(hit, true);
}
{
let hit = false;
try {
await dryRunSet({ expression: "data.{" , data: {} });
} catch (err) {
hit = true;
assert.ok(err instanceof Error);
}
assert.equal(hit, true);
}
console.log("set-dry-run-smoke: ok");
+1 -1
View File
@@ -1,4 +1,4 @@
export { isPlainObject } from "./is-plain-object.js"; export { isPlainObject } from "./is-plain-object.js";
export { mergeProfileConfig, configHasOverlay } from "./profile-config.js"; export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js";
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js"; export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js"; export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
+39
View File
@@ -17,6 +17,45 @@ export function mergeProfileConfig(profileConfig, stepConfig) {
return { ...base, ...stepConfig }; return { ...base, ...stepConfig };
} }
/**
* Inverse of merge for Apply-to-card: keep only keys whose values differ from the profile.
* No profile → return the merged object as-is (full step config).
*
* @param {unknown} profileConfig
* @param {unknown} mergedConfig
* @returns {Record<string, unknown>}
*/
export function overlayFromMerged(profileConfig, mergedConfig) {
const merged =
mergedConfig != null && typeof mergedConfig === "object" && !Array.isArray(mergedConfig)
? { ...mergedConfig }
: {};
const base =
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
? profileConfig
: null;
if (!base) return merged;
const overlay = {};
for (const [key, value] of Object.entries(merged)) {
if (!Object.prototype.hasOwnProperty.call(base, key) || !sameConfigValue(base[key], value)) {
overlay[key] = value;
}
}
return overlay;
}
function sameConfigValue(a, b) {
if (Object.is(a, b)) return true;
if (a == null || b == null) return a === b;
if (typeof a !== "object" || typeof b !== "object") return false;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
/** /**
* @param {unknown} config * @param {unknown} config
* @returns {boolean} * @returns {boolean}
@@ -5,6 +5,7 @@ import {
isPlainObject, isPlainObject,
mergeProfileConfig, mergeProfileConfig,
namespacedPath, namespacedPath,
overlayFromMerged,
} from "./index.js"; } from "./index.js";
describe("isPlainObject", () => { describe("isPlainObject", () => {
@@ -32,6 +33,29 @@ describe("mergeProfileConfig", () => {
}); });
}); });
describe("overlayFromMerged", () => {
it("returns full merged when there is no profile", () => {
expect(overlayFromMerged(null, { a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
expect(overlayFromMerged(undefined, { x: 1 })).toEqual({ x: 1 });
});
it("keeps only keys that differ from the profile", () => {
expect(overlayFromMerged({ a: 1, b: 2 }, { a: 1, b: 3, c: 4 })).toEqual({
b: 3,
c: 4,
});
expect(overlayFromMerged({ a: 1 }, { a: 1 })).toEqual({});
expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { a: 1 } })).toEqual({});
expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { b: 2 } })).toEqual({
nested: { b: 2 },
});
});
it("treats empty-string override as a real overlay key", () => {
expect(overlayFromMerged({ url: "http://a" }, { url: "" })).toEqual({ url: "" });
});
});
describe("configHasOverlay", () => { describe("configHasOverlay", () => {
it("is true only for non-empty plain objects", () => { it("is true only for non-empty plain objects", () => {
expect(configHasOverlay({ a: 1 })).toBe(true); expect(configHasOverlay({ a: 1 })).toBe(true);
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src", "lint": "eslint src",
"test": "node --test src/lib/workflow-graph.test.js" "test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+10 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js"; import { api } from "../client.js";
export function useKvNamespaces() { export function useKvNamespaces() {
@@ -23,3 +23,12 @@ export function useKv(filters = {}) {
}); });
} }
export function useDeleteKv() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ namespace, key }) =>
(await api.delete("/kv", { params: { namespace, key } })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["kv"] }),
});
}
+11 -2
View File
@@ -84,10 +84,19 @@ export function useDeleteScript() {
export function useDryRunScript() { export function useDryRunScript() {
return useMutation({ return useMutation({
mutationFn: async ({ name, content, data, context, config, owner }) => mutationFn: async ({
name,
content,
expression,
data,
context,
config,
owner,
}) =>
( (
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content, ...(content != null ? { content } : {}),
...(expression != null ? { expression } : {}),
data, data,
context, context,
config, config,
+1 -1
View File
@@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl
const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return ( return (
<div className="overflow-hidden rounded-box border border-base-300 h-full min-h-0"> <div className="nokey overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<Editor <Editor
height={height} height={height}
language={language} language={language}
@@ -28,6 +28,7 @@ import {
parseGraphNodeId, parseGraphNodeId,
readGraphLayout, readGraphLayout,
removeStepEdge, removeStepEdge,
stepNodeId,
writeGraphLayout, writeGraphLayout,
} from "../../lib/workflow-graph.js"; } from "../../lib/workflow-graph.js";
import { ConfirmDialog } from "../ConfirmDialog.jsx"; import { ConfirmDialog } from "../ConfirmDialog.jsx";
@@ -41,6 +42,10 @@ import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode }; const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
/** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
export function GraphTab(props) { export function GraphTab(props) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
@@ -61,6 +66,10 @@ function GraphTabInner({
excludeFile, excludeFile,
auths = [], auths = [],
pages = [], pages = [],
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) { }) {
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
const [addStepOpen, setAddStepOpen] = useState(false); const [addStepOpen, setAddStepOpen] = useState(false);
@@ -94,7 +103,7 @@ function GraphTabInner({
}, [doc, disabled, positions]); }, [doc, disabled, positions]);
useEffect(() => { useEffect(() => {
const t = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t); return () => cancelAnimationFrame(t);
}, [owner, file, fitView]); }, [owner, file, fitView]);
@@ -218,7 +227,7 @@ function GraphTabInner({
function resetLayout() { function resetLayout() {
clearGraphLayout(owner, file); clearGraphLayout(owner, file);
persistPositions({}); persistPositions({});
requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); requestAnimationFrame(() => fitView(FIT_VIEW));
} }
const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null; const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null;
@@ -285,6 +294,7 @@ function GraphTabInner({
nodesConnectable={!disabled} nodesConnectable={!disabled}
nodesDraggable={!disabled} nodesDraggable={!disabled}
connectionRadius={80} connectionRadius={80}
defaultViewport={{ x: 0, y: 0, zoom: INITIAL_ZOOM }}
defaultEdgeOptions={{ type: "smoothstep" }} defaultEdgeOptions={{ type: "smoothstep" }}
proOptions={{ hideAttribution: false }} proOptions={{ hideAttribution: false }}
> >
@@ -309,6 +319,20 @@ function GraphTabInner({
excludeFile={excludeFile} excludeFile={excludeFile}
sortable={false} sortable={false}
defaultExpanded defaultExpanded
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === selectedStep.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? selectedStep.uiId : null);
}}
onNavigateTry={(targetUiId) => {
const id = stepNodeId(targetUiId);
setSelectedId(id);
setNodes((prev) =>
prev.map((n) => ({ ...n, selected: n.id === id })),
);
onTryFocus?.(targetUiId);
}}
onChange={(next) => { onChange={(next) => {
const copy = [...steps]; const copy = [...steps];
copy[selectedStepIndex] = next; copy[selectedStepIndex] = next;
@@ -317,6 +341,7 @@ function GraphTabInner({
onRemove={() => { onRemove={() => {
patchSteps(steps.filter((_, i) => i !== selectedStepIndex)); patchSteps(steps.filter((_, i) => i !== selectedStepIndex));
setSelectedId(null); setSelectedId(null);
onTryFocus?.(null);
}} }}
/> />
) : selectedTrigger && selectedTriggerIndex >= 0 ? ( ) : selectedTrigger && selectedTriggerIndex >= 0 ? (
@@ -1,19 +1,42 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { LuChevronDown, LuGripVertical, LuMaximize2, LuMinimize2, LuPlay, LuTrash2 } from "react-icons/lu"; import {
LuChevronDown,
LuGripVertical,
LuMaximize2,
LuMinimize2,
LuPlay,
LuTrash2,
} from "react-icons/lu";
import { useDryRunScript, useScript } from "../../api/hooks.js"; import { useDryRunScript, useScript } from "../../api/hooks.js";
import { errorMessage } from "../../api/client.js"; import { errorMessage } from "../../api/client.js";
import { CodeEditor } from "../CodeEditor.jsx"; import { CodeEditor } from "../CodeEditor.jsx";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx"; import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { JsonTree } from "../JsonViewBlock.jsx";
import { LogViewer } from "../LogViewer.jsx"; import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format"; import { StatusBadge } from "../../lib/format";
import { contextFromMeta, prettyJson } from "../../lib/script.js"; import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
import { needsMode } from "../../lib/workflow-doc.js"; import { needsMode } from "../../lib/workflow-doc.js";
import {
stepPredecessors,
stepSuccessors,
} from "../../lib/workflow-graph.js";
import { ConfigFields } from "./ConfigFields.jsx"; import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx"; import {
ConfigTooltip,
configValueText,
FieldLabel,
previewConfigValue,
SchemaTooltip,
} from "./FieldHelp.jsx";
import { ScriptIcon } from "../ScriptIcon.jsx"; import { ScriptIcon } from "../ScriptIcon.jsx";
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js"; import {
configHasOverlay,
mergeProfileConfig,
overlayFromMerged,
} from "../../lib/profile.js";
export function ScriptCard(props) { export function ScriptCard(props) {
if (props.sortable === false) { if (props.sortable === false) {
@@ -44,9 +67,21 @@ function ScriptCardView({
excludeFile, excludeFile,
sortable = true, sortable = true,
defaultExpanded = false, defaultExpanded = false,
trySession,
onTrySuccess,
tryOpen: tryOpenProp,
onTryOpenChange,
onNavigateTry,
drag, drag,
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? { const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = drag ?? {
attributes: {}, attributes: {},
listeners: {}, listeners: {},
setNodeRef: undefined, setNodeRef: undefined,
@@ -59,8 +94,15 @@ function ScriptCardView({
transition, transition,
opacity: isDragging ? 0.6 : 1, opacity: isDragging ? 0.6 : 1,
}; };
const [tryOpen, setTryOpen] = useState(false); const tryControlled = typeof onTryOpenChange === "function";
const [localTryOpen, setLocalTryOpen] = useState(false);
const tryOpen = tryControlled ? Boolean(tryOpenProp) : localTryOpen;
function setTryOpen(open) {
if (tryControlled) onTryOpenChange(open);
else setLocalTryOpen(open);
}
const [expanded, setExpanded] = useState(defaultExpanded); const [expanded, setExpanded] = useState(defaultExpanded);
const [configMode, setConfigMode] = useState("form");
const profile = step.profile && profilesByName?.get(step.profile); const profile = step.profile && profilesByName?.get(step.profile);
const scriptName = profile?.script || step.script; const scriptName = profile?.script || step.script;
const listed = scriptsByName?.get(scriptName); const listed = scriptsByName?.get(scriptName);
@@ -81,6 +123,27 @@ function ScriptCardView({
step.kind === "set" ? { expression: step.expression ?? "" } : null; step.kind === "set" ? { expression: step.expression ?? "" } : null;
const missingProfile = Boolean(step.profile) && !profile; const missingProfile = Boolean(step.profile) && !profile;
const neighborOpts = useMemo(() => {
const steps = otherSteps ?? [];
const byUi = new Map(steps.map((s, i) => [s.uiId, { step: s, index: i }]));
function labeled(uiIds) {
return uiIds
.map((uiId) => {
const hit = byUi.get(uiId);
if (!hit) return null;
return {
uiId,
label: stepTryLabel(hit.step, hit.index),
};
})
.filter(Boolean);
}
return {
predecessors: labeled(stepPredecessors(steps, step.uiId)),
successors: labeled(stepSuccessors(steps, step.uiId)),
};
}, [otherSteps, step.uiId]);
return ( return (
<article <article
ref={setNodeRef} ref={setNodeRef}
@@ -104,7 +167,11 @@ function ScriptCardView({
</button> </button>
) : null} ) : null}
{step.kind === "script" && scriptName ? ( {step.kind === "script" && scriptName ? (
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" /> <ScriptIcon
name={scriptName}
hasIcon={listed?.hasIcon}
className="size-8 shrink-0"
/>
) : null} ) : null}
<button <button
type="button" type="button"
@@ -112,7 +179,10 @@ function ScriptCardView({
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h3 className="card-title min-w-0 flex-1 text-base font-mono" title={titleFull}> <h3
className="card-title min-w-0 flex-1 text-base font-mono"
title={titleFull}
>
<span className="min-w-0 truncate">{baseName}</span> <span className="min-w-0 truncate">{baseName}</span>
{!expanded && preview ? ( {!expanded && preview ? (
<span <span
@@ -134,11 +204,15 @@ function ScriptCardView({
: `profile ${step.profile}` : `profile ${step.profile}`
} }
> >
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`} {overridden
? `profile ${step.profile} + overridden`
: `profile ${step.profile}`}
</span> </span>
) : null} ) : null}
{step.id ? ( {step.id ? (
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span> <span className="badge badge-ghost badge-sm font-mono shrink-0">
{step.id}
</span>
) : null} ) : null}
</div> </div>
{expanded && step.kind === "script" && missingProfile ? ( {expanded && step.kind === "script" && missingProfile ? (
@@ -159,10 +233,12 @@ function ScriptCardView({
<SchemaTooltip label="Input" fields={meta?.input} /> <SchemaTooltip label="Input" fields={meta?.input} />
) : null} ) : null}
{!expanded ? ( {!expanded ? (
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} /> <ConfigTooltip
config={step.kind === "set" ? setConfig : (mergedConfig ?? {})}
/>
) : null} ) : null}
<div className="card-actions shrink-0"> <div className="card-actions shrink-0">
{step.kind === "script" && scriptName ? ( {step.kind === "set" || (step.kind === "script" && scriptName) ? (
<button <button
type="button" type="button"
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
@@ -190,7 +266,9 @@ function ScriptCardView({
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} /> <LuChevronDown
className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button> </button>
</div> </div>
</div> </div>
@@ -208,10 +286,39 @@ function ScriptCardView({
className="w-full min-h-24 font-mono text-xs" className="w-full min-h-24 font-mono text-xs"
value={step.expression ?? ""} value={step.expression ?? ""}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...step, expression: e.target.value })} onChange={(e) =>
onChange({ ...step, expression: e.target.value })
}
/> />
</div> </div>
) : ( ) : (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="join">
<button
type="button"
className={`btn btn-xs join-item ${configMode === "form" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("form")}
>
Form
</button>
<button
type="button"
className={`btn btn-xs join-item ${configMode === "json" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("json")}
>
JSON
</button>
</div>
{step.profile ? (
<span className="text-xs opacity-60">
JSON is the step overlay, not the merged runtime config
</span>
) : null}
</div>
{configMode === "form" ? (
<ConfigFields <ConfigFields
script={scriptName} script={scriptName}
config={step.config} config={step.config}
@@ -223,10 +330,20 @@ function ScriptCardView({
excludeFile={excludeFile} excludeFile={excludeFile}
onChange={(config) => onChange({ ...step, config })} onChange={(config) => onChange({ ...step, config })}
/> />
) : (
<ConfigJsonEditor
config={step.config}
disabled={disabled}
onChange={(config) => onChange({ ...step, config })}
/>
)}
</div>
)} )}
<details className="collapse collapse-arrow bg-base-200"> <details className="collapse collapse-arrow bg-base-200">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">Advanced</summary> <summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
Advanced
</summary>
<div className="collapse-content space-y-2"> <div className="collapse-content space-y-2">
<label className="form-control"> <label className="form-control">
<span className="label py-0 text-sm">id</span> <span className="label py-0 text-sm">id</span>
@@ -238,7 +355,9 @@ function ScriptCardView({
placeholder="optional step id" placeholder="optional step id"
/> />
{duplicateId ? ( {duplicateId ? (
<span className="text-error text-xs">Duplicate step id</span> <span className="text-error text-xs">
Duplicate step id
</span>
) : null} ) : null}
</label> </label>
<NeedsEditor <NeedsEditor
@@ -253,11 +372,15 @@ function ScriptCardView({
className="w-full font-mono" className="w-full font-mono"
value={step.when ?? ""} value={step.when ?? ""}
disabled={disabled || Boolean(step.needs)} disabled={disabled || Boolean(step.needs)}
onChange={(e) => onChange({ ...step, when: e.target.value })} onChange={(e) =>
onChange({ ...step, when: e.target.value })
}
placeholder="JSONata; skip if false (linear only)" placeholder="JSONata; skip if false (linear only)"
/> />
{step.needs ? ( {step.needs ? (
<span className="text-xs opacity-60">when is not allowed when needs is set</span> <span className="text-xs opacity-60">
when is not allowed when needs is set
</span>
) : null} ) : null}
</label> </label>
</div> </div>
@@ -267,10 +390,27 @@ function ScriptCardView({
</div> </div>
{tryOpen ? ( {tryOpen ? (
<ScriptTryDialog <ScriptTryDialog
script={scriptName} kind={step.kind === "set" ? "set" : "script"}
script={step.kind === "set" ? "set" : scriptName}
expression={step.expression ?? ""}
config={mergedConfig} config={mergedConfig}
inheritedConfig={inheritedConfig}
meta={meta} meta={meta}
owner={owner} owner={owner}
disabled={disabled}
step={step}
index={index}
otherSteps={otherSteps}
trySession={trySession}
onTrySuccess={onTrySuccess}
predecessors={neighborOpts.predecessors}
successors={neighborOpts.successors}
onNavigateTry={onNavigateTry}
onApplyConfig={
step.kind === "set"
? (expression) => onChange({ ...step, expression })
: (config) => onChange({ ...step, config })
}
onClose={() => setTryOpen(false)} onClose={() => setTryOpen(false)}
/> />
) : null} ) : null}
@@ -278,9 +418,60 @@ function ScriptCardView({
); );
} }
function parseConfigObject(text) {
const parsed = JSON.parse(text || "{}");
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("config must be a JSON object");
}
return parsed;
}
function configsEqual(a, b) {
try {
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
} catch {
return false;
}
}
function ConfigJsonEditor({ config, disabled, onChange }) {
const [draft, setDraft] = useState(() => prettyJson(config ?? {}) || "{}");
const [parseError, setParseError] = useState(null);
function commit(nextText) {
setDraft(nextText);
try {
const parsed = parseConfigObject(nextText);
setParseError(null);
if (!configsEqual(parsed, config ?? {})) {
onChange(parsed);
}
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="space-y-1">
{parseError ? <p className="text-error text-xs">{parseError}</p> : null}
<div className="h-48">
<CodeEditor
language="json"
value={draft}
onChange={disabled ? () => {} : commit}
readOnly={disabled}
height="100%"
/>
</div>
</div>
);
}
function NeedsEditor({ step, otherSteps, disabled, onChange }) { function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs); const mode = needsMode(step.needs);
const ids = otherSteps.filter((s) => s.id && s.uiId !== step.uiId).map((s) => s.id); const ids = otherSteps
.filter((s) => s.id && s.uiId !== step.uiId)
.map((s) => s.id);
function setMode(next) { function setMode(next) {
if (next === "none") onChange({ ...step, needs: null }); if (next === "none") onChange({ ...step, needs: null });
@@ -303,10 +494,13 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
{mode === "list" ? ( {mode === "list" ? (
<div className="flex flex-wrap gap-2 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
{ids.length === 0 ? ( {ids.length === 0 ? (
<span className="text-xs opacity-60">Give other steps an id first</span> <span className="text-xs opacity-60">
Give other steps an id first
</span>
) : ( ) : (
ids.map((id) => { ids.map((id) => {
const checked = Array.isArray(step.needs) && step.needs.includes(id); const checked =
Array.isArray(step.needs) && step.needs.includes(id);
return ( return (
<label key={id} className="label cursor-pointer gap-1 py-0"> <label key={id} className="label cursor-pointer gap-1 py-0">
<input <input
@@ -315,7 +509,9 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={(e) => { onChange={(e) => {
const current = Array.isArray(step.needs) ? [...step.needs] : []; const current = Array.isArray(step.needs)
? [...step.needs]
: [];
onChange({ onChange({
...step, ...step,
needs: e.target.checked needs: e.target.checked
@@ -367,7 +563,9 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })} onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
> >
{from && !ids.includes(from) ? <option value={from}>{from}</option> : null} {from && !ids.includes(from) ? (
<option value={from}>{from}</option>
) : null}
{ids.map((id) => ( {ids.map((id) => (
<option key={id} value={id}> <option key={id} value={id}>
{id} {id}
@@ -409,63 +607,263 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
); );
} }
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script); function TryNavCluster({ direction, neighbors, pick, onPick, onGo, disabled }) {
if (!neighbors?.length) return null;
const isPrev = direction === "prev";
if (neighbors.length === 1) {
const only = neighbors[0];
return (
<button
type="button"
className="btn btn-ghost btn-sm max-w-xs truncate"
disabled={disabled}
title={only.label}
onClick={() => onGo(only.uiId)}
>
{isPrev ? `← Prev: ${only.label}` : `Try next: ${only.label}`}
</button>
);
}
const selected =
neighbors.some((n) => n.uiId === pick) ? pick : neighbors[0].uiId;
return (
<div className="join max-w-md">
<FormSelect
className="join-item select-sm max-w-[14rem] font-mono text-xs"
value={selected}
disabled={disabled}
onChange={(e) => onPick(e.target.value)}
aria-label={isPrev ? "Previous step" : "Next step"}
>
{neighbors.map((n) => (
<option key={n.uiId} value={n.uiId}>
{n.label}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-sm join-item"
disabled={disabled || !selected}
onClick={() => onGo(selected)}
>
{isPrev ? "← Go" : "Go →"}
</button>
</div>
);
}
function ScriptTryDialog({
kind = "script",
script,
expression: initialExpression = "",
config,
inheritedConfig,
meta,
owner,
disabled,
step,
index,
otherSteps,
trySession,
onTrySuccess,
predecessors = [],
successors = [],
onNavigateTry,
onApplyConfig,
onClose,
}) {
const isSet = kind === "set";
const existing = useScript(script, !isSet);
const dryRun = useDryRunScript(); const dryRun = useDryRunScript();
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
const defaultData = useMemo(() => prettyJson(defaults.data ?? {}), [defaults]); const seed = useMemo(
const defaultContext = useMemo(() => prettyJson(defaults.context ?? {}), [defaults]); () =>
const [dataJson, setDataJson] = useState(defaultData); seedTryDialog({
const [contextJson, setContextJson] = useState(defaultContext); step: step ?? { uiId: script },
index: index ?? 0,
steps: otherSteps ?? [],
session: trySession ?? { byStep: {}, lastTriedUiId: null },
meta: isSet ? null : meta,
}),
[step, index, otherSteps, trySession, meta, isSet, script],
);
const [dataJson, setDataJson] = useState(() => prettyJson(seed.data ?? {}) || "{}");
const [contextJson, setContextJson] = useState(
() => prettyJson(seed.context ?? {}) || "{}",
);
const [configJson, setConfigJson] = useState(
() => prettyJson(config ?? {}) || "{}",
);
const [expression, setExpression] = useState(() => initialExpression ?? "");
const [baselineMerged, setBaselineMerged] = useState(() => ({
...(config ?? {}),
}));
const [baselineExpression, setBaselineExpression] = useState(
() => initialExpression ?? "",
);
const [parseError, setParseError] = useState(null); const [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [seedHint] = useState(() => seed.source);
const [sessionResult, setSessionResult] = useState(() => seed.lastResult ?? null);
const [prevPick, setPrevPick] = useState(
() => predecessors[0]?.uiId ?? "",
);
const [nextPick, setNextPick] = useState(
() => successors[0]?.uiId ?? "",
);
useEffect(() => { const resultValue = useMemo(() => {
setDataJson(defaultData); if (dryRun.data) {
setContextJson(defaultContext); if (dryRun.data.status === "success") {
}, [defaultData, defaultContext]); return {
const resultJson = dryRun.data
? prettyJson(
dryRun.data.status === "success"
? {
output: dryRun.data.output, output: dryRun.data.output,
context: dryRun.data.context, context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}), ...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
};
} }
: { error: dryRun.data.error }, return { error: dryRun.data.error };
) }
: ""; if (sessionResult) {
return {
output: sessionResult.output,
context: sessionResult.context,
};
}
return undefined;
}, [dryRun.data, sessionResult]);
function parseTryConfig() {
return parseConfigObject(configJson);
}
const configParse = useMemo(() => {
if (isSet) return { ok: true, value: { expression } };
try {
return { ok: true, value: parseConfigObject(configJson) };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}, [configJson, isSet, expression]);
const configDirty = isSet
? expression !== baselineExpression
: configParse.ok && !configsEqual(configParse.value, baselineMerged);
const canApply =
Boolean(onApplyConfig) && !disabled && configParse.ok && configDirty;
function onRun() { function onRun() {
setParseError(null); setParseError(null);
let data; let data;
let context; let context;
let runConfig;
try { try {
data = JSON.parse(dataJson || "null"); data = JSON.parse(dataJson || "null");
context = JSON.parse(contextJson || "{}"); context = JSON.parse(contextJson || "{}");
if (!isSet) runConfig = parseTryConfig();
} 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))) { if (
context != null &&
(typeof context !== "object" || Array.isArray(context))
) {
setParseError("context must be a JSON object"); setParseError("context must be a JSON object");
return; return;
} }
if (isSet) {
if (!expression.trim()) {
setParseError("expression is required");
return;
}
dryRun.mutate(
{
name: "set",
expression,
data,
context: context ?? {},
config: { expression },
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
return;
}
if (!existing.data?.content) { if (!existing.data?.content) {
setParseError("could not load script source"); setParseError("could not load script source");
return; return;
} }
dryRun.mutate({ dryRun.mutate(
{
name: script, name: script,
content: existing.data.content, content: existing.data.content,
data, data,
context: context ?? {}, context: context ?? {},
config: config ?? {}, config: runConfig,
owner, owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
}); });
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
} }
function onApply() {
setParseError(null);
if (isSet) {
onApplyConfig?.(expression);
setBaselineExpression(expression);
return;
}
let parsed;
try {
parsed = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
const overlay = overlayFromMerged(inheritedConfig, parsed);
onApplyConfig?.(overlay);
setBaselineMerged(parsed);
}
const runDisabled =
dryRun.isPending ||
(!isSet && (existing.isLoading || !configParse.ok)) ||
(isSet && !expression.trim());
return ( return (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
<div <div
@@ -477,10 +875,18 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
> >
<div className="flex shrink-0 items-start gap-2"> <div className="flex shrink-0 items-start gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-bold font-mono">{script}</h3> <h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
Dry-run this script with the card config. Does not create an event. {isSet
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
: "Dry-run this script with editable data, context, and config. Does not create an event. Use Apply to card to write config back to the step."}
</p> </p>
{seedHint ? (
<p className="text-xs opacity-60 mt-1">
Seed: {seedHint}
{isSet ? " · sets do not mutate context" : ""}
</p>
) : null}
</div> </div>
<button <button
type="button" type="button"
@@ -489,12 +895,25 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
aria-label={expanded ? "Exit full screen" : "Full screen"} aria-label={expanded ? "Exit full screen" : "Full screen"}
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />} {expanded ? (
<LuMinimize2 className="size-4" />
) : (
<LuMaximize2 className="size-4" />
)}
</button> </button>
</div> </div>
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null} {parseError ? (
<p className="text-error text-sm mt-2 shrink-0">{parseError}</p>
) : null}
{!isSet && !configParse.ok ? (
<p className="text-error text-sm mt-2 shrink-0">
{configParse.error}
</p>
) : null}
{dryRun.isError ? ( {dryRun.isError ? (
<p className="text-error text-sm mt-2 shrink-0">{errorMessage(dryRun.error)}</p> <p className="text-error text-sm mt-2 shrink-0">
{errorMessage(dryRun.error)}
</p>
) : null} ) : null}
<div <div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`} className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
@@ -502,49 +921,150 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
<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">data</p> <p className="text-xs opacity-60 mb-1">data</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={dataJson} onChange={setDataJson} height="100%" /> <CodeEditor
language="json"
value={dataJson}
onChange={setDataJson}
height="100%"
/>
</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> <p className="text-xs opacity-60 mb-1">context</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={contextJson} onChange={setContextJson} height="100%" /> <CodeEditor
language="json"
value={contextJson}
onChange={setContextJson}
height="100%"
/>
</div> </div>
</div> </div>
<div className={`md:col-span-2 ${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 flex items-center gap-2"> <p className="text-xs opacity-60 mb-1">
result (output + context) {isSet ? (
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null} "expression"
) : (
<>
config
{inheritedConfig ? (
<span className="opacity-50">
{" "}
(merged; Apply writes overlay)
</span>
) : 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={resultJson} onChange={() => {}} readOnly height="100%" /> {isSet ? (
<CodeEditor
language="javascript"
value={expression}
onChange={setExpression}
height="100%"
/>
) : (
<CodeEditor
language="json"
value={configJson}
onChange={setConfigJson}
height="100%"
/>
)}
</div> </div>
</div> </div>
</div> <div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0"> <p className="text-xs opacity-60 mb-1 flex items-center gap-2">
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary> result (output + context)
<div className="collapse-content"> {dryRun.data?.status ? (
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre> <StatusBadge status={dryRun.data.status} />
</div> ) : sessionResult && !dryRun.data ? (
</details> <span className="badge badge-ghost badge-xs">session</span>
{dryRun.data?.logs?.length ? (
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
) : null} ) : null}
<div className="modal-action shrink-0"> </p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
{resultValue === undefined ? (
<JsonTree
value={{}}
keyName="result"
className="h-full max-h-full"
/>
) : (
<JsonTree
value={resultValue}
keyName="result"
className="h-full max-h-full"
shortenTextAfterLength={undefined}
/>
)}
</div>
</div>
</div>
{dryRun.data?.logs?.length ? (
<LogViewer
logs={dryRun.data.logs}
className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"}
/>
) : null}
<div className="modal-action shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<TryNavCluster
direction="prev"
neighbors={predecessors}
pick={prevPick}
onPick={setPrevPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
<TryNavCluster
direction="next"
neighbors={successors}
pick={nextPick}
onPick={setNextPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn" onClick={onClose}> <button type="button" className="btn" onClick={onClose}>
Close Close
</button> </button>
<button
type="button"
className="btn"
disabled={!canApply}
onClick={onApply}
title={
disabled
? "Card is read-only"
: isSet
? !configDirty
? "No expression changes to apply"
: "Write expression back to the workflow step"
: !configParse.ok
? "Fix config JSON first"
: !configDirty
? "No config changes to apply"
: "Write overlay back to the workflow step"
}
>
Apply to card
</button>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={dryRun.isPending || existing.isLoading} disabled={runDisabled}
onClick={onRun} onClick={onRun}
> >
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null} {dryRun.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : null}
Run Run
</button> </button>
</div> </div>
</div> </div>
</div>
<form method="dialog" className="modal-backdrop"> <form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}> <button type="button" onClick={onClose}>
close close
@@ -28,6 +28,10 @@ export function ScriptsTab({
workflows = [], workflows = [],
owner, owner,
excludeFile, excludeFile,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) { }) {
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const sensors = useSensors( const sensors = useSensors(
@@ -98,12 +102,24 @@ export function ScriptsTab({
workflows={workflows} workflows={workflows}
owner={owner} owner={owner}
excludeFile={excludeFile} excludeFile={excludeFile}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === step.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? step.uiId : null);
}}
onNavigateTry={(targetUiId) => {
onTryFocus?.(targetUiId);
}}
onChange={(next) => { onChange={(next) => {
const copy = [...steps]; const copy = [...steps];
copy[index] = next; copy[index] = next;
patchSteps(copy); patchSteps(copy);
}} }}
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))} onRemove={() => {
if (tryFocusUiId === step.uiId) onTryFocus?.(null);
patchSteps(steps.filter((_, i) => i !== index));
}}
/> />
))} ))}
</div> </div>
@@ -1,6 +1,11 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js"; import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js"; import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
import {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
} from "../../lib/try-session.js";
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js"; import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
import { FormInput, FormTextarea } from "../FormControls.jsx"; import { FormInput, FormTextarea } from "../FormControls.jsx";
import { GraphTab } from "./GraphTab.jsx"; import { GraphTab } from "./GraphTab.jsx";
@@ -23,6 +28,8 @@ export function WorkflowVisualEditor({
const [tab, setTab] = useState("graph"); const [tab, setTab] = useState("graph");
const [lastEdited, setLastEdited] = useState("yaml"); const [lastEdited, setLastEdited] = useState("yaml");
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc); const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
const [trySession, setTrySession] = useState(emptyTrySession);
const [tryFocusUiId, setTryFocusUiId] = useState(/** @type {string | null} */ (null));
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]); const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc; const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -39,6 +46,38 @@ export function WorkflowVisualEditor({
const scriptCount = displayDoc?.scripts?.length ?? 0; const scriptCount = displayDoc?.scripts?.length ?? 0;
const triggerCount = displayDoc?.triggers?.length ?? 0; const triggerCount = displayDoc?.triggers?.length ?? 0;
const unsaved = savedYaml != null && yaml !== savedYaml; const unsaved = savedYaml != null && yaml !== savedYaml;
const trySessionCount = Object.keys(trySession.byStep ?? {}).length;
useEffect(() => {
setTrySession((prev) => {
const next = pruneTrySession(prev, displayDoc?.scripts ?? []);
const prevKeys = Object.keys(prev.byStep ?? {});
const nextKeys = Object.keys(next.byStep ?? {});
if (
prevKeys.length === nextKeys.length &&
prevKeys.every((k) => next.byStep[k]) &&
prev.lastTriedUiId === next.lastTriedUiId
) {
return prev;
}
return next;
});
const ids = new Set((displayDoc?.scripts ?? []).map((s) => s.uiId));
setTryFocusUiId((cur) => (cur && !ids.has(cur) ? null : cur));
}, [displayDoc?.scripts]);
function onTrySuccess(uiId, result) {
if (!uiId) return;
setTrySession((prev) => recordTrySuccess(prev, uiId, result));
}
function onTryFocus(uiId) {
setTryFocusUiId(uiId ?? null);
}
function clearTrySession() {
setTrySession(emptyTrySession());
}
function patchDoc(mutator) { function patchDoc(mutator) {
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc; const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -108,6 +147,10 @@ export function WorkflowVisualEditor({
excludeFile: file, excludeFile: file,
auths, auths,
pages, pages,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}; };
return ( return (
@@ -138,6 +181,16 @@ export function WorkflowVisualEditor({
} }
/> />
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null} {unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
{trySessionCount > 0 ? (
<button
type="button"
className="btn btn-ghost btn-xs"
title="Clear Try session (cached step outputs for seeding)"
onClick={clearTrySession}
>
Clear try session ({trySessionCount})
</button>
) : null}
</div> </div>
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto"> <div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
@@ -192,6 +245,10 @@ export function WorkflowVisualEditor({
workflows={workflows} workflows={workflows}
owner={owner} owner={owner}
excludeFile={file} excludeFile={file}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryFocusUiId={tryFocusUiId}
onTryFocus={onTryFocus}
/> />
) : null} ) : null}
{tab === "triggers" ? ( {tab === "triggers" ? (
+1 -1
View File
@@ -1 +1 @@
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared"; export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
+251
View File
@@ -0,0 +1,251 @@
/**
* Client-side Try session helpers (hybrid seed for ScriptTryDialog).
* Mirrors server chainCtx / mergeStepData without importing server modules.
*/
import { contextFromMeta } from "./script.js";
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js";
/**
* @typedef {{
* data: unknown,
* context: Record<string, unknown>,
* output: unknown,
* resultContext: Record<string, unknown>,
* at: number,
* }} TryStepRecord
*
* @typedef {{
* byStep: Record<string, TryStepRecord>,
* lastTriedUiId: string | null,
* }} TrySession
*
* @typedef {{
* data: unknown,
* context: Record<string, unknown>,
* source: string,
* lastResult?: { output: unknown, context: Record<string, unknown> },
* }} TrySeed
*/
/** @returns {TrySession} */
export function emptyTrySession() {
return { byStep: {}, lastTriedUiId: null };
}
/**
* @param {TrySession} session
* @param {string} uiId
* @param {{ data: unknown, context: unknown, output: unknown, resultContext: unknown }} result
* @returns {TrySession}
*/
export function recordTrySuccess(session, uiId, result) {
const context =
result.context != null &&
typeof result.context === "object" &&
!Array.isArray(result.context)
? /** @type {Record<string, unknown>} */ (result.context)
: {};
const resultContext =
result.resultContext != null &&
typeof result.resultContext === "object" &&
!Array.isArray(result.resultContext)
? /** @type {Record<string, unknown>} */ (result.resultContext)
: {};
return {
byStep: {
...session.byStep,
[uiId]: {
data: result.data,
context,
output: result.output,
resultContext,
at: Date.now(),
},
},
lastTriedUiId: uiId,
};
}
/**
* Drop records for removed steps.
* @param {TrySession} session
* @param {Array<{ uiId: string }>} steps
* @returns {TrySession}
*/
export function pruneTrySession(session, steps) {
const keep = new Set((steps ?? []).map((s) => s.uiId));
/** @type {Record<string, TryStepRecord>} */
const byStep = {};
for (const [id, rec] of Object.entries(session.byStep ?? {})) {
if (keep.has(id)) byStep[id] = rec;
}
const lastTriedUiId =
session.lastTriedUiId && keep.has(session.lastTriedUiId)
? session.lastTriedUiId
: null;
return { byStep, lastTriedUiId };
}
/**
* Human-readable step label for seed hints.
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string }} step
* @param {number} [index]
*/
export function stepTryLabel(step, index) {
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
const name =
step.kind === "set"
? "set"
: step.profile || step.script || "step";
if (id) return `step ${n} / ${id} (${name})`;
return `step ${n} / ${name}`;
}
/**
* Build data from needs the way the runner does (mergeStepData).
* @param {unknown} needs
* @param {Map<string, TryStepRecord>} byId — keyed by step.id
* @returns {{ ok: true, data: unknown, labels: string[] } | { ok: false }}
*/
function mergeNeedsFromTries(needs, byId) {
const mode = needsMode(needs);
if (mode === "none" || isEmptyNeeds(needs)) return { ok: false };
if (mode === "list") {
const list = /** @type {string[]} */ (needs);
if (list.length === 1) {
const rec = byId.get(list[0]);
if (!rec) return { ok: false };
return { ok: true, data: rec.output ?? null, labels: [list[0]] };
}
/** @type {Record<string, unknown>} */
const data = {};
const labels = [];
for (const from of list) {
const rec = byId.get(from);
if (!rec) return { ok: false };
data[from] = rec.output ?? null;
labels.push(from);
}
return { ok: true, data, labels };
}
// map: { alias: fromId }
const map = /** @type {Record<string, string>} */ (needs);
/** @type {Record<string, unknown>} */
const data = {};
const labels = [];
for (const [alias, from] of Object.entries(map)) {
const rec = byId.get(from);
if (!rec) return { ok: false };
data[alias] = rec.output ?? null;
labels.push(from);
}
return { ok: true, data, labels };
}
/**
* Hybrid seed for opening Try on a step.
*
* @param {{
* step: { uiId: string, kind?: string, script?: string, profile?: string, id?: string, needs?: unknown },
* index: number,
* steps: Array<{ uiId: string, kind?: string, script?: string, profile?: string, id?: string, needs?: unknown }>,
* session: TrySession,
* meta?: unknown,
* }} opts
* @returns {TrySeed}
*/
export function seedTryDialog({ step, index, steps, session, meta }) {
const byStep = session?.byStep ?? {};
const self = byStep[step.uiId];
if (self) {
return {
data: self.data,
context: self.context ?? {},
source: "restored from last try on this step",
lastResult: {
output: self.output,
context: self.resultContext ?? {},
},
};
}
const dag = isDagDoc({ scripts: steps });
if (!dag && index > 0) {
const prev = steps[index - 1];
const prevRec = prev ? byStep[prev.uiId] : null;
if (prevRec) {
return {
data: prevRec.output ?? null,
context: prevRec.resultContext ?? {},
source: `from ${stepTryLabel(prev, index - 1)}`,
};
}
}
if (!isEmptyNeeds(step.needs)) {
/** @type {Map<string, TryStepRecord>} */
const byId = new Map();
/** @type {Map<string, { step: typeof steps[0], index: number }>} */
const stepById = new Map();
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (typeof s.id === "string" && s.id.trim()) {
stepById.set(s.id, { step: s, index: i });
const rec = byStep[s.uiId];
if (rec) byId.set(s.id, rec);
}
}
const merged = mergeNeedsFromTries(step.needs, byId);
if (merged.ok) {
// Context: use the most recently tried upstream among needs (shared clipboard).
let context = {};
let newestAt = -1;
const labelParts = [];
for (const id of merged.labels) {
const info = stepById.get(id);
const rec = byId.get(id);
if (info) labelParts.push(stepTryLabel(info.step, info.index));
if (rec && rec.at > newestAt) {
newestAt = rec.at;
context = rec.resultContext ?? {};
}
}
return {
data: merged.data,
context,
source: `from needs (${labelParts.join(", ")})`,
};
}
}
const lastId = session?.lastTriedUiId;
if (lastId && byStep[lastId]) {
const lastRec = byStep[lastId];
const lastIndex = steps.findIndex((s) => s.uiId === lastId);
const lastStep = lastIndex >= 0 ? steps[lastIndex] : null;
return {
data: lastRec.output ?? null,
context: lastRec.resultContext ?? {},
source: lastStep
? `from last try (${stepTryLabel(lastStep, lastIndex)})`
: "from last try",
};
}
const fromMeta = contextFromMeta(meta);
return {
data: fromMeta.data ?? {},
context:
fromMeta.context != null &&
typeof fromMeta.context === "object" &&
!Array.isArray(fromMeta.context)
? fromMeta.context
: {},
source: meta?.example ? "from script example" : "empty defaults",
};
}
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
seedTryDialog,
} from "./try-session.js";
function step(uiId, extra = {}) {
return {
uiId,
kind: extra.kind ?? "script",
script: extra.script ?? uiId,
profile: "",
config: {},
id: extra.id ?? "",
when: "",
needs: extra.needs ?? null,
expression: extra.expression,
};
}
test("empty defaults when no session", () => {
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a")],
session: emptyTrySession(),
meta: null,
});
assert.deepEqual(seed.data, {});
assert.deepEqual(seed.context, {});
assert.equal(seed.source, "empty defaults");
});
test("restores self when already tried", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: { in: 1 },
context: { c: 1 },
output: { out: 1 },
resultContext: { c: 2 },
});
// Later try on b should not overwrite restore of a
session = recordTrySuccess(session, "b", {
data: { in: 9 },
context: {},
output: { out: 9 },
resultContext: {},
});
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a"), step("b")],
session,
meta: null,
});
assert.deepEqual(seed.data, { in: 1 });
assert.deepEqual(seed.context, { c: 1 });
assert.match(seed.source, /restored/);
assert.deepEqual(seed.lastResult?.output, { out: 1 });
});
test("linear previous step seeds next", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { fromA: true },
resultContext: { clip: 1 },
});
const seed = seedTryDialog({
step: step("b"),
index: 1,
steps: [step("a"), step("b")],
session,
meta: null,
});
assert.deepEqual(seed.data, { fromA: true });
assert.deepEqual(seed.context, { clip: 1 });
assert.match(seed.source, /from step 1/);
});
test("needs merge when all upstream tried", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { a: 1 },
resultContext: { x: 1 },
});
session = recordTrySuccess(session, "b", {
data: {},
context: {},
output: { b: 2 },
resultContext: { x: 2 },
});
const seed = seedTryDialog({
step: step("c", { id: "c", needs: { left: "a", right: "b" } }),
index: 2,
steps: [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: { left: "a", right: "b" } }),
],
session,
meta: null,
});
assert.deepEqual(seed.data, { left: { a: 1 }, right: { b: 2 } });
assert.match(seed.source, /from needs/);
});
test("needs skips when upstream missing then falls back to last try", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: { onlyA: true },
resultContext: {},
});
const seed = seedTryDialog({
step: step("c", { id: "c", needs: ["a", "b"] }),
index: 2,
steps: [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: ["a", "b"] }),
],
session,
meta: null,
});
assert.deepEqual(seed.data, { onlyA: true });
assert.match(seed.source, /from last try/);
});
test("script example when nothing tried", () => {
const seed = seedTryDialog({
step: step("a"),
index: 0,
steps: [step("a")],
session: emptyTrySession(),
meta: { example: { data: { hello: 1 }, context: { k: 2 } } },
});
assert.deepEqual(seed.data, { hello: 1 });
assert.deepEqual(seed.context, { k: 2 });
assert.equal(seed.source, "from script example");
});
test("pruneTrySession drops removed steps", () => {
let session = emptyTrySession();
session = recordTrySuccess(session, "a", {
data: {},
context: {},
output: 1,
resultContext: {},
});
session = recordTrySuccess(session, "b", {
data: {},
context: {},
output: 2,
resultContext: {},
});
const pruned = pruneTrySession(session, [step("a")]);
assert.ok(pruned.byStep.a);
assert.equal(pruned.byStep.b, undefined);
assert.equal(pruned.lastTriedUiId, null);
});
+16
View File
@@ -124,6 +124,22 @@ function adjacencyFromScripts(scripts) {
return children; return children;
} }
/** Outgoing neighbors (linear next, or DAG children). */
export function stepSuccessors(scripts, uiId) {
return adjacencyFromScripts(scripts ?? []).get(uiId) ?? [];
}
/** Incoming neighbors (linear previous, or DAG parents). */
export function stepPredecessors(scripts, uiId) {
const children = adjacencyFromScripts(scripts ?? []);
/** @type {string[]} */
const out = [];
for (const [from, tos] of children) {
if (tos.includes(uiId)) out.push(from);
}
return out;
}
export function wouldCreateCycle(scripts, fromUiId, toUiId) { export function wouldCreateCycle(scripts, fromUiId, toUiId) {
if (fromUiId === toUiId) return true; if (fromUiId === toUiId) return true;
const children = adjacencyFromScripts(scripts); const children = adjacencyFromScripts(scripts);
@@ -6,6 +6,8 @@ import {
canConnectSteps, canConnectSteps,
enteringDagWouldStripWhen, enteringDagWouldStripWhen,
removeStepEdge, removeStepEdge,
stepPredecessors,
stepSuccessors,
wouldCreateCycle, wouldCreateCycle,
} from "./workflow-graph.js"; } from "./workflow-graph.js";
@@ -87,3 +89,27 @@ test("removeStepEdge drops a list dependency", () => {
assert.equal(scripts[1].needs, null); assert.equal(scripts[1].needs, null);
assert.equal(isDagDoc({ scripts }), false); assert.equal(isDagDoc({ scripts }), false);
}); });
test("stepSuccessors / stepPredecessors linear", () => {
const scripts = [step("a"), step("b"), step("c")];
assert.deepEqual(stepSuccessors(scripts, "a"), ["b"]);
assert.deepEqual(stepSuccessors(scripts, "b"), ["c"]);
assert.deepEqual(stepSuccessors(scripts, "c"), []);
assert.deepEqual(stepPredecessors(scripts, "a"), []);
assert.deepEqual(stepPredecessors(scripts, "b"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "c"), ["b"]);
});
test("stepSuccessors / stepPredecessors DAG multi", () => {
const scripts = [
step("a", { id: "a" }),
step("b", { id: "b" }),
step("c", { id: "c", needs: ["a", "b"] }),
step("d", { id: "d", needs: ["a"] }),
];
assert.deepEqual(stepSuccessors(scripts, "a").sort(), ["c", "d"]);
assert.deepEqual(stepSuccessors(scripts, "b"), ["c"]);
assert.deepEqual(stepPredecessors(scripts, "c").sort(), ["a", "b"]);
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "a"), []);
});
+45 -1
View File
@@ -1,6 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useKv, useKvNamespaces } from "../api/hooks.js"; import { LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useDeleteKv, useKv, useKvNamespaces } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx"; import { FormSelect } from "../components/FormControls.jsx";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
@@ -19,8 +22,10 @@ export function KvPage() {
const q = params.get("q") || ""; const q = params.get("q") || "";
const offset = Math.max(Number(params.get("offset")) || 0, 0); const offset = Math.max(Number(params.get("offset")) || 0, 0);
const [expanded, setExpanded] = useState(null); const [expanded, setExpanded] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const { data: namespaces = [] } = useKvNamespaces(); const { data: namespaces = [] } = useKvNamespaces();
const del = useDeleteKv();
const { data, isLoading } = useKv({ const { data, isLoading } = useKv({
namespace: namespace || undefined, namespace: namespace || undefined,
q: q || undefined, q: q || undefined,
@@ -83,6 +88,7 @@ export function KvPage() {
<th>Value</th> <th>Value</th>
<th>Updated</th> <th>Updated</th>
<th>Expires</th> <th>Expires</th>
<th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -119,6 +125,21 @@ export function KvPage() {
<td className="whitespace-nowrap align-top"> <td className="whitespace-nowrap align-top">
{item.expiresAt ? formatTime(item.expiresAt) : "—"} {item.expiresAt ? formatTime(item.expiresAt) : "—"}
</td> </td>
<td className="text-right whitespace-nowrap align-top">
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
aria-label="Delete"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(item);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr> </tr>
); );
})} })}
@@ -148,6 +169,29 @@ export function KvPage() {
</div> </div>
</> </>
)} )}
<ConfirmDialog
open={Boolean(confirmDelete)}
title={
confirmDelete ? `Delete ${confirmDelete.namespace}/${confirmDelete.key}?` : ""
}
message="This cannot be undone. Scripts that read this key will get null."
error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending}
onCancel={() => setConfirmDelete(null)}
onConfirm={() =>
del.mutate(
{ namespace: confirmDelete.namespace, key: confirmDelete.key },
{
onSuccess: () => {
const id = rowId(confirmDelete);
setConfirmDelete(null);
setExpanded((current) => (current === id ? null : current));
},
},
)
}
/>
</div> </div>
); );
} }
@@ -142,11 +142,6 @@ export function WorkflowNewPage() {
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null} saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
> >
<div className="flex min-h-0 flex-1 flex-col gap-3"> <div className="flex min-h-0 flex-1 flex-col gap-3">
<p className="text-sm opacity-70 shrink-0">
A UUID filename is assigned on save (for example{" "}
<span className="font-mono">a1b2c3d4-….yaml</span>). Edit the{" "}
<span className="font-mono">name:</span> field for the display name.
</p>
<WorkflowVisualEditor <WorkflowVisualEditor
yaml={content} yaml={content}
onYamlChange={setContent} onYamlChange={setContent}
+106 -6
View File
@@ -14,11 +14,78 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format"; import { formatTime, WorkflowStatusBadge } from "../lib/format";
const SORT_COLUMNS = [
{ key: "name", label: "Name", defaultOrder: "asc" },
{ key: "owner", label: "Owner", defaultOrder: "asc" },
{ key: "status", label: "Status", defaultOrder: "asc" },
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
{ key: "invocationCount", label: "Runs", defaultOrder: "desc" },
];
function SortHeader({ column, label, sort, order, onSort }) {
const active = sort === column;
return (
<th>
<button
type="button"
className={`font-semibold hover:underline ${active ? "" : "opacity-80"}`}
onClick={() => onSort(column)}
>
{label}
{active ? (order === "asc" ? " ↑" : " ↓") : ""}
</button>
</th>
);
}
function statusSortKey(w) {
if (w.loadError) return "0-broken";
if (!w.enabled) return "1-disabled";
if (w.lastStatus === "failed") return "2-failed";
if (w.lastStatus === "queued") return "3-queued";
if (w.lastStatus === "running") return "4-running";
if (w.lastStatus === "success") return "5-working";
return "6-never";
}
function compareName(a, b) {
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
sensitivity: "base",
});
if (byName !== 0) return byName;
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
}
function compareWorkflows(a, b, sort) {
if (sort === "name") return compareName(a, b);
if (sort === "owner") {
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
sensitivity: "base",
});
return byOwner !== 0 ? byOwner : compareName(a, b);
}
if (sort === "status") {
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
return byStatus !== 0 ? byStatus : compareName(a, b);
}
if (sort === "invocationCount") {
const byCount = (Number(a.invocationCount) || 0) - (Number(b.invocationCount) || 0);
return byCount !== 0 ? byCount : compareName(a, b);
}
const aTime = a[sort] ?? "";
const bTime = b[sort] ?? "";
const byTime = String(aTime).localeCompare(String(bTime));
return byTime !== 0 ? byTime : compareName(a, b);
}
export function WorkflowsPage() { export function WorkflowsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [params] = useSearchParams(); const [params] = useSearchParams();
const editParam = params.get("edit"); const editParam = params.get("edit");
const { data: workflows = [], isLoading } = useWorkflows(); const { data: workflows = [], isLoading } = useWorkflows();
const [sort, setSortColumn] = useState("name");
const [order, setOrder] = useState("asc");
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [duplicateSource, setDuplicateSource] = useState(null); const [duplicateSource, setDuplicateSource] = useState(null);
const [runError, setRunError] = useState(null); const [runError, setRunError] = useState(null);
@@ -68,6 +135,19 @@ export function WorkflowsPage() {
? `${setEnabled.variables.owner}/${setEnabled.variables.file}` ? `${setEnabled.variables.owner}/${setEnabled.variables.file}`
: null; : null;
const dir = order === "desc" ? -1 : 1;
const sortedWorkflows = [...workflows].sort((a, b) => dir * compareWorkflows(a, b, sort));
function setSort(column) {
const spec = SORT_COLUMNS.find((c) => c.key === column);
if (sort === column) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setSortColumn(column);
setOrder(spec?.defaultOrder ?? "asc");
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -115,17 +195,36 @@ export function WorkflowsPage() {
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Name</th> <SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
<th>Owner</th> <SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
<th>Status</th> <SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
<th>Triggers</th> <th>Triggers</th>
<th>Last run</th> <SortHeader
<th>Runs</th> column="lastModifiedAt"
label="Last modified"
sort={sort}
order={order}
onSort={setSort}
/>
<SortHeader
column="lastInvokedAt"
label="Last run"
sort={sort}
order={order}
onSort={setSort}
/>
<SortHeader
column="invocationCount"
label="Runs"
sort={sort}
order={order}
onSort={setSort}
/>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{workflows.map((w) => ( {sortedWorkflows.map((w) => (
<tr key={w.key} className="hover"> <tr key={w.key} className="hover">
<td> <td>
<Link <Link
@@ -152,6 +251,7 @@ export function WorkflowsPage() {
<td> <td>
<TriggerList triggers={w.triggers} /> <TriggerList triggers={w.triggers} />
</td> </td>
<td className="whitespace-nowrap">{formatTime(w.lastModifiedAt)}</td>
<td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td> <td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td>
<td>{w.invocationCount}</td> <td>{w.invocationCount}</td>
<td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">