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:
@@ -8,7 +8,10 @@ export type {
|
||||
RoleOverride,
|
||||
ResolvedConfig,
|
||||
ResolvedRoleConfig,
|
||||
ResolvedTimeouts,
|
||||
TimeoutConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export { loadConfig } from "./loader.js";
|
||||
export { mergeConfig } from "./merge.js";
|
||||
export { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||
|
||||
@@ -14,7 +14,8 @@ import YAML from "yaml";
|
||||
import { ROLE_REGISTRY } from "../roles/registry.js";
|
||||
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||
import { mergeConfig } from "./merge.js";
|
||||
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js";
|
||||
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js";
|
||||
import { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
|
||||
/**
|
||||
@@ -140,20 +141,42 @@ function resolve(config: DevClawConfig): ResolvedConfig {
|
||||
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
|
||||
};
|
||||
|
||||
return { roles, workflow };
|
||||
// Validate structural integrity (cross-references between states)
|
||||
const integrityErrors = validateWorkflowIntegrity(workflow);
|
||||
if (integrityErrors.length > 0) {
|
||||
throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`);
|
||||
}
|
||||
|
||||
const timeouts: ResolvedTimeouts = {
|
||||
gitPullMs: config.timeouts?.gitPullMs ?? 30_000,
|
||||
gatewayMs: config.timeouts?.gatewayMs ?? 15_000,
|
||||
sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000,
|
||||
dispatchMs: config.timeouts?.dispatchMs ?? 600_000,
|
||||
staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2,
|
||||
};
|
||||
|
||||
return { roles, workflow, timeouts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File reading helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read workflow.yaml (new primary config file). */
|
||||
/** Read workflow.yaml (new primary config file). Validates structure via Zod. */
|
||||
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
||||
return YAML.parse(content) as DevClawConfig;
|
||||
} catch { /* not found */ }
|
||||
return null;
|
||||
const parsed = YAML.parse(content);
|
||||
if (parsed) validateConfig(parsed);
|
||||
return parsed as DevClawConfig;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return null;
|
||||
// Re-throw validation errors with file context
|
||||
if (err?.name === "ZodError") {
|
||||
throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read config.yaml (old name, fallback for unmigrated workspaces). */
|
||||
|
||||
@@ -57,6 +57,11 @@ export function mergeConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Merge timeouts
|
||||
if (base.timeouts || overlay.timeouts) {
|
||||
merged.timeouts = { ...base.timeouts, ...overlay.timeouts };
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
114
lib/config/schema.ts
Normal file
114
lib/config/schema.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* config/schema.ts — Zod validation for DevClaw workflow config.
|
||||
*
|
||||
* Validates workflow YAML at load time with clear error messages.
|
||||
* Enforces: transition targets exist, queue states have roles,
|
||||
* terminal states have no outgoing transitions.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { StateType } from "../workflow.js";
|
||||
|
||||
const STATE_TYPES = Object.values(StateType) as [string, ...string[]];
|
||||
|
||||
const TransitionTargetSchema = z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
target: z.string(),
|
||||
actions: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const StateConfigSchema = z.object({
|
||||
type: z.enum(STATE_TYPES),
|
||||
role: z.string().optional(),
|
||||
label: z.string(),
|
||||
color: z.string(),
|
||||
priority: z.number().optional(),
|
||||
description: z.string().optional(),
|
||||
check: z.string().optional(),
|
||||
on: z.record(z.string(), TransitionTargetSchema).optional(),
|
||||
});
|
||||
|
||||
const WorkflowConfigSchema = z.object({
|
||||
initial: z.string(),
|
||||
states: z.record(z.string(), StateConfigSchema),
|
||||
});
|
||||
|
||||
const RoleOverrideSchema = z.union([
|
||||
z.literal(false),
|
||||
z.object({
|
||||
levels: z.array(z.string()).optional(),
|
||||
defaultLevel: z.string().optional(),
|
||||
models: z.record(z.string(), z.string()).optional(),
|
||||
emoji: z.record(z.string(), z.string()).optional(),
|
||||
completionResults: z.array(z.string()).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const TimeoutConfigSchema = z.object({
|
||||
gitPullMs: z.number().positive().optional(),
|
||||
gatewayMs: z.number().positive().optional(),
|
||||
sessionPatchMs: z.number().positive().optional(),
|
||||
dispatchMs: z.number().positive().optional(),
|
||||
staleWorkerHours: z.number().positive().optional(),
|
||||
}).optional();
|
||||
|
||||
export const DevClawConfigSchema = z.object({
|
||||
roles: z.record(z.string(), RoleOverrideSchema).optional(),
|
||||
workflow: WorkflowConfigSchema.partial().optional(),
|
||||
timeouts: TimeoutConfigSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a raw parsed config object.
|
||||
* Returns the validated config or throws with a descriptive error.
|
||||
*/
|
||||
export function validateConfig(raw: unknown): void {
|
||||
DevClawConfigSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate structural integrity of a fully-resolved workflow config.
|
||||
* Checks cross-references that Zod schema alone can't enforce:
|
||||
* - All transition targets point to existing states
|
||||
* - Queue states have a role assigned
|
||||
* - Terminal states have no outgoing transitions
|
||||
*/
|
||||
export function validateWorkflowIntegrity(
|
||||
workflow: { initial: string; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const stateKeys = new Set(Object.keys(workflow.states));
|
||||
|
||||
if (!stateKeys.has(workflow.initial)) {
|
||||
errors.push(`Initial state "${workflow.initial}" does not exist in states`);
|
||||
}
|
||||
|
||||
for (const [key, state] of Object.entries(workflow.states)) {
|
||||
if (state.type === StateType.QUEUE && !state.role) {
|
||||
errors.push(`Queue state "${key}" must have a role assigned`);
|
||||
}
|
||||
|
||||
if (state.type === StateType.ACTIVE && !state.role) {
|
||||
errors.push(`Active state "${key}" must have a role assigned`);
|
||||
}
|
||||
|
||||
if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) {
|
||||
errors.push(`Terminal state "${key}" should not have outgoing transitions`);
|
||||
}
|
||||
|
||||
if (state.on) {
|
||||
for (const [event, transition] of Object.entries(state.on)) {
|
||||
const target = typeof transition === "string"
|
||||
? transition
|
||||
: (transition as { target: string }).target;
|
||||
if (!stateKeys.has(target)) {
|
||||
errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -18,6 +18,18 @@ export type RoleOverride = {
|
||||
completionResults?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Configurable timeout values (in milliseconds).
|
||||
* All fields optional — defaults applied at resolution time.
|
||||
*/
|
||||
export type TimeoutConfig = {
|
||||
gitPullMs?: number;
|
||||
gatewayMs?: number;
|
||||
sessionPatchMs?: number;
|
||||
dispatchMs?: number;
|
||||
staleWorkerHours?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The full workflow.yaml shape.
|
||||
* All fields optional — missing fields inherit from the layer below.
|
||||
@@ -25,6 +37,18 @@ export type RoleOverride = {
|
||||
export type DevClawConfig = {
|
||||
roles?: Record<string, RoleOverride | false>;
|
||||
workflow?: Partial<WorkflowConfig>;
|
||||
timeouts?: TimeoutConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully resolved timeout config — all fields present with defaults.
|
||||
*/
|
||||
export type ResolvedTimeouts = {
|
||||
gitPullMs: number;
|
||||
gatewayMs: number;
|
||||
sessionPatchMs: number;
|
||||
dispatchMs: number;
|
||||
staleWorkerHours: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,6 +58,7 @@ export type DevClawConfig = {
|
||||
export type ResolvedConfig = {
|
||||
roles: Record<string, ResolvedRoleConfig>;
|
||||
workflow: WorkflowConfig;
|
||||
timeouts: ResolvedTimeouts;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user