store.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
  2. import { join } from "node:path"
  3. import type { WorkflowDef, WorkflowState, TaskRun } from "./types"
  4. export class WorkflowStore {
  5. private statePath: string
  6. private runsPath: string
  7. private defsPath: string
  8. constructor(worktree: string) {
  9. const base = join(worktree, ".kirincode", "workflows")
  10. this.defsPath = base
  11. this.statePath = join(base, ".state")
  12. this.runsPath = join(base, ".runs")
  13. mkdirSync(base, { recursive: true })
  14. mkdirSync(this.statePath, { recursive: true })
  15. mkdirSync(this.runsPath, { recursive: true })
  16. }
  17. loadDefs(): WorkflowDef[] {
  18. if (!existsSync(this.defsPath)) return []
  19. const files = readdirSync(this.defsPath).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
  20. const seen = new Set<string>()
  21. const defs: WorkflowDef[] = []
  22. for (const file of files) {
  23. try {
  24. const content = readFileSync(join(this.defsPath, file), "utf-8")
  25. const def = this.parseYaml(content)
  26. def.name = def.name || file.replace(/\.(yml|yaml)$/, "")
  27. if (seen.has(def.name)) continue
  28. seen.add(def.name)
  29. defs.push(def)
  30. } catch {
  31. // skip unparseable
  32. }
  33. }
  34. return defs
  35. }
  36. saveDef(def: WorkflowDef): void {
  37. // Delete existing .yaml if we're writing .yml (avoid duplicates)
  38. const yamlPath = join(this.defsPath, `${def.name}.yaml`)
  39. if (existsSync(yamlPath)) {
  40. try { require("node:fs").unlinkSync(yamlPath) } catch { /* ignore */ }
  41. }
  42. const yml = this.toYaml(def)
  43. writeFileSync(join(this.defsPath, `${def.name}.yml`), yml, "utf-8")
  44. }
  45. loadState(name: string): WorkflowState {
  46. const p = join(this.statePath, `${name}.json`)
  47. if (!existsSync(p)) return { name, runCount: 0, enabled: true }
  48. try {
  49. return JSON.parse(readFileSync(p, "utf-8"))
  50. } catch {
  51. return { name, runCount: 0, enabled: true }
  52. }
  53. }
  54. saveState(state: WorkflowState): void {
  55. writeFileSync(join(this.statePath, `${state.name}.json`), JSON.stringify(state, null, 2), "utf-8")
  56. }
  57. saveRun(run: TaskRun): void {
  58. writeFileSync(join(this.runsPath, `${run.id}.json`), JSON.stringify(run, null, 2), "utf-8")
  59. }
  60. listRuns(limit = 20): TaskRun[] {
  61. if (!existsSync(this.runsPath)) return []
  62. return readdirSync(this.runsPath)
  63. .filter((f) => f.endsWith(".json"))
  64. .reduce((acc: TaskRun[], f) => {
  65. try {
  66. const run = JSON.parse(readFileSync(join(this.runsPath, f), "utf-8")) as TaskRun
  67. acc.push(run)
  68. } catch { /* skip corrupt */ }
  69. return acc
  70. }, [])
  71. .sort((a, b) => b.startedAt - a.startedAt)
  72. .slice(0, limit)
  73. }
  74. private parseYaml(s: string): WorkflowDef {
  75. const def: Record<string, any> = {}
  76. const lines = s.split("\n")
  77. let i = 0
  78. while (i < lines.length) {
  79. const line = lines[i]
  80. // Skip comments and empty lines
  81. if (/^\s*#/.test(line) || /^\s*$/.test(line)) { i++; continue }
  82. const keyMatch = line.match(/^([\w_-]+):\s*(.*)$/)
  83. if (!keyMatch) { i++; continue }
  84. const key = keyMatch[1]
  85. let value = keyMatch[2]
  86. // Multiline block scalar (|)
  87. if (value === "|" || value === "|+") {
  88. const blockLines: string[] = []
  89. i++
  90. while (i < lines.length && /^ /.test(lines[i])) {
  91. blockLines.push(lines[i].slice(2))
  92. i++
  93. }
  94. def[key] = blockLines.join("\n")
  95. continue
  96. }
  97. // Quoted string
  98. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  99. def[key] = value.slice(1, -1)
  100. i++
  101. continue
  102. }
  103. // Array notation
  104. if (value.startsWith("[") && value.endsWith("]")) {
  105. def[key] = value.slice(1, -1).split(",").map((x) => x.trim().replace(/^['"]|['"]$/g, ""))
  106. i++
  107. continue
  108. }
  109. // Booleans
  110. if (value === "true") { def[key] = true; i++; continue }
  111. if (value === "false") { def[key] = false; i++; continue }
  112. // Numbers
  113. if (/^-?\d+$/.test(value)) { def[key] = parseInt(value); i++; continue }
  114. // Plain string
  115. def[key] = value
  116. i++
  117. }
  118. return def as WorkflowDef
  119. }
  120. private toYaml(def: WorkflowDef): string {
  121. const lines: string[] = []
  122. for (const [k, v] of Object.entries(def)) {
  123. if (v === undefined) continue
  124. if (Array.isArray(v)) {
  125. const items = v.map((x) => `"${String(x).replace(/"/g, '\\"')}"`).join(", ")
  126. lines.push(`${k}: [${items}]`)
  127. } else if (typeof v === "boolean") {
  128. lines.push(`${k}: ${v}`)
  129. } else if (typeof v === "number") {
  130. lines.push(`${k}: ${v}`)
  131. } else if (typeof v === "string") {
  132. if (v.includes("\n")) {
  133. lines.push(`${k}: |`)
  134. for (const l of v.split("\n")) lines.push(` ${l}`)
  135. } else {
  136. const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
  137. lines.push(`${k}: "${escaped}"`)
  138. }
  139. }
  140. }
  141. return lines.join("\n") + "\n"
  142. }
  143. }