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:
Lauren ten Hoor
2026-02-16 13:27:14 +08:00
parent a359ffed34
commit 371e760d94
37 changed files with 2444 additions and 263 deletions

View File

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

View File

@@ -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). */

View File

@@ -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
View 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;
}

View File

@@ -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;
};
/**