feat: make workflow states dynamic with XState-style statechart config (#147) (#160)

## 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<string, TransitionTarget>;
};
```

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
This commit is contained in:
Lauren ten Hoor
2026-02-13 18:50:09 +08:00
committed by GitHub
parent 24235cdbf7
commit dfeadf742a
8 changed files with 617 additions and 109 deletions

View File

@@ -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<string> {
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
@@ -41,7 +51,11 @@ export class GitHubProvider implements IssueProvider {
}
async ensureAllStateLabels(): Promise<void> {
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<Issue> {
@@ -65,7 +79,7 @@ export class GitHubProvider implements IssueProvider {
return toIssue(JSON.parse(raw) as GhIssue);
}
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
async listComments(issueId: number): Promise<IssueComment[]> {
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<void> {
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<void> { 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<boolean> {

View File

@@ -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<string> {
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
@@ -25,7 +35,11 @@ export class GitLabProvider implements IssueProvider {
}
async ensureAllStateLabels(): Promise<void> {
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<Issue> {
@@ -52,7 +66,7 @@ export class GitLabProvider implements IssueProvider {
return JSON.parse(raw) as Issue;
}
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
async listComments(issueId: number): Promise<IssueComment[]> {
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<void> {
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<void> { 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<boolean> {

View File

@@ -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<StateLabel, string> = {
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<string, string> = 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<void>;
ensureAllStateLabels(): Promise<void>;

View File

@@ -3,16 +3,16 @@
*
* 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)|
* | 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 |
*/
@@ -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<HealthFix[]> {
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 = {

View File

@@ -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<string, CompletionRule> = {
"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<string, CompletionRule> = {
"qa:blocked": { from: "Testing", to: "Refining" },
};
/**
* @deprecated Use getNextStateDescription() from workflow.ts instead.
*/
export const NEXT_STATE: Record<string, string> = {
"dev:done": "QA queue",
"dev:blocked": "moved to Refining - needs human input",
@@ -36,14 +46,8 @@ export const NEXT_STATE: Record<string, string> = {
"qa:blocked": "moved to Refining - needs human input",
};
const EMOJI: Record<string, string> = {
"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<string, unknown>;
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
runtime?: PluginRuntime;
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
workflow?: WorkflowConfig;
}): Promise<CompletionOutput> {
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,

View File

@@ -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<IssueProvider, "listIssuesByLabel">,
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<IssueProvider, "listIssuesByLabel">,
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<IssueProvider, "listIssuesByLabel" | "transitionLabel" | "listComments">;
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
runtime?: PluginRuntime;
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
workflow?: WorkflowConfig;
}): Promise<TickResult> {
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

View File

@@ -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";

391
lib/workflow.ts Normal file
View File

@@ -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<string, TransitionTarget>;
};
export type WorkflowConfig = {
initial: string;
states: Record<string, StateConfig>;
};
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<WorkflowConfig> {
// 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>): 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<string, string> {
const colors: Record<string, string> = {};
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<string, string> = {
"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<string, string> = {
"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<void>,
): Promise<void> {
const colors = getLabelColors(workflow);
for (const [label, color] of Object.entries(colors)) {
await ensureLabel(label, color);
}
}