// SPDX-FileCopyrightText: 2025-2026 Kforge Labs // SPDX-License-Identifier: GPL-3.0-only // ============================================================================= // Workflows storage (ComfyUI /userdata API with localStorage fallback) + // transaction layer + in-memory mutation operations. // // Schema (recursive, since v0.3 — back-compatible with the v0.2 flat shape): // workflowsCache = { directories: { [name]: DirNode } } // DirNode = { workflows: { [wfName]: WorkflowEntry }, directories: { [name]: DirNode } } // // Every directory can host workflows AND nested subdirectories at any depth. // The root has only `directories` — workflows live exclusively inside named // directories. Old flat data without the nested `directories` field still // loads fine; `normalizeWorkflowsStore` adds an empty `directories: {}` per // node during migration, so a user's existing /userdata file is preserved. // // All mutator/lookup ops take a `path: string[]` of segment names. The empty // path `[]` is the root. Path segments must be non-empty strings. // // `workflowsCache` is module-private. Outside callers go through the exported // helpers — direct access would otherwise hold a stale reference after the // cache rebinds during seed/recovery. // ============================================================================= import { WORKFLOWS_USERDATA_PATH, WORKFLOWS_FALLBACK_KEY, WORKFLOWS_SEEDED_KEY, WORKFLOWS_CHANGED_EVENT, WORKFLOWS_DEFAULTS_URL, MODULE_TAG, compareNames, noStoreUrl, toast, criticalToast, } from "./constants.js"; import { formatLocalStamp } from "./format_time.js"; let workflowsCache = { directories: {} }; // Reject the three keys that can poison Object.prototype when an untrusted // directory/workflow name is used as an object key. Guards the keyed-mutation // sinks below (CodeQL js/prototype-polluting-assignment — e.g. archiveWorkflow's // `dir.workflows[wfName].archived = true`, where a "__proto__" name resolves to // Object.prototype and slips past the truthy existence check). Reads are // harmless; only assignment/delete/mutation by name needs the guard. function isSafeObjectKey(name) { return name !== "__proto__" && name !== "constructor" && name !== "prototype"; } function notifyWorkflowsChanged() { window.dispatchEvent(new CustomEvent(WORKFLOWS_CHANGED_EVENT)); } // ============================================================================= // Normalization (with back-compat migration for the pre-v0.3 flat shape) // ============================================================================= // // Exported so callers outside this module (e.g. ``detectBootDrift`` in // ``snapshot.js``) can canonicalize a freshly-read JSON blob with the // exact same transforms the live ``workflowsCache`` was built from — // otherwise a fingerprint comparison of "live cache vs disk file" gets // confused by ``archived: "false"`` (string) on disk vs the cache's // ``archived: false`` (bool), missing ``directories: {}`` fields on // pre-v0.3 nodes, or unsorted/duplicate tags. The transforms are // idempotent: passing already-normalized data returns an equivalent // (deep-cloned) shape, so calling this on a happy-path read is safe. export function normalizeWorkflowsStore(data) { if (!data || typeof data !== "object") return { directories: {} }; const dirs = data.directories; if (!dirs || typeof dirs !== "object") return { directories: {} }; const stats = { dropped: 0 }; const out = {}; for (const [name, dir] of Object.entries(dirs)) { const cleaned = normalizeDirNode(dir, stats); if (cleaned) out[name] = cleaned; } if (stats.dropped > 0) { console.warn(`[Koolook] dropped ${stats.dropped} malformed workflow entr(y/ies) during normalize`); } return { directories: out }; } function normalizeDirNode(node, stats) { if (!node || typeof node !== "object") return null; const wfs = node.workflows && typeof node.workflows === "object" ? node.workflows : {}; const cleanedWfs = {}; for (const [wfName, wf] of Object.entries(wfs)) { // Drop entries that can't be loaded (missing/non-object graph). // Coerce `archived` to a strict boolean so a stray "false" string // can't accidentally flag an entry as archived. Coerce `tags` to a // clean string[] (trim, drop empties, dedupe) so old entries without // a tags field load with `tags: []` and the rest of the code can // assume the field always exists. if (!wf || typeof wf !== "object" || !wf.graph || typeof wf.graph !== "object") { stats.dropped += 1; continue; } const tags = []; if (Array.isArray(wf.tags)) { const seen = new Set(); for (const raw of wf.tags) { // Reject non-strings outright instead of coercing — `String(null)` // becomes the literal string `"null"`, which would silently // surface as a garbage tag in the Tags section. The schema // contract is `string[]`; anything else is corruption from a // hand-edited /userdata file or a bug elsewhere. if (typeof raw !== "string") { stats.dropped += 1; continue; } const t = raw.trim(); if (!t || seen.has(t)) continue; seen.add(t); tags.push(t); } } const module = wf.module === true || tags.includes(MODULE_TAG); cleanedWfs[wfName] = { ...wf, archived: wf.archived === true, module, tags }; } // Recurse into subdirectories. Pre-v0.3 nodes don't have a `directories` // field — give them an empty one so the rest of the code can assume it // always exists. const subs = node.directories && typeof node.directories === "object" ? node.directories : {}; const cleanedSubs = {}; for (const [subName, subDir] of Object.entries(subs)) { const cleaned = normalizeDirNode(subDir, stats); if (cleaned) cleanedSubs[subName] = cleaned; } return { workflows: cleanedWfs, directories: cleanedSubs }; } function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } // Recursive deep-clone with object keys sorted case-insensitively at every // level. Exported for reuse by two callers that both need the same key-order // canonicalization: snapshot_diff.js (issue #181, Compare view) deciding // whether two workflow graphs differ, and ``detectBootDrift`` in // ``snapshot.js`` (issues #161/#162) comparing the live cache against the // on-disk snapshot — without it, insertion-order-sensitive ``JSON.stringify`` // makes equivalent content produce different fingerprints (false-positive // drift). Kept defined here next to its `compareNames` dependency rather than // moved, so the store's own callers are untouched. export function sortJsonValue(value) { if (Array.isArray(value)) return value.map(sortJsonValue); if (!value || typeof value !== "object") return value; const out = {}; for (const key of Object.keys(value).sort(compareNames)) { out[key] = sortJsonValue(value[key]); } return out; } function normalizedStoresEqual(a, b) { return JSON.stringify(sortJsonValue(a)) === JSON.stringify(sortJsonValue(b)); } function workflowSavedAtMs(wf) { if (!wf || typeof wf !== "object" || typeof wf.savedAt !== "string") return 0; const ms = Date.parse(wf.savedAt); return Number.isFinite(ms) ? ms : 0; } const ARCHIVE_NAME_RE = /^(.*) \(archived (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\)(?: #\d+)?$/; function archiveTimestampFromName(wfName) { if (typeof wfName !== "string") return 0; const m = wfName.match(ARCHIVE_NAME_RE); if (!m) return 0; // Archive keys are generated from `toISOString()` with "T" replaced by // a space, so parse them back as UTC. Parsing the bare string directly // would use the browser's local timezone and skew retention windows. const ms = Date.parse(`${m[2]}T${m[3]}.000Z`); return Number.isFinite(ms) ? ms : 0; } function archiveBaseName(wfName) { if (typeof wfName !== "string") return ""; const m = wfName.match(ARCHIVE_NAME_RE); return m ? m[1] : wfName; } function workflowArchivedAtMs(wfName, wf) { if (wf && typeof wf === "object" && typeof wf.archivedAt === "string") { const ms = Date.parse(wf.archivedAt); if (Number.isFinite(ms)) return ms; } const nameMs = archiveTimestampFromName(wfName); if (nameMs) return nameMs; return workflowSavedAtMs(wf); } function archiveStampForName(d) { return d.toISOString().slice(0, 19).replace("T", " "); } function mergeNewerFallbackDir(serverDir, fallbackDir) { let changed = false; if (!serverDir.workflows || typeof serverDir.workflows !== "object") serverDir.workflows = {}; if (!serverDir.directories || typeof serverDir.directories !== "object") serverDir.directories = {}; const fallbackWorkflows = fallbackDir && fallbackDir.workflows && typeof fallbackDir.workflows === "object" ? fallbackDir.workflows : {}; for (const [wfName, fallbackWorkflow] of Object.entries(fallbackWorkflows)) { const serverWorkflow = serverDir.workflows[wfName]; if (!serverWorkflow || workflowSavedAtMs(fallbackWorkflow) > workflowSavedAtMs(serverWorkflow)) { serverDir.workflows[wfName] = cloneJson(fallbackWorkflow); changed = true; } } const fallbackDirs = fallbackDir && fallbackDir.directories && typeof fallbackDir.directories === "object" ? fallbackDir.directories : {}; for (const [dirName, fallbackSubdir] of Object.entries(fallbackDirs)) { if (!serverDir.directories[dirName]) { serverDir.directories[dirName] = cloneJson(fallbackSubdir); changed = true; } else if (mergeNewerFallbackDir(serverDir.directories[dirName], fallbackSubdir)) { changed = true; } } return changed; } function mergeNewerFallbackStore(serverStore, fallbackStore) { const merged = cloneJson(serverStore || { directories: {} }); if (!merged.directories || typeof merged.directories !== "object") merged.directories = {}; const fallbackDirs = fallbackStore && fallbackStore.directories && typeof fallbackStore.directories === "object" ? fallbackStore.directories : {}; let changed = false; for (const [dirName, fallbackDir] of Object.entries(fallbackDirs)) { if (!merged.directories[dirName]) { merged.directories[dirName] = cloneJson(fallbackDir); changed = true; } else if (mergeNewerFallbackDir(merged.directories[dirName], fallbackDir)) { changed = true; } } return changed ? merged : null; } // ============================================================================= // Server I/O // ============================================================================= // Sentinel for "server reachable but file content is unparseable". // Distinct from `undefined` (server unreachable) and `null` (file missing) // so callers can refuse to auto-seed on top of a corrupt-but-existing file. const SERVER_FILE_CORRUPT = Symbol("workflows-server-corrupt"); async function fetchWorkflowsFromServer() { let resp; try { resp = await fetch(noStoreUrl(`/userdata/${WORKFLOWS_USERDATA_PATH}`), { cache: "no-store", }); } catch (e) { console.warn("[Koolook] /userdata read failed (network):", e); return undefined; } if (resp.status === 404) return null; if (!resp.ok) { console.warn(`[Koolook] /userdata read returned HTTP ${resp.status}`); return undefined; } const text = await resp.text(); if (!text || !text.trim()) return null; try { return JSON.parse(text); } catch (e) { console.error("[Koolook] /userdata workflow file is unreadable; refusing to auto-recover:", e); return SERVER_FILE_CORRUPT; } } // ComfyUI's /userdata endpoint requires `?overwrite=true` to allow POST over // an existing file; without it the server 409s on every save after the // first. The flag is part of the endpoint contract — pinned here as a named // constant so the contract is visible at a glance. const USERDATA_OVERWRITE_QUERY = "?overwrite=true"; // Returns "server" | "fallback" | false. Callers that care about durability // (specifically the seeders) should only treat "server" as a success — a // "fallback"-only write is per-browser and won't reach future page loads if // /userdata becomes reachable again. async function persistWorkflowsToServer(store) { const json = JSON.stringify(store, null, 2); try { const resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}${USERDATA_OVERWRITE_QUERY}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return "server"; } catch (e) { console.warn("[Koolook] /userdata write failed, using localStorage fallback:", e); try { localStorage.setItem(WORKFLOWS_FALLBACK_KEY, json); return "fallback"; } catch (e2) { console.error("[Koolook] both /userdata and localStorage write failed:", e2); return false; } } } // Returns { corrupt: false } on normal load (cache populated), or // { corrupt: true } when /userdata reachable but file content is unparseable — // the caller (setup) must skip seeding so we don't clobber the corrupt-but- // recoverable file with stock defaults. export async function loadWorkflowsStore() { const fromServer = await fetchWorkflowsFromServer(); if (fromServer === SERVER_FILE_CORRUPT) { // Corrupt /userdata file is a recovery situation — the user almost // certainly thinks their data is gone. Sticky toast forces explicit // acknowledgment and tells them where to look. criticalToast( "Workflow file on /userdata is unreadable (parse error). The " + "file is preserved on disk — refusing to auto-recover so you " + "can manually inspect / repair it before any save overwrites " + "the bad blob. Check the browser console for the parse error " + "and the file at /userdata/" + WORKFLOWS_USERDATA_PATH + "." ); workflowsCache = { directories: {} }; return { corrupt: true }; } if (fromServer === null) { // Server reachable, file just doesn't exist yet — return empty. workflowsCache = { directories: {} }; return { corrupt: false }; } if (fromServer === undefined) { // Server unreachable — try localStorage fallback. try { const raw = localStorage.getItem(WORKFLOWS_FALLBACK_KEY); if (raw) { workflowsCache = normalizeWorkflowsStore(JSON.parse(raw)); return { corrupt: false }; } } catch (e) { console.warn("[Koolook] failed to parse localStorage workflows fallback:", e); } workflowsCache = { directories: {} }; return { corrupt: false }; } workflowsCache = normalizeWorkflowsStore(fromServer); // Reconciliation surface: /userdata loaded fine but a localStorage // fallback from an earlier outage may still exist. Missing or newer entries are // merged below, then persisted back to /userdata when possible. // Older/ambiguous fallback data still uses the manual recovery surface // described here. // Server-only entries are preserved during automatic recovery. // Same-name conflicts only auto-recover when the fallback timestamp wins. // We don't fire the recovery toast from here either. The toast is // wired by the entry point (`koolook_sidebar.js`) where the // recovery handlers — Restore-as-snapshot and Discard — have access to // `writePreset` / `loadUserPicks` / `showConfirmModal` without creating // a circular import. We just return the blob so the caller can decide. const fallbackBlob = localStorage.getItem(WORKFLOWS_FALLBACK_KEY); if (fallbackBlob) { let fallbackStore = null; try { fallbackStore = normalizeWorkflowsStore(JSON.parse(fallbackBlob)); } catch (e) { console.warn("[Koolook] failed to parse localStorage workflows fallback for reconciliation:", e); } const mergedStore = fallbackStore ? mergeNewerFallbackStore(workflowsCache, fallbackStore) : null; if (mergedStore) { workflowsCache = mergedStore; const reconcileResult = await persistWorkflowsToServer(workflowsCache); if (reconcileResult === "server") { localStorage.removeItem(WORKFLOWS_FALLBACK_KEY); notifyWorkflowsChanged(); console.warn( "[Koolook] merged browser-local workflow fallback and wrote it back to /userdata." ); return { corrupt: false, fallbackRecovered: true }; } console.warn( `[Koolook] merged browser-local workflow fallback is live, but re-persist landed in ` + `${reconcileResult || "neither"}; keeping recovery banner available.` ); return { corrupt: false, fallbackBlob }; } if (fallbackStore && normalizedStoresEqual(fallbackStore, workflowsCache)) { try { localStorage.removeItem(WORKFLOWS_FALLBACK_KEY); console.warn("[Koolook] cleared redundant browser-local workflow fallback already present in /userdata."); } catch (e) { console.warn("[Koolook] failed to clear redundant localStorage workflows fallback:", e); return { corrupt: false, fallbackBlob }; } return { corrupt: false }; } console.warn( `[Koolook] /userdata loaded, but a stale localStorage fallback exists ` + `at "${WORKFLOWS_FALLBACK_KEY}". If workflows you saved during a previous ` + `outage are missing, recover from there before clearing.` ); } return { corrupt: false, fallbackBlob: fallbackBlob || null }; } // Removes the localStorage fallback blob written during an earlier // `/userdata` outage. Called by the recovery toast's Restore / Discard // actions once the blob has been either persisted as a snapshot or // explicitly discarded — clears the underlying condition so the toast // stops re-firing on subsequent page loads. Best-effort: a quota-disabled // browser would have failed the write anyway. export function clearOfflineFallback() { try { localStorage.removeItem(WORKFLOWS_FALLBACK_KEY); } catch (e) { console.warn("[Koolook] failed to clear offline fallback:", e); } } // ============================================================================= // Transaction layer (commit + rollback + persistMutation) // ============================================================================= // Track whether we've already raised the "fallback-only" sticky toast for // the current outage. Without this, every mutation during a /userdata outage // would stack another red banner on screen — quickly unusable. We re-arm // the warning only after a successful "server" write proves /userdata is // back, so a SECOND outage in the same session still alerts the user. let _fallbackWarnedThisOutage = false; async function commit() { // Both "server" and "fallback" are user-visible successes — the panel // shows the change either way. Only `false` (both backends rejected) // indicates a real loss requiring rollback at the call site. const result = await persistWorkflowsToServer(workflowsCache); if (result === "fallback" && !_fallbackWarnedThisOutage) { _fallbackWarnedThisOutage = true; criticalToast( "Workflow saved to browser-local fallback only — /userdata server " + "unreachable. Data persists per-browser until the server is back. " + "DO NOT clear browser data until you've confirmed a server save." ); } else if (result === "server" && _fallbackWarnedThisOutage) { // Server is back — re-arm the warning so a future outage will surface. _fallbackWarnedThisOutage = false; } if (result) notifyWorkflowsChanged(); return result !== false; } // Snapshot the cache so callers can roll back a mutation when commit() fails. // Pairs with a `restore()` function that puts the cache back and re-renders. function snapshotCache() { const snap = JSON.stringify(workflowsCache); return () => { try { workflowsCache = JSON.parse(snap); notifyWorkflowsChanged(); } catch (e) { console.error("[Koolook] cache rollback failed:", e); } }; } // Mutate-then-commit with automatic rollback on persist failure. Mutation // returning `false` is treated as a no-op (e.g. name collision) and bypasses // commit. `onSuccess(result)` and `onNoOp()` callbacks run their own toasts. export async function persistMutation({ mutate, onSuccess, onNoOp, persistFailedMessage }) { const restore = snapshotCache(); const result = mutate(); if (result === false) { if (onNoOp) onNoOp(); return false; } if (await commit()) { if (onSuccess) onSuccess(result); return true; } // Capture the unsaved state BEFORE rollback so a recovery copy ends up // in the user's clipboard via the critical toast — full backend failure // is rare but devastating, and the brief moment between "user clicked // save" and "rollback applied" is the only chance to surface what was // lost. Pretty-printed for human readability since the user might paste // it into a snapshot file or text editor. const unsavedJson = JSON.stringify(workflowsCache, null, 2); restore(); criticalToast( persistFailedMessage || "Workflow save failed — both /userdata server AND browser " + "localStorage rejected the write. Your last change has been " + "reverted. Click Copy details to save a recovery JSON to your " + "clipboard before retrying.", { copyText: unsavedJson } ); return false; } // ============================================================================= // Seeding // ============================================================================= export async function seedWorkflowDefaultsIfNeeded() { if (localStorage.getItem(WORKFLOWS_SEEDED_KEY)) return; // If existing data is non-empty, respect it and mark seeded. const dirNames = Object.keys(workflowsCache.directories || {}); if (dirNames.length > 0) { localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1"); return; } try { const resp = await fetch(WORKFLOWS_DEFAULTS_URL); if (!resp.ok) { localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1"); return; } const data = await resp.json(); const normalized = normalizeWorkflowsStore(data); const seedDirCount = Object.keys(normalized.directories).length; if (seedDirCount > 0) { workflowsCache = normalized; // Only mark seeded if the persist *reached the server* — a // "fallback"-only write is per-browser, and once /userdata // becomes reachable on a later load the empty server file // would otherwise trump the locally-seeded data. const persistResult = await persistWorkflowsToServer(workflowsCache); if (persistResult !== "server") { console.warn( `[Koolook] seed persist landed in ${persistResult || "neither"}; ` + `not marking seeded so we retry against /userdata next load.` ); return; } } localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1"); console.log(`[Koolook] seeded ${seedDirCount} default workflow director(y/ies)`); } catch (e) { console.warn("[Koolook] failed to load workflow_defaults.json:", e); localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1"); } } // ============================================================================= // Path-based directory operations (the public mutator API) // // `path` is a `string[]` of segment names. `[]` is the root (which has no // workflows; only directories live there). Mutators that take a `parentPath` // position the operation against that parent directory; e.g. // `addDirectory(["UP-scale"], "Type-A")` creates `UP-scale/Type-A`. // // Subdirectories cannot be named "Archive" (case-insensitive) — that name is // reserved by the synthetic Archive folder rendered for archived workflows. // // === Mutator invariants — read this before adding a new mutator === // // 1. **Mutate-then-commit, never one without the other.** Every mutator // below mutates `workflowsCache` in place and returns immediately. The // cache change is invisible on disk until a caller pairs the mutator // with `await commit()` (or wraps both in `persistMutation`, which is // the strongly-preferred path because it also handles snapshot/rollback // and the "no-op" return-false convention). A mutator that "succeeds" // without a paired commit silently drops the change on the next // reload — a class of bug we've already shipped + reverted once. // // 2. **Return `false` for no-op (collisions, missing source, validation // failures), truthy for success.** `persistMutation` treats a `false` // return as a no-op and skips commit. Truthy returns are passed to // `onSuccess(result)` as-is, which is why `saveWorkflowEntry` returns // `{archivedAs}` — the success callback wants to surface that detail. // // 3. **Mutate in place; don't return new structures.** Callers (and the // transaction layer's snapshot) expect `workflowsCache` to be the // single source of truth. Returning a new structure would silently // detach future reads from the change. // // 4. **Never replace `workflowsCache` itself except in seed / load / // rollback / snapshot-apply paths.** The four legitimate rebind sites // are: // - module init (this file's top-level `let workflowsCache = …`) // - `loadWorkflowsStore` and `seedWorkflowDefaultsIfNeeded` // (re-binding the cache to a freshly-normalized server payload // or a default seed) // - the closure returned by `snapshotCache()` running rollback // inside `persistMutation` — this rebind is what makes commit // failure recoverable // - `replaceAllWorkflows` (snapshot-apply) — bulk-replaces the // entire cache from a deserialized snapshot file, with the same // `snapshotCache()` rollback wrapper as `persistMutation` so the // atomic "swap on persist success, restore on failure" contract // is preserved // Outside callers hold no reference to the cache (it's // module-private), so external rebinds are impossible. The constraint // is internal: a fifth rebind site added by a future contributor for // any other reason would break the snapshot/restore semantics that // `persistMutation` and `replaceAllWorkflows` depend on. // ============================================================================= const ARCHIVE_RESERVED_NAME = "archive"; // Read-only render-source override (issue #181, Compare mode). When set, // everything that resolves through `dirOf` — `listDirectoryNames`, // `getWorkflowGraph`, `getWorkflowTags`, `isWorkflowModule`, and the tree // gather in tree.js — reads this store instead of the live `workflowsCache`, // letting the Compare view render a second sidebar from a loaded snapshot // without mutating live state. Mutators also resolve paths through `dirOf`, so // the override is scoped to one synchronous render pass and always cleared // afterwards (see `withSnapshotSource` in tree.js); it must never straddle an // await or a user interaction. let renderSourceOverride = null; function activeStore() { return renderSourceOverride || workflowsCache; } export function setWorkflowsRenderSource(store) { renderSourceOverride = store && typeof store === "object" && store.directories && typeof store.directories === "object" ? store : { directories: {} }; } export function clearWorkflowsRenderSource() { renderSourceOverride = null; } // Resolves to the DirNode at `path`, or `undefined` if any segment is missing. // `[]` returns a synthetic wrapper around the root (with `directories` // pointing at the active store's directories) so callers don't need a special // case for root vs. nested. export function dirOf(path) { if (!Array.isArray(path)) return undefined; const store = activeStore(); if (path.length === 0) { return { workflows: {}, directories: store.directories || {} }; } let node = { directories: store.directories || {} }; for (const seg of path) { if (typeof seg !== "string" || !seg) return undefined; if (!node.directories || !node.directories[seg]) return undefined; node = node.directories[seg]; } return node; } // Direct child directory names at `parentPath`, sorted A→Z. export function listDirectoryNames(parentPath = []) { const node = dirOf(parentPath); if (!node || !node.directories) return []; return Object.keys(node.directories).sort(compareNames); } // Internal: walk to `parentPath` and create intermediate directories along // the way. Used only by `saveWorkflowEntry` so a save into a freshly-typed // new top-level directory always succeeds. Subdirectory creation is explicit // (right-click → Create subdirectory…) so we don't auto-create nested paths. function ensureDirectoryAtPath(path) { if (!Array.isArray(path) || path.length === 0) return null; let node = { directories: workflowsCache.directories }; for (let i = 0; i < path.length; i += 1) { const seg = path[i]; if (typeof seg !== "string" || !seg) return null; if (!node.directories) node.directories = {}; if (!node.directories[seg]) { node.directories[seg] = { workflows: {}, directories: {} }; } node = node.directories[seg]; } return node; } // Add a directory at `parentPath` with `name`. Returns false on: // - empty trimmed name // - reserved name "Archive" (case-insensitive) when nested under another // directory (collides with the synthetic Archive folder rendering) // - parent path doesn't exist // - sibling with the same name already exists export function addDirectory(parentPath, name) { name = (name || "").trim(); if (!name) return false; if (!isSafeObjectKey(name)) return false; // Type guard mirroring `moveDirectory` — without this, a future caller // passing `undefined` would crash on `parentPath.length`. if (!Array.isArray(parentPath)) return false; // Reserved-name check: "Archive" at root is fine (no synthetic Archive // collides at root because root has no archived workflows of its own), // but inside a directory it would shadow the archived-workflows folder. if (parentPath.length > 0 && name.toLowerCase() === ARCHIVE_RESERVED_NAME) return false; const parent = dirOf(parentPath); if (!parent) return false; if (!parent.directories) parent.directories = {}; if (parent.directories[name]) return false; parent.directories[name] = { workflows: {}, directories: {} }; return true; } export function renameDirectory(parentPath, oldName, newName) { newName = (newName || "").trim(); if (!newName || newName === oldName) return false; if (!isSafeObjectKey(oldName) || !isSafeObjectKey(newName)) return false; if (parentPath.length > 0 && newName.toLowerCase() === ARCHIVE_RESERVED_NAME) return false; const parent = dirOf(parentPath); if (!parent || !parent.directories || !parent.directories[oldName]) return false; if (parent.directories[newName]) return false; parent.directories[newName] = parent.directories[oldName]; delete parent.directories[oldName]; return true; } export function deleteDirectory(parentPath, name) { if (!isSafeObjectKey(name)) return false; const parent = dirOf(parentPath); if (!parent || !parent.directories || !parent.directories[name]) return false; delete parent.directories[name]; return true; } // ============================================================================= // Workflow operations (path-addressed) // ============================================================================= export function saveWorkflowEntry(path, wfName, graphData, options = {}) { if (!isSafeObjectKey(wfName)) return false; const dir = ensureDirectoryAtPath(path); if (!dir) return false; let archivedAs = null; const existing = dir.workflows[wfName]; const now = options.now instanceof Date && !isNaN(options.now.getTime()) ? options.now : new Date(); if (existing) { // Same-name save: move the existing version into the directory's // Archive (timestamp-suffixed) before overwriting. const ts = archiveStampForName(now); let archiveName = `${wfName} (archived ${ts})`; let n = 1; while (dir.workflows[archiveName]) { n += 1; archiveName = `${wfName} (archived ${ts}) #${n}`; } dir.workflows[archiveName] = { ...existing, archived: true, archivedAt: now.toISOString() }; archivedAs = archiveName; } dir.workflows[wfName] = { savedAt: now.toISOString(), graph: graphData, module: options.module === true, }; return { archivedAs }; } // Path-preserving copy (#197). Copy a workflow into `store` (any `{directories}` // object) at `dirSegs` — the SAME folder path it lives at on the source side — // creating any missing directories along the way and merging into existing // ones (never duplicating a folder that's already there). Deep-clones the graph // so the copy never aliases the source object. Collision policy at the // destination path + name: // - no active entry there -> add at `wfName` (status "added") // - identical graph -> skip; it's already present (status "skipped") // - graph differs -> keep both at "wfName (from )" // (with a #n counter on further collision) // (status "kept-both") // Used for BOTH the live store (via copyWorkflowIntoLiveStore, inside a // persistMutation) and a loaded snapshot's workflows (Compare A->B write-back). // Returns { status, finalName }. export function copyWorkflowIntoStore(store, dirSegs, wfName, graph, { tags = [], module = false, sourceLabel = "source" } = {}) { if (!store || typeof store !== "object" || !Array.isArray(dirSegs) || typeof wfName !== "string" || !wfName) { return { status: "failed", finalName: wfName }; } // Prototype-pollution guard for the destination keys, matching the other // name-keyed store mutators (#203): a "__proto__"/"constructor"/"prototype" // workflow name or directory segment (from a crafted snapshot) would // otherwise poison Object.prototype on the assignments below. if (!isSafeObjectKey(wfName)) return { status: "failed", finalName: wfName }; if (!store.directories) store.directories = {}; let node = store; for (const seg of dirSegs) { if (typeof seg !== "string" || !seg || !isSafeObjectKey(seg)) return { status: "failed", finalName: wfName }; if (!node.directories) node.directories = {}; if (!node.directories[seg]) node.directories[seg] = { workflows: {}, directories: {} }; node = node.directories[seg]; } if (!node.workflows) node.workflows = {}; const existing = node.workflows[wfName]; const collides = existing && existing.archived !== true; if (collides && normalizedStoresEqual(existing.graph, graph)) { return { status: "skipped", finalName: wfName }; } let finalName = wfName; if (collides) { finalName = `${wfName} (from ${sourceLabel})`; let n = 1; while (node.workflows[finalName]) { n += 1; finalName = `${wfName} (from ${sourceLabel}) #${n}`; } } node.workflows[finalName] = { savedAt: new Date().toISOString(), graph: JSON.parse(JSON.stringify(graph)), module: module === true, tags: Array.isArray(tags) ? [...tags] : [], }; return { status: collides ? "kept-both" : "added", finalName }; } // Live-store wrapper for the copy engine — mutates the working cache so the // result rides the caller's persistMutation commit (and the live panel // re-renders off WORKFLOWS_CHANGED_EVENT). Same return shape. export function copyWorkflowIntoLiveStore(dirSegs, wfName, graph, opts) { return copyWorkflowIntoStore(workflowsCache, dirSegs, wfName, graph, opts); } // Collect every ACTIVE workflow under `dirSegs` in `store` (recursively), each // with its full path segments. Archived versions are skipped — a bulk merge // moves live setups, not old versions. Returns [{ segs, wfName, entry }]. function collectWorkflowsUnderPath(store, dirSegs) { let node = store && typeof store === "object" ? store : { directories: {} }; for (const seg of dirSegs) { if (!node.directories || !node.directories[seg]) return []; node = node.directories[seg]; } const out = []; const walk = (n, segs) => { const wfs = n && typeof n.workflows === "object" && n.workflows ? n.workflows : {}; for (const wfName of Object.keys(wfs)) { if (wfs[wfName] && wfs[wfName].archived === true) continue; out.push({ segs, wfName, entry: wfs[wfName] }); } const dirs = n && typeof n.directories === "object" && n.directories ? n.directories : {}; for (const dn of Object.keys(dirs)) walk(dirs[dn], [...segs, dn]); }; walk(node, [...dirSegs]); return out; } // Bulk path-preserving copy (#197): copy every active workflow under `dirSegs` // in `sourceStore` into `targetStore` at the same path (folders auto-created / // merged, same skip-identical / keep-both collision policy as the single copy). // Powers "Copy folder to the other side" — the merge case for two different // snapshots. `dirSegs` = [] copies the entire workflow tree. Returns a summary // { total, added, skipped, keptBoth }. export function copyFolderIntoStore(targetStore, sourceStore, dirSegs, { sourceLabel = "source" } = {}) { const items = collectWorkflowsUnderPath(sourceStore, Array.isArray(dirSegs) ? dirSegs : []); const summary = { total: items.length, added: 0, skipped: 0, keptBoth: 0 }; for (const it of items) { const res = copyWorkflowIntoStore(targetStore, it.segs, it.wfName, it.entry.graph, { tags: it.entry.tags, module: it.entry.module === true, sourceLabel, }); if (res.status === "added") summary.added += 1; else if (res.status === "skipped") summary.skipped += 1; else if (res.status === "kept-both") summary.keptBoth += 1; } return summary; } export function copyFolderIntoLiveStore(sourceStore, dirSegs, opts) { return copyFolderIntoStore(workflowsCache, sourceStore, dirSegs, opts); } export function archiveWorkflow(path, wfName, options = {}) { if (!isSafeObjectKey(wfName)) return false; const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; dir.workflows[wfName].archived = true; const now = options.now instanceof Date && !isNaN(options.now.getTime()) ? options.now : new Date(); dir.workflows[wfName].archivedAt = now.toISOString(); return true; } export function unarchiveWorkflow(path, wfName) { if (!isSafeObjectKey(wfName)) return false; const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; delete dir.workflows[wfName].archived; return true; } export function renameWorkflow(path, oldWfName, newWfName) { newWfName = (newWfName || "").trim(); if (!newWfName || newWfName === oldWfName) return false; if (!isSafeObjectKey(oldWfName) || !isSafeObjectKey(newWfName)) return false; const dir = dirOf(path); if (!dir || !dir.workflows[oldWfName]) return false; if (dir.workflows[newWfName]) return false; dir.workflows[newWfName] = dir.workflows[oldWfName]; delete dir.workflows[oldWfName]; return true; } export function deleteWorkflow(path, wfName) { if (!isSafeObjectKey(wfName)) return false; const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; delete dir.workflows[wfName]; return true; } // `srcPath` and `dstPath` must both reference existing directories. Returns // false on identical paths, missing source workflow, missing destination, // or a name collision in the destination. export function moveWorkflow(srcPath, wfName, dstPath) { if (!isSafeObjectKey(wfName)) return false; if (pathsEqual(srcPath, dstPath)) return false; const src = dirOf(srcPath); if (!src || !src.workflows[wfName]) return false; const dst = dirOf(dstPath); if (!dst) return false; if (dst.workflows[wfName]) return false; dst.workflows[wfName] = src.workflows[wfName]; delete src.workflows[wfName]; return true; } export function getWorkflowGraph(path, wfName) { const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return null; return dir.workflows[wfName].graph || null; } export function isWorkflowModule(path, wfName) { const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; const wf = dir.workflows[wfName]; const tags = Array.isArray(wf.tags) ? wf.tags : []; return wf.module === true || tags.includes(MODULE_TAG); } // ============================================================================= // Per-workflow tag operations. Tags are insertion-ordered string[] on each // workflow entry. Comparison is case-sensitive: "AI" and "ai" are distinct. // `normalizeDirNode` guarantees `tags` is always an array on cached entries, // but the mutators still defensively coerce so a freshly-saved workflow that // hasn't gone through normalize still gets sane behavior. // ============================================================================= // Tag auto-applied to a workflow when it is published as a setup. Drives the // Workflows-tree badge and the Tools "Published" filter, and lands the // workflow in the Tags section's "published" pool for free. A normal tag, so // it stays user-manageable (and may drift if a setup is later unpublished). export const PUBLISHED_TAG = "published"; export function getWorkflowTags(path, wfName) { const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return null; const tags = dir.workflows[wfName].tags; return Array.isArray(tags) ? [...tags] : []; } // Case-sensitive membership test, shared by the published-discovery badge and // the Tools "Published" filter so both agree on what counts as tagged. Missing // workflow or absent tags -> false (never throws). export function workflowHasTag(path, wfName, tag) { const tags = getWorkflowTags(path, wfName); return Array.isArray(tags) && tags.includes(tag); } export function addTag(path, wfName, tag) { if (!isSafeObjectKey(wfName)) return false; tag = (tag || "").trim(); if (!tag) return false; const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; const wf = dir.workflows[wfName]; if (!Array.isArray(wf.tags)) wf.tags = []; if (wf.tags.includes(tag)) return false; wf.tags.push(tag); if (tag === MODULE_TAG) wf.module = true; return true; } export function removeTag(path, wfName, tag) { if (!isSafeObjectKey(wfName)) return false; const dir = dirOf(path); if (!dir || !dir.workflows[wfName]) return false; const wf = dir.workflows[wfName]; if (!Array.isArray(wf.tags)) return false; const idx = wf.tags.indexOf(tag); if (idx < 0) return false; wf.tags.splice(idx, 1); if (tag === MODULE_TAG) wf.module = false; return true; } // ============================================================================= // Snapshot / preset support — bulk replace + read-only export of the cache. // Used by the snapshot library flow (#46 item 1). // ============================================================================= // Replace the entire `workflowsCache` with a freshly-normalized version of // `rawStore` and persist the result atomically. Returns `true` on // successful persist (server or localStorage fallback), `false` on full // persist rejection — in which case the cache is rolled back to its // pre-call state so the in-memory state stays consistent with disk. // // Atomicity matters because a snapshot apply replaces both pieces of // user state (picks + workflows) and the caller's UI presents the // "Replace current state?" confirm as a single act. A partially-applied // load (cache rebound but persist rejected) would diverge in-memory // from disk; the next reload would revert silently and the user would // have no signal that the load didn't stick. // // Bypasses `persistMutation` because the entire cache is being replaced // (rather than mutated through a single mutator). Uses `snapshotCache` // directly for the same atomic semantics — it's the same primitive // `persistMutation` uses internally. export async function replaceAllWorkflows(rawStore) { const restore = snapshotCache(); workflowsCache = normalizeWorkflowsStore(rawStore); const result = await persistWorkflowsToServer(workflowsCache); if (result === false) { restore(); return false; } notifyWorkflowsChanged(); return true; } // Deep-cloned read-only view of `workflowsCache` for the snapshot export // flow. Returning a clone (rather than the live ref) keeps callers from // accidentally mutating cache state through the export pipeline. export function getAllWorkflowsForExport() { return JSON.parse(JSON.stringify(workflowsCache)); } // Delete every archived workflow under the directory at `path`. Returns // `{ count }` (number of entries removed) on success, `false` if the // directory is missing or has no archived entries. Active (non-archived) // workflows in the same directory are untouched. Used by the Archive // folder's right-click "Delete archive" action. export function clearArchive(path) { const dir = dirOf(path); if (!dir || !dir.workflows) return false; const archivedNames = Object.entries(dir.workflows) .filter(([, wf]) => wf && wf.archived === true) .map(([n]) => n); if (archivedNames.length === 0) return false; for (const name of archivedNames) delete dir.workflows[name]; return { count: archivedNames.length }; } function sortedArchivedEntries(dir) { return Object.entries(dir.workflows || {}) .filter(([, wf]) => wf && wf.archived === true) .map(([name, wf]) => ({ name, wf, baseName: archiveBaseName(name), timestampMs: workflowArchivedAtMs(name, wf), })) .sort((a, b) => { const byTime = b.timestampMs - a.timestampMs; return byTime || compareNames(a.name, b.name); }); } export function getArchiveDisplayInfo(path, wfName, now = new Date()) { const dir = dirOf(path); const wf = dir && dir.workflows ? dir.workflows[wfName] : null; const timestampMs = workflowArchivedAtMs(wfName, wf); const label = archiveBaseName(wfName); const safeNow = now instanceof Date && !isNaN(now.getTime()) ? now : new Date(); const stamp = timestampMs ? formatLocalStamp(new Date(timestampMs), safeNow) : ""; return { label, timestampMs, meta: stamp ? `archived ${stamp}` : "archived", }; } export function getArchiveCleanupPlan(path, now = new Date()) { const dir = dirOf(path); if (!dir || !dir.workflows) { return { keepNames: [], deleteNames: [], keepCount: 0, deleteCount: 0, groups: 0 }; } const nowMs = now instanceof Date && !isNaN(now.getTime()) ? now.getTime() : Date.now(); const windows = [ 5 * 60 * 1000, 60 * 60 * 1000, 24 * 60 * 60 * 1000, ]; const archivedEntries = sortedArchivedEntries(dir); const byBase = new Map(); for (const entry of archivedEntries) { if (!byBase.has(entry.baseName)) byBase.set(entry.baseName, []); byBase.get(entry.baseName).push(entry); } const keep = new Set(); for (const entries of byBase.values()) { const groupKeep = new Set(); for (const windowMs of windows) { const representative = entries.find((entry) => { if (!entry.timestampMs) return false; const ageMs = Math.max(0, nowMs - entry.timestampMs); return ageMs <= windowMs && !groupKeep.has(entry.name); }); if (representative) groupKeep.add(representative.name); } if (groupKeep.size === 0 && entries.length > 0) { groupKeep.add(entries[0].name); } for (const name of groupKeep) keep.add(name); } const allNames = archivedEntries.map((entry) => entry.name); const deleteNames = allNames.filter((name) => !keep.has(name)); const keepNames = allNames.filter((name) => keep.has(name)); return { keepNames, deleteNames, keepCount: keepNames.length, deleteCount: deleteNames.length, groups: byBase.size, }; } export function cleanUpArchive(path, planOrNow = new Date()) { const dir = dirOf(path); if (!dir || !dir.workflows) return false; const plan = planOrNow && typeof planOrNow === "object" && Array.isArray(planOrNow.deleteNames) ? planOrNow : getArchiveCleanupPlan(path, planOrNow); if (!Array.isArray(plan.deleteNames) || plan.deleteNames.length === 0) return false; const deletedNames = []; for (const name of plan.deleteNames) { if (!isSafeObjectKey(name)) continue; const wf = dir.workflows[name]; if (!wf || wf.archived !== true) continue; delete dir.workflows[name]; deletedNames.push(name); } if (deletedNames.length === 0) return false; return { ...plan, deleteNames: deletedNames, deleteCount: deletedNames.length, }; } // Move the directory at `srcParentPath/name` to live under `dstParentPath` // (its name is preserved). Returns false when: // - the source doesn't exist // - the destination parent doesn't exist // - the destination already has a sibling with the same name // - the move would create a cycle (dst is the source itself or any // descendant of the source — you can't drop a folder into itself) // - the source and destination parent are identical (no-op) // - the new location at root level would use the reserved name "Archive" // in a non-root parent (already enforced by addDirectory's check would // not apply here; we re-check explicitly) export function moveDirectory(srcParentPath, name, dstParentPath) { name = (name || "").trim(); if (!name) return false; if (!isSafeObjectKey(name)) return false; if (!Array.isArray(srcParentPath) || !Array.isArray(dstParentPath)) return false; // Same parent → no-op (identical location). if (pathsEqual(srcParentPath, dstParentPath)) return false; // Reserved-name check at the new (non-root) parent. if (dstParentPath.length > 0 && name.toLowerCase() === ARCHIVE_RESERVED_NAME) return false; const src = dirOf(srcParentPath); if (!src || !src.directories || !src.directories[name]) return false; const dst = dirOf(dstParentPath); if (!dst) return false; if (!dst.directories) dst.directories = {}; if (dst.directories[name]) return false; // collision in destination // Cycle prevention: dstParentPath must not be the source itself or a // descendant. Source path is srcParentPath + [name]; reject any dst // path that begins with that prefix. const srcFullPath = [...srcParentPath, name]; if (isPathDescendantOrSame(dstParentPath, srcFullPath)) return false; dst.directories[name] = src.directories[name]; delete src.directories[name]; return true; } // ============================================================================= // Path utilities // ============================================================================= export function pathsEqual(a, b) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; return true; } // True when `testPath` equals or is a descendant of `ancestorPath`. // e.g. isPathDescendantOrSame(["A","B"], ["A"]) → true (B is under A) // isPathDescendantOrSame(["A"], ["A","B"]) → false (A is above) // isPathDescendantOrSame(["A","B"], ["A","B"]) → true (same) function isPathDescendantOrSame(testPath, ancestorPath) { if (!Array.isArray(testPath) || !Array.isArray(ancestorPath)) return false; if (testPath.length < ancestorPath.length) return false; for (let i = 0; i < ancestorPath.length; i += 1) { if (testPath[i] !== ancestorPath[i]) return false; } return true; } // `isArchiveReservedName` was previously exported but never imported — the // reserved-name check is enforced inline by every mutator that creates or // renames a directory (`addDirectory`, `renameDirectory`, `moveDirectory`). // Removed during a dead-export sweep; revive as an export only if a future // caller needs to gate UI before submit (e.g. live-validate the new-dir // input in the save modal).