feat(HAI-041): wire WorktreePool through dashboard and add tests
- Wire WorktreePool instance through dashboard.ts command setup - Add comprehensive WorktreePool wiring tests in dashboard.test.ts - Refactor engine executor, scheduler, and merger to use WorktreePool - Remove legacy worktree-names module and unused dashboard routes/hooks - Clean up dashboard UI components and simplify task card/modal logic
This commit is contained in:
142
packages/cli/src/commands/dashboard.test.ts
Normal file
142
packages/cli/src/commands/dashboard.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// ── Capture instances & arguments ───────────────────────────────────
|
||||
|
||||
let capturedExecutorOpts: Record<string, unknown> | undefined;
|
||||
|
||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||
function makeMockStore() {
|
||||
const emitter = new EventEmitter();
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
stopWatching: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
}),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mock @hai/core ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@hai/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||
}));
|
||||
|
||||
// ── Mock @hai/dashboard ─────────────────────────────────────────────
|
||||
|
||||
const mockListen = vi.fn();
|
||||
vi.mock("@hai/dashboard", () => ({
|
||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||
}));
|
||||
|
||||
// ── Mock @hai/engine ────────────────────────────────────────────────
|
||||
|
||||
// We need the real WorktreePool class so we can assert `instanceof`.
|
||||
const { WorktreePool } = await import("@hai/engine");
|
||||
|
||||
vi.mock("@hai/engine", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@hai/engine")>();
|
||||
return {
|
||||
...original,
|
||||
// Keep real WorktreePool & AgentSemaphore
|
||||
WorktreePool: original.WorktreePool,
|
||||
AgentSemaphore: original.AgentSemaphore,
|
||||
// Stub heavy classes/functions
|
||||
TriageProcessor: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
TaskExecutor: vi.fn().mockImplementation((_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return {
|
||||
resumeOrphaned: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
Scheduler: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
aiMergeTask: vi.fn().mockImplementation(() => Promise.resolve({ merged: true })),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Import module under test (after mocks) ──────────────────────────
|
||||
|
||||
const { runDashboard } = await import("./dashboard.js");
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("runDashboard — WorktreePool wiring", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
// Re-set TaskStore mock (clearAllMocks wipes implementations)
|
||||
const { TaskStore } = await import("@hai/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
// Re-set engine mocks
|
||||
const engine = await import("@hai/engine");
|
||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ merged: true }),
|
||||
);
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a WorktreePool instance to TaskExecutor", async () => {
|
||||
await runDashboard(0, { engine: true, open: false });
|
||||
|
||||
expect(capturedExecutorOpts).toBeDefined();
|
||||
expect(capturedExecutorOpts!.pool).toBeInstanceOf(WorktreePool);
|
||||
});
|
||||
|
||||
it("passes a WorktreePool instance to aiMergeTask via rawMerge", async () => {
|
||||
const { aiMergeTask } = await import("@hai/engine");
|
||||
const { createServer } = await import("@hai/dashboard");
|
||||
|
||||
await runDashboard(0, { engine: false, open: false });
|
||||
|
||||
// rawMerge is exposed as the onMerge callback wired into createServer.
|
||||
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
||||
|
||||
// Invoke the merge handler
|
||||
await serverOpts.onMerge("HAI-TEST");
|
||||
|
||||
expect(aiMergeTask).toHaveBeenCalled();
|
||||
const mergeCallOpts = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
||||
expect(mergeCallOpts.pool).toBeInstanceOf(WorktreePool);
|
||||
});
|
||||
|
||||
it("shares the same WorktreePool instance between executor and merger", async () => {
|
||||
const { aiMergeTask } = await import("@hai/engine");
|
||||
const { createServer } = await import("@hai/dashboard");
|
||||
|
||||
await runDashboard(0, { engine: true, open: false });
|
||||
|
||||
// Trigger merger via onMerge
|
||||
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
||||
await serverOpts.onMerge("HAI-TEST");
|
||||
|
||||
const executorPool = capturedExecutorOpts!.pool;
|
||||
const mergerPool = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3].pool;
|
||||
|
||||
expect(executorPool).toBeInstanceOf(WorktreePool);
|
||||
expect(mergerPool).toBeInstanceOf(WorktreePool);
|
||||
expect(executorPool).toBe(mergerPool);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { TaskStore } from "@hai/core";
|
||||
import { createServer } from "@hai/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, aiMergeTask } from "@hai/engine";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@hai/engine";
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const cmd =
|
||||
@@ -32,11 +32,25 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
let cachedMaxConcurrent = initialSettings.maxConcurrent;
|
||||
const semaphore = new AgentSemaphore(() => cachedMaxConcurrent);
|
||||
|
||||
// ── Shared worktree pool ──────────────────────────────────────────
|
||||
//
|
||||
// Enables worktree recycling across tasks when `recycleWorktrees` is
|
||||
// enabled in settings. Completed task worktrees are returned to the
|
||||
// pool instead of being deleted; new tasks acquire a warm worktree
|
||||
// preserving build caches (node_modules, dist/, etc.).
|
||||
//
|
||||
// Created unconditionally — the `recycleWorktrees` gating logic lives
|
||||
// inside TaskExecutor and aiMergeTask (see HAI-037). When the setting
|
||||
// is off the pool simply stays empty.
|
||||
//
|
||||
const pool = new WorktreePool();
|
||||
|
||||
// AI-powered merge handler (used by the web UI for manual merges).
|
||||
// Wrapped with the shared semaphore so merges count toward the global
|
||||
// concurrency limit alongside triage and execution agents.
|
||||
const rawMerge = (taskId: string) =>
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
pool,
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
|
||||
});
|
||||
@@ -138,6 +152,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
|
||||
const executor = new TaskExecutor(store, cwd, {
|
||||
semaphore,
|
||||
pool,
|
||||
onStart: (t, p) => console.log(`[engine] Executing ${t.id} in ${p}`),
|
||||
onComplete: (t) => console.log(`[engine] ✓ ${t.id} → in-review`),
|
||||
onError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
||||
|
||||
Reference in New Issue
Block a user