import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs" import { join } from "node:path" import type { WorkflowDef, WorkflowState, TaskRun } from "./types" export class WorkflowStore { private statePath: string private runsPath: string private defsPath: string constructor(worktree: string) { const base = join(worktree, ".kirincode", "workflows") this.defsPath = base this.statePath = join(base, ".state") this.runsPath = join(base, ".runs") mkdirSync(base, { recursive: true }) mkdirSync(this.statePath, { recursive: true }) mkdirSync(this.runsPath, { recursive: true }) } loadDefs(): WorkflowDef[] { if (!existsSync(this.defsPath)) return [] const files = readdirSync(this.defsPath).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) const seen = new Set() const defs: WorkflowDef[] = [] for (const file of files) { try { const content = readFileSync(join(this.defsPath, file), "utf-8") const def = this.parseYaml(content) def.name = def.name || file.replace(/\.(yml|yaml)$/, "") if (seen.has(def.name)) continue seen.add(def.name) defs.push(def) } catch { // skip unparseable } } return defs } saveDef(def: WorkflowDef): void { // Delete existing .yaml if we're writing .yml (avoid duplicates) const yamlPath = join(this.defsPath, `${def.name}.yaml`) if (existsSync(yamlPath)) { try { require("node:fs").unlinkSync(yamlPath) } catch { /* ignore */ } } const yml = this.toYaml(def) writeFileSync(join(this.defsPath, `${def.name}.yml`), yml, "utf-8") } loadState(name: string): WorkflowState { const p = join(this.statePath, `${name}.json`) if (!existsSync(p)) return { name, runCount: 0, enabled: true } try { return JSON.parse(readFileSync(p, "utf-8")) } catch { return { name, runCount: 0, enabled: true } } } saveState(state: WorkflowState): void { writeFileSync(join(this.statePath, `${state.name}.json`), JSON.stringify(state, null, 2), "utf-8") } saveRun(run: TaskRun): void { writeFileSync(join(this.runsPath, `${run.id}.json`), JSON.stringify(run, null, 2), "utf-8") } listRuns(limit = 20): TaskRun[] { if (!existsSync(this.runsPath)) return [] return readdirSync(this.runsPath) .filter((f) => f.endsWith(".json")) .reduce((acc: TaskRun[], f) => { try { const run = JSON.parse(readFileSync(join(this.runsPath, f), "utf-8")) as TaskRun acc.push(run) } catch { /* skip corrupt */ } return acc }, []) .sort((a, b) => b.startedAt - a.startedAt) .slice(0, limit) } private parseYaml(s: string): WorkflowDef { const def: Record = {} const lines = s.split("\n") let i = 0 while (i < lines.length) { const line = lines[i] // Skip comments and empty lines if (/^\s*#/.test(line) || /^\s*$/.test(line)) { i++; continue } const keyMatch = line.match(/^([\w_-]+):\s*(.*)$/) if (!keyMatch) { i++; continue } const key = keyMatch[1] let value = keyMatch[2] // Multiline block scalar (|) if (value === "|" || value === "|+") { const blockLines: string[] = [] i++ while (i < lines.length && /^ /.test(lines[i])) { blockLines.push(lines[i].slice(2)) i++ } def[key] = blockLines.join("\n") continue } // Quoted string if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { def[key] = value.slice(1, -1) i++ continue } // Array notation if (value.startsWith("[") && value.endsWith("]")) { def[key] = value.slice(1, -1).split(",").map((x) => x.trim().replace(/^['"]|['"]$/g, "")) i++ continue } // Booleans if (value === "true") { def[key] = true; i++; continue } if (value === "false") { def[key] = false; i++; continue } // Numbers if (/^-?\d+$/.test(value)) { def[key] = parseInt(value); i++; continue } // Plain string def[key] = value i++ } return def as WorkflowDef } private toYaml(def: WorkflowDef): string { const lines: string[] = [] for (const [k, v] of Object.entries(def)) { if (v === undefined) continue if (Array.isArray(v)) { const items = v.map((x) => `"${String(x).replace(/"/g, '\\"')}"`).join(", ") lines.push(`${k}: [${items}]`) } else if (typeof v === "boolean") { lines.push(`${k}: ${v}`) } else if (typeof v === "number") { lines.push(`${k}: ${v}`) } else if (typeof v === "string") { if (v.includes("\n")) { lines.push(`${k}: |`) for (const l of v.split("\n")) lines.push(` ${l}`) } else { const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') lines.push(`${k}: "${escaped}"`) } } } return lines.join("\n") + "\n" } }