feat: enhance workflow and testing infrastructure
- Introduced ExecutionMode type for project execution modes (parallel, sequential). - Updated SetupOpts to use ExecutionMode instead of string literals. - Enhanced workflow states to include a new "In Review" state with appropriate transitions. - Implemented TestHarness for end-to-end testing, including command interception and workspace setup. - Created TestProvider for in-memory issue tracking during tests. - Refactored project registration and setup tools to utilize ExecutionMode. - Updated various tools to ensure compatibility with new workflow and execution modes. - Added new dependencies: cockatiel for resilience and zod for schema validation.
This commit is contained in:
148
lib/workflow.ts
148
lib/workflow.ts
@@ -13,14 +13,60 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StateType = "queue" | "active" | "hold" | "terminal";
|
||||
/** Built-in state types. */
|
||||
export const StateType = {
|
||||
QUEUE: "queue",
|
||||
ACTIVE: "active",
|
||||
HOLD: "hold",
|
||||
TERMINAL: "terminal",
|
||||
REVIEW: "review",
|
||||
} as const;
|
||||
export type StateType = (typeof StateType)[keyof typeof StateType];
|
||||
|
||||
/** Built-in execution modes for role and project parallelism. */
|
||||
export const ExecutionMode = {
|
||||
PARALLEL: "parallel",
|
||||
SEQUENTIAL: "sequential",
|
||||
} as const;
|
||||
export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode];
|
||||
|
||||
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
|
||||
export type Role = string;
|
||||
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
||||
/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */
|
||||
export type TransitionAction = string;
|
||||
|
||||
/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */
|
||||
export const Action = {
|
||||
GIT_PULL: "gitPull",
|
||||
DETECT_PR: "detectPr",
|
||||
CLOSE_ISSUE: "closeIssue",
|
||||
REOPEN_ISSUE: "reopenIssue",
|
||||
} as const;
|
||||
|
||||
/** Built-in review check types for review states. */
|
||||
export const ReviewCheck = {
|
||||
PR_APPROVED: "prApproved",
|
||||
PR_MERGED: "prMerged",
|
||||
} as const;
|
||||
export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck];
|
||||
|
||||
/** Built-in workflow events. */
|
||||
export const WorkflowEvent = {
|
||||
PICKUP: "PICKUP",
|
||||
COMPLETE: "COMPLETE",
|
||||
REVIEW: "REVIEW",
|
||||
APPROVED: "APPROVED",
|
||||
PASS: "PASS",
|
||||
FAIL: "FAIL",
|
||||
REFINE: "REFINE",
|
||||
BLOCKED: "BLOCKED",
|
||||
APPROVE: "APPROVE",
|
||||
} as const;
|
||||
|
||||
export type TransitionTarget = string | {
|
||||
target: string;
|
||||
actions?: TransitionAction[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type StateConfig = {
|
||||
@@ -29,6 +75,8 @@ export type StateConfig = {
|
||||
label: string;
|
||||
color: string;
|
||||
priority?: number;
|
||||
description?: string;
|
||||
check?: ReviewCheckType;
|
||||
on?: Record<string, TransitionTarget>;
|
||||
};
|
||||
|
||||
@@ -40,10 +88,7 @@ export type WorkflowConfig = {
|
||||
export type CompletionRule = {
|
||||
from: string;
|
||||
to: string;
|
||||
gitPull?: boolean;
|
||||
detectPr?: boolean;
|
||||
closeIssue?: boolean;
|
||||
reopenIssue?: boolean;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,84 +99,95 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
||||
initial: "planning",
|
||||
states: {
|
||||
planning: {
|
||||
type: "hold",
|
||||
type: StateType.HOLD,
|
||||
label: "Planning",
|
||||
color: "#95a5a6",
|
||||
on: { APPROVE: "todo" },
|
||||
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||
},
|
||||
todo: {
|
||||
type: "queue",
|
||||
type: StateType.QUEUE,
|
||||
role: "developer",
|
||||
label: "To Do",
|
||||
color: "#428bca",
|
||||
priority: 1,
|
||||
on: { PICKUP: "doing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||
},
|
||||
doing: {
|
||||
type: "active",
|
||||
type: StateType.ACTIVE,
|
||||
role: "developer",
|
||||
label: "Doing",
|
||||
color: "#f0ad4e",
|
||||
on: {
|
||||
COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] },
|
||||
BLOCKED: "refining",
|
||||
[WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] },
|
||||
[WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] },
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
toTest: {
|
||||
type: "queue",
|
||||
type: StateType.QUEUE,
|
||||
role: "tester",
|
||||
label: "To Test",
|
||||
color: "#5bc0de",
|
||||
priority: 2,
|
||||
on: { PICKUP: "testing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "testing" },
|
||||
},
|
||||
testing: {
|
||||
type: "active",
|
||||
type: StateType.ACTIVE,
|
||||
role: "tester",
|
||||
label: "Testing",
|
||||
color: "#9b59b6",
|
||||
on: {
|
||||
PASS: { target: "done", actions: ["closeIssue"] },
|
||||
FAIL: { target: "toImprove", actions: ["reopenIssue"] },
|
||||
REFINE: "refining",
|
||||
BLOCKED: "refining",
|
||||
[WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] },
|
||||
[WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] },
|
||||
[WorkflowEvent.REFINE]: "refining",
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
toImprove: {
|
||||
type: "queue",
|
||||
type: StateType.QUEUE,
|
||||
role: "developer",
|
||||
label: "To Improve",
|
||||
color: "#d9534f",
|
||||
priority: 3,
|
||||
on: { PICKUP: "doing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||
},
|
||||
refining: {
|
||||
type: "hold",
|
||||
type: StateType.HOLD,
|
||||
label: "Refining",
|
||||
color: "#f39c12",
|
||||
on: { APPROVE: "todo" },
|
||||
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||
},
|
||||
reviewing: {
|
||||
type: StateType.REVIEW,
|
||||
label: "In Review",
|
||||
color: "#c5def5",
|
||||
check: ReviewCheck.PR_MERGED,
|
||||
on: {
|
||||
[WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] },
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
done: {
|
||||
type: "terminal",
|
||||
type: StateType.TERMINAL,
|
||||
label: "Done",
|
||||
color: "#5cb85c",
|
||||
},
|
||||
toDesign: {
|
||||
type: "queue",
|
||||
type: StateType.QUEUE,
|
||||
role: "architect",
|
||||
label: "To Design",
|
||||
color: "#0075ca",
|
||||
priority: 1,
|
||||
on: { PICKUP: "designing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "designing" },
|
||||
},
|
||||
designing: {
|
||||
type: "active",
|
||||
type: StateType.ACTIVE,
|
||||
role: "architect",
|
||||
label: "Designing",
|
||||
color: "#d4c5f9",
|
||||
on: {
|
||||
COMPLETE: "planning",
|
||||
BLOCKED: "refining",
|
||||
[WorkflowEvent.COMPLETE]: "planning",
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -181,7 +237,7 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
|
||||
*/
|
||||
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||
return Object.values(workflow.states)
|
||||
.filter((s) => s.type === "queue" && s.role === role)
|
||||
.filter((s) => s.type === StateType.QUEUE && s.role === role)
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
.map((s) => s.label);
|
||||
}
|
||||
@@ -191,7 +247,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||
*/
|
||||
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||
return Object.values(workflow.states)
|
||||
.filter((s) => s.type === "queue")
|
||||
.filter((s) => s.type === StateType.QUEUE)
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
.map((s) => s.label);
|
||||
}
|
||||
@@ -201,7 +257,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||
*/
|
||||
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
const state = Object.values(workflow.states).find(
|
||||
(s) => s.type === "active" && s.role === role,
|
||||
(s) => s.type === StateType.ACTIVE && s.role === role,
|
||||
);
|
||||
if (!state) throw new Error(`No active state for role "${role}"`);
|
||||
return state.label;
|
||||
@@ -219,8 +275,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
|
||||
// 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 (state.type !== StateType.QUEUE || state.role !== role) continue;
|
||||
const pickup = state.on?.[WorkflowEvent.PICKUP];
|
||||
if (pickup === activeStateKey) {
|
||||
return state.label;
|
||||
}
|
||||
@@ -235,7 +291,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
*/
|
||||
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) {
|
||||
if (state.label === label && state.type === StateType.QUEUE && state.role) {
|
||||
return state.role;
|
||||
}
|
||||
}
|
||||
@@ -247,7 +303,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro
|
||||
*/
|
||||
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
return Object.values(workflow.states).some(
|
||||
(s) => s.label === label && s.type === "queue",
|
||||
(s) => s.label === label && s.type === StateType.QUEUE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +312,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
*/
|
||||
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
return Object.values(workflow.states).some(
|
||||
(s) => s.label === label && s.type === "active",
|
||||
(s) => s.label === label && s.type === StateType.ACTIVE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,7 +339,8 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
|
||||
* Convention: "done" → COMPLETE, others → uppercase.
|
||||
*/
|
||||
function resultToEvent(result: string): string {
|
||||
if (result === "done") return "COMPLETE";
|
||||
if (result === "done") return WorkflowEvent.COMPLETE;
|
||||
if (result === "review") return WorkflowEvent.REVIEW;
|
||||
return result.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -320,10 +377,7 @@ export function getCompletionRule(
|
||||
return {
|
||||
from: activeLabel,
|
||||
to: targetState.label,
|
||||
gitPull: actions?.includes("gitPull"),
|
||||
detectPr: actions?.includes("detectPr"),
|
||||
closeIssue: actions?.includes("closeIssue"),
|
||||
reopenIssue: actions?.includes("reopenIssue"),
|
||||
actions: actions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,9 +396,10 @@ export function getNextStateDescription(
|
||||
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" && targetState.role) {
|
||||
if (targetState.type === StateType.TERMINAL) return "Done!";
|
||||
if (targetState.type === StateType.REVIEW) return "awaiting PR review";
|
||||
if (targetState.type === StateType.HOLD) return "awaiting human decision";
|
||||
if (targetState.type === StateType.QUEUE && targetState.role) {
|
||||
return `${targetState.role.toUpperCase()} queue`;
|
||||
}
|
||||
|
||||
@@ -357,6 +412,7 @@ export function getNextStateDescription(
|
||||
*/
|
||||
const RESULT_EMOJI: Record<string, string> = {
|
||||
done: "✅",
|
||||
review: "👀",
|
||||
pass: "🎉",
|
||||
fail: "❌",
|
||||
refine: "🤔",
|
||||
|
||||
Reference in New Issue
Block a user