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:
@@ -1,12 +1,61 @@
|
||||
/**
|
||||
* Atomic projects.json read/write operations.
|
||||
* 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 path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { migrateProject } from "./migrations.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 = {
|
||||
active: boolean;
|
||||
@@ -28,7 +77,7 @@ export type Project = {
|
||||
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
|
||||
provider?: "github" | "gitlab";
|
||||
/** 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;
|
||||
maxQaWorkers?: number;
|
||||
/** 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.
|
||||
* 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(
|
||||
workspaceDir: string,
|
||||
@@ -116,22 +166,27 @@ export async function updateWorker(
|
||||
role: string,
|
||||
updates: Partial<WorkerState>,
|
||||
): Promise<ProjectsData> {
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[groupId];
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for groupId: ${groupId}`);
|
||||
await acquireLock(workspaceDir);
|
||||
try {
|
||||
const data = await readProjects(workspaceDir);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user