From beb7e226521eba8d3c233ef88d361c2867f119e7 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 17:52:17 +0700 Subject: [PATCH] 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 --- packages/server/fs-store.js | 25 + packages/server/json-preview.js | 93 ++- packages/server/package.json | 3 +- packages/server/profile-config.js | 2 +- packages/server/src/api/kv.js | 15 +- packages/server/src/api/scripts.js | 105 ++- packages/server/src/api/workflows.js | 8 + packages/server/test/json-preview-smoke.js | 21 +- packages/server/test/set-dry-run-smoke.js | 81 ++ packages/shared/src/index.js | 2 +- packages/shared/src/profile-config.js | 39 + packages/shared/src/profile-config.test.js | 24 + packages/web/package.json | 2 +- packages/web/src/api/hooks/kv.js | 11 +- packages/web/src/api/hooks/scripts.js | 13 +- packages/web/src/components/CodeEditor.jsx | 2 +- .../web/src/components/workflow/GraphTab.jsx | 29 +- .../src/components/workflow/ScriptCard.jsx | 718 +++++++++++++++--- .../src/components/workflow/ScriptsTab.jsx | 18 +- .../workflow/WorkflowVisualEditor.jsx | 59 +- packages/web/src/lib/profile.js | 2 +- packages/web/src/lib/try-session.js | 251 ++++++ packages/web/src/lib/try-session.test.js | 168 ++++ packages/web/src/lib/workflow-graph.js | 16 + packages/web/src/lib/workflow-graph.test.js | 26 + packages/web/src/pages/KvPage.jsx | 46 +- packages/web/src/pages/WorkflowEditPage.jsx | 5 - packages/web/src/pages/WorkflowsPage.jsx | 112 ++- 28 files changed, 1753 insertions(+), 143 deletions(-) create mode 100644 packages/server/test/set-dry-run-smoke.js create mode 100644 packages/web/src/lib/try-session.js create mode 100644 packages/web/src/lib/try-session.test.js diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js index 72a419b..bf96205 100644 --- a/packages/server/fs-store.js +++ b/packages/server/fs-store.js @@ -110,6 +110,31 @@ export function readWorkflowYaml(owner, file) { 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) { assertOwner(owner); assertWorkflowFile(file); diff --git a/packages/server/json-preview.js b/packages/server/json-preview.js index c730632..ad68640 100644 --- a/packages/server/json-preview.js +++ b/packages/server/json-preview.js @@ -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). * @param {Buffer | ArrayBufferView | ArrayBuffer} value */ export function summarizeBinary(value) { - const buf = Buffer.isBuffer(value) - ? value - : value instanceof ArrayBuffer - ? Buffer.from(value) - : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + const buf = toBuffer(value); const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES); return { 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} */ + 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} */ (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: * Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer. diff --git a/packages/server/package.json b/packages/server/package.json index 46e1616..44d1c59 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,7 +14,8 @@ "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: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": { "@jerapah-flow/shared": "workspace:*", diff --git a/packages/server/profile-config.js b/packages/server/profile-config.js index 2607783..c4776ef 100644 --- a/packages/server/profile-config.js +++ b/packages/server/profile-config.js @@ -1 +1 @@ -export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared"; +export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared"; diff --git a/packages/server/src/api/kv.js b/packages/server/src/api/kv.js index 3d7fe73..7629548 100644 --- a/packages/server/src/api/kv.js +++ b/packages/server/src/api/kv.js @@ -1,4 +1,4 @@ -import { kvNamespaces, kvQuery } from "../../kv-store.js"; +import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js"; /** * @param {import("fastify").FastifyInstance} fastify @@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) { offset: Number.isFinite(offset) ? offset : undefined, }); }); + + fastify.delete("/kv", async (req, reply) => { + const q = /** @type {Record} */ (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 }); + } + }); } diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index af1e017..c799a56 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -21,11 +21,16 @@ import { installPluginFromZipBuffer, } from "../../plugin-install.js"; import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js"; +import { + encodeBinaryForWire, + reviveBinaryFromWire, +} from "../../json-preview.js"; import { normalizeStepResult } from "../../step-result.js"; import { resolveConfigRefs } from "../../config-refs.js"; import { getAppVersion } from "../../app-version.js"; import { EXAMPLE_PLUGINS_DIR } from "../../paths.js"; import { pluginScriptRef } from "../../plugin-manifest.js"; +import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js"; /** * @param {{ referencedScripts: () => Set }} registry @@ -249,12 +254,9 @@ export default function scriptsPluginFactory(registry) { const rawName = decodeURIComponent( /** @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 ?? {} ); - if (typeof body.content !== "string") { - return reply.code(400).send({ error: "content is required" }); - } let owner = "default"; if (body.owner != null && body.owner !== "") { @@ -265,12 +267,92 @@ export default function scriptsPluginFactory(registry) { } } - const incomingContext = + const incomingContext = reviveBinaryFromWire( body.context != null && - typeof body.context === "object" && - !Array.isArray(body.context) + typeof body.context === "object" && + !Array.isArray(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} */ (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 pluginDir = @@ -278,9 +360,6 @@ export default function scriptsPluginFactory(registry) { ? resolved.pluginDir ?? null : null; - const { log, logs } = createDryRunLogger(); - const started = Date.now(); - try { const config = await resolveConfigRefs(body.config ?? null, { owner, @@ -288,7 +367,7 @@ export default function scriptsPluginFactory(registry) { context: incomingContext, }); const ctx = { - data: body.data ?? null, + data: incomingData, context: incomingContext, config, }; @@ -312,6 +391,8 @@ export default function scriptsPluginFactory(registry) { status: "success", output: safeSerialize(result.output), context: safeSerialize(result.context), + wireOutput: encodeBinaryForWire(result.output), + wireContext: encodeBinaryForWire(result.context), skipRemaining: result.skipRemaining, error: null, logs, diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index a417091..d5cae01 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -366,6 +366,7 @@ export default function workflowsPluginFactory(registry) { enabled: parsed ? parsed.enabled !== false : false, registered: registered.includes(file), loadError: loadError ?? (parsed ? null : "unreadable"), + lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file), lastInvokedAt: st.lastInvokedAt, lastStatus: st.lastStatus ?? null, 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 }; }); diff --git a/packages/server/test/json-preview-smoke.js b/packages/server/test/json-preview-smoke.js index bb87cda..ad5c9f8 100644 --- a/packages/server/test/json-preview-smoke.js +++ b/packages/server/test/json-preview-smoke.js @@ -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 { 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)}`); } +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"); diff --git a/packages/server/test/set-dry-run-smoke.js b/packages/server/test/set-dry-run-smoke.js new file mode 100644 index 0000000..8e6c963 --- /dev/null +++ b/packages/server/test/set-dry-run-smoke.js @@ -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"); diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js index aba8437..a19bec6 100644 --- a/packages/shared/src/index.js +++ b/packages/shared/src/index.js @@ -1,4 +1,4 @@ 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 { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js"; diff --git a/packages/shared/src/profile-config.js b/packages/shared/src/profile-config.js index 1be3c9d..fe4d57b 100644 --- a/packages/shared/src/profile-config.js +++ b/packages/shared/src/profile-config.js @@ -17,6 +17,45 @@ export function mergeProfileConfig(profileConfig, 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} + */ +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 * @returns {boolean} diff --git a/packages/shared/src/profile-config.test.js b/packages/shared/src/profile-config.test.js index d0c734a..98c804a 100644 --- a/packages/shared/src/profile-config.test.js +++ b/packages/shared/src/profile-config.test.js @@ -5,6 +5,7 @@ import { isPlainObject, mergeProfileConfig, namespacedPath, + overlayFromMerged, } from "./index.js"; 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", () => { it("is true only for non-empty plain objects", () => { expect(configHasOverlay({ a: 1 })).toBe(true); diff --git a/packages/web/package.json b/packages/web/package.json index 2e9d639..6a8e645 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,7 @@ "build": "vite build", "preview": "vite preview", "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": { "@dnd-kit/core": "^6.3.1", diff --git a/packages/web/src/api/hooks/kv.js b/packages/web/src/api/hooks/kv.js index 71ac369..19a6cf8 100644 --- a/packages/web/src/api/hooks/kv.js +++ b/packages/web/src/api/hooks/kv.js @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../client.js"; 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"] }), + }); +} + diff --git a/packages/web/src/api/hooks/scripts.js b/packages/web/src/api/hooks/scripts.js index 96bb319..cef656e 100644 --- a/packages/web/src/api/hooks/scripts.js +++ b/packages/web/src/api/hooks/scripts.js @@ -84,10 +84,19 @@ export function useDeleteScript() { export function useDryRunScript() { 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`, { - content, + ...(content != null ? { content } : {}), + ...(expression != null ? { expression } : {}), data, context, config, diff --git a/packages/web/src/components/CodeEditor.jsx b/packages/web/src/components/CodeEditor.jsx index 9640d6a..ba3f6c7 100644 --- a/packages/web/src/components/CodeEditor.jsx +++ b/packages/web/src/components/CodeEditor.jsx @@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl const isMobile = typeof window !== "undefined" && window.innerWidth < 768; return ( -
+
@@ -61,6 +66,10 @@ function GraphTabInner({ excludeFile, auths = [], pages = [], + trySession, + onTrySuccess, + tryFocusUiId, + onTryFocus, }) { const { fitView } = useReactFlow(); const [addStepOpen, setAddStepOpen] = useState(false); @@ -94,7 +103,7 @@ function GraphTabInner({ }, [doc, disabled, positions]); useEffect(() => { - const t = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); + const t = requestAnimationFrame(() => fitView(FIT_VIEW)); return () => cancelAnimationFrame(t); }, [owner, file, fitView]); @@ -218,7 +227,7 @@ function GraphTabInner({ function resetLayout() { clearGraphLayout(owner, file); 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; @@ -285,6 +294,7 @@ function GraphTabInner({ nodesConnectable={!disabled} nodesDraggable={!disabled} connectionRadius={80} + defaultViewport={{ x: 0, y: 0, zoom: INITIAL_ZOOM }} defaultEdgeOptions={{ type: "smoothstep" }} proOptions={{ hideAttribution: false }} > @@ -309,6 +319,20 @@ function GraphTabInner({ excludeFile={excludeFile} sortable={false} 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) => { const copy = [...steps]; copy[selectedStepIndex] = next; @@ -317,6 +341,7 @@ function GraphTabInner({ onRemove={() => { patchSteps(steps.filter((_, i) => i !== selectedStepIndex)); setSelectedId(null); + onTryFocus?.(null); }} /> ) : selectedTrigger && selectedTriggerIndex >= 0 ? ( diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx index c4e70c9..c57ae54 100644 --- a/packages/web/src/components/workflow/ScriptCard.jsx +++ b/packages/web/src/components/workflow/ScriptCard.jsx @@ -1,19 +1,42 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useSortable } from "@dnd-kit/sortable"; 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 { errorMessage } from "../../api/client.js"; import { CodeEditor } from "../CodeEditor.jsx"; import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx"; +import { JsonTree } from "../JsonViewBlock.jsx"; import { LogViewer } from "../LogViewer.jsx"; 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 { + stepPredecessors, + stepSuccessors, +} from "../../lib/workflow-graph.js"; 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 { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js"; +import { + configHasOverlay, + mergeProfileConfig, + overlayFromMerged, +} from "../../lib/profile.js"; export function ScriptCard(props) { if (props.sortable === false) { @@ -44,9 +67,21 @@ function ScriptCardView({ excludeFile, sortable = true, defaultExpanded = false, + trySession, + onTrySuccess, + tryOpen: tryOpenProp, + onTryOpenChange, + onNavigateTry, drag, }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = drag ?? { attributes: {}, listeners: {}, setNodeRef: undefined, @@ -59,8 +94,15 @@ function ScriptCardView({ transition, 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 [configMode, setConfigMode] = useState("form"); const profile = step.profile && profilesByName?.get(step.profile); const scriptName = profile?.script || step.script; const listed = scriptsByName?.get(scriptName); @@ -81,6 +123,27 @@ function ScriptCardView({ step.kind === "set" ? { expression: step.expression ?? "" } : null; 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 (
) : null} {step.kind === "script" && scriptName ? ( - + ) : null}
@@ -208,25 +286,64 @@ function ScriptCardView({ className="w-full min-h-24 font-mono text-xs" value={step.expression ?? ""} disabled={disabled} - onChange={(e) => onChange({ ...step, expression: e.target.value })} + onChange={(e) => + onChange({ ...step, expression: e.target.value }) + } /> ) : ( - onChange({ ...step, config })} - /> +
+
+
+ + +
+ {step.profile ? ( + + JSON is the step overlay, not the merged runtime config + + ) : null} +
+ {configMode === "form" ? ( + onChange({ ...step, config })} + /> + ) : ( + onChange({ ...step, config })} + /> + )} +
)}
- Advanced + + Advanced +
onChange({ ...step, when: e.target.value })} + onChange={(e) => + onChange({ ...step, when: e.target.value }) + } placeholder="JSONata; skip if false (linear only)" /> {step.needs ? ( - when is not allowed when needs is set + + when is not allowed when needs is set + ) : null}
@@ -267,10 +390,27 @@ function ScriptCardView({ {tryOpen ? ( onChange({ ...step, expression }) + : (config) => onChange({ ...step, config }) + } onClose={() => setTryOpen(false)} /> ) : 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 ( +
+ {parseError ?

{parseError}

: null} +
+ {} : commit} + readOnly={disabled} + height="100%" + /> +
+
+ ); +} + function NeedsEditor({ step, otherSteps, disabled, onChange }) { 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) { if (next === "none") onChange({ ...step, needs: null }); @@ -303,10 +494,13 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) { {mode === "list" ? (
{ids.length === 0 ? ( - Give other steps an id first + + Give other steps an id first + ) : ( ids.map((id) => { - const checked = Array.isArray(step.needs) && step.needs.includes(id); + const checked = + Array.isArray(step.needs) && step.needs.includes(id); return (
@@ -192,6 +245,10 @@ export function WorkflowVisualEditor({ workflows={workflows} owner={owner} excludeFile={file} + trySession={trySession} + onTrySuccess={onTrySuccess} + tryFocusUiId={tryFocusUiId} + onTryFocus={onTryFocus} /> ) : null} {tab === "triggers" ? ( diff --git a/packages/web/src/lib/profile.js b/packages/web/src/lib/profile.js index 2607783..c4776ef 100644 --- a/packages/web/src/lib/profile.js +++ b/packages/web/src/lib/profile.js @@ -1 +1 @@ -export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared"; +export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared"; diff --git a/packages/web/src/lib/try-session.js b/packages/web/src/lib/try-session.js new file mode 100644 index 0000000..b63b533 --- /dev/null +++ b/packages/web/src/lib/try-session.js @@ -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, + * output: unknown, + * resultContext: Record, + * at: number, + * }} TryStepRecord + * + * @typedef {{ + * byStep: Record, + * lastTriedUiId: string | null, + * }} TrySession + * + * @typedef {{ + * data: unknown, + * context: Record, + * source: string, + * lastResult?: { output: unknown, context: Record }, + * }} 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} */ (result.context) + : {}; + const resultContext = + result.resultContext != null && + typeof result.resultContext === "object" && + !Array.isArray(result.resultContext) + ? /** @type {Record} */ (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} */ + 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} 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} */ + 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} */ (needs); + /** @type {Record} */ + 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} */ + const byId = new Map(); + /** @type {Map} */ + 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", + }; +} diff --git a/packages/web/src/lib/try-session.test.js b/packages/web/src/lib/try-session.test.js new file mode 100644 index 0000000..687285c --- /dev/null +++ b/packages/web/src/lib/try-session.test.js @@ -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); +}); diff --git a/packages/web/src/lib/workflow-graph.js b/packages/web/src/lib/workflow-graph.js index 521054c..d8542ff 100644 --- a/packages/web/src/lib/workflow-graph.js +++ b/packages/web/src/lib/workflow-graph.js @@ -124,6 +124,22 @@ function adjacencyFromScripts(scripts) { 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) { if (fromUiId === toUiId) return true; const children = adjacencyFromScripts(scripts); diff --git a/packages/web/src/lib/workflow-graph.test.js b/packages/web/src/lib/workflow-graph.test.js index 6de1062..dd78ba4 100644 --- a/packages/web/src/lib/workflow-graph.test.js +++ b/packages/web/src/lib/workflow-graph.test.js @@ -6,6 +6,8 @@ import { canConnectSteps, enteringDagWouldStripWhen, removeStepEdge, + stepPredecessors, + stepSuccessors, wouldCreateCycle, } from "./workflow-graph.js"; @@ -87,3 +89,27 @@ test("removeStepEdge drops a list dependency", () => { assert.equal(scripts[1].needs, null); 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"), []); +}); diff --git a/packages/web/src/pages/KvPage.jsx b/packages/web/src/pages/KvPage.jsx index 0e36583..7e96519 100644 --- a/packages/web/src/pages/KvPage.jsx +++ b/packages/web/src/pages/KvPage.jsx @@ -1,6 +1,9 @@ import { useState } from "react"; 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 { formatTime } from "../lib/format"; @@ -19,8 +22,10 @@ export function KvPage() { const q = params.get("q") || ""; const offset = Math.max(Number(params.get("offset")) || 0, 0); const [expanded, setExpanded] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); const { data: namespaces = [] } = useKvNamespaces(); + const del = useDeleteKv(); const { data, isLoading } = useKv({ namespace: namespace || undefined, q: q || undefined, @@ -83,6 +88,7 @@ export function KvPage() { Value Updated Expires + @@ -119,6 +125,21 @@ export function KvPage() { {item.expiresAt ? formatTime(item.expiresAt) : "—"} + + + ); })} @@ -148,6 +169,29 @@ export function KvPage() {
)} + + setConfirmDelete(null)} + onConfirm={() => + del.mutate( + { namespace: confirmDelete.namespace, key: confirmDelete.key }, + { + onSuccess: () => { + const id = rowId(confirmDelete); + setConfirmDelete(null); + setExpanded((current) => (current === id ? null : current)); + }, + }, + ) + } + /> ); } diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx index efb6849..aabd1d0 100644 --- a/packages/web/src/pages/WorkflowEditPage.jsx +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -142,11 +142,6 @@ export function WorkflowNewPage() { saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null} >
-

- A UUID filename is assigned on save (for example{" "} - a1b2c3d4-….yaml). Edit the{" "} - name: field for the display name. -

+ + + ); +} + +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() { const navigate = useNavigate(); const [params] = useSearchParams(); const editParam = params.get("edit"); const { data: workflows = [], isLoading } = useWorkflows(); + const [sort, setSortColumn] = useState("name"); + const [order, setOrder] = useState("asc"); const [confirmDelete, setConfirmDelete] = useState(null); const [duplicateSource, setDuplicateSource] = useState(null); const [runError, setRunError] = useState(null); @@ -68,6 +135,19 @@ export function WorkflowsPage() { ? `${setEnabled.variables.owner}/${setEnabled.variables.file}` : 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 (
@@ -115,17 +195,36 @@ export function WorkflowsPage() { - - - + + + - - + + + - {workflows.map((w) => ( + {sortedWorkflows.map((w) => ( +
NameOwnerStatusTriggersLast runRuns
{formatTime(w.lastModifiedAt)} {formatTime(w.lastInvokedAt)} {w.invocationCount}