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:
@@ -33,36 +33,61 @@ export function parseDevClawSessionKey(
|
|||||||
return { projectName: match[1], role: match[2] };
|
return { projectName: match[1], role: match[2] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of loading role instructions — includes the source for traceability.
|
||||||
|
*/
|
||||||
|
export type RoleInstructionsResult = {
|
||||||
|
content: string;
|
||||||
|
/** Which file the instructions were loaded from, or null if none found. */
|
||||||
|
source: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load role-specific instructions from workspace.
|
* Load role-specific instructions from workspace.
|
||||||
* Tries project-specific file first, then falls back to default.
|
* Tries project-specific file first, then falls back to default.
|
||||||
|
* Returns both the content and the source path for logging/traceability.
|
||||||
*
|
*
|
||||||
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
|
* Resolution order:
|
||||||
* now called from the bootstrap hook instead of during dispatch.
|
* 1. devclaw/projects/<project>/prompts/<role>.md (project-specific)
|
||||||
|
* 2. projects/roles/<project>/<role>.md (old project-specific)
|
||||||
|
* 3. devclaw/prompts/<role>.md (workspace default)
|
||||||
|
* 4. projects/roles/default/<role>.md (old default)
|
||||||
*/
|
*/
|
||||||
export async function loadRoleInstructions(
|
export async function loadRoleInstructions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<string> {
|
): Promise<string>;
|
||||||
|
export async function loadRoleInstructions(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName: string,
|
||||||
|
role: string,
|
||||||
|
opts: { withSource: true },
|
||||||
|
): Promise<RoleInstructionsResult>;
|
||||||
|
export async function loadRoleInstructions(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName: string,
|
||||||
|
role: string,
|
||||||
|
opts?: { withSource: true },
|
||||||
|
): Promise<string | RoleInstructionsResult> {
|
||||||
const dataDir = path.join(workspaceDir, DATA_DIR);
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
|
|
||||||
// Project-specific: devclaw/projects/<project>/prompts/<role>.md
|
const candidates = [
|
||||||
const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`);
|
path.join(dataDir, "projects", projectName, "prompts", `${role}.md`),
|
||||||
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ }
|
path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`),
|
||||||
|
path.join(dataDir, "prompts", `${role}.md`),
|
||||||
|
path.join(workspaceDir, "projects", "roles", "default", `${role}.md`),
|
||||||
|
];
|
||||||
|
|
||||||
// Fallback old path: projects/roles/<project>/<role>.md
|
for (const filePath of candidates) {
|
||||||
const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
try {
|
||||||
try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ }
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
|
if (opts?.withSource) return { content, source: filePath };
|
||||||
// Default: devclaw/prompts/<role>.md
|
return content;
|
||||||
const defaultFile = path.join(dataDir, "prompts", `${role}.md`);
|
} catch { /* not found, try next */ }
|
||||||
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ }
|
}
|
||||||
|
|
||||||
// Fallback old default: projects/roles/default/<role>.md
|
|
||||||
const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
|
||||||
try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ }
|
|
||||||
|
|
||||||
|
if (opts?.withSource) return { content: "", source: null };
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,25 +127,26 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
|||||||
const bootstrapFiles = context.bootstrapFiles;
|
const bootstrapFiles = context.bootstrapFiles;
|
||||||
if (!Array.isArray(bootstrapFiles)) return;
|
if (!Array.isArray(bootstrapFiles)) return;
|
||||||
|
|
||||||
const instructions = await loadRoleInstructions(
|
const { content, source } = await loadRoleInstructions(
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
parsed.projectName,
|
parsed.projectName,
|
||||||
parsed.role,
|
parsed.role,
|
||||||
|
{ withSource: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!instructions) return;
|
if (!content) return;
|
||||||
|
|
||||||
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
||||||
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
||||||
bootstrapFiles.push({
|
bootstrapFiles.push({
|
||||||
name: "WORKER_INSTRUCTIONS.md" as any,
|
name: "WORKER_INSTRUCTIONS.md" as any,
|
||||||
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
||||||
content: instructions.trim(),
|
content: content.trim(),
|
||||||
missing: false,
|
missing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
|
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export type {
|
|||||||
RoleOverride,
|
RoleOverride,
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
ResolvedRoleConfig,
|
ResolvedRoleConfig,
|
||||||
|
ResolvedTimeouts,
|
||||||
|
TimeoutConfig,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export { loadConfig } from "./loader.js";
|
export { loadConfig } from "./loader.js";
|
||||||
export { mergeConfig } from "./merge.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 { ROLE_REGISTRY } from "../roles/registry.js";
|
||||||
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
import { mergeConfig } from "./merge.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";
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,20 +141,42 @@ function resolve(config: DevClawConfig): ResolvedConfig {
|
|||||||
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
|
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
|
// 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> {
|
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
||||||
return YAML.parse(content) as DevClawConfig;
|
const parsed = YAML.parse(content);
|
||||||
} catch { /* not found */ }
|
if (parsed) validateConfig(parsed);
|
||||||
return null;
|
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). */
|
/** 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;
|
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[];
|
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.
|
* The full workflow.yaml shape.
|
||||||
* All fields optional — missing fields inherit from the layer below.
|
* All fields optional — missing fields inherit from the layer below.
|
||||||
@@ -25,6 +37,18 @@ export type RoleOverride = {
|
|||||||
export type DevClawConfig = {
|
export type DevClawConfig = {
|
||||||
roles?: Record<string, RoleOverride | false>;
|
roles?: Record<string, RoleOverride | false>;
|
||||||
workflow?: Partial<WorkflowConfig>;
|
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 = {
|
export type ResolvedConfig = {
|
||||||
roles: Record<string, ResolvedRoleConfig>;
|
roles: Record<string, ResolvedRoleConfig>;
|
||||||
workflow: WorkflowConfig;
|
workflow: WorkflowConfig;
|
||||||
|
timeouts: ResolvedTimeouts;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export async function dispatchTask(
|
|||||||
|
|
||||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
const resolvedRole = resolvedConfig.roles[role];
|
const resolvedRole = resolvedConfig.roles[role];
|
||||||
|
const { timeouts } = resolvedConfig;
|
||||||
const model = resolveModel(role, level, resolvedRole);
|
const model = resolveModel(role, level, resolvedRole);
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
const existingSessionKey = getSessionForLevel(worker, level);
|
const existingSessionKey = getSessionForLevel(worker, level);
|
||||||
@@ -194,16 +195,22 @@ export async function dispatchTask(
|
|||||||
channel: opts.channel ?? "telegram",
|
channel: opts.channel ?? "telegram",
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
).catch(() => { /* non-fatal */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "dispatch_warning", {
|
||||||
|
step: "notify", issue: issueId, role,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
|
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
|
||||||
// Session key is deterministic, so we can proceed immediately
|
// Session key is deterministic, so we can proceed immediately
|
||||||
ensureSessionFireAndForget(sessionKey, model);
|
ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs);
|
||||||
|
|
||||||
// Step 4: Send task to agent (fire-and-forget)
|
// Step 4: Send task to agent (fire-and-forget)
|
||||||
sendToAgent(sessionKey, taskMessage, {
|
sendToAgent(sessionKey, taskMessage, {
|
||||||
agentId, projectName: project.name, issueId, role,
|
agentId, projectName: project.name, issueId, role, level,
|
||||||
orchestratorSessionKey: opts.sessionKey,
|
orchestratorSessionKey: opts.sessionKey, workspaceDir,
|
||||||
|
dispatchTimeoutMs: timeouts.dispatchMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Update worker state
|
// Step 5: Update worker state
|
||||||
@@ -241,19 +248,24 @@ export async function dispatchTask(
|
|||||||
* Session key is deterministic, so we don't need to wait for confirmation.
|
* Session key is deterministic, so we don't need to wait for confirmation.
|
||||||
* If this fails, health check will catch orphaned state later.
|
* If this fails, health check will catch orphaned state later.
|
||||||
*/
|
*/
|
||||||
function ensureSessionFireAndForget(sessionKey: string, model: string): void {
|
function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void {
|
||||||
runCommand(
|
runCommand(
|
||||||
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
|
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
|
||||||
{ timeoutMs: 30_000 },
|
{ timeoutMs },
|
||||||
).catch(() => { /* fire-and-forget */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "dispatch_warning", {
|
||||||
|
step: "ensureSession", sessionKey,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToAgent(
|
function sendToAgent(
|
||||||
sessionKey: string, taskMessage: string,
|
sessionKey: string, taskMessage: string,
|
||||||
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
|
opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number },
|
||||||
): void {
|
): void {
|
||||||
const gatewayParams = JSON.stringify({
|
const gatewayParams = JSON.stringify({
|
||||||
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
|
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`,
|
||||||
agentId: opts.agentId ?? "devclaw",
|
agentId: opts.agentId ?? "devclaw",
|
||||||
sessionKey,
|
sessionKey,
|
||||||
message: taskMessage,
|
message: taskMessage,
|
||||||
@@ -264,8 +276,14 @@ function sendToAgent(
|
|||||||
// Fire-and-forget: long-running agent turn, don't await
|
// Fire-and-forget: long-running agent turn, don't await
|
||||||
runCommand(
|
runCommand(
|
||||||
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
|
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
|
||||||
{ timeoutMs: 600_000 },
|
{ timeoutMs: opts.dispatchTimeoutMs ?? 600_000 },
|
||||||
).catch(() => { /* fire-and-forget */ });
|
).catch((err) => {
|
||||||
|
auditLog(opts.workspaceDir, "dispatch_warning", {
|
||||||
|
step: "sendToAgent", sessionKey,
|
||||||
|
issue: opts.issueId, role: opts.role,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordWorkerState(
|
async function recordWorkerState(
|
||||||
|
|||||||
@@ -1,12 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* Atomic projects.json read/write operations.
|
* Atomic projects.json read/write operations.
|
||||||
* All state mutations go through this module to prevent corruption.
|
* All state mutations go through this module to prevent corruption.
|
||||||
|
*
|
||||||
|
* Uses file-level locking to prevent concurrent read-modify-write races.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { migrateProject } from "./migrations.js";
|
import { migrateProject } from "./migrations.js";
|
||||||
import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js";
|
import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js";
|
||||||
|
import type { ExecutionMode } from "./workflow.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File locking — prevents concurrent read-modify-write races
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LOCK_STALE_MS = 30_000;
|
||||||
|
const LOCK_RETRY_MS = 50;
|
||||||
|
const LOCK_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
function lockPath(workspaceDir: string): string {
|
||||||
|
return projectsPath(workspaceDir) + ".lock";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireLock(workspaceDir: string): Promise<void> {
|
||||||
|
const lock = lockPath(workspaceDir);
|
||||||
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== "EEXIST") throw err;
|
||||||
|
|
||||||
|
// Check for stale lock
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(lock, "utf-8");
|
||||||
|
const lockTime = Number(content);
|
||||||
|
if (Date.now() - lockTime > LOCK_STALE_MS) {
|
||||||
|
try { await fs.unlink(lock); } catch { /* race */ }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch { /* lock disappeared — retry */ }
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: force remove potentially stale lock
|
||||||
|
try { await fs.unlink(lockPath(workspaceDir)); } catch { /* ignore */ }
|
||||||
|
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseLock(workspaceDir: string): Promise<void> {
|
||||||
|
try { await fs.unlink(lockPath(workspaceDir)); } catch { /* already removed */ }
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkerState = {
|
export type WorkerState = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -28,7 +77,7 @@ export type Project = {
|
|||||||
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
|
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
|
||||||
provider?: "github" | "gitlab";
|
provider?: "github" | "gitlab";
|
||||||
/** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
/** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
||||||
roleExecution?: "parallel" | "sequential";
|
roleExecution?: ExecutionMode;
|
||||||
maxDevWorkers?: number;
|
maxDevWorkers?: number;
|
||||||
maxQaWorkers?: number;
|
maxQaWorkers?: number;
|
||||||
/** Worker state per role (developer, tester, architect, or custom roles). */
|
/** Worker state per role (developer, tester, architect, or custom roles). */
|
||||||
@@ -109,6 +158,7 @@ export function getWorker(
|
|||||||
/**
|
/**
|
||||||
* Update worker state for a project. Only provided fields are updated.
|
* Update worker state for a project. Only provided fields are updated.
|
||||||
* Sessions are merged (not replaced) when both existing and new sessions are present.
|
* Sessions are merged (not replaced) when both existing and new sessions are present.
|
||||||
|
* Uses file locking to prevent concurrent read-modify-write races.
|
||||||
*/
|
*/
|
||||||
export async function updateWorker(
|
export async function updateWorker(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
@@ -116,22 +166,27 @@ export async function updateWorker(
|
|||||||
role: string,
|
role: string,
|
||||||
updates: Partial<WorkerState>,
|
updates: Partial<WorkerState>,
|
||||||
): Promise<ProjectsData> {
|
): Promise<ProjectsData> {
|
||||||
const data = await readProjects(workspaceDir);
|
await acquireLock(workspaceDir);
|
||||||
const project = data.projects[groupId];
|
try {
|
||||||
if (!project) {
|
const data = await readProjects(workspaceDir);
|
||||||
throw new Error(`Project not found for groupId: ${groupId}`);
|
const project = data.projects[groupId];
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(`Project not found for groupId: ${groupId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = project.workers[role] ?? emptyWorkerState([]);
|
||||||
|
|
||||||
|
if (updates.sessions && worker.sessions) {
|
||||||
|
updates.sessions = { ...worker.sessions, ...updates.sessions };
|
||||||
|
}
|
||||||
|
|
||||||
|
project.workers[role] = { ...worker, ...updates };
|
||||||
|
|
||||||
|
await writeProjects(workspaceDir, data);
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
await releaseLock(workspaceDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = project.workers[role] ?? emptyWorkerState([]);
|
|
||||||
|
|
||||||
if (updates.sessions && worker.sessions) {
|
|
||||||
updates.sessions = { ...worker.sessions, ...updates.sessions };
|
|
||||||
}
|
|
||||||
|
|
||||||
project.workers[role] = { ...worker, ...updates };
|
|
||||||
|
|
||||||
await writeProjects(workspaceDir, data);
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
|
type PrStatus,
|
||||||
|
PrState,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { withResilience } from "./resilience.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getStateLabels,
|
getStateLabels,
|
||||||
@@ -41,8 +44,10 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
return withResilience(async () => {
|
||||||
return result.stdout.trim();
|
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||||
|
return result.stdout.trim();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureLabel(name: string, color: string): Promise<void> {
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
@@ -125,6 +130,28 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
const pat = `#${issueId}`;
|
||||||
|
// Check open PRs first
|
||||||
|
try {
|
||||||
|
const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]);
|
||||||
|
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>;
|
||||||
|
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
|
||||||
|
if (pr) {
|
||||||
|
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
|
||||||
|
return { state, url: pr.url };
|
||||||
|
}
|
||||||
|
} catch { /* continue to merged check */ }
|
||||||
|
// Check merged PRs
|
||||||
|
try {
|
||||||
|
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]);
|
||||||
|
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>;
|
||||||
|
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
|
||||||
|
if (pr) return { state: PrState.MERGED, url: pr.url };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { state: PrState.CLOSED, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
async addComment(issueId: number, body: string): Promise<void> {
|
async addComment(issueId: number, body: string): Promise<void> {
|
||||||
await this.gh(["issue", "comment", String(issueId), "--body", body]);
|
await this.gh(["issue", "comment", String(issueId), "--body", body]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
|
type PrStatus,
|
||||||
|
PrState,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { withResilience } from "./resilience.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getStateLabels,
|
getStateLabels,
|
||||||
@@ -25,8 +28,10 @@ export class GitLabProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
return withResilience(async () => {
|
||||||
return result.stdout.trim();
|
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||||
|
return result.stdout.trim();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureLabel(name: string, color: string): Promise<void> {
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
@@ -122,6 +127,28 @@ export class GitLabProvider implements IssueProvider {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
const pat = `#${issueId}`;
|
||||||
|
// Check open MRs first
|
||||||
|
try {
|
||||||
|
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]);
|
||||||
|
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array<unknown> }>;
|
||||||
|
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
|
||||||
|
if (mr) {
|
||||||
|
const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN;
|
||||||
|
return { state, url: mr.web_url };
|
||||||
|
}
|
||||||
|
} catch { /* continue to merged check */ }
|
||||||
|
// Check merged MRs
|
||||||
|
try {
|
||||||
|
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||||
|
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>;
|
||||||
|
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
|
||||||
|
if (mr) return { state: PrState.MERGED, url: mr.web_url };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { state: PrState.CLOSED, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
async addComment(issueId: number, body: string): Promise<void> {
|
async addComment(issueId: number, body: string): Promise<void> {
|
||||||
// Pass message directly as argv — no shell escaping needed with spawn
|
// Pass message directly as argv — no shell escaping needed with spawn
|
||||||
await this.glab(["issue", "note", String(issueId), "--message", body]);
|
await this.glab(["issue", "note", String(issueId), "--message", body]);
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ export type IssueComment = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Built-in PR states. */
|
||||||
|
export const PrState = {
|
||||||
|
OPEN: "open",
|
||||||
|
APPROVED: "approved",
|
||||||
|
MERGED: "merged",
|
||||||
|
CLOSED: "closed",
|
||||||
|
} as const;
|
||||||
|
export type PrState = (typeof PrState)[keyof typeof PrState];
|
||||||
|
|
||||||
|
export type PrStatus = {
|
||||||
|
state: PrState;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider interface
|
// Provider interface
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -46,6 +60,7 @@ export interface IssueProvider {
|
|||||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||||
hasMergedMR(issueId: number): Promise<boolean>;
|
hasMergedMR(issueId: number): Promise<boolean>;
|
||||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||||
|
getPrStatus(issueId: number): Promise<PrStatus>;
|
||||||
addComment(issueId: number, body: string): Promise<void>;
|
addComment(issueId: number, body: string): Promise<void>;
|
||||||
healthCheck(): Promise<boolean>;
|
healthCheck(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/providers/resilience.ts
Normal file
49
lib/providers/resilience.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* providers/resilience.ts — Retry and circuit breaker policies for provider calls.
|
||||||
|
*
|
||||||
|
* Uses cockatiel for lightweight resilience without heavyweight orchestration.
|
||||||
|
* Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ExponentialBackoff,
|
||||||
|
retry,
|
||||||
|
circuitBreaker,
|
||||||
|
ConsecutiveBreaker,
|
||||||
|
handleAll,
|
||||||
|
wrap,
|
||||||
|
type IPolicy,
|
||||||
|
} from "cockatiel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default retry policy: 3 attempts with exponential backoff.
|
||||||
|
* Handles all errors (network, timeout, CLI failure).
|
||||||
|
*/
|
||||||
|
const retryPolicy = retry(handleAll, {
|
||||||
|
maxAttempts: 3,
|
||||||
|
backoff: new ExponentialBackoff({
|
||||||
|
initialDelay: 500,
|
||||||
|
maxDelay: 5_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit breaker: opens after 5 consecutive failures, half-opens after 30s.
|
||||||
|
* Prevents hammering a provider that's down.
|
||||||
|
*/
|
||||||
|
const breakerPolicy = circuitBreaker(handleAll, {
|
||||||
|
halfOpenAfter: 30_000,
|
||||||
|
breaker: new ConsecutiveBreaker(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined policy: circuit breaker wrapping retry.
|
||||||
|
* If circuit is open, calls fail fast without retrying.
|
||||||
|
*/
|
||||||
|
export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a provider call with retry + circuit breaker.
|
||||||
|
*/
|
||||||
|
export function withResilience<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return providerPolicy.execute(() => fn());
|
||||||
|
}
|
||||||
@@ -185,13 +185,14 @@ describe("emoji", () => {
|
|||||||
|
|
||||||
describe("completion results", () => {
|
describe("completion results", () => {
|
||||||
it("should return valid results per role", () => {
|
it("should return valid results per role", () => {
|
||||||
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]);
|
||||||
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
|
||||||
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate results", () => {
|
it("should validate results", () => {
|
||||||
assert.strictEqual(isValidResult("developer", "done"), true);
|
assert.strictEqual(isValidResult("developer", "done"), true);
|
||||||
|
assert.strictEqual(isValidResult("developer", "review"), true);
|
||||||
assert.strictEqual(isValidResult("developer", "pass"), false);
|
assert.strictEqual(isValidResult("developer", "pass"), false);
|
||||||
assert.strictEqual(isValidResult("tester", "pass"), true);
|
assert.strictEqual(isValidResult("tester", "pass"), true);
|
||||||
assert.strictEqual(isValidResult("tester", "done"), false);
|
assert.strictEqual(isValidResult("tester", "done"), false);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
|||||||
senior: "🧠",
|
senior: "🧠",
|
||||||
},
|
},
|
||||||
fallbackEmoji: "🔧",
|
fallbackEmoji: "🔧",
|
||||||
completionResults: ["done", "blocked"],
|
completionResults: ["done", "review", "blocked"],
|
||||||
sessionKeyPattern: "developer",
|
sessionKeyPattern: "developer",
|
||||||
notifications: { onStart: true, onComplete: true },
|
notifications: { onStart: true, onComplete: true },
|
||||||
},
|
},
|
||||||
|
|||||||
253
lib/services/bootstrap.e2e.test.ts
Normal file
253
lib/services/bootstrap.e2e.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* E2E bootstrap tests — verifies the full bootstrap hook chain:
|
||||||
|
* dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected
|
||||||
|
*
|
||||||
|
* Uses simulateBootstrap() which registers the real hook with a mock API,
|
||||||
|
* fires it with the session key from dispatch, and returns the resulting
|
||||||
|
* bootstrapFiles array — proving instructions actually reach the worker.
|
||||||
|
*
|
||||||
|
* Run: npx tsx --test lib/services/bootstrap.e2e.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||||
|
import { dispatchTask } from "../dispatch.js";
|
||||||
|
|
||||||
|
describe("E2E bootstrap — hook injection", () => {
|
||||||
|
let h: TestHarness;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (h) await h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject project-specific instructions into bootstrapFiles", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "my-app" });
|
||||||
|
h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Write both default and project-specific prompts
|
||||||
|
await h.writePrompt("developer", "# Default Developer\nGeneric instructions.");
|
||||||
|
await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app");
|
||||||
|
|
||||||
|
// Dispatch to get the session key
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 1,
|
||||||
|
issueTitle: "Add feature",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/1",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire the actual bootstrap hook with the dispatch session key
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
// Should have exactly one injected file
|
||||||
|
assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`);
|
||||||
|
assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md");
|
||||||
|
assert.strictEqual(files[0].missing, false);
|
||||||
|
assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`);
|
||||||
|
assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`);
|
||||||
|
|
||||||
|
// Content should be project-specific, NOT default
|
||||||
|
const content = files[0].content!;
|
||||||
|
assert.ok(content.includes("My App Developer"), `Got: ${content}`);
|
||||||
|
assert.ok(content.includes("Use React"));
|
||||||
|
assert.ok(!content.includes("Generic instructions"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to default instructions when no project override exists", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "other-app" });
|
||||||
|
h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Only write default prompt — no project-specific
|
||||||
|
await h.writePrompt("developer", "# Default Developer\nFollow coding standards.");
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 2,
|
||||||
|
issueTitle: "Fix bug",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/2",
|
||||||
|
role: "developer",
|
||||||
|
level: "junior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Default Developer"));
|
||||||
|
assert.ok(files[0].content!.includes("Follow coding standards"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject scaffolded default instructions when no overrides exist", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "bare-app" });
|
||||||
|
h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 3,
|
||||||
|
issueTitle: "Chore",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/3",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
// Default developer instructions are scaffolded by ensureDefaultFiles
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading");
|
||||||
|
assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT inject anything for unknown custom roles", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "custom-app" });
|
||||||
|
|
||||||
|
// Simulate a session key for a custom role that has no prompt file
|
||||||
|
// This key won't parse because "reviewer" isn't in the role registry
|
||||||
|
const files = await h.simulateBootstrap(
|
||||||
|
"agent:main:subagent:custom-app-reviewer-medior",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 0, "Should not inject files for unknown roles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve tester instructions independently from developer", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "multi-role" });
|
||||||
|
h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] });
|
||||||
|
|
||||||
|
// Write project-specific for developer, default for tester
|
||||||
|
await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role");
|
||||||
|
await h.writePrompt("tester", "# Default Tester\nRun integration tests.");
|
||||||
|
|
||||||
|
// Dispatch as tester
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 4,
|
||||||
|
issueTitle: "Test thing",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/4",
|
||||||
|
role: "tester",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Test",
|
||||||
|
toLabel: "Testing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate bootstrap for the tester session
|
||||||
|
const testerFiles = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
assert.strictEqual(testerFiles.length, 1);
|
||||||
|
assert.ok(testerFiles[0].content!.includes("Default Tester"));
|
||||||
|
assert.ok(!testerFiles[0].content!.includes("Dev for multi-role"));
|
||||||
|
|
||||||
|
// Simulate bootstrap for a developer session on the same project
|
||||||
|
const devKey = result.sessionKey.replace("-tester-", "-developer-");
|
||||||
|
const devFiles = await h.simulateBootstrap(devKey);
|
||||||
|
assert.strictEqual(devFiles.length, 1);
|
||||||
|
assert.ok(devFiles[0].content!.includes("Dev for multi-role"));
|
||||||
|
assert.ok(devFiles[0].content!.includes("Specific dev rules"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project names with hyphens correctly", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "my-cool-project" });
|
||||||
|
h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] });
|
||||||
|
|
||||||
|
await h.writePrompt(
|
||||||
|
"developer",
|
||||||
|
"# Hyphenated Project\nThis project has hyphens in the name.",
|
||||||
|
"my-cool-project",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 5,
|
||||||
|
issueTitle: "Hyphen test",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/5",
|
||||||
|
role: "developer",
|
||||||
|
level: "senior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Hyphenated Project"));
|
||||||
|
assert.ok(files[0].path.includes("my-cool-project"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve architect instructions with project override", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "arch-proj" });
|
||||||
|
h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["To Design"] });
|
||||||
|
|
||||||
|
await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines.");
|
||||||
|
await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj");
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 6,
|
||||||
|
issueTitle: "Design API",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/6",
|
||||||
|
role: "architect",
|
||||||
|
level: "senior",
|
||||||
|
fromLabel: "To Design",
|
||||||
|
toLabel: "Designing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Arch Proj Architect"));
|
||||||
|
assert.ok(files[0].content!.includes("event-driven"));
|
||||||
|
assert.ok(!files[0].content!.includes("General design guidelines"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not inject when session key is not a DevClaw subagent", async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
|
||||||
|
// Non-DevClaw session key — hook should no-op
|
||||||
|
const files = await h.simulateBootstrap("agent:main:orchestrator");
|
||||||
|
assert.strictEqual(files.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,13 +83,13 @@ export type SessionLookup = Map<string, GatewaySession>;
|
|||||||
* Returns null if gateway is unavailable (timeout, error, etc).
|
* Returns null if gateway is unavailable (timeout, error, etc).
|
||||||
* Callers should skip session liveness checks if null — unknown ≠ dead.
|
* Callers should skip session liveness checks if null — unknown ≠ dead.
|
||||||
*/
|
*/
|
||||||
export async function fetchGatewaySessions(): Promise<SessionLookup | null> {
|
export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise<SessionLookup | null> {
|
||||||
const lookup: SessionLookup = new Map();
|
const lookup: SessionLookup = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runCommand(
|
const result = await runCommand(
|
||||||
["openclaw", "gateway", "call", "status", "--json"],
|
["openclaw", "gateway", "call", "status", "--json"],
|
||||||
{ timeoutMs: 15_000 },
|
{ timeoutMs: gatewayTimeoutMs },
|
||||||
);
|
);
|
||||||
|
|
||||||
const jsonStart = result.stdout.indexOf("{");
|
const jsonStart = result.stdout.indexOf("{");
|
||||||
@@ -151,10 +151,13 @@ export async function checkWorkerHealth(opts: {
|
|||||||
sessions: SessionLookup | null;
|
sessions: SessionLookup | null;
|
||||||
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||||
workflow?: WorkflowConfig;
|
workflow?: WorkflowConfig;
|
||||||
|
/** Hours after which an active worker is considered stale (default: 2) */
|
||||||
|
staleWorkerHours?: number;
|
||||||
}): Promise<HealthFix[]> {
|
}): Promise<HealthFix[]> {
|
||||||
const {
|
const {
|
||||||
workspaceDir, groupId, project, role, autoFix, provider, sessions,
|
workspaceDir, groupId, project, role, autoFix, provider, sessions,
|
||||||
workflow = DEFAULT_WORKFLOW,
|
workflow = DEFAULT_WORKFLOW,
|
||||||
|
staleWorkerHours = 2,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const fixes: HealthFix[] = [];
|
const fixes: HealthFix[] = [];
|
||||||
@@ -316,7 +319,7 @@ export async function checkWorkerHealth(opts: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
|
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
|
||||||
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
||||||
if (hours > 2) {
|
if (hours > staleWorkerHours) {
|
||||||
const fix: HealthFix = {
|
const fix: HealthFix = {
|
||||||
issue: {
|
issue: {
|
||||||
type: "stale_worker",
|
type: "stale_worker",
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { log as auditLog } from "../audit.js";
|
|||||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
||||||
import { projectTick } from "./tick.js";
|
import { projectTick } from "./tick.js";
|
||||||
|
import { reviewPass } from "./review.js";
|
||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -39,6 +42,7 @@ type TickResult = {
|
|||||||
totalPickups: number;
|
totalPickups: number;
|
||||||
totalHealthFixes: number;
|
totalHealthFixes: number;
|
||||||
totalSkipped: number;
|
totalSkipped: number;
|
||||||
|
totalReviewTransitions: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServiceContext = {
|
type ServiceContext = {
|
||||||
@@ -191,6 +195,7 @@ async function processAllAgents(
|
|||||||
totalPickups: 0,
|
totalPickups: 0,
|
||||||
totalHealthFixes: 0,
|
totalHealthFixes: 0,
|
||||||
totalSkipped: 0,
|
totalSkipped: 0,
|
||||||
|
totalReviewTransitions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch gateway sessions once for all agents/projects
|
// Fetch gateway sessions once for all agents/projects
|
||||||
@@ -209,6 +214,7 @@ async function processAllAgents(
|
|||||||
result.totalPickups += agentResult.totalPickups;
|
result.totalPickups += agentResult.totalPickups;
|
||||||
result.totalHealthFixes += agentResult.totalHealthFixes;
|
result.totalHealthFixes += agentResult.totalHealthFixes;
|
||||||
result.totalSkipped += agentResult.totalSkipped;
|
result.totalSkipped += agentResult.totalSkipped;
|
||||||
|
result.totalReviewTransitions += agentResult.totalReviewTransitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -218,9 +224,9 @@ async function processAllAgents(
|
|||||||
* Log tick results if anything happened.
|
* Log tick results if anything happened.
|
||||||
*/
|
*/
|
||||||
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
|
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
|
||||||
if (result.totalPickups > 0 || result.totalHealthFixes > 0) {
|
if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`,
|
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,60 +249,83 @@ export async function tick(opts: {
|
|||||||
const projectIds = Object.keys(data.projects);
|
const projectIds = Object.keys(data.projects);
|
||||||
|
|
||||||
if (projectIds.length === 0) {
|
if (projectIds.length === 0) {
|
||||||
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 };
|
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: TickResult = {
|
const result: TickResult = {
|
||||||
totalPickups: 0,
|
totalPickups: 0,
|
||||||
totalHealthFixes: 0,
|
totalHealthFixes: 0,
|
||||||
totalSkipped: 0,
|
totalSkipped: 0,
|
||||||
|
totalReviewTransitions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||||
let activeProjects = 0;
|
let activeProjects = 0;
|
||||||
|
|
||||||
for (const groupId of projectIds) {
|
for (const groupId of projectIds) {
|
||||||
const project = data.projects[groupId];
|
try {
|
||||||
if (!project) continue;
|
const project = data.projects[groupId];
|
||||||
|
if (!project) continue;
|
||||||
|
|
||||||
// Health pass: auto-fix zombies and stale workers
|
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
||||||
result.totalHealthFixes += await performHealthPass(
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
workspaceDir,
|
|
||||||
groupId,
|
|
||||||
project,
|
|
||||||
sessions,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Budget check: stop if we've hit the limit
|
// Health pass: auto-fix zombies and stale workers
|
||||||
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
result.totalHealthFixes += await performHealthPass(
|
||||||
if (remaining <= 0) break;
|
workspaceDir,
|
||||||
|
groupId,
|
||||||
|
project,
|
||||||
|
sessions,
|
||||||
|
provider,
|
||||||
|
resolvedConfig.timeouts.staleWorkerHours,
|
||||||
|
);
|
||||||
|
|
||||||
// Sequential project guard: don't start new projects if one is active
|
// Review pass: transition issues whose PR check condition is met
|
||||||
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
result.totalReviewTransitions += await reviewPass({
|
||||||
if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) {
|
workspaceDir,
|
||||||
|
groupId,
|
||||||
|
workflow: resolvedConfig.workflow,
|
||||||
|
provider,
|
||||||
|
repoPath: project.repo,
|
||||||
|
gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Budget check: stop if we've hit the limit
|
||||||
|
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
|
||||||
|
// Sequential project guard: don't start new projects if one is active
|
||||||
|
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
||||||
|
if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) {
|
||||||
|
result.totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick pass: fill free worker slots
|
||||||
|
const tickResult = await projectTick({
|
||||||
|
workspaceDir,
|
||||||
|
groupId,
|
||||||
|
agentId,
|
||||||
|
pluginConfig,
|
||||||
|
maxPickups: remaining,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.totalPickups += tickResult.pickups.length;
|
||||||
|
result.totalSkipped += tickResult.skipped.length;
|
||||||
|
|
||||||
|
// Notifications now handled by dispatchTask
|
||||||
|
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
||||||
|
} catch (err) {
|
||||||
|
// Per-project isolation: one failing project doesn't crash the entire tick
|
||||||
|
opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`);
|
||||||
result.totalSkipped++;
|
result.totalSkipped++;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick pass: fill free worker slots
|
|
||||||
const tickResult = await projectTick({
|
|
||||||
workspaceDir,
|
|
||||||
groupId,
|
|
||||||
agentId,
|
|
||||||
pluginConfig,
|
|
||||||
maxPickups: remaining,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.totalPickups += tickResult.pickups.length;
|
|
||||||
result.totalSkipped += tickResult.skipped.length;
|
|
||||||
|
|
||||||
// Notifications now handled by dispatchTask
|
|
||||||
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||||
projectsScanned: projectIds.length,
|
projectsScanned: projectIds.length,
|
||||||
healthFixes: result.totalHealthFixes,
|
healthFixes: result.totalHealthFixes,
|
||||||
|
reviewTransitions: result.totalReviewTransitions,
|
||||||
pickups: result.totalPickups,
|
pickups: result.totalPickups,
|
||||||
skipped: result.totalSkipped,
|
skipped: result.totalSkipped,
|
||||||
});
|
});
|
||||||
@@ -312,8 +341,9 @@ async function performHealthPass(
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
project: any,
|
project: any,
|
||||||
sessions: SessionLookup | null,
|
sessions: SessionLookup | null,
|
||||||
|
provider: import("../providers/provider.js").IssueProvider,
|
||||||
|
staleWorkerHours?: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
|
||||||
let fixedCount = 0;
|
let fixedCount = 0;
|
||||||
|
|
||||||
for (const role of Object.keys(project.workers)) {
|
for (const role of Object.keys(project.workers)) {
|
||||||
@@ -326,6 +356,7 @@ async function performHealthPass(
|
|||||||
sessions,
|
sessions,
|
||||||
autoFix: true,
|
autoFix: true,
|
||||||
provider,
|
provider,
|
||||||
|
staleWorkerHours,
|
||||||
});
|
});
|
||||||
fixedCount += healthFixes.filter((f) => f.fixed).length;
|
fixedCount += healthFixes.filter((f) => f.fixed).length;
|
||||||
|
|
||||||
|
|||||||
747
lib/services/pipeline.e2e.test.ts
Normal file
747
lib/services/pipeline.e2e.test.ts
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
/**
|
||||||
|
* E2E pipeline tests — exercises the full workflow lifecycle.
|
||||||
|
*
|
||||||
|
* Tests dispatch → completion → review pass using:
|
||||||
|
* - TestProvider (in-memory issues, call tracking)
|
||||||
|
* - Mock runCommand (captures gateway calls, task messages)
|
||||||
|
* - Real projects.json on disk (temp workspace)
|
||||||
|
*
|
||||||
|
* Run: npx tsx --test lib/services/pipeline.e2e.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||||
|
import { dispatchTask } from "../dispatch.js";
|
||||||
|
import { executeCompletion } from "./pipeline.js";
|
||||||
|
import { reviewPass } from "./review.js";
|
||||||
|
import { DEFAULT_WORKFLOW } from "../workflow.js";
|
||||||
|
import { readProjects, getWorker } from "../projects.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("E2E pipeline", () => {
|
||||||
|
let h: TestHarness;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (h) await h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dispatch
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("dispatchTask", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
// Seed a "To Do" issue
|
||||||
|
h.provider.seedIssue({ iid: 42, title: "Add login page", labels: ["To Do"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition label, update worker state, and fire gateway calls", async () => {
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "test-agent",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 42,
|
||||||
|
issueTitle: "Add login page",
|
||||||
|
issueDescription: "Build the login page",
|
||||||
|
issueUrl: "https://example.com/issues/42",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify dispatch result
|
||||||
|
assert.strictEqual(result.sessionAction, "spawn");
|
||||||
|
assert.ok(result.sessionKey.includes("test-project-developer-medior"));
|
||||||
|
assert.ok(result.announcement.includes("#42"));
|
||||||
|
assert.ok(result.announcement.includes("Add login page"));
|
||||||
|
|
||||||
|
// Verify label transitioned on the issue
|
||||||
|
const issue = await h.provider.getIssue(42);
|
||||||
|
assert.ok(issue.labels.includes("Doing"), `Expected "Doing" label, got: ${issue.labels}`);
|
||||||
|
assert.ok(!issue.labels.includes("To Do"), "Should not have 'To Do' label");
|
||||||
|
|
||||||
|
// Verify worker state updated in projects.json
|
||||||
|
const data = await readProjects(h.workspaceDir);
|
||||||
|
const worker = getWorker(data.projects[h.groupId], "developer");
|
||||||
|
assert.strictEqual(worker.active, true);
|
||||||
|
assert.strictEqual(worker.issueId, "42");
|
||||||
|
assert.strictEqual(worker.level, "medior");
|
||||||
|
|
||||||
|
// Verify gateway commands were fired
|
||||||
|
assert.ok(h.commands.sessionPatches().length > 0, "Should have patched session");
|
||||||
|
assert.ok(h.commands.taskMessages().length > 0, "Should have sent task message");
|
||||||
|
|
||||||
|
// Verify task message contains issue context
|
||||||
|
const taskMsg = h.commands.taskMessages()[0];
|
||||||
|
assert.ok(taskMsg.includes("Add login page"), "Task message should include title");
|
||||||
|
assert.ok(taskMsg.includes(h.groupId), "Task message should include groupId");
|
||||||
|
assert.ok(taskMsg.includes("work_finish"), "Task message should reference work_finish");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include comments in task message", async () => {
|
||||||
|
h.provider.comments.set(42, [
|
||||||
|
{ author: "alice", body: "Please use OAuth", created_at: "2026-01-01T00:00:00Z" },
|
||||||
|
{ author: "bob", body: "Agreed, OAuth2 flow", created_at: "2026-01-02T00:00:00Z" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 42,
|
||||||
|
issueTitle: "Add login page",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/42",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskMsg = h.commands.taskMessages()[0];
|
||||||
|
assert.ok(taskMsg.includes("alice"), "Should include comment author");
|
||||||
|
assert.ok(taskMsg.includes("Please use OAuth"), "Should include comment body");
|
||||||
|
assert.ok(taskMsg.includes("bob"), "Should include second comment author");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reuse existing session when available", async () => {
|
||||||
|
// Set up worker with existing session
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
developer: {
|
||||||
|
sessions: { medior: "agent:test-agent:subagent:test-project-developer-medior" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 42, title: "Quick fix", labels: ["To Do"] });
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "test-agent",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 42,
|
||||||
|
issueTitle: "Quick fix",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/42",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.sessionAction, "send");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Completion — developer:done
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("executeCompletion — developer:done", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "10", level: "medior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => {
|
||||||
|
h.provider.mergedMrUrls.set(10, "https://example.com/mr/5");
|
||||||
|
|
||||||
|
const output = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "done",
|
||||||
|
issueId: 10,
|
||||||
|
summary: "Built feature X",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label transition
|
||||||
|
assert.strictEqual(output.labelTransition, "Doing → To Test");
|
||||||
|
assert.ok(output.announcement.includes("#10"));
|
||||||
|
|
||||||
|
// Issue state
|
||||||
|
const issue = await h.provider.getIssue(10);
|
||||||
|
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
|
||||||
|
assert.ok(!issue.labels.includes("Doing"));
|
||||||
|
|
||||||
|
// Worker deactivated
|
||||||
|
const data = await readProjects(h.workspaceDir);
|
||||||
|
const worker = getWorker(data.projects[h.groupId], "developer");
|
||||||
|
assert.strictEqual(worker.active, false);
|
||||||
|
|
||||||
|
// PR URL detected
|
||||||
|
assert.strictEqual(output.prUrl, "https://example.com/mr/5");
|
||||||
|
|
||||||
|
// gitPull action was executed
|
||||||
|
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
|
||||||
|
assert.ok(gitCmds.length > 0, "Should have run git pull");
|
||||||
|
assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]);
|
||||||
|
|
||||||
|
// Issue NOT closed (done goes to To Test, not Done)
|
||||||
|
assert.strictEqual(output.issueClosed, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Completion — developer:review
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("executeCompletion — developer:review", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "20", level: "senior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition Doing → In Review, deactivate worker", async () => {
|
||||||
|
const output = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "review",
|
||||||
|
issueId: 20,
|
||||||
|
summary: "PR open for review",
|
||||||
|
prUrl: "https://example.com/pr/3",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(output.labelTransition, "Doing → In Review");
|
||||||
|
assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`);
|
||||||
|
|
||||||
|
const issue = await h.provider.getIssue(20);
|
||||||
|
assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`);
|
||||||
|
|
||||||
|
// Worker should be deactivated
|
||||||
|
const data = await readProjects(h.workspaceDir);
|
||||||
|
assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false);
|
||||||
|
|
||||||
|
// Issue should NOT be closed
|
||||||
|
assert.strictEqual(output.issueClosed, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Completion — tester:pass
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("executeCompletion — tester:pass", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
tester: { active: true, issueId: "30", level: "medior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 30, title: "Verify login", labels: ["Testing"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition Testing → Done, close issue", async () => {
|
||||||
|
const output = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "pass",
|
||||||
|
issueId: 30,
|
||||||
|
summary: "All tests pass",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(output.labelTransition, "Testing → Done");
|
||||||
|
assert.strictEqual(output.issueClosed, true);
|
||||||
|
|
||||||
|
const issue = await h.provider.getIssue(30);
|
||||||
|
assert.ok(issue.labels.includes("Done"));
|
||||||
|
assert.strictEqual(issue.state, "closed");
|
||||||
|
|
||||||
|
// Verify closeIssue was called
|
||||||
|
const closeCalls = h.provider.callsTo("closeIssue");
|
||||||
|
assert.strictEqual(closeCalls.length, 1);
|
||||||
|
assert.strictEqual(closeCalls[0].args.issueId, 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Completion — tester:fail
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("executeCompletion — tester:fail", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
tester: { active: true, issueId: "40", level: "medior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 40, title: "Check signup", labels: ["Testing"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition Testing → To Improve, reopen issue", async () => {
|
||||||
|
const output = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "fail",
|
||||||
|
issueId: 40,
|
||||||
|
summary: "Signup form validation broken",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(output.labelTransition, "Testing → To Improve");
|
||||||
|
assert.strictEqual(output.issueReopened, true);
|
||||||
|
|
||||||
|
const issue = await h.provider.getIssue(40);
|
||||||
|
assert.ok(issue.labels.includes("To Improve"));
|
||||||
|
assert.strictEqual(issue.state, "opened");
|
||||||
|
|
||||||
|
const reopenCalls = h.provider.callsTo("reopenIssue");
|
||||||
|
assert.strictEqual(reopenCalls.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Completion — developer:blocked
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("executeCompletion — developer:blocked", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "50", level: "junior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 50, title: "Fix CSS", labels: ["Doing"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition Doing → Refining, no close/reopen", async () => {
|
||||||
|
const output = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "blocked",
|
||||||
|
issueId: 50,
|
||||||
|
summary: "Need design decision",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(output.labelTransition, "Doing → Refining");
|
||||||
|
assert.strictEqual(output.issueClosed, false);
|
||||||
|
assert.strictEqual(output.issueReopened, false);
|
||||||
|
|
||||||
|
const issue = await h.provider.getIssue(50);
|
||||||
|
assert.ok(issue.labels.includes("Refining"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Review pass
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("reviewPass", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition In Review → To Test when PR is merged", async () => {
|
||||||
|
// Seed issue in "In Review" state
|
||||||
|
h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] });
|
||||||
|
h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" });
|
||||||
|
|
||||||
|
const transitions = await reviewPass({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
workflow: DEFAULT_WORKFLOW,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(transitions, 1);
|
||||||
|
|
||||||
|
// Issue should now have "To Test" label
|
||||||
|
const issue = await h.provider.getIssue(60);
|
||||||
|
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
|
||||||
|
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
|
||||||
|
|
||||||
|
// gitPull action should have been attempted
|
||||||
|
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
|
||||||
|
assert.ok(gitCmds.length > 0, "Should have run git pull");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT transition when PR is still open", async () => {
|
||||||
|
h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] });
|
||||||
|
h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" });
|
||||||
|
|
||||||
|
const transitions = await reviewPass({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
workflow: DEFAULT_WORKFLOW,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(transitions, 0);
|
||||||
|
|
||||||
|
// Issue should still have "In Review"
|
||||||
|
const issue = await h.provider.getIssue(61);
|
||||||
|
assert.ok(issue.labels.includes("In Review"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple review issues in one pass", async () => {
|
||||||
|
h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] });
|
||||||
|
h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] });
|
||||||
|
h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" });
|
||||||
|
h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" });
|
||||||
|
|
||||||
|
const transitions = await reviewPass({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
workflow: DEFAULT_WORKFLOW,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(transitions, 2);
|
||||||
|
|
||||||
|
const issue70 = await h.provider.getIssue(70);
|
||||||
|
const issue71 = await h.provider.getIssue(71);
|
||||||
|
assert.ok(issue70.labels.includes("To Test"));
|
||||||
|
assert.ok(issue71.labels.includes("To Test"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Full lifecycle: dispatch → complete → review → test → done
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("full lifecycle", () => {
|
||||||
|
it("developer:done → tester:pass (direct path)", async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
|
||||||
|
// 1. Seed issue in To Do
|
||||||
|
h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// 2. Dispatch developer
|
||||||
|
await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 100,
|
||||||
|
issueTitle: "Build dashboard",
|
||||||
|
issueDescription: "Create the main dashboard view",
|
||||||
|
issueUrl: "https://example.com/issues/100",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
let issue = await h.provider.getIssue(100);
|
||||||
|
assert.ok(issue.labels.includes("Doing"));
|
||||||
|
|
||||||
|
// 3. Developer completes → To Test
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "done",
|
||||||
|
issueId: 100,
|
||||||
|
summary: "Dashboard built",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
issue = await h.provider.getIssue(100);
|
||||||
|
assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`);
|
||||||
|
|
||||||
|
// 4. Simulate tester dispatch (activate worker manually for completion)
|
||||||
|
const { activateWorker } = await import("../projects.js");
|
||||||
|
await activateWorker(h.workspaceDir, h.groupId, "tester", {
|
||||||
|
issueId: "100", level: "medior",
|
||||||
|
});
|
||||||
|
await h.provider.transitionLabel(100, "To Test", "Testing");
|
||||||
|
|
||||||
|
// 5. Tester passes → Done
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "pass",
|
||||||
|
issueId: 100,
|
||||||
|
summary: "All checks passed",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
issue = await h.provider.getIssue(100);
|
||||||
|
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
|
||||||
|
assert.strictEqual(issue.state, "closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("developer:review → review pass → tester:pass (review path)", async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
|
||||||
|
// 1. Seed issue in To Do
|
||||||
|
h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// 2. Dispatch developer
|
||||||
|
await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 200,
|
||||||
|
issueTitle: "Auth refactor",
|
||||||
|
issueDescription: "Refactor authentication system",
|
||||||
|
issueUrl: "https://example.com/issues/200",
|
||||||
|
role: "developer",
|
||||||
|
level: "senior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Developer finishes with "review" → In Review
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "review",
|
||||||
|
issueId: 200,
|
||||||
|
summary: "PR ready for review",
|
||||||
|
prUrl: "https://example.com/pr/50",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
let issue = await h.provider.getIssue(200);
|
||||||
|
assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`);
|
||||||
|
|
||||||
|
// 4. PR gets merged — review pass picks it up
|
||||||
|
h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" });
|
||||||
|
|
||||||
|
const transitions = await reviewPass({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
workflow: DEFAULT_WORKFLOW,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(transitions, 1);
|
||||||
|
issue = await h.provider.getIssue(200);
|
||||||
|
assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`);
|
||||||
|
|
||||||
|
// 5. Tester passes → Done
|
||||||
|
const { activateWorker } = await import("../projects.js");
|
||||||
|
await activateWorker(h.workspaceDir, h.groupId, "tester", {
|
||||||
|
issueId: "200", level: "medior",
|
||||||
|
});
|
||||||
|
await h.provider.transitionLabel(200, "To Test", "Testing");
|
||||||
|
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "pass",
|
||||||
|
issueId: 200,
|
||||||
|
summary: "Auth refactor verified",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
issue = await h.provider.getIssue(200);
|
||||||
|
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
|
||||||
|
assert.strictEqual(issue.state, "closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
|
||||||
|
h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// 1. Dispatch developer
|
||||||
|
await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 300,
|
||||||
|
issueTitle: "Payment flow",
|
||||||
|
issueDescription: "Implement payment",
|
||||||
|
issueUrl: "https://example.com/issues/300",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Developer done → To Test
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "done",
|
||||||
|
issueId: 300,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Activate tester + transition
|
||||||
|
const { activateWorker } = await import("../projects.js");
|
||||||
|
await activateWorker(h.workspaceDir, h.groupId, "tester", {
|
||||||
|
issueId: "300", level: "medior",
|
||||||
|
});
|
||||||
|
await h.provider.transitionLabel(300, "To Test", "Testing");
|
||||||
|
|
||||||
|
// 4. Tester FAILS → To Improve
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "fail",
|
||||||
|
issueId: 300,
|
||||||
|
summary: "Validation broken",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
let issue = await h.provider.getIssue(300);
|
||||||
|
assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`);
|
||||||
|
assert.strictEqual(issue.state, "opened"); // reopened
|
||||||
|
|
||||||
|
// 5. Developer picks up again (To Improve → Doing)
|
||||||
|
await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: (await readProjects(h.workspaceDir)).projects[h.groupId],
|
||||||
|
issueId: 300,
|
||||||
|
issueTitle: "Payment flow",
|
||||||
|
issueDescription: "Implement payment",
|
||||||
|
issueUrl: "https://example.com/issues/300",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Improve",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Developer fixes it → To Test
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "developer",
|
||||||
|
result: "done",
|
||||||
|
issueId: 300,
|
||||||
|
summary: "Fixed validation",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
issue = await h.provider.getIssue(300);
|
||||||
|
assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`);
|
||||||
|
|
||||||
|
// 7. Tester passes → Done
|
||||||
|
await activateWorker(h.workspaceDir, h.groupId, "tester", {
|
||||||
|
issueId: "300", level: "medior",
|
||||||
|
});
|
||||||
|
await h.provider.transitionLabel(300, "To Test", "Testing");
|
||||||
|
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "pass",
|
||||||
|
issueId: 300,
|
||||||
|
summary: "All good now",
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
issue = await h.provider.getIssue(300);
|
||||||
|
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
|
||||||
|
assert.strictEqual(issue.state, "closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider call tracking
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe("provider call tracking", () => {
|
||||||
|
it("should track all provider interactions during completion", async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
tester: { active: true, issueId: "90", level: "medior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue({ iid: 90, title: "Test tracking", labels: ["Testing"] });
|
||||||
|
h.provider.resetCalls();
|
||||||
|
|
||||||
|
await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
role: "tester",
|
||||||
|
result: "pass",
|
||||||
|
issueId: 90,
|
||||||
|
provider: h.provider,
|
||||||
|
repoPath: "/tmp/test-repo",
|
||||||
|
projectName: "test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have: getIssue (for URL), transitionLabel, closeIssue
|
||||||
|
assert.ok(h.provider.callsTo("getIssue").length >= 1, "Should call getIssue");
|
||||||
|
assert.strictEqual(h.provider.callsTo("transitionLabel").length, 1);
|
||||||
|
assert.strictEqual(h.provider.callsTo("closeIssue").length, 1);
|
||||||
|
|
||||||
|
// Verify transition args
|
||||||
|
const transition = h.provider.callsTo("transitionLabel")[0];
|
||||||
|
assert.strictEqual(transition.args.issueId, 90);
|
||||||
|
assert.strictEqual(transition.args.from, "Testing");
|
||||||
|
assert.strictEqual(transition.args.to, "Done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,8 +8,11 @@ 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 { log as auditLog } from "../audit.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
|
Action,
|
||||||
getCompletionRule,
|
getCompletionRule,
|
||||||
getNextStateDescription,
|
getNextStateDescription,
|
||||||
getCompletionEmoji,
|
getCompletionEmoji,
|
||||||
@@ -72,18 +75,23 @@ export async function executeCompletion(opts: {
|
|||||||
const rule = getCompletionRule(workflow, role, result);
|
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}`);
|
||||||
|
|
||||||
|
const { timeouts } = await loadConfig(workspaceDir, projectName);
|
||||||
let prUrl = opts.prUrl;
|
let prUrl = opts.prUrl;
|
||||||
|
|
||||||
// Git pull (dev:done)
|
// Execute pre-notification actions
|
||||||
if (rule.gitPull) {
|
for (const action of rule.actions) {
|
||||||
try {
|
switch (action) {
|
||||||
await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
|
case Action.GIT_PULL:
|
||||||
} catch { /* best-effort */ }
|
try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) {
|
||||||
}
|
auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
}
|
||||||
// Auto-detect PR URL (dev:done)
|
break;
|
||||||
if (rule.detectPr && !prUrl) {
|
case Action.DETECT_PR:
|
||||||
try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ }
|
if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get issue early (for URL in notification)
|
// Get issue early (for URL in notification)
|
||||||
@@ -113,15 +121,25 @@ export async function executeCompletion(opts: {
|
|||||||
channel: channel ?? "telegram",
|
channel: channel ?? "telegram",
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
).catch(() => { /* non-fatal */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// Deactivate worker + transition label
|
// Deactivate worker + transition label
|
||||||
await deactivateWorker(workspaceDir, groupId, role);
|
await deactivateWorker(workspaceDir, groupId, role);
|
||||||
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
|
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
|
||||||
|
|
||||||
// Close/reopen
|
// Execute post-transition actions
|
||||||
if (rule.closeIssue) await provider.closeIssue(issueId);
|
for (const action of rule.actions) {
|
||||||
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
switch (action) {
|
||||||
|
case Action.CLOSE_ISSUE:
|
||||||
|
await provider.closeIssue(issueId);
|
||||||
|
break;
|
||||||
|
case Action.REOPEN_ISSUE:
|
||||||
|
await provider.reopenIssue(issueId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build announcement using workflow-derived emoji
|
// Build announcement using workflow-derived emoji
|
||||||
const emoji = getCompletionEmoji(role, result);
|
const emoji = getCompletionEmoji(role, result);
|
||||||
@@ -138,7 +156,7 @@ export async function executeCompletion(opts: {
|
|||||||
nextState,
|
nextState,
|
||||||
prUrl,
|
prUrl,
|
||||||
issueUrl: issue.web_url,
|
issueUrl: issue.web_url,
|
||||||
issueClosed: rule.closeIssue,
|
issueClosed: rule.actions.includes(Action.CLOSE_ISSUE),
|
||||||
issueReopened: rule.reopenIssue,
|
issueReopened: rule.actions.includes(Action.REOPEN_ISSUE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
88
lib/services/queue-scan.ts
Normal file
88
lib/services/queue-scan.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* queue-scan.ts — Issue queue scanning helpers.
|
||||||
|
*
|
||||||
|
* Shared by: tick (projectTick), work-start (auto-pickup), and other consumers
|
||||||
|
* that need to find queued issues or detect roles/levels from labels.
|
||||||
|
*/
|
||||||
|
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||||
|
import type { IssueProvider } from "../providers/provider.js";
|
||||||
|
import { getLevelsForRole, getAllLevels } from "../roles/index.js";
|
||||||
|
import {
|
||||||
|
getQueueLabels,
|
||||||
|
getAllQueueLabels,
|
||||||
|
detectRoleFromLabel as workflowDetectRole,
|
||||||
|
type WorkflowConfig,
|
||||||
|
type Role,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||||
|
const lower = labels.map((l) => l.toLowerCase());
|
||||||
|
|
||||||
|
// Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior")
|
||||||
|
for (const l of lower) {
|
||||||
|
const dot = l.indexOf(".");
|
||||||
|
if (dot === -1) continue;
|
||||||
|
const role = l.slice(0, dot);
|
||||||
|
const level = l.slice(dot + 1);
|
||||||
|
const roleLevels = getLevelsForRole(role);
|
||||||
|
if (roleLevels.includes(level)) return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: plain level name
|
||||||
|
const all = getAllLevels();
|
||||||
|
return all.find((l) => lower.includes(l)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect role from a label using workflow config.
|
||||||
|
*/
|
||||||
|
export function detectRoleFromLabel(
|
||||||
|
label: StateLabel,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Role | null {
|
||||||
|
return workflowDetectRole(workflow, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue queue queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function findNextIssueForRole(
|
||||||
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
|
role: Role,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
|
const labels = getQueueLabels(workflow, role);
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
|
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
||||||
|
*/
|
||||||
|
export async function findNextIssue(
|
||||||
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
|
role: Role | undefined,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
|
const labels = role
|
||||||
|
? getQueueLabels(workflow, role)
|
||||||
|
: getAllQueueLabels(workflow);
|
||||||
|
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
|
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import type { Project } from "../projects.js";
|
import type { Project } from "../projects.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
|
StateType,
|
||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
type Role,
|
type Role,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
@@ -27,7 +28,7 @@ export function getQueueLabelsWithPriority(
|
|||||||
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
|
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
|
||||||
|
|
||||||
for (const state of Object.values(workflow.states)) {
|
for (const state of Object.values(workflow.states)) {
|
||||||
if (state.type === "queue") {
|
if (state.type === StateType.QUEUE) {
|
||||||
labels.push({
|
labels.push({
|
||||||
label: state.label,
|
label: state.label,
|
||||||
priority: state.priority ?? 0,
|
priority: state.priority ?? 0,
|
||||||
|
|||||||
98
lib/services/review.ts
Normal file
98
lib/services/review.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* review.ts — Poll review-type states for PR status changes.
|
||||||
|
*
|
||||||
|
* Scans review states in the workflow and transitions issues
|
||||||
|
* whose PR check condition (merged/approved) is met.
|
||||||
|
* Called by the heartbeat service during its periodic sweep.
|
||||||
|
*/
|
||||||
|
import type { IssueProvider } from "../providers/provider.js";
|
||||||
|
import { PrState } from "../providers/provider.js";
|
||||||
|
import {
|
||||||
|
Action,
|
||||||
|
ReviewCheck,
|
||||||
|
WorkflowEvent,
|
||||||
|
StateType,
|
||||||
|
type WorkflowConfig,
|
||||||
|
type StateConfig,
|
||||||
|
} from "../workflow.js";
|
||||||
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { log as auditLog } from "../audit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan review-type states and transition issues whose PR check condition is met.
|
||||||
|
* Returns the number of transitions made.
|
||||||
|
*/
|
||||||
|
export async function reviewPass(opts: {
|
||||||
|
workspaceDir: string;
|
||||||
|
groupId: string;
|
||||||
|
workflow: WorkflowConfig;
|
||||||
|
provider: IssueProvider;
|
||||||
|
repoPath: string;
|
||||||
|
gitPullTimeoutMs?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
|
||||||
|
let transitions = 0;
|
||||||
|
|
||||||
|
// Find all review-type states
|
||||||
|
const reviewStates = Object.entries(workflow.states)
|
||||||
|
.filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][];
|
||||||
|
|
||||||
|
for (const [stateKey, state] of reviewStates) {
|
||||||
|
if (!state.on || !state.check) continue;
|
||||||
|
|
||||||
|
const issues = await provider.listIssuesByLabel(state.label);
|
||||||
|
for (const issue of issues) {
|
||||||
|
const status = await provider.getPrStatus(issue.iid);
|
||||||
|
|
||||||
|
const conditionMet =
|
||||||
|
(state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) ||
|
||||||
|
(state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED));
|
||||||
|
|
||||||
|
if (!conditionMet) continue;
|
||||||
|
|
||||||
|
// Find the success transition (first non-BLOCKED event)
|
||||||
|
const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED);
|
||||||
|
if (!successEvent) continue;
|
||||||
|
|
||||||
|
const transition = state.on[successEvent];
|
||||||
|
const targetKey = typeof transition === "string" ? transition : transition.target;
|
||||||
|
const actions = typeof transition === "object" ? transition.actions : undefined;
|
||||||
|
const targetState = workflow.states[targetKey];
|
||||||
|
if (!targetState) continue;
|
||||||
|
|
||||||
|
// Execute transition actions
|
||||||
|
if (actions) {
|
||||||
|
for (const action of actions) {
|
||||||
|
switch (action) {
|
||||||
|
case Action.GIT_PULL:
|
||||||
|
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
|
||||||
|
break;
|
||||||
|
case Action.CLOSE_ISSUE:
|
||||||
|
await provider.closeIssue(issue.iid);
|
||||||
|
break;
|
||||||
|
case Action.REOPEN_ISSUE:
|
||||||
|
await provider.reopenIssue(issue.iid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition label
|
||||||
|
await provider.transitionLabel(issue.iid, state.label, targetState.label);
|
||||||
|
|
||||||
|
await auditLog(workspaceDir, "review_transition", {
|
||||||
|
groupId,
|
||||||
|
issueId: issue.iid,
|
||||||
|
from: state.label,
|
||||||
|
to: targetState.label,
|
||||||
|
check: state.check,
|
||||||
|
prState: status.state,
|
||||||
|
prUrl: status.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transitions;
|
||||||
|
}
|
||||||
@@ -11,84 +11,15 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
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 { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
|
import { roleForLevel } from "../roles/index.js";
|
||||||
import { loadConfig } from "../config/index.js";
|
import { loadConfig } from "../config/index.js";
|
||||||
import {
|
import {
|
||||||
getQueueLabels,
|
ExecutionMode,
|
||||||
getAllQueueLabels,
|
|
||||||
getActiveLabel,
|
getActiveLabel,
|
||||||
detectRoleFromLabel as workflowDetectRole,
|
|
||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
type Role,
|
type Role,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
|
import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js";
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared helpers (used by tick, work-start, auto-pickup)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
|
||||||
const lower = labels.map((l) => l.toLowerCase());
|
|
||||||
|
|
||||||
// Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior")
|
|
||||||
for (const l of lower) {
|
|
||||||
const dot = l.indexOf(".");
|
|
||||||
if (dot === -1) continue;
|
|
||||||
const role = l.slice(0, dot);
|
|
||||||
const level = l.slice(dot + 1);
|
|
||||||
const roleLevels = getLevelsForRole(role);
|
|
||||||
if (roleLevels.includes(level)) return level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: plain level name
|
|
||||||
const all = getAllLevels();
|
|
||||||
return all.find((l) => lower.includes(l)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect role from a label using workflow config.
|
|
||||||
*/
|
|
||||||
export function detectRoleFromLabel(
|
|
||||||
label: StateLabel,
|
|
||||||
workflow: WorkflowConfig,
|
|
||||||
): Role | null {
|
|
||||||
return workflowDetectRole(workflow, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findNextIssueForRole(
|
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
|
||||||
role: Role,
|
|
||||||
workflow: WorkflowConfig,
|
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
|
||||||
const labels = getQueueLabels(workflow, role);
|
|
||||||
for (const label of labels) {
|
|
||||||
try {
|
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
|
||||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
|
||||||
} catch { /* continue */ }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
|
||||||
*/
|
|
||||||
export async function findNextIssue(
|
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
|
||||||
role: Role | undefined,
|
|
||||||
workflow: WorkflowConfig,
|
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
|
||||||
const labels = role
|
|
||||||
? getQueueLabels(workflow, role)
|
|
||||||
: getAllQueueLabels(workflow);
|
|
||||||
|
|
||||||
for (const label of labels) {
|
|
||||||
try {
|
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
|
||||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
|
||||||
} catch { /* continue */ }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// projectTick
|
// projectTick
|
||||||
@@ -146,7 +77,7 @@ export async function projectTick(opts: {
|
|||||||
const workflow = opts.workflow ?? resolvedConfig.workflow;
|
const workflow = opts.workflow ?? resolvedConfig.workflow;
|
||||||
|
|
||||||
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
|
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
|
||||||
const roleExecution = project.roleExecution ?? "parallel";
|
const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL;
|
||||||
const enabledRoles = Object.entries(resolvedConfig.roles)
|
const enabledRoles = Object.entries(resolvedConfig.roles)
|
||||||
.filter(([, r]) => r.enabled)
|
.filter(([, r]) => r.enabled)
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
@@ -173,7 +104,7 @@ export async function projectTick(opts: {
|
|||||||
}
|
}
|
||||||
// Check sequential role execution: any other role must be inactive
|
// Check sequential role execution: any other role must be inactive
|
||||||
const otherRoles = enabledRoles.filter((r: string) => r !== role);
|
const otherRoles = enabledRoles.filter((r: string) => r !== role);
|
||||||
if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
|
if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
|
||||||
skipped.push({ role, reason: "Sequential: other role active" });
|
skipped.push({ role, reason: "Sequential: other role active" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||||
|
import type { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write DevClaw plugin config to openclaw.json plugins section.
|
* Write DevClaw plugin config to openclaw.json plugins section.
|
||||||
@@ -21,7 +22,7 @@ import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
|||||||
export async function writePluginConfig(
|
export async function writePluginConfig(
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
projectExecution?: "parallel" | "sequential",
|
projectExecution?: ExecutionMode,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js";
|
|||||||
import { writePluginConfig } from "./config.js";
|
import { writePluginConfig } from "./config.js";
|
||||||
import { scaffoldWorkspace } from "./workspace.js";
|
import { scaffoldWorkspace } from "./workspace.js";
|
||||||
import { DATA_DIR } from "./migrate-layout.js";
|
import { DATA_DIR } from "./migrate-layout.js";
|
||||||
|
import type { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export type ModelConfig = Record<string, Record<string, string>>;
|
export type ModelConfig = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export type SetupOpts = {
|
|||||||
/** Model overrides per role.level. Missing levels use defaults. */
|
/** Model overrides per role.level. Missing levels use defaults. */
|
||||||
models?: Record<string, Partial<Record<string, string>>>;
|
models?: Record<string, Partial<Record<string, string>>>;
|
||||||
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
||||||
projectExecution?: "parallel" | "sequential";
|
projectExecution?: ExecutionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetupResult = {
|
export type SetupResult = {
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ Read the comments carefully — they often contain clarifications, decisions, or
|
|||||||
|
|
||||||
- Work in a git worktree (never switch branches in the main repo)
|
- Work in a git worktree (never switch branches in the main repo)
|
||||||
- Run tests before completing
|
- Run tests before completing
|
||||||
- Create an MR/PR to the base branch and merge it
|
- Create an MR/PR to the base branch
|
||||||
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
||||||
- Clean up the worktree after merging
|
- **Merge or request review:**
|
||||||
- When done, call work_finish with role "developer", result "done", and a brief summary
|
- Merge the PR yourself → call work_finish with result "done"
|
||||||
|
- Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged)
|
||||||
|
- Clean up the worktree after merging (if you merged)
|
||||||
- If you discover unrelated bugs, call task_create to file them
|
- If you discover unrelated bugs, call task_create to file them
|
||||||
- Do NOT call work_start, status, health, or project_register
|
- Do NOT call work_start, status, health, or project_register
|
||||||
`;
|
`;
|
||||||
@@ -144,7 +146,8 @@ Skip the orchestrator section. Follow your task message and role instructions (a
|
|||||||
|
|
||||||
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
||||||
|
|
||||||
- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||||
|
- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||||
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||||
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||||
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||||
@@ -224,10 +227,12 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
|
|||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
Planning → To Do → Doing → To Test → Testing → Done
|
Planning → To Do → Doing → To Test → Testing → Done
|
||||||
↓
|
↓ ↑
|
||||||
To Improve → Doing (fix cycle)
|
In Review ─────┘ (auto-advances when PR merged)
|
||||||
↓
|
↓
|
||||||
Refining (human decision)
|
To Improve → Doing (fix cycle)
|
||||||
|
↓
|
||||||
|
Refining (human decision)
|
||||||
|
|
||||||
To Design → Designing → Planning (design complete)
|
To Design → Designing → Planning (design complete)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -257,6 +262,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr
|
|||||||
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
|
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
|
||||||
|
|
||||||
- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester
|
- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester
|
||||||
|
- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-advances to "To Test" when merged
|
||||||
- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer
|
- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer
|
||||||
- Tester "pass" → Done, no further dispatch
|
- Tester "pass" → Done, no further dispatch
|
||||||
- Tester "refine" / blocked → needs human input
|
- Tester "refine" / blocked → needs human input
|
||||||
@@ -270,7 +276,7 @@ Workers receive role-specific instructions appended to their task message. These
|
|||||||
|
|
||||||
### Heartbeats
|
### Heartbeats
|
||||||
|
|
||||||
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
||||||
|
|
||||||
### Safety
|
### Safety
|
||||||
|
|
||||||
@@ -333,6 +339,9 @@ workflow:
|
|||||||
COMPLETE:
|
COMPLETE:
|
||||||
target: toTest
|
target: toTest
|
||||||
actions: [gitPull, detectPr]
|
actions: [gitPull, detectPr]
|
||||||
|
REVIEW:
|
||||||
|
target: reviewing
|
||||||
|
actions: [detectPr]
|
||||||
BLOCKED: refining
|
BLOCKED: refining
|
||||||
toTest:
|
toTest:
|
||||||
type: queue
|
type: queue
|
||||||
@@ -370,6 +379,16 @@ workflow:
|
|||||||
color: "#f39c12"
|
color: "#f39c12"
|
||||||
on:
|
on:
|
||||||
APPROVE: todo
|
APPROVE: todo
|
||||||
|
reviewing:
|
||||||
|
type: review
|
||||||
|
label: In Review
|
||||||
|
color: "#c5def5"
|
||||||
|
check: prMerged
|
||||||
|
on:
|
||||||
|
APPROVED:
|
||||||
|
target: toTest
|
||||||
|
actions: [gitPull]
|
||||||
|
BLOCKED: refining
|
||||||
done:
|
done:
|
||||||
type: terminal
|
type: terminal
|
||||||
label: Done
|
label: Done
|
||||||
|
|||||||
292
lib/testing/harness.ts
Normal file
292
lib/testing/harness.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Test harness — scaffolds a temporary workspace with projects.json,
|
||||||
|
* installs a mock runCommand, and provides helpers for E2E pipeline tests.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const h = await createTestHarness({ ... });
|
||||||
|
* try { ... } finally { await h.cleanup(); }
|
||||||
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { initRunCommand } from "../run-command.js";
|
||||||
|
import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js";
|
||||||
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
|
import { registerBootstrapHook } from "../bootstrap-hook.js";
|
||||||
|
import { TestProvider } from "./test-provider.js";
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap file type (mirrors OpenClaw's internal type)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type BootstrapFile = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
content?: string;
|
||||||
|
missing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command interceptor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CapturedCommand = {
|
||||||
|
argv: string[];
|
||||||
|
opts: { timeoutMs: number; cwd?: string };
|
||||||
|
/** Extracted from gateway `agent` call params, if applicable. */
|
||||||
|
taskMessage?: string;
|
||||||
|
/** Extracted from gateway `sessions.patch` params, if applicable. */
|
||||||
|
sessionPatch?: { key: string; model: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandInterceptor = {
|
||||||
|
/** All captured commands, in order. */
|
||||||
|
commands: CapturedCommand[];
|
||||||
|
/** Filter commands by first argv element. */
|
||||||
|
commandsFor(cmd: string): CapturedCommand[];
|
||||||
|
/** Get all task messages sent via `openclaw gateway call agent`. */
|
||||||
|
taskMessages(): string[];
|
||||||
|
/** Get all session patches. */
|
||||||
|
sessionPatches(): Array<{ key: string; model: string }>;
|
||||||
|
/** Reset captured commands. */
|
||||||
|
reset(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCommandInterceptor(): {
|
||||||
|
interceptor: CommandInterceptor;
|
||||||
|
handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>;
|
||||||
|
} {
|
||||||
|
const commands: CapturedCommand[] = [];
|
||||||
|
|
||||||
|
const handler = async (
|
||||||
|
argv: string[],
|
||||||
|
optsOrTimeout: number | { timeoutMs: number; cwd?: string },
|
||||||
|
) => {
|
||||||
|
const opts = typeof optsOrTimeout === "number"
|
||||||
|
? { timeoutMs: optsOrTimeout }
|
||||||
|
: optsOrTimeout;
|
||||||
|
|
||||||
|
const captured: CapturedCommand = { argv, opts };
|
||||||
|
|
||||||
|
// Parse gateway agent calls to extract task message
|
||||||
|
if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") {
|
||||||
|
const rpcMethod = argv[3];
|
||||||
|
const paramsIdx = argv.indexOf("--params");
|
||||||
|
if (paramsIdx !== -1 && argv[paramsIdx + 1]) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(argv[paramsIdx + 1]);
|
||||||
|
if (rpcMethod === "agent" && params.message) {
|
||||||
|
captured.taskMessage = params.message;
|
||||||
|
}
|
||||||
|
if (rpcMethod === "sessions.patch") {
|
||||||
|
captured.sessionPatch = { key: params.key, model: params.model };
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push(captured);
|
||||||
|
|
||||||
|
return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
const interceptor: CommandInterceptor = {
|
||||||
|
commands,
|
||||||
|
commandsFor(cmd: string) {
|
||||||
|
return commands.filter((c) => c.argv[0] === cmd);
|
||||||
|
},
|
||||||
|
taskMessages() {
|
||||||
|
return commands
|
||||||
|
.filter((c) => c.taskMessage !== undefined)
|
||||||
|
.map((c) => c.taskMessage!);
|
||||||
|
},
|
||||||
|
sessionPatches() {
|
||||||
|
return commands
|
||||||
|
.filter((c) => c.sessionPatch !== undefined)
|
||||||
|
.map((c) => c.sessionPatch!);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
commands.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { interceptor, handler };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TestHarness = {
|
||||||
|
/** Temporary workspace directory. */
|
||||||
|
workspaceDir: string;
|
||||||
|
/** In-memory issue provider. */
|
||||||
|
provider: TestProvider;
|
||||||
|
/** Command interceptor — captures all runCommand calls. */
|
||||||
|
commands: CommandInterceptor;
|
||||||
|
/** The project group ID used for test data. */
|
||||||
|
groupId: string;
|
||||||
|
/** The project data. */
|
||||||
|
project: Project;
|
||||||
|
/** Workflow config. */
|
||||||
|
workflow: WorkflowConfig;
|
||||||
|
/** Write updated projects data to disk. */
|
||||||
|
writeProjects(data: ProjectsData): Promise<void>;
|
||||||
|
/** Read current projects data from disk. */
|
||||||
|
readProjects(): Promise<ProjectsData>;
|
||||||
|
/**
|
||||||
|
* Write a role prompt file to the workspace.
|
||||||
|
* @param role - Role name (e.g. "developer", "tester")
|
||||||
|
* @param content - Prompt file content
|
||||||
|
* @param projectName - If provided, writes project-specific prompt; otherwise writes default.
|
||||||
|
*/
|
||||||
|
writePrompt(role: string, content: string, projectName?: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Simulate the agent:bootstrap hook firing for a session key.
|
||||||
|
* Registers the real hook with a mock API, fires it, returns the injected bootstrap files.
|
||||||
|
* This tests the full hook chain: session key → parse → load instructions → inject.
|
||||||
|
*/
|
||||||
|
simulateBootstrap(sessionKey: string): Promise<BootstrapFile[]>;
|
||||||
|
/** Clean up temp directory. */
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HarnessOptions = {
|
||||||
|
/** Project name (default: "test-project"). */
|
||||||
|
projectName?: string;
|
||||||
|
/** Group ID (default: "-1234567890"). */
|
||||||
|
groupId?: string;
|
||||||
|
/** Repo path (default: "/tmp/test-repo"). */
|
||||||
|
repo?: string;
|
||||||
|
/** Base branch (default: "main"). */
|
||||||
|
baseBranch?: string;
|
||||||
|
/** Workflow config (default: DEFAULT_WORKFLOW). */
|
||||||
|
workflow?: WorkflowConfig;
|
||||||
|
/** Initial worker state overrides. */
|
||||||
|
workers?: Record<string, Partial<import("../projects.js").WorkerState>>;
|
||||||
|
/** Additional projects to seed. */
|
||||||
|
extraProjects?: Record<string, Project>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarness> {
|
||||||
|
const {
|
||||||
|
projectName = "test-project",
|
||||||
|
groupId = "-1234567890",
|
||||||
|
repo = "/tmp/test-repo",
|
||||||
|
baseBranch = "main",
|
||||||
|
workflow = DEFAULT_WORKFLOW,
|
||||||
|
workers: workerOverrides,
|
||||||
|
extraProjects,
|
||||||
|
} = opts ?? {};
|
||||||
|
|
||||||
|
// Create temp workspace
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-"));
|
||||||
|
const dataDir = path.join(workspaceDir, "devclaw");
|
||||||
|
const logDir = path.join(dataDir, "log");
|
||||||
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
|
|
||||||
|
// Build project
|
||||||
|
const defaultWorkers: Record<string, import("../projects.js").WorkerState> = {
|
||||||
|
developer: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
tester: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
architect: emptyWorkerState(["junior", "senior"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply worker overrides
|
||||||
|
if (workerOverrides) {
|
||||||
|
for (const [role, overrides] of Object.entries(workerOverrides)) {
|
||||||
|
if (defaultWorkers[role]) {
|
||||||
|
defaultWorkers[role] = { ...defaultWorkers[role], ...overrides };
|
||||||
|
} else {
|
||||||
|
defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
name: projectName,
|
||||||
|
repo,
|
||||||
|
groupName: "Test Group",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch,
|
||||||
|
deployBranch: baseBranch,
|
||||||
|
provider: "github",
|
||||||
|
workers: defaultWorkers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectsData: ProjectsData = {
|
||||||
|
projects: {
|
||||||
|
[groupId]: project,
|
||||||
|
...extraProjects,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeProjects(workspaceDir, projectsData);
|
||||||
|
|
||||||
|
// Install mock runCommand
|
||||||
|
const { interceptor, handler } = createCommandInterceptor();
|
||||||
|
initRunCommand({
|
||||||
|
runtime: {
|
||||||
|
system: { runCommandWithTimeout: handler },
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawPluginApi);
|
||||||
|
|
||||||
|
// Create test provider
|
||||||
|
const provider = new TestProvider({ workflow });
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceDir,
|
||||||
|
provider,
|
||||||
|
commands: interceptor,
|
||||||
|
groupId,
|
||||||
|
project,
|
||||||
|
workflow,
|
||||||
|
async writeProjects(data: ProjectsData) {
|
||||||
|
await writeProjects(workspaceDir, data);
|
||||||
|
},
|
||||||
|
async readProjects() {
|
||||||
|
const { readProjects } = await import("../projects.js");
|
||||||
|
return readProjects(workspaceDir);
|
||||||
|
},
|
||||||
|
async writePrompt(role: string, content: string, forProject?: string) {
|
||||||
|
const dir = forProject
|
||||||
|
? path.join(dataDir, "projects", forProject, "prompts")
|
||||||
|
: path.join(dataDir, "prompts");
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8");
|
||||||
|
},
|
||||||
|
async simulateBootstrap(sessionKey: string) {
|
||||||
|
// Capture the hook callback by mocking api.registerHook
|
||||||
|
let hookCallback: ((event: any) => Promise<void>) | null = null;
|
||||||
|
const mockApi = {
|
||||||
|
registerHook(_name: string, cb: (event: any) => Promise<void>) {
|
||||||
|
hookCallback = cb;
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
|
registerBootstrapHook(mockApi);
|
||||||
|
if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback");
|
||||||
|
|
||||||
|
// Build a bootstrap event matching what OpenClaw sends
|
||||||
|
const bootstrapFiles: BootstrapFile[] = [];
|
||||||
|
await hookCallback({
|
||||||
|
sessionKey,
|
||||||
|
context: {
|
||||||
|
workspaceDir,
|
||||||
|
bootstrapFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return bootstrapFiles;
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
lib/testing/index.ts
Normal file
16
lib/testing/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* testing/ — Test infrastructure for DevClaw integration tests.
|
||||||
|
*
|
||||||
|
* Exports:
|
||||||
|
* - TestProvider: In-memory IssueProvider with call tracking
|
||||||
|
* - createTestHarness: Scaffolds temp workspace + mock runCommand
|
||||||
|
*/
|
||||||
|
export { TestProvider, type ProviderCall } from "./test-provider.js";
|
||||||
|
export {
|
||||||
|
createTestHarness,
|
||||||
|
type TestHarness,
|
||||||
|
type HarnessOptions,
|
||||||
|
type CommandInterceptor,
|
||||||
|
type CapturedCommand,
|
||||||
|
type BootstrapFile,
|
||||||
|
} from "./harness.js";
|
||||||
224
lib/testing/test-provider.ts
Normal file
224
lib/testing/test-provider.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* TestProvider — In-memory IssueProvider for integration tests.
|
||||||
|
*
|
||||||
|
* Tracks all method calls for assertion. Issues are stored in a simple map.
|
||||||
|
* No external dependencies — pure TypeScript.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
IssueProvider,
|
||||||
|
Issue,
|
||||||
|
StateLabel,
|
||||||
|
IssueComment,
|
||||||
|
PrStatus,
|
||||||
|
} from "../providers/provider.js";
|
||||||
|
import { getStateLabels } from "../workflow.js";
|
||||||
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Call tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProviderCall =
|
||||||
|
| { method: "ensureLabel"; args: { name: string; color: string } }
|
||||||
|
| { method: "ensureAllStateLabels"; args: {} }
|
||||||
|
| { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } }
|
||||||
|
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
|
||||||
|
| { method: "getIssue"; args: { issueId: number } }
|
||||||
|
| { method: "listComments"; args: { issueId: number } }
|
||||||
|
| { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } }
|
||||||
|
| { method: "closeIssue"; args: { issueId: number } }
|
||||||
|
| { method: "reopenIssue"; args: { issueId: number } }
|
||||||
|
| { method: "hasMergedMR"; args: { issueId: number } }
|
||||||
|
| { method: "getMergedMRUrl"; args: { issueId: number } }
|
||||||
|
| { method: "getPrStatus"; args: { issueId: number } }
|
||||||
|
| { method: "addComment"; args: { issueId: number; body: string } }
|
||||||
|
| { method: "healthCheck"; args: {} };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class TestProvider implements IssueProvider {
|
||||||
|
/** All issues keyed by iid. */
|
||||||
|
issues = new Map<number, Issue>();
|
||||||
|
/** Comments per issue. */
|
||||||
|
comments = new Map<number, IssueComment[]>();
|
||||||
|
/** Labels that have been ensured. */
|
||||||
|
labels = new Map<string, string>();
|
||||||
|
/** PR status overrides per issue. Default: { state: "closed", url: null }. */
|
||||||
|
prStatuses = new Map<number, PrStatus>();
|
||||||
|
/** Merged MR URLs per issue. */
|
||||||
|
mergedMrUrls = new Map<number, string>();
|
||||||
|
/** All calls, in order. */
|
||||||
|
calls: ProviderCall[] = [];
|
||||||
|
|
||||||
|
private nextIssueId = 1;
|
||||||
|
private workflow: WorkflowConfig;
|
||||||
|
|
||||||
|
constructor(opts?: { workflow?: WorkflowConfig }) {
|
||||||
|
this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Create an issue directly in the store (bypasses createIssue tracking). */
|
||||||
|
seedIssue(overrides: Partial<Issue> & { iid: number }): Issue {
|
||||||
|
const issue: Issue = {
|
||||||
|
iid: overrides.iid,
|
||||||
|
title: overrides.title ?? `Issue #${overrides.iid}`,
|
||||||
|
description: overrides.description ?? "",
|
||||||
|
labels: overrides.labels ?? [],
|
||||||
|
state: overrides.state ?? "opened",
|
||||||
|
web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
|
||||||
|
};
|
||||||
|
this.issues.set(issue.iid, issue);
|
||||||
|
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set PR status for an issue (used by review pass tests). */
|
||||||
|
setPrStatus(issueId: number, status: PrStatus): void {
|
||||||
|
this.prStatuses.set(issueId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get calls filtered by method name. */
|
||||||
|
callsTo<M extends ProviderCall["method"]>(
|
||||||
|
method: M,
|
||||||
|
): Extract<ProviderCall, { method: M }>[] {
|
||||||
|
return this.calls.filter((c) => c.method === method) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset call tracking (keeps issue state). */
|
||||||
|
resetCalls(): void {
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full reset — clear everything. */
|
||||||
|
reset(): void {
|
||||||
|
this.issues.clear();
|
||||||
|
this.comments.clear();
|
||||||
|
this.labels.clear();
|
||||||
|
this.prStatuses.clear();
|
||||||
|
this.mergedMrUrls.clear();
|
||||||
|
this.calls = [];
|
||||||
|
this.nextIssueId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// IssueProvider implementation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
|
this.calls.push({ method: "ensureLabel", args: { name, color } });
|
||||||
|
this.labels.set(name, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureAllStateLabels(): Promise<void> {
|
||||||
|
this.calls.push({ method: "ensureAllStateLabels", args: {} });
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
for (const label of stateLabels) {
|
||||||
|
this.labels.set(label, "#000000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssue(
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
label: StateLabel,
|
||||||
|
assignees?: string[],
|
||||||
|
): Promise<Issue> {
|
||||||
|
this.calls.push({ method: "createIssue", args: { title, description, label, assignees } });
|
||||||
|
const iid = this.nextIssueId++;
|
||||||
|
const issue: Issue = {
|
||||||
|
iid,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
labels: [label],
|
||||||
|
state: "opened",
|
||||||
|
web_url: `https://example.com/issues/${iid}`,
|
||||||
|
};
|
||||||
|
this.issues.set(iid, issue);
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||||
|
this.calls.push({ method: "listIssuesByLabel", args: { label } });
|
||||||
|
return [...this.issues.values()].filter((i) => i.labels.includes(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssue(issueId: number): Promise<Issue> {
|
||||||
|
this.calls.push({ method: "getIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listComments(issueId: number): Promise<IssueComment[]> {
|
||||||
|
this.calls.push({ method: "listComments", args: { issueId } });
|
||||||
|
return this.comments.get(issueId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async transitionLabel(
|
||||||
|
issueId: number,
|
||||||
|
from: StateLabel,
|
||||||
|
to: StateLabel,
|
||||||
|
): Promise<void> {
|
||||||
|
this.calls.push({ method: "transitionLabel", args: { issueId, from, to } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||||
|
// Remove all state labels, add the new one
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
issue.labels = issue.labels.filter((l) => !stateLabels.includes(l));
|
||||||
|
issue.labels.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeIssue(issueId: number): Promise<void> {
|
||||||
|
this.calls.push({ method: "closeIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue) issue.state = "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async reopenIssue(issueId: number): Promise<void> {
|
||||||
|
this.calls.push({ method: "reopenIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue) issue.state = "opened";
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
|
||||||
|
return issue.labels.includes(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
return stateLabels.find((l) => issue.labels.includes(l)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
|
this.calls.push({ method: "hasMergedMR", args: { issueId } });
|
||||||
|
return this.mergedMrUrls.has(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||||
|
this.calls.push({ method: "getMergedMRUrl", args: { issueId } });
|
||||||
|
return this.mergedMrUrls.get(issueId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
this.calls.push({ method: "getPrStatus", args: { issueId } });
|
||||||
|
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComment(issueId: number, body: string): Promise<void> {
|
||||||
|
this.calls.push({ method: "addComment", args: { issueId, body } });
|
||||||
|
const existing = this.comments.get(issueId) ?? [];
|
||||||
|
existing.push({ author: "test", body, created_at: new Date().toISOString() });
|
||||||
|
this.comments.set(issueId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
this.calls.push({ method: "healthCheck", args: {} });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { resolveRepoPath } from "../projects.js";
|
|||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { ExecutionMode } from "../workflow.js";
|
||||||
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
||||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export function createProjectRegisterTool() {
|
|||||||
},
|
},
|
||||||
roleExecution: {
|
roleExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: Object.values(ExecutionMode),
|
||||||
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,7 +100,7 @@ export function createProjectRegisterTool() {
|
|||||||
const baseBranch = params.baseBranch as string;
|
const baseBranch = params.baseBranch as string;
|
||||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||||
const deployUrl = (params.deployUrl as string) ?? "";
|
const deployUrl = (params.deployUrl as string) ?? "";
|
||||||
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
|
const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL;
|
||||||
const workspaceDir = ctx.workspaceDir;
|
const workspaceDir = ctx.workspaceDir;
|
||||||
|
|
||||||
if (!workspaceDir) {
|
if (!workspaceDir) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
|||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { runSetup, type SetupOpts } from "../setup/index.js";
|
import { runSetup, type SetupOpts } from "../setup/index.js";
|
||||||
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createSetupTool(api: OpenClawPluginApi) {
|
export function createSetupTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -51,7 +52,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
},
|
},
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: Object.values(ExecutionMode),
|
||||||
description: "Project execution mode. Default: parallel.",
|
description: "Project execution mode. Default: parallel.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -68,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||||
models: params.models as SetupOpts["models"],
|
models: params.models as SetupOpts["models"],
|
||||||
projectExecution: params.projectExecution as
|
projectExecution: params.projectExecution as
|
||||||
| "parallel"
|
| ExecutionMode
|
||||||
| "sequential"
|
|
||||||
| undefined,
|
| undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
||||||
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { loadWorkflow } from "../workflow.js";
|
import { loadWorkflow, ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createStatusTool(api: OpenClawPluginApi) {
|
export function createStatusTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -30,7 +30,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
|||||||
const groupId = params.projectGroupId as string | undefined;
|
const groupId = params.projectGroupId as string | undefined;
|
||||||
|
|
||||||
const pluginConfig = getPluginConfig(api);
|
const pluginConfig = getPluginConfig(api);
|
||||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||||
|
|
||||||
// Load workspace-level workflow (per-project loaded inside map)
|
// Load workspace-level workflow (per-project loaded inside map)
|
||||||
const workflow = await loadWorkflow(workspaceDir);
|
const workflow = await loadWorkflow(workspaceDir);
|
||||||
@@ -66,7 +66,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
|||||||
return {
|
return {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
roleExecution: project.roleExecution ?? "parallel",
|
roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL,
|
||||||
workers,
|
workers,
|
||||||
queue: queueCounts,
|
queue: queueCounts,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ describe("task_update tool", () => {
|
|||||||
"Done",
|
"Done",
|
||||||
"To Improve",
|
"To Improve",
|
||||||
"Refining",
|
"Refining",
|
||||||
|
"In Review",
|
||||||
];
|
];
|
||||||
|
|
||||||
// In a real test, we'd verify these against the tool's enum
|
// In a real test, we'd verify these against the tool's enum
|
||||||
assert.strictEqual(validStates.length, 8);
|
assert.strictEqual(validStates.length, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates required parameters", () => {
|
it("validates required parameters", () => {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import type { StateLabel } from "../providers/provider.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { getWorker } from "../projects.js";
|
import { getWorker } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js";
|
||||||
import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
|
import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { loadWorkflow, getActiveLabel } from "../workflow.js";
|
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -70,7 +70,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
// Check worker availability
|
// Check worker availability
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) {
|
||||||
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
|
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
|
||||||
if (otherRole !== role && otherWorker.active) {
|
if (otherRole !== role && otherWorker.active) {
|
||||||
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
|
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
|
||||||
|
|||||||
148
lib/workflow.ts
148
lib/workflow.ts
@@ -13,14 +13,60 @@
|
|||||||
// Types
|
// 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. */
|
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
|
||||||
export type Role = string;
|
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 | {
|
export type TransitionTarget = string | {
|
||||||
target: string;
|
target: string;
|
||||||
actions?: TransitionAction[];
|
actions?: TransitionAction[];
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StateConfig = {
|
export type StateConfig = {
|
||||||
@@ -29,6 +75,8 @@ export type StateConfig = {
|
|||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
description?: string;
|
||||||
|
check?: ReviewCheckType;
|
||||||
on?: Record<string, TransitionTarget>;
|
on?: Record<string, TransitionTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,10 +88,7 @@ export type WorkflowConfig = {
|
|||||||
export type CompletionRule = {
|
export type CompletionRule = {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
gitPull?: boolean;
|
actions: string[];
|
||||||
detectPr?: boolean;
|
|
||||||
closeIssue?: boolean;
|
|
||||||
reopenIssue?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,84 +99,95 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
|||||||
initial: "planning",
|
initial: "planning",
|
||||||
states: {
|
states: {
|
||||||
planning: {
|
planning: {
|
||||||
type: "hold",
|
type: StateType.HOLD,
|
||||||
label: "Planning",
|
label: "Planning",
|
||||||
color: "#95a5a6",
|
color: "#95a5a6",
|
||||||
on: { APPROVE: "todo" },
|
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||||
},
|
},
|
||||||
todo: {
|
todo: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "developer",
|
role: "developer",
|
||||||
label: "To Do",
|
label: "To Do",
|
||||||
color: "#428bca",
|
color: "#428bca",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
on: { PICKUP: "doing" },
|
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||||
},
|
},
|
||||||
doing: {
|
doing: {
|
||||||
type: "active",
|
type: StateType.ACTIVE,
|
||||||
role: "developer",
|
role: "developer",
|
||||||
label: "Doing",
|
label: "Doing",
|
||||||
color: "#f0ad4e",
|
color: "#f0ad4e",
|
||||||
on: {
|
on: {
|
||||||
COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] },
|
[WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] },
|
||||||
BLOCKED: "refining",
|
[WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] },
|
||||||
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toTest: {
|
toTest: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "tester",
|
role: "tester",
|
||||||
label: "To Test",
|
label: "To Test",
|
||||||
color: "#5bc0de",
|
color: "#5bc0de",
|
||||||
priority: 2,
|
priority: 2,
|
||||||
on: { PICKUP: "testing" },
|
on: { [WorkflowEvent.PICKUP]: "testing" },
|
||||||
},
|
},
|
||||||
testing: {
|
testing: {
|
||||||
type: "active",
|
type: StateType.ACTIVE,
|
||||||
role: "tester",
|
role: "tester",
|
||||||
label: "Testing",
|
label: "Testing",
|
||||||
color: "#9b59b6",
|
color: "#9b59b6",
|
||||||
on: {
|
on: {
|
||||||
PASS: { target: "done", actions: ["closeIssue"] },
|
[WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] },
|
||||||
FAIL: { target: "toImprove", actions: ["reopenIssue"] },
|
[WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] },
|
||||||
REFINE: "refining",
|
[WorkflowEvent.REFINE]: "refining",
|
||||||
BLOCKED: "refining",
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toImprove: {
|
toImprove: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "developer",
|
role: "developer",
|
||||||
label: "To Improve",
|
label: "To Improve",
|
||||||
color: "#d9534f",
|
color: "#d9534f",
|
||||||
priority: 3,
|
priority: 3,
|
||||||
on: { PICKUP: "doing" },
|
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||||
},
|
},
|
||||||
refining: {
|
refining: {
|
||||||
type: "hold",
|
type: StateType.HOLD,
|
||||||
label: "Refining",
|
label: "Refining",
|
||||||
color: "#f39c12",
|
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: {
|
done: {
|
||||||
type: "terminal",
|
type: StateType.TERMINAL,
|
||||||
label: "Done",
|
label: "Done",
|
||||||
color: "#5cb85c",
|
color: "#5cb85c",
|
||||||
},
|
},
|
||||||
toDesign: {
|
toDesign: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "architect",
|
role: "architect",
|
||||||
label: "To Design",
|
label: "To Design",
|
||||||
color: "#0075ca",
|
color: "#0075ca",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
on: { PICKUP: "designing" },
|
on: { [WorkflowEvent.PICKUP]: "designing" },
|
||||||
},
|
},
|
||||||
designing: {
|
designing: {
|
||||||
type: "active",
|
type: StateType.ACTIVE,
|
||||||
role: "architect",
|
role: "architect",
|
||||||
label: "Designing",
|
label: "Designing",
|
||||||
color: "#d4c5f9",
|
color: "#d4c5f9",
|
||||||
on: {
|
on: {
|
||||||
COMPLETE: "planning",
|
[WorkflowEvent.COMPLETE]: "planning",
|
||||||
BLOCKED: "refining",
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -181,7 +237,7 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
|
|||||||
*/
|
*/
|
||||||
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||||
return Object.values(workflow.states)
|
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))
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||||
.map((s) => s.label);
|
.map((s) => s.label);
|
||||||
}
|
}
|
||||||
@@ -191,7 +247,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||||
return Object.values(workflow.states)
|
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))
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||||
.map((s) => s.label);
|
.map((s) => s.label);
|
||||||
}
|
}
|
||||||
@@ -201,7 +257,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
||||||
const state = Object.values(workflow.states).find(
|
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}"`);
|
if (!state) throw new Error(`No active state for role "${role}"`);
|
||||||
return state.label;
|
return state.label;
|
||||||
@@ -219,8 +275,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
|||||||
|
|
||||||
// Find queue states that transition to this active state
|
// Find queue states that transition to this active state
|
||||||
for (const [, state] of Object.entries(workflow.states)) {
|
for (const [, state] of Object.entries(workflow.states)) {
|
||||||
if (state.type !== "queue" || state.role !== role) continue;
|
if (state.type !== StateType.QUEUE || state.role !== role) continue;
|
||||||
const pickup = state.on?.PICKUP;
|
const pickup = state.on?.[WorkflowEvent.PICKUP];
|
||||||
if (pickup === activeStateKey) {
|
if (pickup === activeStateKey) {
|
||||||
return state.label;
|
return state.label;
|
||||||
}
|
}
|
||||||
@@ -235,7 +291,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
|||||||
*/
|
*/
|
||||||
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
|
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
|
||||||
for (const state of Object.values(workflow.states)) {
|
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;
|
return state.role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +303,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro
|
|||||||
*/
|
*/
|
||||||
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||||
return Object.values(workflow.states).some(
|
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 {
|
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||||
return Object.values(workflow.states).some(
|
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.
|
* Convention: "done" → COMPLETE, others → uppercase.
|
||||||
*/
|
*/
|
||||||
function resultToEvent(result: string): string {
|
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();
|
return result.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,10 +377,7 @@ export function getCompletionRule(
|
|||||||
return {
|
return {
|
||||||
from: activeLabel,
|
from: activeLabel,
|
||||||
to: targetState.label,
|
to: targetState.label,
|
||||||
gitPull: actions?.includes("gitPull"),
|
actions: actions ?? [],
|
||||||
detectPr: actions?.includes("detectPr"),
|
|
||||||
closeIssue: actions?.includes("closeIssue"),
|
|
||||||
reopenIssue: actions?.includes("reopenIssue"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,9 +396,10 @@ export function getNextStateDescription(
|
|||||||
const targetState = findStateByLabel(workflow, rule.to);
|
const targetState = findStateByLabel(workflow, rule.to);
|
||||||
if (!targetState) return "";
|
if (!targetState) return "";
|
||||||
|
|
||||||
if (targetState.type === "terminal") return "Done!";
|
if (targetState.type === StateType.TERMINAL) return "Done!";
|
||||||
if (targetState.type === "hold") return "awaiting human decision";
|
if (targetState.type === StateType.REVIEW) return "awaiting PR review";
|
||||||
if (targetState.type === "queue" && targetState.role) {
|
if (targetState.type === StateType.HOLD) return "awaiting human decision";
|
||||||
|
if (targetState.type === StateType.QUEUE && targetState.role) {
|
||||||
return `${targetState.role.toUpperCase()} queue`;
|
return `${targetState.role.toUpperCase()} queue`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +412,7 @@ export function getNextStateDescription(
|
|||||||
*/
|
*/
|
||||||
const RESULT_EMOJI: Record<string, string> = {
|
const RESULT_EMOJI: Record<string, string> = {
|
||||||
done: "✅",
|
done: "✅",
|
||||||
|
review: "👀",
|
||||||
pass: "🎉",
|
pass: "🎉",
|
||||||
fail: "❌",
|
fail: "❌",
|
||||||
refine: "🤔",
|
refine: "🤔",
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -9,7 +9,9 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.8.2"
|
"cockatiel": "^3.2.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
@@ -3769,6 +3771,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cockatiel": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -8789,7 +8800,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.8.2"
|
"cockatiel": "^3.2.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user