UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

432 lines (396 loc) • 14.7 kB
/** * KXM Workflow Management Subsystem. * Supports add, remove, modify, list, and inspection of workflow YAML definitions. * Handles global (~/.config/kxm/workflows/) and local (.kxm/workflows/) scoping. */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { stringify } from "yaml"; import { repoConfigDirectory, userConfigDirectory } from "./config.ts"; import { compileKxmWorkflow } from "./engine-compile.ts"; import { KxmConfigError, KxmSchemaRegistry, kxmResourceIdentifier, parseRestrictedYaml } from "./project-config.ts"; function assertWorkflowId(workflowId: string): void { if (!kxmResourceIdentifier(workflowId)) { throw new Error(`workflow_id_invalid: '${workflowId}' must be a flat lowercase slug of at most 64 characters, starting with a letter and using letters, digits, single hyphens or underscores; paths and platform-reserved names are not allowed (use 'bug-fix', not 'software-engineering/bug-fix')`); } } let workflowSchemaRegistry: KxmSchemaRegistry | undefined; function workflowPayload(workflowId: string, content: Record<string, unknown> | string, filePath: string): string { const payload = typeof content === "string" ? content : stringify(content); const value = parseRestrictedYaml(payload, filePath); const issues = (workflowSchemaRegistry ??= new KxmSchemaRegistry()).validate("workflow", value, filePath); if (issues.length > 0) throw new KxmConfigError(issues); compileKxmWorkflow({ id: workflowId, value, logicalPath: filePath }); return payload; } export interface WorkflowDefSummary { id: string; description: string; scope: "global" | "local" | "overridden"; filePath: string; stepCount: number; roles: string[]; } // Fresh objects per use: YAML stringify turns a repeated object into an // anchor and alias, which the restricted project loader refuses. const completed = () => ({ target: "$terminal", terminalStatus: "completed" }); const failed = () => ({ target: "$terminal", terminalStatus: "failed" }); /** The project's `test` gate from `kxm init`. A gate step settles on `passed` or * `implementation-failure`, never `failed`. `maxAttempts` counts entries into a * step, so every step a back-edge re-enters allows one entry per transition. */ function verifyStep(retryStep: string): Record<string, unknown> { return { id: "verify", kind: "gate", gate: "test", expect: "pass", maxAttempts: 3, repositories: { control: "write" }, on: { passed: completed(), "implementation-failure": { target: retryStep, maxTransitions: 2 }, }, }; } /** * Built-in `kxm workflow add --template` definitions. Each is a complete * kxm.workflow.v1 document (the file name is the workflow id) that uses only * what `kxm init` creates: the planner and writer agents, the control * repository, and the `test` gate. Agent steps declare `failed` because the * producer falls back to it. `coordinator` and `implementer` still resolve * as aliases of `planner` and `writer`. */ export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = { "implement-and-verify": { schema: "kxm.workflow.v1", description: "Implement a change, then run the project's test gate; a failing gate sends the work back to writer.", coordinator: "planner", limits: { maxTransitions: 8, }, steps: [ { id: "writer", aliases: ["implement"], kind: "agent", agent: "writer", maxAttempts: 3, repositories: { control: "write" }, on: { passed: "verify", failed: failed(), }, }, verifyStep("writer"), ], }, "dual-critic-review": { schema: "kxm.workflow.v1", description: "Write, review twice, then run the project's test gate. Both reviews run as the planner agent; for independent reviewers, add reviewer-arch and reviewer-cli agents and point the review steps at them.", coordinator: "planner", limits: { maxTransitions: 12, }, steps: [ { id: "writer", aliases: ["implement"], kind: "agent", agent: "writer", maxAttempts: 3, repositories: { control: "write" }, on: { passed: "reviewer-arch", failed: failed(), }, }, { id: "reviewer-arch", aliases: ["review-arch", "critic-arch"], kind: "agent", agent: "planner", maxAttempts: 3, repositories: { control: "read" }, on: { passed: "reviewer-cli", failed: { target: "writer", maxTransitions: 2 }, }, }, { id: "reviewer-cli", aliases: ["review-cli", "critic-cli"], kind: "agent", agent: "planner", maxAttempts: 3, repositories: { control: "read" }, on: { passed: "verify", failed: { target: "writer", maxTransitions: 2 }, }, }, verifyStep("writer"), ], }, "spec-and-plan": { schema: "kxm.workflow.v1", description: "Plan a change, then review the plan. Both steps run as the planner agent and only read the repository.", coordinator: "planner", limits: { maxTransitions: 8, }, steps: [ { id: "plan", kind: "agent", agent: "planner", maxAttempts: 3, repositories: { control: "read" }, on: { passed: "reviewer-arch", failed: failed(), }, }, { id: "reviewer-arch", aliases: ["review-arch", "critic-arch"], kind: "agent", agent: "planner", maxAttempts: 3, repositories: { control: "read" }, on: { passed: completed(), failed: { target: "plan", maxTransitions: 2 }, }, }, ], }, }; /** The one-step definition `kxm workflow add <id>` writes without a template or file. */ export function scaffoldWorkflowDefinition(description: string): Record<string, unknown> { return { schema: "kxm.workflow.v1", description, coordinator: "planner", limits: { maxTransitions: 8 }, steps: [ { id: "step-1", kind: "agent", agent: "writer", repositories: { control: "write" }, on: { passed: completed(), failed: failed() }, }, ], }; } export const DEFAULT_WORKFLOW_TEMPLATE = WORKFLOW_TEMPLATES["implement-and-verify"]!; export function workflowsDirectory( scope: "global" | "local", repoRoot = process.cwd(), userConfigDir?: string, ): string { if (scope === "global") { return join(userConfigDirectory(userConfigDir), "workflows"); } return join(repoConfigDirectory(repoRoot), "workflows"); } export function ensureWorkflowsDirectory( scope: "global" | "local", repoRoot = process.cwd(), userConfigDir?: string, ): string { const dir = workflowsDirectory(scope, repoRoot, userConfigDir); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } return dir; } export function parseWorkflowFile(filePath: string): Record<string, unknown> | undefined { if (!existsSync(filePath)) return undefined; try { const raw = readFileSync(filePath, "utf8"); return parseRestrictedYaml(raw, filePath); } catch { return undefined; } } export function listWorkflowDefinitions(options: { scope?: "all" | "global" | "local" | undefined; repoRoot?: string | undefined; userConfigDir?: string | undefined; } = {}): WorkflowDefSummary[] { const scopeFilter = options.scope ?? "all"; const repoRoot = options.repoRoot ?? process.cwd(); const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir); const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir); const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>(); if (scopeFilter !== "global" && existsSync(localDir)) { for (const entry of readdirSync(localDir)) { const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined; if (id && kxmResourceIdentifier(id)) { const filePath = join(localDir, entry); const def = parseWorkflowFile(filePath); if (def) { localDefs.set(id, { def, filePath }); } } } } const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>(); if (scopeFilter !== "local" && existsSync(globalDir)) { for (const entry of readdirSync(globalDir)) { const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined; if (id && kxmResourceIdentifier(id)) { const filePath = join(globalDir, entry); const def = parseWorkflowFile(filePath); if (def) { globalDefs.set(id, { def, filePath }); } } } } const extractRoles = (def: Record<string, unknown>): string[] => { const roles: string[] = []; const steps = Array.isArray(def.steps) ? def.steps : []; for (const step of steps) { if (step && typeof step === "object") { const role = (step as Record<string, unknown>).agent; if (typeof role === "string" && !roles.includes(role)) { roles.push(role); } } } return roles; }; const result: WorkflowDefSummary[] = []; for (const [id, { def, filePath }] of localDefs) { const isOverridden = globalDefs.has(id); const steps = Array.isArray(def.steps) ? def.steps : []; result.push({ id, description: typeof def.description === "string" ? def.description : "", scope: isOverridden ? "overridden" : "local", filePath, stepCount: steps.length, roles: extractRoles(def), }); } for (const [id, { def, filePath }] of globalDefs) { if (scopeFilter === "global" || !localDefs.has(id)) { const steps = Array.isArray(def.steps) ? def.steps : []; result.push({ id, description: typeof def.description === "string" ? def.description : "", scope: "global", filePath, stepCount: steps.length, roles: extractRoles(def), }); } } result.sort((a, b) => a.id.localeCompare(b.id)); return result; } export function getWorkflowDefinition( workflowId: string, options: { scope?: "all" | "global" | "local" | undefined; repoRoot?: string | undefined; userConfigDir?: string | undefined; } = {}, ): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined { assertWorkflowId(workflowId); const scope = options.scope ?? "all"; const repoRoot = options.repoRoot ?? process.cwd(); // 1. Check local first if (scope !== "global") { const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir); const localFile = join(localDir, `${workflowId}.yaml`); const parsed = parseWorkflowFile(localFile); if (parsed) return { workflow: parsed, scope: "local", filePath: localFile }; } // 2. Check global if (scope !== "local") { const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir); const globalFile = join(globalDir, `${workflowId}.yaml`); const parsed = parseWorkflowFile(globalFile); if (parsed) return { workflow: parsed, scope: "global", filePath: globalFile }; } return undefined; } export function addWorkflowDefinition( workflowId: string, content: Record<string, unknown> | string = DEFAULT_WORKFLOW_TEMPLATE, options: { scope?: "global" | "local" | undefined; repoRoot?: string | undefined; userConfigDir?: string | undefined; overwrite?: boolean | undefined; dryRun?: boolean | undefined; } = {}, ): { id: string; filePath: string; scope: "global" | "local" } { assertWorkflowId(workflowId); const scope = options.scope ?? "local"; const repoRoot = options.repoRoot ?? process.cwd(); const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir); const filePath = join(dir, `${workflowId}.yaml`); if (existsSync(filePath) && !options.overwrite) { throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`); } const payload = workflowPayload(workflowId, content, filePath); if (!options.dryRun) { ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir); writeFileSync(filePath, payload, "utf8"); } return { id: workflowId, filePath, scope }; } export function removeWorkflowDefinition( workflowId: string, options: { scope?: "global" | "local" | undefined; repoRoot?: string | undefined; userConfigDir?: string | undefined; dryRun?: boolean | undefined; } = {}, ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } { assertWorkflowId(workflowId); const scope = options.scope ?? "local"; const repoRoot = options.repoRoot ?? process.cwd(); const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir); const filePath = join(dir, `${workflowId}.yaml`); if (!existsSync(filePath)) { throw new Error(`workflow_not_found: workflow '${workflowId}' not found in ${scope} directory (${filePath})`); } if (options.dryRun) return { id: workflowId, removed: false, filePath, scope }; rmSync(filePath); return { id: workflowId, removed: true, filePath, scope }; } export function modifyWorkflowDefinition( workflowId: string, updates: Record<string, unknown>, options: { scope?: "global" | "local" | undefined; repoRoot?: string | undefined; userConfigDir?: string | undefined; dryRun?: boolean | undefined; } = {}, ): { id: string; workflow: Record<string, unknown>; filePath: string; scope: "global" | "local" } { const target = getWorkflowDefinition(workflowId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir }); if (!target) { throw new Error(`workflow_not_found: workflow '${workflowId}' does not exist`); } const updated: Record<string, unknown> = { ...target.workflow, ...updates, }; const scope = options.scope ?? target.scope; const repoRoot = options.repoRoot ?? process.cwd(); const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir); const filePath = join(dir, `${workflowId}.yaml`); const payload = workflowPayload(workflowId, updated, filePath); if (!options.dryRun) { ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir); writeFileSync(filePath, payload, "utf8"); } return { id: workflowId, workflow: updated, filePath, scope }; }