|
@@ -1,15 +1,13 @@
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
|
|
|
-import { join, basename } from "node:path"
|
|
|
|
|
|
|
+import { join } from "node:path"
|
|
|
import type { WorkflowDef, WorkflowState, TaskRun } from "./types"
|
|
import type { WorkflowDef, WorkflowState, TaskRun } from "./types"
|
|
|
|
|
|
|
|
export class WorkflowStore {
|
|
export class WorkflowStore {
|
|
|
private statePath: string
|
|
private statePath: string
|
|
|
private runsPath: string
|
|
private runsPath: string
|
|
|
private defsPath: string
|
|
private defsPath: string
|
|
|
- private worktree: string
|
|
|
|
|
|
|
|
|
|
constructor(worktree: string) {
|
|
constructor(worktree: string) {
|
|
|
- this.worktree = worktree
|
|
|
|
|
const base = join(worktree, ".kirincode", "workflows")
|
|
const base = join(worktree, ".kirincode", "workflows")
|
|
|
this.defsPath = base
|
|
this.defsPath = base
|
|
|
this.statePath = join(base, ".state")
|
|
this.statePath = join(base, ".state")
|
|
@@ -22,29 +20,41 @@ export class WorkflowStore {
|
|
|
loadDefs(): WorkflowDef[] {
|
|
loadDefs(): WorkflowDef[] {
|
|
|
if (!existsSync(this.defsPath)) return []
|
|
if (!existsSync(this.defsPath)) return []
|
|
|
const files = readdirSync(this.defsPath).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
|
|
const files = readdirSync(this.defsPath).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
|
|
|
|
|
+ const seen = new Set<string>()
|
|
|
const defs: WorkflowDef[] = []
|
|
const defs: WorkflowDef[] = []
|
|
|
for (const file of files) {
|
|
for (const file of files) {
|
|
|
try {
|
|
try {
|
|
|
const content = readFileSync(join(this.defsPath, file), "utf-8")
|
|
const content = readFileSync(join(this.defsPath, file), "utf-8")
|
|
|
const def = this.parseYaml(content)
|
|
const def = this.parseYaml(content)
|
|
|
- def.name = def.name || basename(file, file.includes(".yml") ? ".yml" : ".yaml")
|
|
|
|
|
|
|
+ def.name = def.name || file.replace(/\.(yml|yaml)$/, "")
|
|
|
|
|
+ if (seen.has(def.name)) continue
|
|
|
|
|
+ seen.add(def.name)
|
|
|
defs.push(def)
|
|
defs.push(def)
|
|
|
} catch {
|
|
} catch {
|
|
|
- // skip invalid
|
|
|
|
|
|
|
+ // skip unparseable
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
return defs
|
|
return defs
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
saveDef(def: WorkflowDef): void {
|
|
saveDef(def: WorkflowDef): void {
|
|
|
- const yaml = this.toYaml(def)
|
|
|
|
|
- writeFileSync(join(this.defsPath, `${def.name}.yml`), yaml, "utf-8")
|
|
|
|
|
|
|
+ // 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 {
|
|
loadState(name: string): WorkflowState {
|
|
|
const p = join(this.statePath, `${name}.json`)
|
|
const p = join(this.statePath, `${name}.json`)
|
|
|
if (!existsSync(p)) return { name, runCount: 0, enabled: true }
|
|
if (!existsSync(p)) return { name, runCount: 0, enabled: true }
|
|
|
- return JSON.parse(readFileSync(p, "utf-8"))
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(readFileSync(p, "utf-8"))
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return { name, runCount: 0, enabled: true }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
saveState(state: WorkflowState): void {
|
|
saveState(state: WorkflowState): void {
|
|
@@ -59,34 +69,71 @@ export class WorkflowStore {
|
|
|
if (!existsSync(this.runsPath)) return []
|
|
if (!existsSync(this.runsPath)) return []
|
|
|
return readdirSync(this.runsPath)
|
|
return readdirSync(this.runsPath)
|
|
|
.filter((f) => f.endsWith(".json"))
|
|
.filter((f) => f.endsWith(".json"))
|
|
|
- .map((f) => {
|
|
|
|
|
- try { return JSON.parse(readFileSync(join(this.runsPath, f), "utf-8")) }
|
|
|
|
|
- catch { return null }
|
|
|
|
|
- })
|
|
|
|
|
- .filter(Boolean)
|
|
|
|
|
- .sort((a, b) => b!.startedAt - a!.startedAt)
|
|
|
|
|
- .slice(0, limit) as TaskRun[]
|
|
|
|
|
|
|
+ .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 {
|
|
private parseYaml(s: string): WorkflowDef {
|
|
|
- const def: any = {}
|
|
|
|
|
|
|
+ const def: Record<string, any> = {}
|
|
|
const lines = s.split("\n")
|
|
const lines = s.split("\n")
|
|
|
- let key = ""
|
|
|
|
|
- for (const line of lines) {
|
|
|
|
|
- const m = line.match(/^(\w[\w_-]*):\s*(.*)$/)
|
|
|
|
|
- if (m) {
|
|
|
|
|
- key = m[1]
|
|
|
|
|
- const val = m[2].trim()
|
|
|
|
|
- if (val.startsWith("[") && val.endsWith("]")) {
|
|
|
|
|
- def[key] = val.slice(1, -1).split(",").map((x) => x.trim().replace(/['"]/g, ""))
|
|
|
|
|
- } else if (val === "true") def[key] = true
|
|
|
|
|
- else if (val === "false") def[key] = false
|
|
|
|
|
- else if (/^\d+$/.test(val)) def[key] = parseInt(val)
|
|
|
|
|
- else def[key] = val
|
|
|
|
|
- } else if (line.startsWith(" ") && key) {
|
|
|
|
|
- if (typeof def[key] === "string") def[key] += "\n" + line.trim()
|
|
|
|
|
|
|
+ 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
|
|
return def as WorkflowDef
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -94,14 +141,22 @@ export class WorkflowStore {
|
|
|
const lines: string[] = []
|
|
const lines: string[] = []
|
|
|
for (const [k, v] of Object.entries(def)) {
|
|
for (const [k, v] of Object.entries(def)) {
|
|
|
if (v === undefined) continue
|
|
if (v === undefined) continue
|
|
|
- if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => `"${x}"`).join(", ")}]`)
|
|
|
|
|
- else if (typeof v === "boolean") lines.push(`${k}: ${v}`)
|
|
|
|
|
- else if (typeof v === "number") lines.push(`${k}: ${v}`)
|
|
|
|
|
- else if (typeof v === "string" && v.includes("\n")) {
|
|
|
|
|
- lines.push(`${k}: |`)
|
|
|
|
|
- for (const l of v.split("\n")) lines.push(` ${l}`)
|
|
|
|
|
|
|
+ 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}"`)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- else lines.push(`${k}: "${v}"`)
|
|
|
|
|
}
|
|
}
|
|
|
return lines.join("\n") + "\n"
|
|
return lines.join("\n") + "\n"
|
|
|
}
|
|
}
|