feat(KB-500): add central infrastructure for multi-project support

- Add CentralCore API for cross-project coordination (project registry, health tracking)
- Add CentralDatabase with SQLite storage for projects, activity log, and concurrency
- Export central infrastructure types: RegisteredProject, ProjectHealth, GlobalConcurrencyState
- Add central integration tests for multi-project workflows
- Update AGENTS.md with CentralCore usage documentation
This commit is contained in:
gsxdsm
2026-03-31 19:59:59 -07:00
parent 801f0d2b1c
commit 1937adc7be
9 changed files with 2953 additions and 1 deletions

View File

@@ -0,0 +1,974 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralCore } from "./central-core.js";
import type {
RegisteredProject,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState,
} from "./types.js";
describe("CentralCore", () => {
let tempDir: string;
let central: CentralCore;
let projectPaths: string[] = [];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-central-core-test-"));
central = new CentralCore(tempDir);
projectPaths = [];
});
afterEach(async () => {
await central.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe("lifecycle", () => {
it("should initialize and create database", async () => {
await central.init();
expect(central.isInitialized()).toBe(true);
expect(central.getDatabasePath()).toBe(join(tempDir, "kb-central.db"));
});
it("should be idempotent on multiple init calls", async () => {
await central.init();
await central.init();
expect(central.isInitialized()).toBe(true);
});
it("should close and clean up", async () => {
await central.init();
await central.close();
expect(central.isInitialized()).toBe(false);
});
it("should throw if operations called before init", async () => {
await expect(central.listProjects()).rejects.toThrow("not initialized");
});
});
describe("project registration", () => {
beforeEach(async () => {
await central.init();
});
it("should register a project with valid inputs", async () => {
const projectPath = join(tempDir, "project1");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Test Project",
path: projectPath,
});
expect(project.id).toMatch(/^proj_[a-f0-9]+$/);
expect(project.name).toBe("Test Project");
expect(project.path).toBe(projectPath);
expect(project.status).toBe("initializing");
expect(project.isolationMode).toBe("in-process");
expect(project.createdAt).toBeDefined();
expect(project.updatedAt).toBeDefined();
expect(project.lastActivityAt).toBeDefined();
});
it("should reject relative paths", async () => {
await expect(
central.registerProject({
name: "Test",
path: "relative/path",
})
).rejects.toThrow("must be absolute");
});
it("should reject non-existent paths", async () => {
await expect(
central.registerProject({
name: "Test",
path: "/nonexistent/path",
})
).rejects.toThrow("does not exist");
});
it("should reject non-directory paths", async () => {
const filePath = join(tempDir, "not-a-dir.txt");
// Create a file (can't use writeFileSync with these imports, use native fs via db or skip)
// Actually let's create it using standard fs which is available in node
const { writeFileSync } = await import("node:fs");
writeFileSync(filePath, "content");
await expect(
central.registerProject({
name: "Test",
path: filePath,
})
).rejects.toThrow("must be a directory");
});
it("should reject duplicate paths", async () => {
const projectPath = join(tempDir, "dup-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({
name: "First",
path: projectPath,
});
await expect(
central.registerProject({
name: "Second",
path: projectPath,
})
).rejects.toThrow("already registered");
});
it("should accept custom isolation mode", async () => {
const projectPath = join(tempDir, "isolated-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Isolated",
path: projectPath,
isolationMode: "child-process",
});
expect(project.isolationMode).toBe("child-process");
});
it("should emit project:registered event", async () => {
const projectPath = join(tempDir, "event-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
let emittedProject: RegisteredProject | undefined;
central.on("project:registered", (p) => {
emittedProject = p;
});
await central.registerProject({
name: "Event Test",
path: projectPath,
});
expect(emittedProject).toBeDefined();
expect(emittedProject?.name).toBe("Event Test");
});
it("should initialize project health on registration", async () => {
const projectPath = join(tempDir, "health-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Health Test",
path: projectPath,
});
const health = await central.getProjectHealth(project.id);
expect(health).toBeDefined();
expect(health?.projectId).toBe(project.id);
expect(health?.status).toBe("initializing");
expect(health?.activeTaskCount).toBe(0);
expect(health?.inFlightAgentCount).toBe(0);
expect(health?.totalTasksCompleted).toBe(0);
expect(health?.totalTasksFailed).toBe(0);
});
});
describe("project unregistration", () => {
beforeEach(async () => {
await central.init();
});
it("should unregister a project", async () => {
const projectPath = join(tempDir, "unreg-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "To Unregister",
path: projectPath,
});
await central.unregisterProject(project.id);
const found = await central.getProject(project.id);
expect(found).toBeUndefined();
});
it("should be idempotent for non-existent projects", async () => {
await expect(central.unregisterProject("nonexistent")).resolves.toBeUndefined();
});
it("should emit project:unregistered event", async () => {
const projectPath = join(tempDir, "unreg-event-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "To Unregister",
path: projectPath,
});
let emittedId: string | undefined;
central.on("project:unregistered", (id) => {
emittedId = id;
});
await central.unregisterProject(project.id);
expect(emittedId).toBe(project.id);
});
it("should cascade delete health records", async () => {
const projectPath = join(tempDir, "cascade-health");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Cascade",
path: projectPath,
});
await central.unregisterProject(project.id);
const health = await central.getProjectHealth(project.id);
expect(health).toBeUndefined();
});
it("should cascade delete activity log entries", async () => {
const projectPath = join(tempDir, "cascade-activity");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Cascade Activity",
path: projectPath,
});
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Test activity",
});
await central.unregisterProject(project.id);
const activities = await central.getRecentActivity({ projectId: project.id });
expect(activities).toHaveLength(0);
});
});
describe("project queries", () => {
beforeEach(async () => {
await central.init();
});
it("should get project by id", async () => {
const projectPath = join(tempDir, "get-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Get Test",
path: projectPath,
});
const found = await central.getProject(project.id);
expect(found).toEqual(project);
});
it("should return undefined for non-existent id", async () => {
const found = await central.getProject("nonexistent");
expect(found).toBeUndefined();
});
it("should get project by path", async () => {
const projectPath = join(tempDir, "by-path-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "By Path",
path: projectPath,
});
const found = await central.getProjectByPath(projectPath);
expect(found).toEqual(project);
});
it("should list all projects", async () => {
const projects: RegisteredProject[] = [];
for (let i = 0; i < 3; i++) {
const projectPath = join(tempDir, `list-project-${i}`);
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: `Project ${i}`,
path: projectPath,
});
projects.push(project);
}
const listed = await central.listProjects();
expect(listed).toHaveLength(3);
// Should be sorted by name
expect(listed.map((p) => p.name)).toEqual(["Project 0", "Project 1", "Project 2"]);
});
it("should return empty array when no projects", async () => {
const listed = await central.listProjects();
expect(listed).toEqual([]);
});
it("should update project fields", async () => {
const projectPath = join(tempDir, "update-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Original",
path: projectPath,
});
// Add small delay to ensure different timestamp
await new Promise((r) => setTimeout(r, 10));
const updated = await central.updateProject(project.id, {
name: "Updated",
status: "active",
});
expect(updated.name).toBe("Updated");
expect(updated.status).toBe("active");
expect(updated.id).toBe(project.id);
expect(updated.createdAt).toBe(project.createdAt);
expect(updated.updatedAt).not.toBe(project.updatedAt);
});
it("should throw when updating non-existent project", async () => {
await expect(
central.updateProject("nonexistent", { name: "New Name" })
).rejects.toThrow("not found");
});
it("should emit project:updated event", async () => {
const projectPath = join(tempDir, "update-event-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Original",
path: projectPath,
});
let emittedProject: RegisteredProject | undefined;
central.on("project:updated", (p) => {
emittedProject = p;
});
await central.updateProject(project.id, { name: "Updated" });
expect(emittedProject).toBeDefined();
expect(emittedProject?.name).toBe("Updated");
});
});
describe("project health", () => {
beforeEach(async () => {
await central.init();
});
it("should update health metrics", async () => {
const projectPath = join(tempDir, "health-update");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Health Update",
path: projectPath,
});
const updated = await central.updateProjectHealth(project.id, {
activeTaskCount: 5,
inFlightAgentCount: 2,
status: "active",
});
expect(updated.activeTaskCount).toBe(5);
expect(updated.inFlightAgentCount).toBe(2);
expect(updated.status).toBe("active");
});
it("should emit project:health:changed event", async () => {
const projectPath = join(tempDir, "health-event");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Health Event",
path: projectPath,
});
let emittedHealth: ProjectHealth | undefined;
central.on("project:health:changed", (h) => {
emittedHealth = h;
});
await central.updateProjectHealth(project.id, { activeTaskCount: 3 });
expect(emittedHealth).toBeDefined();
expect(emittedHealth?.activeTaskCount).toBe(3);
});
it("should record successful task completion", async () => {
const projectPath = join(tempDir, "complete-task");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Complete Task",
path: projectPath,
});
await central.recordTaskCompletion(project.id, 5000, true);
const health = await central.getProjectHealth(project.id);
expect(health?.totalTasksCompleted).toBe(1);
expect(health?.totalTasksFailed).toBe(0);
expect(health?.averageTaskDurationMs).toBe(5000);
});
it("should record failed task completion", async () => {
const projectPath = join(tempDir, "fail-task");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Fail Task",
path: projectPath,
});
await central.recordTaskCompletion(project.id, 3000, false);
const health = await central.getProjectHealth(project.id);
expect(health?.totalTasksCompleted).toBe(0);
expect(health?.totalTasksFailed).toBe(1);
// Average duration should not be updated for failures
expect(health?.averageTaskDurationMs).toBeUndefined();
});
it("should calculate rolling average duration", async () => {
const projectPath = join(tempDir, "rolling-avg");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Rolling Avg",
path: projectPath,
});
await central.recordTaskCompletion(project.id, 1000, true);
await central.recordTaskCompletion(project.id, 2000, true);
await central.recordTaskCompletion(project.id, 3000, true);
const health = await central.getProjectHealth(project.id);
expect(health?.totalTasksCompleted).toBe(3);
// Average of 1000, 2000, 3000 = 2000
expect(health?.averageTaskDurationMs).toBe(2000);
});
it("should list all health records", async () => {
const projects: RegisteredProject[] = [];
for (let i = 0; i < 3; i++) {
const projectPath = join(tempDir, `health-list-${i}`);
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: `Health ${i}`,
path: projectPath,
});
projects.push(project);
}
const allHealth = await central.listAllHealth();
expect(allHealth).toHaveLength(3);
});
});
describe("unified activity feed", () => {
beforeEach(async () => {
await central.init();
});
it("should log activity with auto-generated id", async () => {
const projectPath = join(tempDir, "activity-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Activity Test",
path: projectPath,
});
const entry = await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Task created",
});
expect(entry.id).toMatch(/^[0-9a-f-]+$/); // UUID format
expect(entry.type).toBe("task:created");
});
it("should update project lastActivityAt on log", async () => {
const projectPath = join(tempDir, "activity-update");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Activity Update",
path: projectPath,
});
const beforeActivity = project.lastActivityAt;
// Small delay
await new Promise((r) => setTimeout(r, 10));
await central.logActivity({
type: "task:moved",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Task moved",
});
const updated = await central.getProject(project.id);
expect(updated?.lastActivityAt).not.toBe(beforeActivity);
});
it("should emit activity:logged event", async () => {
const projectPath = join(tempDir, "activity-event");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Activity Event",
path: projectPath,
});
let emittedEntry: CentralActivityLogEntry | undefined;
central.on("activity:logged", (e) => {
emittedEntry = e;
});
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Event test",
});
expect(emittedEntry).toBeDefined();
expect(emittedEntry?.details).toBe("Event test");
});
it("should get recent activity with default limit", async () => {
const projectPath = join(tempDir, "recent-activity");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Recent Activity",
path: projectPath,
});
// Log 150 activities
for (let i = 0; i < 150; i++) {
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: `Activity ${i}`,
});
}
const recent = await central.getRecentActivity();
expect(recent).toHaveLength(100); // Default limit
// Should be newest first
expect(recent[0].details).toBe("Activity 149");
expect(recent[99].details).toBe("Activity 50");
});
it("should filter activity by project", async () => {
const projectPath1 = join(tempDir, "filter-project-1");
const projectPath2 = join(tempDir, "filter-project-2");
mkdirSync(projectPath1);
mkdirSync(projectPath2);
projectPaths.push(projectPath1, projectPath2);
const project1 = await central.registerProject({
name: "Filter 1",
path: projectPath1,
});
const project2 = await central.registerProject({
name: "Filter 2",
path: projectPath2,
});
await central.logActivity({
type: "task:created",
projectId: project1.id,
projectName: project1.name,
timestamp: new Date().toISOString(),
details: "Project 1 activity",
});
await central.logActivity({
type: "task:created",
projectId: project2.id,
projectName: project2.name,
timestamp: new Date().toISOString(),
details: "Project 2 activity",
});
const p1Activities = await central.getRecentActivity({ projectId: project1.id });
expect(p1Activities).toHaveLength(1);
expect(p1Activities[0].details).toBe("Project 1 activity");
});
it("should filter activity by type", async () => {
const projectPath = join(tempDir, "type-filter");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Type Filter",
path: projectPath,
});
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Created",
});
await central.logActivity({
type: "task:moved",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Moved",
});
const createdActivities = await central.getRecentActivity({
types: ["task:created"],
});
expect(createdActivities).toHaveLength(1);
expect(createdActivities[0].details).toBe("Created");
});
it("should get activity count", async () => {
const projectPath = join(tempDir, "count-activity");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Count Activity",
path: projectPath,
});
for (let i = 0; i < 5; i++) {
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: `Count ${i}`,
});
}
const totalCount = await central.getActivityCount();
expect(totalCount).toBe(5);
const projectCount = await central.getActivityCount(project.id);
expect(projectCount).toBe(5);
});
it("should cleanup old activity entries", async () => {
const projectPath = join(tempDir, "cleanup-activity");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Cleanup Activity",
path: projectPath,
});
// Log a recent activity
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
timestamp: new Date().toISOString(),
details: "Recent",
});
const deleted = await central.cleanupOldActivity(0); // Delete all older than 0 days
expect(deleted).toBe(0); // The recent one shouldn't be deleted
const countAfter = await central.getActivityCount();
expect(countAfter).toBe(1);
});
});
describe("global concurrency", () => {
beforeEach(async () => {
await central.init();
});
it("should get initial concurrency state", async () => {
const state = await central.getGlobalConcurrencyState();
expect(state.globalMaxConcurrent).toBe(4);
expect(state.currentlyActive).toBe(0);
expect(state.queuedCount).toBe(0);
expect(state.projectsActive).toEqual({});
});
it("should update global max concurrent", async () => {
await central.updateGlobalConcurrency({ globalMaxConcurrent: 8 });
const state = await central.getGlobalConcurrencyState();
expect(state.globalMaxConcurrent).toBe(8);
});
it("should emit concurrency:changed event on update", async () => {
let emittedState: GlobalConcurrencyState | undefined;
central.on("concurrency:changed", (s) => {
emittedState = s;
});
await central.updateGlobalConcurrency({ globalMaxConcurrent: 6 });
expect(emittedState).toBeDefined();
expect(emittedState?.globalMaxConcurrent).toBe(6);
});
it("should acquire slot when available", async () => {
const projectPath = join(tempDir, "acquire-slot");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Acquire Slot",
path: projectPath,
});
const acquired = await central.acquireGlobalSlot(project.id);
expect(acquired).toBe(true);
const state = await central.getGlobalConcurrencyState();
expect(state.currentlyActive).toBe(1);
expect(state.projectsActive[project.id]).toBe(1);
});
it("should fail to acquire when at limit", async () => {
const projectPath = join(tempDir, "at-limit");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "At Limit",
path: projectPath,
});
// Set limit to 1
await central.updateGlobalConcurrency({ globalMaxConcurrent: 1 });
// First acquire succeeds
const first = await central.acquireGlobalSlot(project.id);
expect(first).toBe(true);
// Second acquire fails (queued)
const second = await central.acquireGlobalSlot(project.id);
expect(second).toBe(false);
const state = await central.getGlobalConcurrencyState();
expect(state.currentlyActive).toBe(1);
expect(state.queuedCount).toBe(1);
});
it("should release slot", async () => {
const projectPath = join(tempDir, "release-slot");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Release Slot",
path: projectPath,
});
await central.acquireGlobalSlot(project.id);
await central.releaseGlobalSlot(project.id);
const state = await central.getGlobalConcurrencyState();
expect(state.currentlyActive).toBe(0);
expect(state.projectsActive[project.id]).toBeUndefined();
});
it("should track per-project active counts", async () => {
const projectPath1 = join(tempDir, "multi-1");
const projectPath2 = join(tempDir, "multi-2");
mkdirSync(projectPath1);
mkdirSync(projectPath2);
projectPaths.push(projectPath1, projectPath2);
const project1 = await central.registerProject({
name: "Multi 1",
path: projectPath1,
});
const project2 = await central.registerProject({
name: "Multi 2",
path: projectPath2,
});
await central.acquireGlobalSlot(project1.id);
await central.acquireGlobalSlot(project1.id);
await central.acquireGlobalSlot(project2.id);
const state = await central.getGlobalConcurrencyState();
expect(state.currentlyActive).toBe(3);
expect(state.projectsActive[project1.id]).toBe(2);
expect(state.projectsActive[project2.id]).toBe(1);
});
it("should throw when acquiring for non-existent project", async () => {
await expect(central.acquireGlobalSlot("nonexistent")).rejects.toThrow("not found");
});
it("should throw when releasing for non-existent project", async () => {
await expect(central.releaseGlobalSlot("nonexistent")).rejects.toThrow("not found");
});
});
describe("utility methods", () => {
beforeEach(async () => {
await central.init();
});
it("should get database path", async () => {
const path = central.getDatabasePath();
expect(path).toBe(join(tempDir, "kb-central.db"));
});
it("should get global directory", async () => {
const dir = central.getGlobalDir();
expect(dir).toBe(tempDir);
});
it("should get stats", async () => {
const stats = await central.getStats();
expect(stats.projectCount).toBe(0);
expect(stats.totalTasksCompleted).toBe(0);
expect(typeof stats.dbSizeBytes).toBe("number");
});
it("should update stats after project registration", async () => {
const projectPath = join(tempDir, "stats-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({
name: "Stats Test",
path: projectPath,
});
const stats = await central.getStats();
expect(stats.projectCount).toBe(1);
});
it("should update stats after task completion", async () => {
const projectPath = join(tempDir, "stats-tasks");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Stats Tasks",
path: projectPath,
});
await central.recordTaskCompletion(project.id, 5000, true);
await central.recordTaskCompletion(project.id, 3000, true);
const stats = await central.getStats();
expect(stats.totalTasksCompleted).toBe(2);
});
});
describe("isolation modes", () => {
beforeEach(async () => {
await central.init();
});
it("should support in-process isolation", async () => {
const projectPath = join(tempDir, "in-process");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "In Process",
path: projectPath,
isolationMode: "in-process",
});
expect(project.isolationMode).toBe("in-process");
});
it("should support child-process isolation", async () => {
const projectPath = join(tempDir, "child-process");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Child Process",
path: projectPath,
isolationMode: "child-process",
});
expect(project.isolationMode).toBe("child-process");
});
it("should support all project statuses", async () => {
const projectPath = join(tempDir, "status-test");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Status Test",
path: projectPath,
});
const statuses = ["active", "paused", "errored", "initializing"] as const;
for (const status of statuses) {
const updated = await central.updateProject(project.id, { status });
expect(updated.status).toBe(status);
}
});
});
});