import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs"; import { join, resolve, relative, isAbsolute } from "node:path"; import { homedir } from "node:os"; import { Workflow } from "./schema.ts"; import { parseWorkflowText, formatForPath } from "./yaml.ts"; /** * Workflow persistence — ONE FILE PER WORKFLOW, under a workflows directory. * * A workflow is a larger, authored graph, so each lives in its own file. The canonical * on-disk format the app/editor writes is `w-.json`; hand-authored `*.yaml` / `*.yml` * files are read too. The directory defaults to `~/.pi/workflows` and is overridable with * `PI_WORKFLOWS_DIR` (a host — e.g. a daemon — can point this at its own confined dir). * * A workflow file is an EXECUTABLE control artifact (it can run `script` steps), so every * file is written owner-only (0600) inside an owner-only (0700) directory. Those fs perms * are the machine-local trust root — they stop other local users reading/altering it. What * stops an untrusted network peer from writing one is the HOST's own signed-mutation gate, * which lives above this layer, not here. */ export function workflowsDir(): string { const override = process.env.PI_WORKFLOWS_DIR; return override && override.trim() ? override : join(homedir(), ".pi", "workflows"); } // The canonical path for a workflow id. Ids are `w-` (see newWorkflowId) — already // filesystem-safe — but we re-assert the shape so a hostile id can never escape the dir. export function workflowFilePath(id: string, dir = workflowsDir()): string { if (!/^w-[a-zA-Z0-9]+$/.test(id)) throw new Error(`unsafe workflow id "${id}"`); return join(dir, `${id}.json`); } function tryChmod(path: string, mode: number): void { try { chmodSync(path, mode); } catch { /* non-POSIX filesystem or insufficient perms — nothing we can do */ } } // Parse + fully validate one file's contents into a Workflow, or null if it's corrupt, // fails the strict schema, or has a broken route graph. Per-file so ONE bad file never // hides the rest. function parseFile(raw: string, path: string): Workflow | null { const res = parseWorkflowText(raw, formatForPath(path)); return res.ok ? res.workflow! : null; } // All valid workflows in the dir, sorted by name. Corrupt/invalid files are skipped, not // thrown — a hand-mangled file shouldn't take down a listing. export function loadWorkflows(dir = workflowsDir()): Workflow[] { if (!existsSync(dir)) return []; const out: Workflow[] = []; for (const entry of readdirSync(dir)) { if (!/\.(ya?ml|json)$/i.test(entry)) continue; try { const wf = parseFile(readFileSync(join(dir, entry), "utf8"), entry); if (wf) out.push(wf); } catch { /* unreadable file — skip */ } } return out.sort((a, b) => a.workflow.name.localeCompare(b.workflow.name)); } // Look up by id first, then by (case-insensitive) name. export function findWorkflow(workflows: Workflow[], idOrName: string): Workflow | undefined { const needle = idOrName.trim().toLowerCase(); return ( workflows.find((w) => w.workflow.id === idOrName) ?? workflows.find((w) => w.workflow.name.toLowerCase() === needle) ); } // Load a single workflow by id or name. export function loadWorkflow(idOrName: string, dir = workflowsDir()): Workflow | undefined { return findWorkflow(loadWorkflows(dir), (idOrName ?? "").trim()); } // Write a workflow to its own file (create or overwrite by id) as canonical JSON. Caller // has already schema-validated it — we re-assert nothing here beyond the id-shape guard. export function saveWorkflow(wf: Workflow, dir = workflowsDir()): void { mkdirSync(dir, { recursive: true }); tryChmod(dir, 0o700); const path = workflowFilePath(wf.workflow.id, dir); writeFileSync(path, JSON.stringify(wf, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); tryChmod(path, 0o600); } // Remove a workflow by id or name. Returns the removed workflow, or null if absent. export function removeWorkflow(idOrName: string, dir = workflowsDir()): Workflow | null { const target = findWorkflow(loadWorkflows(dir), (idOrName ?? "").trim()); if (!target) return null; try { unlinkSync(workflowFilePath(target.workflow.id, dir)); } catch { return null; // already gone / unwritable } return target; } /** * Resolve a sub-workflow ref (a schema-validated local relative `*.yaml`/`*.json` path) * to a loaded Workflow, CONFINED to the workflows dir. This is the loader you wire into * `RunnerDeps.loadSubWorkflow`. Defense-in-depth: the schema already rejects `..`, * absolute, `~`, and scheme/`@` refs, but we still resolve the final path and refuse * anything that lands outside the dir — so no combination of the ref ever escapes it. * Returns null when the file is missing, escapes the dir, or fails to parse/validate. */ export function resolveSubWorkflow(ref: string, dir = workflowsDir()): Workflow | null { const full = resolve(dir, ref); const rel = relative(resolve(dir), full); if (rel.startsWith("..") || isAbsolute(rel)) return null; // escaped the confinement dir if (!existsSync(full)) return null; try { return parseFile(readFileSync(full, "utf8"), full); } catch { return null; } }