fix(dashboard): start engine for every registered project on first access

Tasks in projects other than the primary (cwd) project were never triaged
because only one ProjectEngine was started. When a project is accessed via
?projectId= API/SSE, getOrCreateProjectStore created a TaskStore but left
the Scheduler, TriageProcessor, and TaskExecutor unstarted.

Fix: introduce setOnProjectFirstCreated callback in project-store-resolver
so the dashboard server is notified when any new project is first accessed.
dashboard.ts creates a ProjectManager that lazily starts an InProcessRuntime
(Scheduler + TriageProcessor + TaskExecutor) for each project the first time
it is accessed — works for any number of registered projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-12 21:58:19 -07:00
parent 91c167d856
commit 897398a3fc
5 changed files with 568 additions and 831 deletions

View File

@@ -14,6 +14,7 @@ import {
getOrCreateProjectStore,
evictProjectStore,
evictAllProjectStores,
setOnProjectFirstCreated,
} from "../project-store-resolver.js";
import type { TaskStore } from "@fusion/core";
@@ -189,13 +190,98 @@ describe("project-store-resolver", () => {
expect(sameStore).toBe(store);
// Emit events from "mutation path" — same store instance
sameStore.emit("task:created", { id: "FN-001" });
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" });
sameStore.emit("task:created", { id: "FN-001" } as any);
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" } as any);
// SSE listener should have received both events through the shared EventEmitter
expect(events).toEqual(["created:FN-001", "updated:FN-001"]);
});
describe("setOnProjectFirstCreated", () => {
afterEach(() => {
// Always clear the callback so it doesn't bleed into other tests
setOnProjectFirstCreated(undefined);
});
it("fires callback once when a new project store is first created", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_new");
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith("proj_cb_new");
});
it("does not fire callback on subsequent cache hits for the same project", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_cached");
await getOrCreateProjectStore("proj_cb_cached");
await getOrCreateProjectStore("proj_cb_cached");
expect(cb).toHaveBeenCalledTimes(1);
});
it("fires callback once per unique project (any number of projects)", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
// Use sequential awaits to avoid concurrent import() race in vitest mock resolution
await getOrCreateProjectStore("proj_multi_1");
await getOrCreateProjectStore("proj_multi_2");
await getOrCreateProjectStore("proj_multi_3");
await getOrCreateProjectStore("proj_multi_4");
await getOrCreateProjectStore("proj_multi_5");
expect(cb).toHaveBeenCalledTimes(5);
expect(cb).toHaveBeenCalledWith("proj_multi_1");
expect(cb).toHaveBeenCalledWith("proj_multi_2");
expect(cb).toHaveBeenCalledWith("proj_multi_3");
expect(cb).toHaveBeenCalledWith("proj_multi_4");
expect(cb).toHaveBeenCalledWith("proj_multi_5");
});
it("deduplicates concurrent calls — callback fires exactly once even under concurrency", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await Promise.all([
getOrCreateProjectStore("proj_concurrent_cb"),
getOrCreateProjectStore("proj_concurrent_cb"),
getOrCreateProjectStore("proj_concurrent_cb"),
]);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith("proj_concurrent_cb");
});
it("stops firing after callback is cleared with undefined", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
setOnProjectFirstCreated(undefined);
await getOrCreateProjectStore("proj_cb_cleared");
expect(cb).not.toHaveBeenCalled();
});
it("fires for a re-created project after eviction", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_evict");
expect(cb).toHaveBeenCalledTimes(1);
evictProjectStore("proj_cb_evict");
createdStores.length = 0;
await getOrCreateProjectStore("proj_cb_evict");
expect(cb).toHaveBeenCalledTimes(2);
});
});
it("SSE stream receives live events via shared store (integration)", async () => {
// This test simulates the full server-side path:
// 1. SSE handler calls getOrCreateProjectStore("proj_sse") → store A
@@ -223,14 +309,14 @@ describe("project-store-resolver", () => {
// Simulate task creation via API (this is what routes.ts does)
const newTask = { id: "FN-100", description: "Integration test task" };
apiStore.emit("task:created", newTask);
apiStore.emit("task:created", newTask as any);
// Simulate task update
const updatedTask = { ...newTask, title: "Updated title" };
apiStore.emit("task:updated", updatedTask);
apiStore.emit("task:updated", updatedTask as any);
// Simulate task move
apiStore.emit("task:moved", { task: updatedTask, from: "triage", to: "todo" });
apiStore.emit("task:moved", { task: updatedTask as any, from: "triage", to: "todo" });
// Assert SSE listener received all events in order
expect(sseMessages).toHaveLength(3);