|
|
@@ -0,0 +1,168 @@
|
|
|
+import type { Plugin } from "@kirincode-ai/plugin"
|
|
|
+import type { Part } from "@kirincode-ai/sdk"
|
|
|
+import { CronScheduler } from "./scheduler"
|
|
|
+import { FileWatcher } from "./watcher"
|
|
|
+import { WorkflowStore } from "./store"
|
|
|
+import type { WorkflowDef, TaskRun } from "./types"
|
|
|
+
|
|
|
+const RUNNING_TASKS = new Map<string, TaskRun>()
|
|
|
+
|
|
|
+function randomId(): string {
|
|
|
+ return Math.random().toString(36).slice(2, 10)
|
|
|
+}
|
|
|
+
|
|
|
+export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, worktree, $ }) => {
|
|
|
+ const store = new WorkflowStore(worktree)
|
|
|
+ const scheduler = new CronScheduler()
|
|
|
+ const watcher = new FileWatcher()
|
|
|
+
|
|
|
+ async function executeWorkflow(def: WorkflowDef): Promise<void> {
|
|
|
+ const now = Date.now()
|
|
|
+ const run: TaskRun = {
|
|
|
+ id: `run_${randomId()}`,
|
|
|
+ workflow: def.name,
|
|
|
+ startedAt: now,
|
|
|
+ status: "running",
|
|
|
+ }
|
|
|
+ RUNNING_TASKS.set(run.id, run)
|
|
|
+ store.saveRun(run)
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await client.session.create({ body: { title: `[WF] ${def.name}` } })
|
|
|
+ if (result.error) throw new Error(result.error as string)
|
|
|
+
|
|
|
+ const sessionId = result.data!.id
|
|
|
+ run.sessionId = sessionId
|
|
|
+ store.saveRun(run)
|
|
|
+
|
|
|
+ await client.session.prompt({
|
|
|
+ path: { id: sessionId },
|
|
|
+ body: {
|
|
|
+ parts: [{ type: "text", text: def.prompt }],
|
|
|
+ model: def.model && def.agent
|
|
|
+ ? { providerID: def.model.split("/")[0], modelID: def.model.split("/").slice(1).join("/") }
|
|
|
+ : undefined,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ run.status = "completed"
|
|
|
+ run.endedAt = Date.now()
|
|
|
+ } catch (e: any) {
|
|
|
+ run.status = "failed"
|
|
|
+ run.error = e?.message ?? String(e)
|
|
|
+ run.endedAt = Date.now()
|
|
|
+ } finally {
|
|
|
+ RUNNING_TASKS.delete(run.id)
|
|
|
+ store.saveRun(run)
|
|
|
+
|
|
|
+ const state = store.loadState(def.name)
|
|
|
+ state.lastRun = run.startedAt
|
|
|
+ state.runCount++
|
|
|
+ store.saveState(state)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Load and register all workflow definitions
|
|
|
+ function reloadWorkflows(): void {
|
|
|
+ scheduler.stopAll()
|
|
|
+ watcher.stopAll()
|
|
|
+
|
|
|
+ const defs = store.loadDefs()
|
|
|
+ for (const def of defs) {
|
|
|
+ if (def.enabled === false) continue
|
|
|
+
|
|
|
+ if (def.schedule) {
|
|
|
+ const cron = scheduler.parse(def.schedule)
|
|
|
+ const intervalMs = 30000 // check every 30s
|
|
|
+ scheduler.start(`wf_${def.name}`, intervalMs, () => {
|
|
|
+ const next = cron.next()
|
|
|
+ const now = Date.now()
|
|
|
+ if (Math.abs(next - now) < intervalMs) {
|
|
|
+ executeWorkflow(def)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (def.trigger === "file_change") {
|
|
|
+ watcher.watch(def, worktree, () => executeWorkflow(def))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ reloadWorkflows()
|
|
|
+
|
|
|
+ return {
|
|
|
+ "command.execute.before": async (input, output) => {
|
|
|
+ const args = input.arguments.trim()
|
|
|
+
|
|
|
+ if (input.command === "workflow") {
|
|
|
+ if (!args || args === "list") {
|
|
|
+ const defs = store.loadDefs()
|
|
|
+ const runs = store.listRuns(10)
|
|
|
+ const lines = ["## Workflows", ""]
|
|
|
+ for (const d of defs) {
|
|
|
+ const state = store.loadState(d.name)
|
|
|
+ const icon = d.enabled === false ? "⏸" : "▶"
|
|
|
+ const sch = d.schedule ? ` schedule: ${d.schedule}` : ""
|
|
|
+ const trig = d.trigger ? ` trigger: ${d.trigger}` : ""
|
|
|
+ lines.push(`- ${icon} **${d.name}** — ${d.description || d.prompt.slice(0, 60)}${sch}${trig} (runs: ${state.runCount})`)
|
|
|
+ }
|
|
|
+ if (defs.length === 0) lines.push("*No workflows defined. Create .kirincode/workflows/*.yml files.*")
|
|
|
+ lines.push("", "## Recent Runs", "")
|
|
|
+ for (const r of runs) {
|
|
|
+ const icon = r.status === "completed" ? "✅" : r.status === "failed" ? "❌" : "🔄"
|
|
|
+ lines.push(`- ${icon} ${r.workflow} — ${new Date(r.startedAt).toLocaleTimeString()}`)
|
|
|
+ }
|
|
|
+ output.parts = [{ type: "text", text: lines.join("\n") }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (args === "reload") {
|
|
|
+ reloadWorkflows()
|
|
|
+ output.parts = [{ type: "text", text: `Workflows reloaded. ${store.loadDefs().length} active.` }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (args.startsWith("run ")) {
|
|
|
+ const name = args.slice(4).trim()
|
|
|
+ const def = store.loadDefs().find((d) => d.name === name)
|
|
|
+ if (!def) {
|
|
|
+ output.parts = [{ type: "text", text: `Workflow "${name}" not found.` }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+ executeWorkflow(def)
|
|
|
+ output.parts = [{ type: "text", text: `Started workflow: ${name}` }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (args.startsWith("create ")) {
|
|
|
+ const parts = args.slice(7).split(/\s+/)
|
|
|
+ if (parts.length < 2) {
|
|
|
+ output.parts = [{ type: "text", text: "Usage: /workflow create <name> <cron|file_change> [prompt...]" }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const name = parts[0]
|
|
|
+ const trigger = parts[1]
|
|
|
+ const prompt = parts.slice(2).join(" ") || "TODO: add your prompt here"
|
|
|
+
|
|
|
+ const def: WorkflowDef = { name, prompt }
|
|
|
+ if (trigger.match(/^[\d\*,\-\/ ]{9,}$/)) def.schedule = trigger
|
|
|
+ else if (trigger === "file_change") def.trigger = "file_change"
|
|
|
+ else {
|
|
|
+ output.parts = [{ type: "text", text: "Invalid trigger. Use a cron expression or 'file_change'." }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+ store.saveDef(def)
|
|
|
+ reloadWorkflows()
|
|
|
+ output.parts = [{ type: "text", text: `Workflow '${name}' created at .kirincode/workflows/${name}.yml` }]
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ dispose: async () => {
|
|
|
+ scheduler.stopAll()
|
|
|
+ watcher.stopAll()
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|