## 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:
@@ -5,10 +5,15 @@ import {
|
|||||||
type IssueProvider,
|
type IssueProvider,
|
||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
STATE_LABELS,
|
type IssueComment,
|
||||||
LABEL_COLORS,
|
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_WORKFLOW,
|
||||||
|
getStateLabels,
|
||||||
|
getLabelColors,
|
||||||
|
type WorkflowConfig,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
type GhIssue = {
|
type GhIssue = {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -28,7 +33,12 @@ function toIssue(gh: GhIssue): Issue {
|
|||||||
|
|
||||||
export class GitHubProvider implements IssueProvider {
|
export class GitHubProvider implements IssueProvider {
|
||||||
private repoPath: string;
|
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> {
|
private async gh(args: string[]): Promise<string> {
|
||||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
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> {
|
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> {
|
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);
|
return toIssue(JSON.parse(raw) as GhIssue);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
|
async listComments(issueId: number): Promise<IssueComment[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await this.gh(["api", `repos/:owner/:repo/issues/${issueId}/comments`, "--jq", ".[] | {author: .user.login, body: .body, created_at: .created_at}"]);
|
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 [];
|
if (!raw) return [];
|
||||||
@@ -75,9 +89,10 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
|
|
||||||
async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
|
async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
|
||||||
const issue = await this.getIssue(issueId);
|
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)];
|
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);
|
args.push("--add-label", to);
|
||||||
await this.gh(args);
|
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)]); }
|
async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); }
|
||||||
|
|
||||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
||||||
|
|
||||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
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> {
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import {
|
|||||||
type IssueProvider,
|
type IssueProvider,
|
||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
STATE_LABELS,
|
type IssueComment,
|
||||||
LABEL_COLORS,
|
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_WORKFLOW,
|
||||||
|
getStateLabels,
|
||||||
|
getLabelColors,
|
||||||
|
type WorkflowConfig,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
export class GitLabProvider implements IssueProvider {
|
export class GitLabProvider implements IssueProvider {
|
||||||
private repoPath: string;
|
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> {
|
private async glab(args: string[]): Promise<string> {
|
||||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
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> {
|
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> {
|
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;
|
return JSON.parse(raw) as Issue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listComments(issueId: number): Promise<import("./provider.js").IssueComment[]> {
|
async listComments(issueId: number): Promise<IssueComment[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await this.glab(["api", `projects/:id/issues/${issueId}/notes`, "--paginate"]);
|
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 }>;
|
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> {
|
async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
|
||||||
const issue = await this.getIssue(issueId);
|
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)];
|
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);
|
args.push("--label", to);
|
||||||
await this.glab(args);
|
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)]); }
|
async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); }
|
||||||
|
|
||||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
||||||
|
|
||||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
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> {
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
|
|||||||
@@ -2,18 +2,37 @@
|
|||||||
* IssueProvider — Abstract interface for issue tracker operations.
|
* IssueProvider — Abstract interface for issue tracker operations.
|
||||||
*
|
*
|
||||||
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
|
* 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",
|
// State labels — derived from default workflow for backward compatibility
|
||||||
] as const;
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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",
|
* StateLabel type — union of all valid state labels.
|
||||||
Testing: "#9b59b6", Done: "#5cb85c", "To Improve": "#d9534f", Refining: "#f39c12",
|
* 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 = {
|
export type Issue = {
|
||||||
iid: number;
|
iid: number;
|
||||||
@@ -30,6 +49,10 @@ export type IssueComment = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface IssueProvider {
|
export interface IssueProvider {
|
||||||
ensureLabel(name: string, color: string): Promise<void>;
|
ensureLabel(name: string, color: string): Promise<void>;
|
||||||
ensureAllStateLabels(): Promise<void>;
|
ensureAllStateLabels(): Promise<void>;
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
*
|
*
|
||||||
* Triangulates THREE sources of truth:
|
* Triangulates THREE sources of truth:
|
||||||
* 1. projects.json — worker state (active, issueId, level, sessions)
|
* 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
|
* 3. Session state — whether the OpenClaw session exists via gateway status
|
||||||
*
|
*
|
||||||
* Detection matrix:
|
* Detection matrix:
|
||||||
* | projects.json | Issue label | Session | Action |
|
* | projects.json | Issue label | Session | Action |
|
||||||
* |---------------|-------------------|--------------|-------------------------------------------|
|
* |---------------|-------------------|--------------|-------------------------------------------|
|
||||||
* | active | Doing/Testing ✅ | dead/missing | Deactivate worker, revert to To Do/To Test |
|
* | active | Active label ✅ | dead/missing | Deactivate worker, revert to queue |
|
||||||
* | active | NOT Doing/Testing | any | Deactivate worker (moved externally) |
|
* | active | NOT Active label | any | Deactivate worker (moved externally) |
|
||||||
* | active | Doing/Testing ✅ | alive | Healthy (flag if stale >2h) |
|
* | active | Active label ✅ | alive | Healthy (flag if stale >2h) |
|
||||||
* | inactive | Doing/Testing | any | Revert issue to To Do/To Test (label stuck)|
|
* | inactive | Active label | any | Revert issue to queue (label stuck) |
|
||||||
* | inactive | issueId set | any | Clear issueId (warning) |
|
* | inactive | issueId set | any | Clear issueId (warning) |
|
||||||
* | active | issue deleted | any | Deactivate worker, clear state |
|
* | active | issue deleted | any | Deactivate worker, clear state |
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +24,13 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
} from "../projects.js";
|
} from "../projects.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_WORKFLOW,
|
||||||
|
getActiveLabel,
|
||||||
|
getRevertLabel,
|
||||||
|
type WorkflowConfig,
|
||||||
|
type Role,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -32,15 +39,15 @@ import { runCommand } from "../run-command.js";
|
|||||||
export type HealthIssue = {
|
export type HealthIssue = {
|
||||||
type:
|
type:
|
||||||
| "session_dead" // Case 1: active worker but session missing/dead
|
| "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
|
| "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
|
| "orphan_issue_id" // Case 5: inactive but issueId set
|
||||||
| "issue_gone"; // Case 6: active but issue deleted/closed
|
| "issue_gone"; // Case 6: active but issue deleted/closed
|
||||||
severity: "critical" | "warning";
|
severity: "critical" | "warning";
|
||||||
project: string;
|
project: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
role: "dev" | "qa";
|
role: Role;
|
||||||
message: string;
|
message: string;
|
||||||
level?: string | null;
|
level?: string | null;
|
||||||
sessionKey?: string | null;
|
sessionKey?: string | null;
|
||||||
@@ -130,38 +137,29 @@ async function fetchIssue(
|
|||||||
// Health check logic
|
// 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: {
|
export async function checkWorkerHealth(opts: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
project: Project;
|
project: Project;
|
||||||
role: "dev" | "qa";
|
role: Role;
|
||||||
autoFix: boolean;
|
autoFix: boolean;
|
||||||
provider: IssueProvider;
|
provider: IssueProvider;
|
||||||
sessions: SessionLookup;
|
sessions: SessionLookup;
|
||||||
|
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||||
|
workflow?: WorkflowConfig;
|
||||||
}): Promise<HealthFix[]> {
|
}): 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 fixes: HealthFix[] = [];
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
||||||
|
|
||||||
const expectedLabel = ACTIVE_LABELS[role];
|
// Get labels from workflow config
|
||||||
const queueLabel = QUEUE_LABELS[role];
|
const expectedLabel = getActiveLabel(workflow, role);
|
||||||
|
const queueLabel = getRevertLabel(workflow, role);
|
||||||
|
|
||||||
// Parse issueId (may be comma-separated for batch, take first)
|
// Parse issueId (may be comma-separated for batch, take first)
|
||||||
const issueIdNum = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
|
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) {
|
if (!worker.active && issue && currentLabel === expectedLabel) {
|
||||||
const fix: HealthFix = {
|
const fix: HealthFix = {
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Pipeline service — declarative completion rules.
|
* 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 { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import type { StateLabel, IssueProvider } from "../providers/provider.js";
|
import type { StateLabel, IssueProvider } from "../providers/provider.js";
|
||||||
import { deactivateWorker } from "../projects.js";
|
import { deactivateWorker } from "../projects.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
import { notify, getNotificationConfig } from "../notify.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;
|
// Backward compatibility exports
|
||||||
to: StateLabel;
|
// ---------------------------------------------------------------------------
|
||||||
gitPull?: boolean;
|
|
||||||
detectPr?: boolean;
|
|
||||||
closeIssue?: boolean;
|
|
||||||
reopenIssue?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getCompletionRule() from workflow.ts instead.
|
||||||
|
* Kept for backward compatibility.
|
||||||
|
*/
|
||||||
export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
||||||
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
|
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
|
||||||
"qa:pass": { from: "Testing", to: "Done", closeIssue: 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" },
|
"qa:blocked": { from: "Testing", to: "Refining" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getNextStateDescription() from workflow.ts instead.
|
||||||
|
*/
|
||||||
export const NEXT_STATE: Record<string, string> = {
|
export const NEXT_STATE: Record<string, string> = {
|
||||||
"dev:done": "QA queue",
|
"dev:done": "QA queue",
|
||||||
"dev:blocked": "moved to Refining - needs human input",
|
"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",
|
"qa:blocked": "moved to Refining - needs human input",
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMOJI: Record<string, string> = {
|
// Re-export CompletionRule type for backward compatibility
|
||||||
"dev:done": "✅",
|
export type { CompletionRule };
|
||||||
"qa:pass": "🎉",
|
|
||||||
"qa:fail": "❌",
|
|
||||||
"qa:refine": "🤔",
|
|
||||||
"dev:blocked": "🚫",
|
|
||||||
"qa:blocked": "🚫",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompletionOutput = {
|
export type CompletionOutput = {
|
||||||
labelTransition: string;
|
labelTransition: string;
|
||||||
@@ -55,8 +59,16 @@ export type CompletionOutput = {
|
|||||||
issueReopened?: boolean;
|
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>;
|
pluginConfig?: Record<string, unknown>;
|
||||||
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
|
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
|
||||||
runtime?: PluginRuntime;
|
runtime?: PluginRuntime;
|
||||||
|
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||||
|
workflow?: WorkflowConfig;
|
||||||
}): Promise<CompletionOutput> {
|
}): 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 key = `${role}:${result}`;
|
||||||
const rule = COMPLETION_RULES[key];
|
const rule = getCompletionRule(workflow, role, result);
|
||||||
if (!rule) throw new Error(`No completion rule for ${key}`);
|
if (!rule) throw new Error(`No completion rule for ${key}`);
|
||||||
|
|
||||||
let prUrl = opts.prUrl;
|
let prUrl = opts.prUrl;
|
||||||
@@ -100,8 +119,10 @@ export async function executeCompletion(opts: {
|
|||||||
// Get issue early (for URL in notification)
|
// Get issue early (for URL in notification)
|
||||||
const issue = await provider.getIssue(issueId);
|
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)
|
// 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);
|
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||||
notify(
|
notify(
|
||||||
{
|
{
|
||||||
@@ -113,7 +134,7 @@ export async function executeCompletion(opts: {
|
|||||||
role,
|
role,
|
||||||
result: result as "done" | "pass" | "fail" | "refine" | "blocked",
|
result: result as "done" | "pass" | "fail" | "refine" | "blocked",
|
||||||
summary,
|
summary,
|
||||||
nextState: NEXT_STATE[key],
|
nextState,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
@@ -126,25 +147,25 @@ export async function executeCompletion(opts: {
|
|||||||
|
|
||||||
// Deactivate worker + transition label
|
// Deactivate worker + transition label
|
||||||
await deactivateWorker(workspaceDir, groupId, role);
|
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
|
// Close/reopen
|
||||||
if (rule.closeIssue) await provider.closeIssue(issueId);
|
if (rule.closeIssue) await provider.closeIssue(issueId);
|
||||||
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
||||||
|
|
||||||
// Build announcement
|
// Build announcement using workflow-derived emoji
|
||||||
const emoji = EMOJI[key] ?? "📋";
|
const emoji = getCompletionEmoji(role, result);
|
||||||
const label = key.replace(":", " ").toUpperCase();
|
const label = key.replace(":", " ").toUpperCase();
|
||||||
let announcement = `${emoji} ${label} #${issueId}`;
|
let announcement = `${emoji} ${label} #${issueId}`;
|
||||||
if (summary) announcement += ` — ${summary}`;
|
if (summary) announcement += ` — ${summary}`;
|
||||||
announcement += `\n📋 Issue: ${issue.web_url}`;
|
announcement += `\n📋 Issue: ${issue.web_url}`;
|
||||||
if (prUrl) announcement += `\n🔗 PR: ${prUrl}`;
|
if (prUrl) announcement += `\n🔗 PR: ${prUrl}`;
|
||||||
announcement += `\n${NEXT_STATE[key]}.`;
|
announcement += `\n${nextState}.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labelTransition: `${rule.from} → ${rule.to}`,
|
labelTransition: `${rule.from} → ${rule.to}`,
|
||||||
announcement,
|
announcement,
|
||||||
nextState: NEXT_STATE[key],
|
nextState,
|
||||||
prUrl,
|
prUrl,
|
||||||
issueUrl: issue.web_url,
|
issueUrl: issue.web_url,
|
||||||
issueClosed: rule.closeIssue,
|
issueClosed: rule.closeIssue,
|
||||||
|
|||||||
@@ -12,14 +12,38 @@ import { selectLevel } from "../model-selector.js";
|
|||||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.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"];
|
* @deprecated Use getQueueLabels(workflow, "dev") instead.
|
||||||
export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
*/
|
||||||
|
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 {
|
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||||
const lower = labels.map((l) => l.toLowerCase());
|
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;
|
return all.find((l) => lower.includes(l)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
/**
|
||||||
if (DEV_LABELS.includes(label)) return "dev";
|
* Detect role from a label using workflow config.
|
||||||
if (QA_LABELS.includes(label)) return "qa";
|
*/
|
||||||
return null;
|
export function detectRoleFromLabel(
|
||||||
|
label: StateLabel,
|
||||||
|
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||||
|
): Role | null {
|
||||||
|
return workflowDetectRole(workflow, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findNextIssueForRole(
|
export async function findNextIssueForRole(
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
role: "dev" | "qa",
|
role: Role,
|
||||||
|
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
const labels = role === "dev"
|
const labels = getQueueLabels(workflow, role);
|
||||||
? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
|
||||||
: PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
try {
|
try {
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
@@ -66,11 +93,13 @@ export async function findNextIssueForRole(
|
|||||||
*/
|
*/
|
||||||
export async function findNextIssue(
|
export async function findNextIssue(
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
role?: "dev" | "qa",
|
role?: Role,
|
||||||
|
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
const labels = role === "dev" ? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
const labels = role
|
||||||
: role === "qa" ? PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l))
|
? getQueueLabels(workflow, role)
|
||||||
: PRIORITY_ORDER;
|
: getAllQueueLabels(workflow);
|
||||||
|
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
try {
|
try {
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
@@ -90,7 +119,7 @@ export type TickAction = {
|
|||||||
issueId: number;
|
issueId: number;
|
||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa";
|
role: Role;
|
||||||
level: string;
|
level: string;
|
||||||
sessionAction: "spawn" | "send";
|
sessionAction: "spawn" | "send";
|
||||||
announcement: string;
|
announcement: string;
|
||||||
@@ -116,20 +145,26 @@ export async function projectTick(opts: {
|
|||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
maxPickups?: number;
|
maxPickups?: number;
|
||||||
/** Only attempt this role. Used by work_start to fill the other slot. */
|
/** 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. */
|
/** Optional provider override (for testing). Uses createProvider if omitted. */
|
||||||
provider?: Pick<IssueProvider, "listIssuesByLabel" | "transitionLabel" | "listComments">;
|
provider?: Pick<IssueProvider, "listIssuesByLabel" | "transitionLabel" | "listComments">;
|
||||||
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
|
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
|
||||||
runtime?: PluginRuntime;
|
runtime?: PluginRuntime;
|
||||||
|
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||||
|
workflow?: WorkflowConfig;
|
||||||
}): Promise<TickResult> {
|
}): 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];
|
const project = (await readProjects(workspaceDir)).projects[groupId];
|
||||||
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
|
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
|
||||||
|
|
||||||
const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider;
|
const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider;
|
||||||
const roleExecution = project.roleExecution ?? "parallel";
|
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 pickups: TickAction[] = [];
|
||||||
const skipped: TickResult["skipped"] = [];
|
const skipped: TickResult["skipped"] = [];
|
||||||
@@ -155,11 +190,11 @@ export async function projectTick(opts: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await findNextIssueForRole(provider, role);
|
const next = await findNextIssueForRole(provider, role, workflow);
|
||||||
if (!next) continue;
|
if (!next) continue;
|
||||||
|
|
||||||
const { issue, label: currentLabel } = next;
|
const { issue, label: currentLabel } = next;
|
||||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
const targetLabel = getActiveLabel(workflow, role);
|
||||||
|
|
||||||
// Level selection: label → heuristic
|
// Level selection: label → heuristic
|
||||||
const selectedLevel = resolveLevelForIssue(issue, role);
|
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.
|
* 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);
|
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||||
if (labelLevel) {
|
if (labelLevel) {
|
||||||
// QA role but label specifies a dev level → heuristic picks the right QA level
|
// QA role but label specifies a dev level → heuristic picks the right QA level
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { dispatchTask } from "../dispatch.js";
|
|||||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
||||||
import { isDevLevel } from "../tiers.js";
|
import { isDevLevel } from "../tiers.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
|
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
||||||
|
|
||||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -43,6 +44,9 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
const { project } = await resolveProject(workspaceDir, groupId);
|
const { project } = await resolveProject(workspaceDir, groupId);
|
||||||
const { provider } = await resolveProvider(project);
|
const { provider } = await resolveProvider(project);
|
||||||
|
|
||||||
|
// TODO: Load per-project workflow when supported
|
||||||
|
const workflow = DEFAULT_WORKFLOW;
|
||||||
|
|
||||||
// Find issue
|
// Find issue
|
||||||
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
||||||
let currentLabel: StateLabel;
|
let currentLabel: StateLabel;
|
||||||
@@ -52,14 +56,14 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`);
|
if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`);
|
||||||
currentLabel = label;
|
currentLabel = label;
|
||||||
} else {
|
} 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.` });
|
if (!next) return jsonResult({ success: false, error: `No issues available. Queue is empty.` });
|
||||||
issue = next.issue;
|
issue = next.issue;
|
||||||
currentLabel = next.label;
|
currentLabel = next.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect role
|
// Detect role
|
||||||
const detectedRole = detectRoleFromLabel(currentLabel);
|
const detectedRole = detectRoleFromLabel(currentLabel, workflow);
|
||||||
if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`);
|
if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`);
|
||||||
const role = roleParam ?? detectedRole;
|
const role = roleParam ?? detectedRole;
|
||||||
if (roleParam && roleParam !== detectedRole) throw new Error(`Role mismatch: "${currentLabel}" → ${detectedRole}, requested ${roleParam}`);
|
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`);
|
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
|
// Select level
|
||||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
|
||||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||||
if (levelParam) {
|
if (levelParam) {
|
||||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||||
|
|||||||
391
lib/workflow.ts
Normal file
391
lib/workflow.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user