From dfeadf742ad2d7d1f774499445f4cd6d61556a59 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor <32955832+laurentenhoor@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:50:09 +0800 Subject: [PATCH] feat: make workflow states dynamic with XState-style statechart config (#147) (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a configurable workflow state machine that replaces all hardcoded state labels. The default workflow matches current behavior exactly, ensuring backward compatibility. ## Architecture ### lib/workflow.ts — Core workflow engine XState-style statechart configuration: ```typescript type StateConfig = { type: 'queue' | 'active' | 'hold' | 'terminal'; role?: 'dev' | 'qa'; label: string; color: string; priority?: number; on?: Record; }; ``` All behavior is derived from the config: - Queue states: `type: 'queue'`, grouped by role, ordered by priority - Active states: `type: 'active'` — worker occupied - Transitions: defined with optional actions (gitPull, detectPr, closeIssue, reopenIssue) - Labels and colors: derived from state.label and state.color ### Derivation functions - `getStateLabels()` — all labels for issue tracker sync - `getLabelColors()` — label → color mapping - `getQueueLabels(role)` — queue labels for a role, ordered by priority - `getActiveLabel(role)` — the active/in-progress label for a role - `getRevertLabel(role)` — queue label to revert to on failure - `detectRoleFromLabel()` — detect role from a queue label - `getCompletionRule(role, result)` — derive transition rule from config ## Files Changed - **lib/workflow.ts** — NEW: workflow engine and default config - **lib/providers/provider.ts** — deprecate STATE_LABELS, LABEL_COLORS; derive from workflow - **lib/providers/github.ts** — use workflow config for label operations - **lib/providers/gitlab.ts** — use workflow config for label operations - **lib/services/pipeline.ts** — use getCompletionRule() from workflow - **lib/services/tick.ts** — use workflow for queue/active labels - **lib/services/health.ts** — use workflow for active/revert labels - **lib/tools/work-start.ts** — use workflow for target label ## Backward Compatibility - DEFAULT_WORKFLOW matches current hardcoded behavior exactly - Deprecated exports kept for any external consumers - No breaking changes to tool interfaces or project state ## Future Work - Load per-project workflow overrides from projects.json - User-facing config in projects/workflow.json - Tool schema generation from workflow states --- lib/providers/github.ts | 33 +++- lib/providers/gitlab.ts | 33 +++- lib/providers/provider.ts | 39 +++- lib/services/health.ts | 60 +++--- lib/services/pipeline.ts | 77 +++++--- lib/services/tick.ts | 81 +++++--- lib/tools/work-start.ts | 12 +- lib/workflow.ts | 391 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 617 insertions(+), 109 deletions(-) create mode 100644 lib/workflow.ts diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 6957597..6166b76 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -5,10 +5,15 @@ import { type IssueProvider, type Issue, type StateLabel, - STATE_LABELS, - LABEL_COLORS, + type IssueComment, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { + DEFAULT_WORKFLOW, + getStateLabels, + getLabelColors, + type WorkflowConfig, +} from "../workflow.js"; type GhIssue = { number: number; @@ -28,7 +33,12 @@ function toIssue(gh: GhIssue): Issue { export class GitHubProvider implements IssueProvider { private repoPath: string; - constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; } + private workflow: WorkflowConfig; + + constructor(opts: { repoPath: string; workflow?: WorkflowConfig }) { + this.repoPath = opts.repoPath; + this.workflow = opts.workflow ?? DEFAULT_WORKFLOW; + } private async gh(args: string[]): Promise { const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); @@ -41,7 +51,11 @@ export class GitHubProvider implements IssueProvider { } async ensureAllStateLabels(): Promise { - for (const label of STATE_LABELS) await this.ensureLabel(label, LABEL_COLORS[label]); + const labels = getStateLabels(this.workflow); + const colors = getLabelColors(this.workflow); + for (const label of labels) { + await this.ensureLabel(label, colors[label]); + } } async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise { @@ -65,7 +79,7 @@ export class GitHubProvider implements IssueProvider { return toIssue(JSON.parse(raw) as GhIssue); } - async listComments(issueId: number): Promise { + async listComments(issueId: number): Promise { try { const raw = await this.gh(["api", `repos/:owner/:repo/issues/${issueId}/comments`, "--jq", ".[] | {author: .user.login, body: .body, created_at: .created_at}"]); if (!raw) return []; @@ -75,9 +89,10 @@ export class GitHubProvider implements IssueProvider { async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise { const issue = await this.getIssue(issueId); - const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); + const stateLabels = getStateLabels(this.workflow); + const currentStateLabels = issue.labels.filter((l) => stateLabels.includes(l)); const args = ["issue", "edit", String(issueId)]; - for (const l of stateLabels) args.push("--remove-label", l); + for (const l of currentStateLabels) args.push("--remove-label", l); args.push("--add-label", to); await this.gh(args); } @@ -86,8 +101,10 @@ export class GitHubProvider implements IssueProvider { async reopenIssue(issueId: number): Promise { await this.gh(["issue", "reopen", String(issueId)]); } hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); } + getCurrentStateLabel(issue: Issue): StateLabel | null { - return STATE_LABELS.find((l) => issue.labels.includes(l)) ?? null; + const stateLabels = getStateLabels(this.workflow); + return stateLabels.find((l) => issue.labels.includes(l)) ?? null; } async hasMergedMR(issueId: number): Promise { diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index e0a8624..fc59466 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -5,14 +5,24 @@ import { type IssueProvider, type Issue, type StateLabel, - STATE_LABELS, - LABEL_COLORS, + type IssueComment, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { + DEFAULT_WORKFLOW, + getStateLabels, + getLabelColors, + type WorkflowConfig, +} from "../workflow.js"; export class GitLabProvider implements IssueProvider { private repoPath: string; - constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; } + private workflow: WorkflowConfig; + + constructor(opts: { repoPath: string; workflow?: WorkflowConfig }) { + this.repoPath = opts.repoPath; + this.workflow = opts.workflow ?? DEFAULT_WORKFLOW; + } private async glab(args: string[]): Promise { const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); @@ -25,7 +35,11 @@ export class GitLabProvider implements IssueProvider { } async ensureAllStateLabels(): Promise { - for (const label of STATE_LABELS) await this.ensureLabel(label, LABEL_COLORS[label]); + const labels = getStateLabels(this.workflow); + const colors = getLabelColors(this.workflow); + for (const label of labels) { + await this.ensureLabel(label, colors[label]); + } } async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise { @@ -52,7 +66,7 @@ export class GitLabProvider implements IssueProvider { return JSON.parse(raw) as Issue; } - async listComments(issueId: number): Promise { + async listComments(issueId: number): Promise { try { const raw = await this.glab(["api", `projects/:id/issues/${issueId}/notes`, "--paginate"]); const notes = JSON.parse(raw) as Array<{ author: { username: string }; body: string; created_at: string; system: boolean }>; @@ -69,9 +83,10 @@ export class GitLabProvider implements IssueProvider { async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise { const issue = await this.getIssue(issueId); - const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel)); + const stateLabels = getStateLabels(this.workflow); + const currentStateLabels = issue.labels.filter((l) => stateLabels.includes(l)); const args = ["issue", "update", String(issueId)]; - for (const l of stateLabels) args.push("--unlabel", l); + for (const l of currentStateLabels) args.push("--unlabel", l); args.push("--label", to); await this.glab(args); } @@ -80,8 +95,10 @@ export class GitLabProvider implements IssueProvider { async reopenIssue(issueId: number): Promise { await this.glab(["issue", "reopen", String(issueId)]); } hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); } + getCurrentStateLabel(issue: Issue): StateLabel | null { - return STATE_LABELS.find((l) => issue.labels.includes(l)) ?? null; + const stateLabels = getStateLabels(this.workflow); + return stateLabels.find((l) => issue.labels.includes(l)) ?? null; } async hasMergedMR(issueId: number): Promise { diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 709eac9..04957a3 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -2,18 +2,37 @@ * IssueProvider — Abstract interface for issue tracker operations. * * Implementations: GitHub (gh CLI), GitLab (glab CLI). + * + * Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility + * but new code should use the workflow config via lib/workflow.ts. */ +import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js"; -export const STATE_LABELS = [ - "Planning", "To Do", "Doing", "To Test", "Testing", "Done", "To Improve", "Refining", -] as const; +// --------------------------------------------------------------------------- +// State labels — derived from default workflow for backward compatibility +// --------------------------------------------------------------------------- -export type StateLabel = (typeof STATE_LABELS)[number]; +/** + * @deprecated Use workflow.getStateLabels() instead. + * Kept for backward compatibility with existing code. + */ +export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[]; -export const LABEL_COLORS: Record = { - Planning: "#95a5a6", "To Do": "#428bca", Doing: "#f0ad4e", "To Test": "#5bc0de", - Testing: "#9b59b6", Done: "#5cb85c", "To Improve": "#d9534f", Refining: "#f39c12", -}; +/** + * StateLabel type — union of all valid state labels. + * This remains a string type for flexibility with custom workflows. + */ +export type StateLabel = string; + +/** + * @deprecated Use workflow.getLabelColors() instead. + * Kept for backward compatibility with existing code. + */ +export const LABEL_COLORS: Record = getLabelColors(DEFAULT_WORKFLOW); + +// --------------------------------------------------------------------------- +// Issue types +// --------------------------------------------------------------------------- export type Issue = { iid: number; @@ -30,6 +49,10 @@ export type IssueComment = { created_at: string; }; +// --------------------------------------------------------------------------- +// Provider interface +// --------------------------------------------------------------------------- + export interface IssueProvider { ensureLabel(name: string, color: string): Promise; ensureAllStateLabels(): Promise; diff --git a/lib/services/health.ts b/lib/services/health.ts index e1083a1..5ac006b 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -3,18 +3,18 @@ * * Triangulates THREE sources of truth: * 1. projects.json — worker state (active, issueId, level, sessions) - * 2. Issue label — current GitHub/GitLab label (Doing, Testing, To Do, etc.) + * 2. Issue label — current GitHub/GitLab label (from workflow config) * 3. Session state — whether the OpenClaw session exists via gateway status * * Detection matrix: * | projects.json | Issue label | Session | Action | * |---------------|-------------------|--------------|-------------------------------------------| - * | active | Doing/Testing ✅ | dead/missing | Deactivate worker, revert to To Do/To Test | - * | active | NOT Doing/Testing | any | Deactivate worker (moved externally) | - * | active | Doing/Testing ✅ | alive | Healthy (flag if stale >2h) | - * | inactive | Doing/Testing | any | Revert issue to To Do/To Test (label stuck)| - * | inactive | issueId set | any | Clear issueId (warning) | - * | active | issue deleted | any | Deactivate worker, clear state | + * | active | Active label ✅ | dead/missing | Deactivate worker, revert to queue | + * | active | NOT Active label | any | Deactivate worker (moved externally) | + * | active | Active label ✅ | alive | Healthy (flag if stale >2h) | + * | inactive | Active label | any | Revert issue to queue (label stuck) | + * | inactive | issueId set | any | Clear issueId (warning) | + * | active | issue deleted | any | Deactivate worker, clear state | */ import type { StateLabel, IssueProvider, Issue } from "../providers/provider.js"; import { @@ -24,6 +24,13 @@ import { type Project, } from "../projects.js"; import { runCommand } from "../run-command.js"; +import { + DEFAULT_WORKFLOW, + getActiveLabel, + getRevertLabel, + type WorkflowConfig, + type Role, +} from "../workflow.js"; // --------------------------------------------------------------------------- // Types @@ -32,15 +39,15 @@ import { runCommand } from "../run-command.js"; export type HealthIssue = { type: | "session_dead" // Case 1: active worker but session missing/dead - | "label_mismatch" // Case 2: active worker but issue not in Doing/Testing + | "label_mismatch" // Case 2: active worker but issue not in active label | "stale_worker" // Case 3: active for >2h - | "stuck_label" // Case 4: inactive but issue still has Doing/Testing + | "stuck_label" // Case 4: inactive but issue still has active label | "orphan_issue_id" // Case 5: inactive but issueId set | "issue_gone"; // Case 6: active but issue deleted/closed severity: "critical" | "warning"; project: string; groupId: string; - role: "dev" | "qa"; + role: Role; message: string; level?: string | null; sessionKey?: string | null; @@ -130,38 +137,29 @@ async function fetchIssue( // Health check logic // --------------------------------------------------------------------------- -/** - * Expected in-progress labels for each role. - */ -const ACTIVE_LABELS: Record<"dev" | "qa", StateLabel> = { - dev: "Doing", - qa: "Testing", -}; - -/** - * Queue labels to revert to when clearing stuck state. - */ -const QUEUE_LABELS: Record<"dev" | "qa", StateLabel> = { - dev: "To Do", - qa: "To Test", -}; - export async function checkWorkerHealth(opts: { workspaceDir: string; groupId: string; project: Project; - role: "dev" | "qa"; + role: Role; autoFix: boolean; provider: IssueProvider; sessions: SessionLookup; + /** Workflow config (defaults to DEFAULT_WORKFLOW) */ + workflow?: WorkflowConfig; }): Promise { - const { workspaceDir, groupId, project, role, autoFix, provider, sessions } = opts; + const { + workspaceDir, groupId, project, role, autoFix, provider, sessions, + workflow = DEFAULT_WORKFLOW, + } = opts; + const fixes: HealthFix[] = []; const worker = getWorker(project, role); const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null; - const expectedLabel = ACTIVE_LABELS[role]; - const queueLabel = QUEUE_LABELS[role]; + // Get labels from workflow config + const expectedLabel = getActiveLabel(workflow, role); + const queueLabel = getRevertLabel(workflow, role); // Parse issueId (may be comma-separated for batch, take first) const issueIdNum = worker.issueId ? Number(worker.issueId.split(",")[0]) : null; @@ -339,7 +337,7 @@ export async function checkWorkerHealth(opts: { } // --------------------------------------------------------------------------- - // Case 4: Inactive but issue has stuck Doing/Testing label + // Case 4: Inactive but issue has stuck active label // --------------------------------------------------------------------------- if (!worker.active && issue && currentLabel === expectedLabel) { const fix: HealthFix = { diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index ba2559f..68df960 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -1,23 +1,30 @@ /** * Pipeline service — declarative completion rules. * - * Replaces 7 if-blocks with a data-driven lookup table. + * Uses workflow config to determine transitions and side effects. */ import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { StateLabel, IssueProvider } from "../providers/provider.js"; import { deactivateWorker } from "../projects.js"; import { runCommand } from "../run-command.js"; import { notify, getNotificationConfig } from "../notify.js"; +import { + DEFAULT_WORKFLOW, + getCompletionRule, + getNextStateDescription, + getCompletionEmoji, + type CompletionRule, + type WorkflowConfig, +} from "../workflow.js"; -export type CompletionRule = { - from: StateLabel; - to: StateLabel; - gitPull?: boolean; - detectPr?: boolean; - closeIssue?: boolean; - reopenIssue?: boolean; -}; +// --------------------------------------------------------------------------- +// Backward compatibility exports +// --------------------------------------------------------------------------- +/** + * @deprecated Use getCompletionRule() from workflow.ts instead. + * Kept for backward compatibility. + */ export const COMPLETION_RULES: Record = { "dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true }, "qa:pass": { from: "Testing", to: "Done", closeIssue: true }, @@ -27,6 +34,9 @@ export const COMPLETION_RULES: Record = { "qa:blocked": { from: "Testing", to: "Refining" }, }; +/** + * @deprecated Use getNextStateDescription() from workflow.ts instead. + */ export const NEXT_STATE: Record = { "dev:done": "QA queue", "dev:blocked": "moved to Refining - needs human input", @@ -36,14 +46,8 @@ export const NEXT_STATE: Record = { "qa:blocked": "moved to Refining - needs human input", }; -const EMOJI: Record = { - "dev:done": "✅", - "qa:pass": "🎉", - "qa:fail": "❌", - "qa:refine": "🤔", - "dev:blocked": "🚫", - "qa:blocked": "🚫", -}; +// Re-export CompletionRule type for backward compatibility +export type { CompletionRule }; export type CompletionOutput = { labelTransition: string; @@ -55,8 +59,16 @@ export type CompletionOutput = { issueReopened?: boolean; }; -export function getRule(role: string, result: string): CompletionRule | undefined { - return COMPLETION_RULES[`${role}:${result}`]; +/** + * Get completion rule for a role:result pair. + * Uses workflow config when available. + */ +export function getRule( + role: string, + result: string, + workflow: WorkflowConfig = DEFAULT_WORKFLOW, +): CompletionRule | undefined { + return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined; } /** @@ -77,10 +89,17 @@ export async function executeCompletion(opts: { pluginConfig?: Record; /** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */ runtime?: PluginRuntime; + /** Workflow config (defaults to DEFAULT_WORKFLOW) */ + workflow?: WorkflowConfig; }): Promise { - const { workspaceDir, groupId, role, result, issueId, summary, provider, repoPath, projectName, channel, pluginConfig, runtime } = opts; + const { + workspaceDir, groupId, role, result, issueId, summary, provider, + repoPath, projectName, channel, pluginConfig, runtime, + workflow = DEFAULT_WORKFLOW, + } = opts; + const key = `${role}:${result}`; - const rule = COMPLETION_RULES[key]; + const rule = getCompletionRule(workflow, role, result); if (!rule) throw new Error(`No completion rule for ${key}`); let prUrl = opts.prUrl; @@ -100,8 +119,10 @@ export async function executeCompletion(opts: { // Get issue early (for URL in notification) const issue = await provider.getIssue(issueId); + // Get next state description from workflow + const nextState = getNextStateDescription(workflow, role, result); + // Send notification early (before deactivation and label transition which can fail) - // This ensures users see the notification even if subsequent steps have issues const notifyConfig = getNotificationConfig(pluginConfig); notify( { @@ -113,7 +134,7 @@ export async function executeCompletion(opts: { role, result: result as "done" | "pass" | "fail" | "refine" | "blocked", summary, - nextState: NEXT_STATE[key], + nextState, }, { workspaceDir, @@ -126,25 +147,25 @@ export async function executeCompletion(opts: { // Deactivate worker + transition label await deactivateWorker(workspaceDir, groupId, role); - await provider.transitionLabel(issueId, rule.from, rule.to); + await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel); // Close/reopen if (rule.closeIssue) await provider.closeIssue(issueId); if (rule.reopenIssue) await provider.reopenIssue(issueId); - // Build announcement - const emoji = EMOJI[key] ?? "📋"; + // Build announcement using workflow-derived emoji + const emoji = getCompletionEmoji(role, result); const label = key.replace(":", " ").toUpperCase(); let announcement = `${emoji} ${label} #${issueId}`; if (summary) announcement += ` — ${summary}`; announcement += `\n📋 Issue: ${issue.web_url}`; if (prUrl) announcement += `\n🔗 PR: ${prUrl}`; - announcement += `\n${NEXT_STATE[key]}.`; + announcement += `\n${nextState}.`; return { labelTransition: `${rule.from} → ${rule.to}`, announcement, - nextState: NEXT_STATE[key], + nextState, prUrl, issueUrl: issue.web_url, issueClosed: rule.closeIssue, diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 5da132d..3f606ec 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -12,14 +12,38 @@ import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js"; +import { + DEFAULT_WORKFLOW, + getQueueLabels, + getAllQueueLabels, + getActiveLabel, + detectRoleFromLabel as workflowDetectRole, + type WorkflowConfig, + type Role, +} from "../workflow.js"; // --------------------------------------------------------------------------- -// Shared constants + helpers (used by tick, work-start, auto-pickup) +// Backward compatibility exports (deprecated) // --------------------------------------------------------------------------- -export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; -export const QA_LABELS: StateLabel[] = ["To Test"]; -export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"]; +/** + * @deprecated Use getQueueLabels(workflow, "dev") instead. + */ +export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev"); + +/** + * @deprecated Use getQueueLabels(workflow, "qa") instead. + */ +export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa"); + +/** + * @deprecated Use getAllQueueLabels(workflow) instead. + */ +export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); + +// --------------------------------------------------------------------------- +// Shared helpers (used by tick, work-start, auto-pickup) +// --------------------------------------------------------------------------- export function detectLevelFromLabels(labels: string[]): string | null { const lower = labels.map((l) => l.toLowerCase()); @@ -39,19 +63,22 @@ export function detectLevelFromLabels(labels: string[]): string | null { return all.find((l) => lower.includes(l)) ?? null; } -export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null { - if (DEV_LABELS.includes(label)) return "dev"; - if (QA_LABELS.includes(label)) return "qa"; - return null; +/** + * Detect role from a label using workflow config. + */ +export function detectRoleFromLabel( + label: StateLabel, + workflow: WorkflowConfig = DEFAULT_WORKFLOW, +): Role | null { + return workflowDetectRole(workflow, label); } export async function findNextIssueForRole( provider: Pick, - role: "dev" | "qa", + role: Role, + workflow: WorkflowConfig = DEFAULT_WORKFLOW, ): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = role === "dev" - ? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l)) - : PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l)); + const labels = getQueueLabels(workflow, role); for (const label of labels) { try { const issues = await provider.listIssuesByLabel(label); @@ -66,11 +93,13 @@ export async function findNextIssueForRole( */ export async function findNextIssue( provider: Pick, - role?: "dev" | "qa", + role?: Role, + workflow: WorkflowConfig = DEFAULT_WORKFLOW, ): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = role === "dev" ? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l)) - : role === "qa" ? PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l)) - : PRIORITY_ORDER; + const labels = role + ? getQueueLabels(workflow, role) + : getAllQueueLabels(workflow); + for (const label of labels) { try { const issues = await provider.listIssuesByLabel(label); @@ -90,7 +119,7 @@ export type TickAction = { issueId: number; issueTitle: string; issueUrl: string; - role: "dev" | "qa"; + role: Role; level: string; sessionAction: "spawn" | "send"; announcement: string; @@ -116,20 +145,26 @@ export async function projectTick(opts: { dryRun?: boolean; maxPickups?: number; /** Only attempt this role. Used by work_start to fill the other slot. */ - targetRole?: "dev" | "qa"; + targetRole?: Role; /** Optional provider override (for testing). Uses createProvider if omitted. */ provider?: Pick; /** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */ runtime?: PluginRuntime; + /** Workflow config (defaults to DEFAULT_WORKFLOW) */ + workflow?: WorkflowConfig; }): Promise { - const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole, runtime } = opts; + const { + workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, + maxPickups, targetRole, runtime, + workflow = DEFAULT_WORKFLOW, + } = opts; const project = (await readProjects(workspaceDir)).projects[groupId]; if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] }; const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider; const roleExecution = project.roleExecution ?? "parallel"; - const roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"]; + const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa"]; const pickups: TickAction[] = []; const skipped: TickResult["skipped"] = []; @@ -155,11 +190,11 @@ export async function projectTick(opts: { continue; } - const next = await findNextIssueForRole(provider, role); + const next = await findNextIssueForRole(provider, role, workflow); if (!next) continue; const { issue, label: currentLabel } = next; - const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; + const targetLabel = getActiveLabel(workflow, role); // Level selection: label → heuristic const selectedLevel = resolveLevelForIssue(issue, role); @@ -206,7 +241,7 @@ export async function projectTick(opts: { /** * Determine the level for an issue based on labels, role overrides, and heuristic fallback. */ -function resolveLevelForIssue(issue: Issue, role: "dev" | "qa"): string { +function resolveLevelForIssue(issue: Issue, role: Role): string { const labelLevel = detectLevelFromLabels(issue.labels); if (labelLevel) { // QA role but label specifies a dev level → heuristic picks the right QA level diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 99c57ad..d7acdde 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -15,6 +15,7 @@ import { dispatchTask } from "../dispatch.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; import { isDevLevel } from "../tiers.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; +import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -43,6 +44,9 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); + // TODO: Load per-project workflow when supported + const workflow = DEFAULT_WORKFLOW; + // Find issue let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; let currentLabel: StateLabel; @@ -52,14 +56,14 @@ export function createWorkStartTool(api: OpenClawPluginApi) { if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`); currentLabel = label; } else { - const next = await findNextIssue(provider, roleParam); + const next = await findNextIssue(provider, roleParam, workflow); if (!next) return jsonResult({ success: false, error: `No issues available. Queue is empty.` }); issue = next.issue; currentLabel = next.label; } // Detect role - const detectedRole = detectRoleFromLabel(currentLabel); + const detectedRole = detectRoleFromLabel(currentLabel, workflow); if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`); const role = roleParam ?? detectedRole; if (roleParam && roleParam !== detectedRole) throw new Error(`Role mismatch: "${currentLabel}" → ${detectedRole}, requested ${roleParam}`); @@ -72,8 +76,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) { if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); } + // Get target label from workflow + const targetLabel = getActiveLabel(workflow, role); + // Select level - const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; let selectedLevel: string, levelReason: string, levelSource: string; if (levelParam) { selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm"; diff --git a/lib/workflow.ts b/lib/workflow.ts new file mode 100644 index 0000000..4a27943 --- /dev/null +++ b/lib/workflow.ts @@ -0,0 +1,391 @@ +/** + * workflow.ts — XState-style statechart configuration for workflow states. + * + * The workflow config defines: + * - States with types (queue, active, hold, terminal) + * - Transitions with actions (gitPull, detectPr, closeIssue, reopenIssue) + * - Role assignments (dev, qa) + * - Priority ordering for queue states + * + * All workflow behavior is derived from this config — no hardcoded state names. + */ +import fs from "node:fs/promises"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type StateType = "queue" | "active" | "hold" | "terminal"; +export type Role = "dev" | "qa"; +export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; + +export type TransitionTarget = string | { + target: string; + actions?: TransitionAction[]; +}; + +export type StateConfig = { + type: StateType; + role?: Role; + label: string; + color: string; + priority?: number; + on?: Record; +}; + +export type WorkflowConfig = { + initial: string; + states: Record; +}; + +export type CompletionRule = { + from: string; + to: string; + gitPull?: boolean; + detectPr?: boolean; + closeIssue?: boolean; + reopenIssue?: boolean; +}; + +// --------------------------------------------------------------------------- +// Default workflow — matches current hardcoded behavior +// --------------------------------------------------------------------------- + +export const DEFAULT_WORKFLOW: WorkflowConfig = { + initial: "planning", + states: { + planning: { + type: "hold", + label: "Planning", + color: "#95a5a6", + on: { APPROVE: "todo" }, + }, + todo: { + type: "queue", + role: "dev", + label: "To Do", + color: "#428bca", + priority: 1, + on: { PICKUP: "doing" }, + }, + doing: { + type: "active", + role: "dev", + label: "Doing", + color: "#f0ad4e", + on: { + COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] }, + BLOCKED: "refining", + }, + }, + toTest: { + type: "queue", + role: "qa", + label: "To Test", + color: "#5bc0de", + priority: 2, + on: { PICKUP: "testing" }, + }, + testing: { + type: "active", + role: "qa", + label: "Testing", + color: "#9b59b6", + on: { + PASS: { target: "done", actions: ["closeIssue"] }, + FAIL: { target: "toImprove", actions: ["reopenIssue"] }, + REFINE: "refining", + BLOCKED: "refining", + }, + }, + toImprove: { + type: "queue", + role: "dev", + label: "To Improve", + color: "#d9534f", + priority: 3, + on: { PICKUP: "doing" }, + }, + refining: { + type: "hold", + label: "Refining", + color: "#f39c12", + on: { APPROVE: "todo" }, + }, + done: { + type: "terminal", + label: "Done", + color: "#5cb85c", + }, + }, +}; + +// --------------------------------------------------------------------------- +// Workflow loading +// --------------------------------------------------------------------------- + +/** + * Load workflow config for a project. + * Priority: project-specific → workspace default → built-in default + */ +export async function loadWorkflow( + workspaceDir: string, + _groupId?: string, +): Promise { + // TODO: Support per-project overrides from projects.json when needed + // For now, try workspace-level config, fall back to default + + const workflowPath = path.join(workspaceDir, "projects", "workflow.json"); + try { + const content = await fs.readFile(workflowPath, "utf-8"); + const parsed = JSON.parse(content) as { workflow?: WorkflowConfig }; + if (parsed.workflow) { + return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow); + } + } catch { + // No custom workflow, use default + } + + return DEFAULT_WORKFLOW; +} + +/** + * Merge custom workflow config over defaults. + * Custom states are merged, not replaced entirely. + */ +function mergeWorkflow(base: WorkflowConfig, custom: Partial): WorkflowConfig { + return { + initial: custom.initial ?? base.initial, + states: { ...base.states, ...custom.states }, + }; +} + +// --------------------------------------------------------------------------- +// Derived helpers — all behavior comes from the config +// --------------------------------------------------------------------------- + +/** + * Get all state labels (for GitHub/GitLab label creation). + */ +export function getStateLabels(workflow: WorkflowConfig): string[] { + return Object.values(workflow.states).map((s) => s.label); +} + +/** + * Get label → color mapping. + */ +export function getLabelColors(workflow: WorkflowConfig): Record { + const colors: Record = {}; + for (const state of Object.values(workflow.states)) { + colors[state.label] = state.color; + } + return colors; +} + +/** + * Get queue labels for a role, ordered by priority (highest first). + */ +export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] { + return Object.values(workflow.states) + .filter((s) => s.type === "queue" && s.role === role) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + .map((s) => s.label); +} + +/** + * Get all queue labels ordered by priority (for findNextIssue). + */ +export function getAllQueueLabels(workflow: WorkflowConfig): string[] { + return Object.values(workflow.states) + .filter((s) => s.type === "queue") + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + .map((s) => s.label); +} + +/** + * Get the active (in-progress) label for a role. + */ +export function getActiveLabel(workflow: WorkflowConfig, role: Role): string { + const state = Object.values(workflow.states).find( + (s) => s.type === "active" && s.role === role, + ); + if (!state) throw new Error(`No active state for role "${role}"`); + return state.label; +} + +/** + * Get the revert label for a role (first queue state for that role). + */ +export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { + // Find the state that PICKUP transitions to the active state, then find its label + const activeLabel = getActiveLabel(workflow, role); + const activeStateKey = Object.entries(workflow.states).find( + ([, s]) => s.label === activeLabel, + )?.[0]; + + // Find queue states that transition to this active state + for (const [, state] of Object.entries(workflow.states)) { + if (state.type !== "queue" || state.role !== role) continue; + const pickup = state.on?.PICKUP; + if (pickup === activeStateKey) { + return state.label; + } + } + + // Fallback: first queue state for role + return getQueueLabels(workflow, role)[0] ?? ""; +} + +/** + * Detect role from a label. + */ +export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null { + for (const state of Object.values(workflow.states)) { + if (state.label === label && state.type === "queue" && state.role) { + return state.role; + } + } + return null; +} + +/** + * Check if a label is a queue label. + */ +export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean { + return Object.values(workflow.states).some( + (s) => s.label === label && s.type === "queue", + ); +} + +/** + * Check if a label is an active label. + */ +export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean { + return Object.values(workflow.states).some( + (s) => s.label === label && s.type === "active", + ); +} + +/** + * Find state config by label. + */ +export function findStateByLabel(workflow: WorkflowConfig, label: string): StateConfig | null { + return Object.values(workflow.states).find((s) => s.label === label) ?? null; +} + +/** + * Find state key by label. + */ +export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): string | null { + return Object.entries(workflow.states).find(([, s]) => s.label === label)?.[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Completion rules — derived from transitions +// --------------------------------------------------------------------------- + +/** + * Map role:result to completion event name. + */ +const RESULT_TO_EVENT: Record = { + "dev:done": "COMPLETE", + "dev:blocked": "BLOCKED", + "qa:pass": "PASS", + "qa:fail": "FAIL", + "qa:refine": "REFINE", + "qa:blocked": "BLOCKED", +}; + +/** + * Get completion rule for a role:result pair. + */ +export function getCompletionRule( + workflow: WorkflowConfig, + role: Role, + result: string, +): CompletionRule | null { + const event = RESULT_TO_EVENT[`${role}:${result}`]; + if (!event) return null; + + const activeLabel = getActiveLabel(workflow, role); + const activeKey = findStateKeyByLabel(workflow, activeLabel); + if (!activeKey) return null; + + const activeState = workflow.states[activeKey]; + if (!activeState.on) return null; + + const transition = activeState.on[event]; + if (!transition) return null; + + const targetKey = typeof transition === "string" ? transition : transition.target; + const actions = typeof transition === "object" ? transition.actions : undefined; + const targetState = workflow.states[targetKey]; + if (!targetState) return null; + + return { + from: activeLabel, + to: targetState.label, + gitPull: actions?.includes("gitPull"), + detectPr: actions?.includes("detectPr"), + closeIssue: actions?.includes("closeIssue"), + reopenIssue: actions?.includes("reopenIssue"), + }; +} + +/** + * Get human-readable next state description. + */ +export function getNextStateDescription( + workflow: WorkflowConfig, + role: Role, + result: string, +): string { + const rule = getCompletionRule(workflow, role, result); + if (!rule) return ""; + + // Find the target state to determine the description + const targetState = findStateByLabel(workflow, rule.to); + if (!targetState) return ""; + + if (targetState.type === "terminal") return "Done!"; + if (targetState.type === "hold") return "awaiting human decision"; + if (targetState.type === "queue") { + if (targetState.role === "qa") return "QA queue"; + if (targetState.role === "dev") return "back to DEV"; + } + + return rule.to; +} + +/** + * Get emoji for a completion result. + */ +export function getCompletionEmoji(role: Role, result: string): string { + const map: Record = { + "dev:done": "✅", + "qa:pass": "🎉", + "qa:fail": "❌", + "qa:refine": "🤔", + "dev:blocked": "🚫", + "qa:blocked": "🚫", + }; + return map[`${role}:${result}`] ?? "📋"; +} + +// --------------------------------------------------------------------------- +// Sync helper — ensure workflow states exist as labels in issue tracker +// --------------------------------------------------------------------------- + +/** + * Ensure all workflow state labels exist in the issue tracker. + */ +export async function ensureWorkflowLabels( + workflow: WorkflowConfig, + ensureLabel: (name: string, color: string) => Promise, +): Promise { + const colors = getLabelColors(workflow); + for (const [label, color] of Object.entries(colors)) { + await ensureLabel(label, color); + } +}