| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- 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<string>()
- 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<string, any> = {}
- 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"
- }
- }
|