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:
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared";
|
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,25 +286,64 @@ 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>
|
||||||
) : (
|
) : (
|
||||||
<ConfigFields
|
<div className="space-y-2">
|
||||||
script={scriptName}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
config={step.config}
|
<div className="join">
|
||||||
inheritedConfig={inheritedConfig}
|
<button
|
||||||
meta={meta}
|
type="button"
|
||||||
disabled={disabled}
|
className={`btn btn-xs join-item ${configMode === "form" ? "btn-active" : ""}`}
|
||||||
workflows={workflows}
|
disabled={disabled}
|
||||||
owner={owner}
|
onClick={() => setConfigMode("form")}
|
||||||
excludeFile={excludeFile}
|
>
|
||||||
onChange={(config) => onChange({ ...step, config })}
|
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
|
||||||
|
script={scriptName}
|
||||||
|
config={step.config}
|
||||||
|
inheritedConfig={inheritedConfig}
|
||||||
|
meta={meta}
|
||||||
|
disabled={disabled}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
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 {
|
||||||
|
output: dryRun.data.output,
|
||||||
|
context: dryRun.data.context,
|
||||||
|
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { error: dryRun.data.error };
|
||||||
|
}
|
||||||
|
if (sessionResult) {
|
||||||
|
return {
|
||||||
|
output: sessionResult.output,
|
||||||
|
context: sessionResult.context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [dryRun.data, sessionResult]);
|
||||||
|
|
||||||
const resultJson = dryRun.data
|
function parseTryConfig() {
|
||||||
? prettyJson(
|
return parseConfigObject(configJson);
|
||||||
dryRun.data.status === "success"
|
}
|
||||||
? {
|
|
||||||
output: dryRun.data.output,
|
const configParse = useMemo(() => {
|
||||||
context: dryRun.data.context,
|
if (isSet) return { ok: true, value: { expression } };
|
||||||
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
|
try {
|
||||||
}
|
return { ok: true, value: parseConfigObject(configJson) };
|
||||||
: { error: dryRun.data.error },
|
} 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,
|
{
|
||||||
content: existing.data.content,
|
name: script,
|
||||||
data,
|
content: existing.data.content,
|
||||||
context: context ?? {},
|
data,
|
||||||
config: config ?? {},
|
context: context ?? {},
|
||||||
owner,
|
config: runConfig,
|
||||||
});
|
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,47 +921,148 @@ 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 className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
|
||||||
|
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||||
|
result (output + context)
|
||||||
|
{dryRun.data?.status ? (
|
||||||
|
<StatusBadge status={dryRun.data.status} />
|
||||||
|
) : sessionResult && !dryRun.data ? (
|
||||||
|
<span className="badge badge-ghost badge-xs">session</span>
|
||||||
|
) : null}
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0">
|
|
||||||
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary>
|
|
||||||
<div className="collapse-content">
|
|
||||||
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{dryRun.data?.logs?.length ? (
|
{dryRun.data?.logs?.length ? (
|
||||||
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
|
<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">
|
<div className="modal-action shrink-0 flex-wrap items-center justify-between gap-2">
|
||||||
<button type="button" className="btn" onClick={onClose}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Close
|
<TryNavCluster
|
||||||
</button>
|
direction="prev"
|
||||||
<button
|
neighbors={predecessors}
|
||||||
type="button"
|
pick={prevPick}
|
||||||
className="btn btn-primary"
|
onPick={setPrevPick}
|
||||||
disabled={dryRun.isPending || existing.isLoading}
|
onGo={(uiId) => onNavigateTry?.(uiId)}
|
||||||
onClick={onRun}
|
disabled={!onNavigateTry}
|
||||||
>
|
/>
|
||||||
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
<TryNavCluster
|
||||||
Run
|
direction="next"
|
||||||
</button>
|
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}>
|
||||||
|
Close
|
||||||
|
</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
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={runDisabled}
|
||||||
|
onClick={onRun}
|
||||||
|
>
|
||||||
|
{dryRun.isPending ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : null}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" className="modal-backdrop">
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared";
|
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user