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 591345c791
commit 5ae3b1e891
9 changed files with 2953 additions and 1 deletions

View File

@@ -0,0 +1,206 @@
/**
* Integration test for CentralCore infrastructure.
*
* This test verifies the end-to-end functionality of the central infrastructure:
* - Project registration and management
* - Activity logging across projects
* - Health tracking
* - Global concurrency management
*/
import { describe, it, expect, beforeAll, afterAll } 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 } from "../types.js";
describe("CentralCore Integration", () => {
let tempDir: string;
let central: CentralCore;
const projects: RegisteredProject[] = [];
beforeAll(async () => {
// Create temp directory for test
tempDir = mkdtempSync(join(tmpdir(), "kb-central-integration-"));
// Initialize CentralCore
central = new CentralCore(tempDir);
await central.init();
});
afterAll(async () => {
// Cleanup
await central.close();
rmSync(tempDir, { recursive: true, force: true });
});
it("should register multiple projects", async () => {
for (let i = 0; i < 3; i++) {
const projectPath = join(tempDir, `integration-project-${i}`);
mkdirSync(projectPath);
const project = await central.registerProject({
name: `Integration Project ${i}`,
path: projectPath,
});
projects.push(project);
expect(project.id).toMatch(/^proj_/);
expect(project.name).toBe(`Integration Project ${i}`);
}
const allProjects = await central.listProjects();
expect(allProjects).toHaveLength(3);
});
it("should log activity for each project", async () => {
for (const project of projects) {
await central.logActivity({
type: "task:created",
projectId: project.id,
projectName: project.name,
taskId: `KB-${projects.indexOf(project) + 1}`,
taskTitle: `Test Task ${projects.indexOf(project) + 1}`,
timestamp: new Date().toISOString(),
details: `Task created in ${project.name}`,
});
}
const allActivity = await central.getRecentActivity();
expect(allActivity).toHaveLength(3);
for (const project of projects) {
const projectActivity = await central.getRecentActivity({ projectId: project.id });
expect(projectActivity).toHaveLength(1);
expect(projectActivity[0].projectId).toBe(project.id);
}
});
it("should update health for each project", async () => {
for (const project of projects) {
await central.updateProjectHealth(project.id, {
activeTaskCount: projects.indexOf(project) + 1,
inFlightAgentCount: 1,
status: "active",
});
const health = await central.getProjectHealth(project.id);
expect(health).toBeDefined();
expect(health?.activeTaskCount).toBe(projects.indexOf(project) + 1);
expect(health?.inFlightAgentCount).toBe(1);
expect(health?.status).toBe("active");
}
const allHealth = await central.listAllHealth();
expect(allHealth).toHaveLength(3);
});
it("should record task completions", async () => {
for (const project of projects) {
// Record some successful completions
await central.recordTaskCompletion(project.id, 5000, true);
await central.recordTaskCompletion(project.id, 3000, true);
// Record a failure
await central.recordTaskCompletion(project.id, 1000, false);
}
for (const project of projects) {
const health = await central.getProjectHealth(project.id);
expect(health?.totalTasksCompleted).toBe(2);
expect(health?.totalTasksFailed).toBe(1);
}
const stats = await central.getStats();
expect(stats.projectCount).toBe(3);
expect(stats.totalTasksCompleted).toBe(6);
});
it("should manage global concurrency", async () => {
// Reset state first by releasing any held slots
for (const project of projects) {
const health = await central.getProjectHealth(project.id);
if (health && health.inFlightAgentCount > 0) {
// Release all held slots
for (let i = 0; i < health.inFlightAgentCount; i++) {
await central.releaseGlobalSlot(project.id);
}
}
}
// Set a low limit for testing
await central.updateGlobalConcurrency({ globalMaxConcurrent: 2, currentlyActive: 0, queuedCount: 0 });
const initialState = await central.getGlobalConcurrencyState();
expect(initialState.globalMaxConcurrent).toBe(2);
expect(initialState.currentlyActive).toBe(0);
// Acquire slots
const acquired1 = await central.acquireGlobalSlot(projects[0].id);
expect(acquired1).toBe(true);
const acquired2 = await central.acquireGlobalSlot(projects[1].id);
expect(acquired2).toBe(true);
// Third should fail (at limit)
const acquired3 = await central.acquireGlobalSlot(projects[2].id);
expect(acquired3).toBe(false);
const atLimitState = await central.getGlobalConcurrencyState();
expect(atLimitState.currentlyActive).toBe(2);
expect(atLimitState.queuedCount).toBe(1);
// Release slots
await central.releaseGlobalSlot(projects[0].id);
await central.releaseGlobalSlot(projects[1].id);
const finalState = await central.getGlobalConcurrencyState();
expect(finalState.currentlyActive).toBe(0);
expect(finalState.projectsActive[projects[0].id]).toBeUndefined();
expect(finalState.projectsActive[projects[1].id]).toBeUndefined();
});
it("should have consistent unified feed across projects", async () => {
// Get all activity
const allActivity = await central.getRecentActivity({ limit: 10 });
// Verify we have activity from all projects
const projectIds = new Set(allActivity.map((a) => a.projectId));
expect(projectIds.size).toBeGreaterThanOrEqual(1);
// Verify activity count matches
const count = await central.getActivityCount();
expect(count).toBeGreaterThanOrEqual(3);
});
it("should unregister projects cleanly", async () => {
// Keep the first project, unregister the others
for (let i = 1; i < projects.length; i++) {
await central.unregisterProject(projects[i].id);
}
const remainingProjects = await central.listProjects();
expect(remainingProjects).toHaveLength(1);
expect(remainingProjects[0].id).toBe(projects[0].id);
// Health records for unregistered projects should be gone
for (let i = 1; i < projects.length; i++) {
const health = await central.getProjectHealth(projects[i].id);
expect(health).toBeUndefined();
}
});
it("should verify database path and stats", async () => {
const dbPath = central.getDatabasePath();
expect(dbPath).toContain("kb-central.db");
const globalDir = central.getGlobalDir();
expect(globalDir).toBe(tempDir);
const stats = await central.getStats();
expect(stats.projectCount).toBe(1); // Only first project remains
expect(typeof stats.dbSizeBytes).toBe("number");
expect(typeof stats.totalTasksCompleted).toBe("number");
});
});

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);
}
});
});
});

View File

@@ -0,0 +1,948 @@
/**
* CentralCore — Main API for kb's multi-project central infrastructure.
*
* Provides project registry, health tracking, unified activity feed,
* and global concurrency management across all registered projects.
*
* The central database is located at `~/.pi/kb/kb-central.db`.
*
* @example
* ```typescript
* const central = new CentralCore();
* await central.init();
*
* // Register a project
* const project = await central.registerProject({
* name: "My Project",
* path: "/path/to/project"
* });
*
* // Log activity
* await central.logActivity({
* type: "task:created",
* projectId: project.id,
* projectName: project.name,
* details: "Task KB-001 created"
* });
* ```
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import type {
RegisteredProject,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState,
IsolationMode,
ProjectStatus,
ActivityEventType,
ProjectSettings,
} from "./types.js";
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
import { defaultGlobalDir } from "./global-settings.js";
// ── Event Types ───────────────────────────────────────────────────────────
export interface CentralCoreEvents {
/** Emitted when a new project is registered */
"project:registered": [project: RegisteredProject];
/** Emitted when a project is unregistered */
"project:unregistered": [projectId: string];
/** Emitted when project metadata is updated */
"project:updated": [project: RegisteredProject];
/** Emitted when project health metrics change */
"project:health:changed": [health: ProjectHealth];
/** Emitted when a new activity is logged */
"activity:logged": [entry: CentralActivityLogEntry];
/** Emitted when global concurrency state changes */
"concurrency:changed": [state: GlobalConcurrencyState];
}
// ── CentralCore Class ─────────────────────────────────────────────────────
export class CentralCore extends EventEmitter<CentralCoreEvents> {
private db: CentralDatabase | null = null;
private readonly globalDir: string;
private initialized = false;
/**
* Create a CentralCore instance.
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
* Accepts a custom path for testing.
*/
constructor(globalDir?: string) {
super();
this.setMaxListeners(100);
this.globalDir = globalDir ?? defaultGlobalDir();
}
/**
* Initialize the central infrastructure.
* Ensures the directory and database exist with proper schema.
* Idempotent — safe to call multiple times.
*/
async init(): Promise<void> {
if (this.initialized) return;
// Ensure directory exists
await mkdir(this.globalDir, { recursive: true });
// Initialize database
if (!this.db) {
this.db = new CentralDatabase(this.globalDir);
this.db.init();
}
this.initialized = true;
}
/**
* Close the central infrastructure.
* Closes database connections and releases resources.
*/
async close(): Promise<void> {
if (this.db) {
this.db.close();
this.db = null;
}
this.initialized = false;
this.removeAllListeners();
}
/**
* Check if the central infrastructure is initialized.
*/
isInitialized(): boolean {
return this.initialized;
}
// ── Project Registry API ────────────────────────────────────────────────
/**
* Register a new project in the central database.
*
* @param input — Project registration input
* @returns The registered project
* @throws Error if path doesn't exist, isn't absolute, or is already registered
*/
async registerProject(input: {
name: string;
path: string;
isolationMode?: IsolationMode;
settings?: ProjectSettings;
}): Promise<RegisteredProject> {
this.ensureInitialized();
// Validate path
if (!isAbsolute(input.path)) {
throw new Error(`Project path must be absolute: ${input.path}`);
}
if (!existsSync(input.path)) {
throw new Error(`Project path does not exist: ${input.path}`);
}
if (!statSync(input.path).isDirectory()) {
throw new Error(`Project path must be a directory: ${input.path}`);
}
// Check for duplicate path
const existingByPath = await this.getProjectByPath(input.path);
if (existingByPath) {
throw new Error(`Project already registered at path: ${input.path}`);
}
const now = new Date().toISOString();
const project: RegisteredProject = {
id: `proj_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
name: input.name,
path: input.path,
status: "initializing",
isolationMode: input.isolationMode ?? "in-process",
createdAt: now,
updatedAt: now,
lastActivityAt: now,
settings: input.settings,
};
this.db!.transaction(() => {
// Insert project
this.db!.prepare(
`INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt, lastActivityAt, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
project.id,
project.name,
project.path,
project.status,
project.isolationMode,
project.createdAt,
project.updatedAt,
project.lastActivityAt ?? null,
toJsonNullable(project.settings)
);
// Initialize health record
this.db!.prepare(
`INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed)
VALUES (?, ?, ?, 0, 0)`
).run(project.id, project.status, now);
});
this.db!.bumpLastModified();
this.emit("project:registered", project);
return project;
}
/**
* Unregister a project from the central database.
* Cascades to delete health records and activity log entries.
*
* @param id — Project ID to unregister
*/
async unregisterProject(id: string): Promise<void> {
this.ensureInitialized();
// Check if project exists
const project = await this.getProject(id);
if (!project) {
return; // Idempotent
}
// Delete will cascade to health and activity log
this.db!.prepare("DELETE FROM projects WHERE id = ?").run(id);
this.db!.bumpLastModified();
this.emit("project:unregistered", id);
}
/**
* Get a registered project by ID.
*
* @param id — Project ID
* @returns The project or undefined if not found
*/
async getProject(id: string): Promise<RegisteredProject | undefined> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
| {
id: string;
name: string;
path: string;
status: string;
isolationMode: string;
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
settings: string | null;
}
| undefined;
if (!row) return undefined;
return this.rowToProject(row);
}
/**
* Get a registered project by path.
*
* @param path — Absolute project path
* @returns The project or undefined if not found
*/
async getProjectByPath(path: string): Promise<RegisteredProject | undefined> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM projects WHERE path = ?").get(path) as
| {
id: string;
name: string;
path: string;
status: string;
isolationMode: string;
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
settings: string | null;
}
| undefined;
if (!row) return undefined;
return this.rowToProject(row);
}
/**
* List all registered projects.
*
* @returns Array of all registered projects
*/
async listProjects(): Promise<RegisteredProject[]> {
this.ensureInitialized();
const rows = this.db!.prepare("SELECT * FROM projects ORDER BY name").all() as Array<{
id: string;
name: string;
path: string;
status: string;
isolationMode: string;
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
settings: string | null;
}>;
return rows.map((row) => this.rowToProject(row));
}
/**
* Update a registered project's metadata.
*
* @param id — Project ID to update
* @param updates — Partial project updates (id, createdAt cannot be changed)
* @returns Updated project
* @throws Error if project not found
*/
async updateProject(
id: string,
updates: Partial<Omit<RegisteredProject, "id" | "createdAt">>
): Promise<RegisteredProject> {
this.ensureInitialized();
const project = await this.getProject(id);
if (!project) {
throw new Error(`Project not found: ${id}`);
}
const now = new Date().toISOString();
const updated: RegisteredProject = {
...project,
...updates,
id, // Ensure ID doesn't change
createdAt: project.createdAt, // Ensure createdAt doesn't change
updatedAt: now,
};
this.db!.prepare(
`UPDATE projects SET
name = ?,
path = ?,
status = ?,
isolationMode = ?,
updatedAt = ?,
lastActivityAt = ?,
settings = ?
WHERE id = ?`
).run(
updated.name,
updated.path,
updated.status,
updated.isolationMode,
updated.updatedAt,
updated.lastActivityAt ?? null,
toJsonNullable(updated.settings),
id
);
this.db!.bumpLastModified();
this.emit("project:updated", updated);
return updated;
}
// ── Project Health API ──────────────────────────────────────────────────
/**
* Update project health metrics.
*
* @param projectId — Project ID
* @param updates — Partial health updates
* @returns Updated health metrics
*/
async updateProjectHealth(
projectId: string,
updates: Partial<ProjectHealth>
): Promise<ProjectHealth> {
this.ensureInitialized();
const current = await this.getProjectHealth(projectId);
if (!current) {
throw new Error(`Project health not found for: ${projectId}`);
}
const now = new Date().toISOString();
const updated: ProjectHealth = {
...current,
...updates,
projectId, // Ensure projectId doesn't change
updatedAt: now,
};
this.db!.prepare(
`UPDATE projectHealth SET
status = ?,
activeTaskCount = ?,
inFlightAgentCount = ?,
lastActivityAt = ?,
lastErrorAt = ?,
lastErrorMessage = ?,
totalTasksCompleted = ?,
totalTasksFailed = ?,
averageTaskDurationMs = ?,
updatedAt = ?
WHERE projectId = ?`
).run(
updated.status,
updated.activeTaskCount,
updated.inFlightAgentCount,
updated.lastActivityAt ?? null,
updated.lastErrorAt ?? null,
updated.lastErrorMessage ?? null,
updated.totalTasksCompleted,
updated.totalTasksFailed,
updated.averageTaskDurationMs ?? null,
updated.updatedAt,
projectId
);
this.emit("project:health:changed", updated);
return updated;
}
/**
* Get project health metrics.
*
* @param projectId — Project ID
* @returns Health metrics or undefined if not found
*/
async getProjectHealth(projectId: string): Promise<ProjectHealth | undefined> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get(projectId) as
| {
projectId: string;
status: string;
activeTaskCount: number;
inFlightAgentCount: number;
lastActivityAt: string | null;
lastErrorAt: string | null;
lastErrorMessage: string | null;
totalTasksCompleted: number;
totalTasksFailed: number;
averageTaskDurationMs: number | null;
updatedAt: string;
}
| undefined;
if (!row) return undefined;
return this.rowToHealth(row);
}
/**
* List health metrics for all projects.
*
* @returns Array of all project health metrics
*/
async listAllHealth(): Promise<ProjectHealth[]> {
this.ensureInitialized();
const rows = this.db!.prepare("SELECT * FROM projectHealth").all() as Array<{
projectId: string;
status: string;
activeTaskCount: number;
inFlightAgentCount: number;
lastActivityAt: string | null;
lastErrorAt: string | null;
lastErrorMessage: string | null;
totalTasksCompleted: number;
totalTasksFailed: number;
averageTaskDurationMs: number | null;
updatedAt: string;
}>;
return rows.map((row) => this.rowToHealth(row));
}
/**
* Record a task completion/failure for health tracking.
* Atomically updates counters and rolling average duration.
*
* @param projectId — Project ID
* @param durationMs — Task duration in milliseconds
* @param success — Whether the task completed successfully
*/
async recordTaskCompletion(projectId: string, durationMs: number, success: boolean): Promise<void> {
this.ensureInitialized();
const health = await this.getProjectHealth(projectId);
if (!health) {
throw new Error(`Project health not found for: ${projectId}`);
}
const now = new Date().toISOString();
const totalCompleted = health.totalTasksCompleted + (success ? 1 : 0);
const totalFailed = health.totalTasksFailed + (success ? 0 : 1);
// Calculate rolling average duration
let averageDuration: number | undefined;
if (success) {
const currentAvg = health.averageTaskDurationMs ?? 0;
const newCount = totalCompleted;
// Rolling average: newAvg = (oldAvg * (n-1) + newValue) / n
averageDuration = Math.round((currentAvg * (newCount - 1) + durationMs) / newCount);
} else {
averageDuration = health.averageTaskDurationMs;
}
this.db!.prepare(
`UPDATE projectHealth SET
totalTasksCompleted = ?,
totalTasksFailed = ?,
averageTaskDurationMs = ?,
lastActivityAt = ?,
updatedAt = ?
WHERE projectId = ?`
).run(totalCompleted, totalFailed, averageDuration ?? null, now, now, projectId);
const updated = await this.getProjectHealth(projectId);
if (updated) {
this.emit("project:health:changed", updated);
}
}
// ── Unified Activity Feed API ───────────────────────────────────────────
/**
* Log an activity to the unified central feed.
* Also updates the project's lastActivityAt timestamp.
*
* @param entry — Activity entry (without id - will be generated)
* @returns The logged entry with generated id
*/
async logActivity(
entry: Omit<CentralActivityLogEntry, "id">
): Promise<CentralActivityLogEntry> {
this.ensureInitialized();
const fullEntry: CentralActivityLogEntry = {
...entry,
id: randomUUID(),
};
this.db!.transaction(() => {
// Insert activity log entry
this.db!.prepare(
`INSERT INTO centralActivityLog (id, timestamp, type, projectId, projectName, taskId, taskTitle, details, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
fullEntry.id,
fullEntry.timestamp,
fullEntry.type,
fullEntry.projectId,
fullEntry.projectName,
fullEntry.taskId ?? null,
fullEntry.taskTitle ?? null,
fullEntry.details,
toJsonNullable(fullEntry.metadata)
);
// Update project's lastActivityAt
this.db!.prepare("UPDATE projects SET lastActivityAt = ? WHERE id = ?").run(
fullEntry.timestamp,
fullEntry.projectId
);
});
this.db!.bumpLastModified();
this.emit("activity:logged", fullEntry);
return fullEntry;
}
/**
* Get recent activity from the unified feed.
*
* @param options — Query options (limit, projectId filter, type filter)
* @returns Array of activity entries, newest first
*/
async getRecentActivity(options?: {
limit?: number;
projectId?: string;
types?: ActivityEventType[];
}): Promise<CentralActivityLogEntry[]> {
this.ensureInitialized();
const limit = options?.limit ?? 100;
const conditions: string[] = [];
const params: (string | number | string[])[] = [limit];
if (options?.projectId) {
conditions.push("projectId = ?");
params.unshift(options.projectId);
}
if (options?.types && options.types.length > 0) {
conditions.push(`type IN (${options.types.map(() => "?").join(",")})`);
params.unshift(...options.types);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Reorder params: types first, then projectId, then limit
const queryParams: (string | number)[] = [];
if (options?.types) queryParams.push(...options.types);
if (options?.projectId) queryParams.push(options.projectId);
queryParams.push(limit);
const sql = `SELECT * FROM centralActivityLog ${whereClause} ORDER BY timestamp DESC LIMIT ?`;
const rows = this.db!.prepare(sql).all(...queryParams) as Array<{
id: string;
timestamp: string;
type: string;
projectId: string;
projectName: string;
taskId: string | null;
taskTitle: string | null;
details: string;
metadata: string | null;
}>;
return rows.map((row) => this.rowToActivityEntry(row));
}
/**
* Get the total count of activity log entries.
*
* @param projectId — Optional project filter
* @returns Count of entries
*/
async getActivityCount(projectId?: string): Promise<number> {
this.ensureInitialized();
let sql = "SELECT COUNT(*) as count FROM centralActivityLog";
const params: string[] = [];
if (projectId) {
sql += " WHERE projectId = ?";
params.push(projectId);
}
const row = this.db!.prepare(sql).get(...params) as { count: number };
return row.count;
}
/**
* Clean up old activity log entries.
*
* @param olderThanDays — Delete entries older than this many days
* @returns Number of entries deleted
*/
async cleanupOldActivity(olderThanDays: number): Promise<number> {
this.ensureInitialized();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
const cutoff = cutoffDate.toISOString();
const result = this.db!.prepare("DELETE FROM centralActivityLog WHERE timestamp < ?").run(cutoff);
const deletedCount = typeof result.changes === "bigint" ? Number(result.changes) : (result.changes ?? 0);
if (deletedCount > 0) {
this.db!.bumpLastModified();
}
return deletedCount;
}
// ── Global Concurrency API ─────────────────────────────────────────────
/**
* Get the current global concurrency state.
*
* @returns Current concurrency state including per-project active counts
*/
async getGlobalConcurrencyState(): Promise<GlobalConcurrencyState> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as {
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
};
// Calculate per-project active counts
const healthRows = this.db!.prepare(
"SELECT projectId, inFlightAgentCount FROM projectHealth WHERE inFlightAgentCount > 0"
).all() as Array<{ projectId: string; inFlightAgentCount: number }>;
const projectsActive: Record<string, number> = {};
for (const { projectId, inFlightAgentCount } of healthRows) {
projectsActive[projectId] = inFlightAgentCount;
}
return {
globalMaxConcurrent: row.globalMaxConcurrent,
currentlyActive: row.currentlyActive,
queuedCount: row.queuedCount,
projectsActive,
};
}
/**
* Update global concurrency settings.
* Only allows updating globalMaxConcurrent, currentlyActive, and queuedCount.
*
* @param updates — Partial concurrency state updates
* @returns Updated concurrency state
*/
async updateGlobalConcurrency(
updates: Partial<Pick<GlobalConcurrencyState, "globalMaxConcurrent" | "currentlyActive" | "queuedCount">>
): Promise<GlobalConcurrencyState> {
this.ensureInitialized();
const current = await this.getGlobalConcurrencyState();
const updated = {
...current,
...updates,
};
this.db!.prepare(
`UPDATE globalConcurrency SET
globalMaxConcurrent = ?,
currentlyActive = ?,
queuedCount = ?,
updatedAt = ?
WHERE id = 1`
).run(
updated.globalMaxConcurrent,
updated.currentlyActive,
updated.queuedCount,
new Date().toISOString()
);
this.emit("concurrency:changed", updated);
return updated;
}
/**
* Acquire a global concurrency slot.
* Atomically checks if a slot is available and acquires it if so.
*
* @param projectId — Project requesting the slot
* @returns true if slot acquired, false if at limit (queued)
*/
async acquireGlobalSlot(projectId: string): Promise<boolean> {
this.ensureInitialized();
// Check if project exists
const project = await this.getProject(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
let acquired = false;
this.db!.transaction(() => {
const row = this.db!.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as {
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
};
if (row.currentlyActive < row.globalMaxConcurrent) {
// Acquire slot
this.db!.prepare(
"UPDATE globalConcurrency SET currentlyActive = currentlyActive + 1, updatedAt = ? WHERE id = 1"
).run(new Date().toISOString());
// Increment project's active count
this.db!.prepare(
"UPDATE projectHealth SET inFlightAgentCount = inFlightAgentCount + 1, updatedAt = ? WHERE projectId = ?"
).run(new Date().toISOString(), projectId);
acquired = true;
} else {
// Queue the request
this.db!.prepare(
"UPDATE globalConcurrency SET queuedCount = queuedCount + 1, updatedAt = ? WHERE id = 1"
).run(new Date().toISOString());
acquired = false;
}
});
const state = await this.getGlobalConcurrencyState();
this.emit("concurrency:changed", state);
return acquired;
}
/**
* Release a global concurrency slot.
* Decrements the global active count and project's active count.
*
* @param projectId — Project releasing the slot
*/
async releaseGlobalSlot(projectId: string): Promise<void> {
this.ensureInitialized();
// Check if project exists
const project = await this.getProject(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
this.db!.transaction(() => {
// Decrement global active count (don't go below 0)
this.db!.prepare(
`UPDATE globalConcurrency SET
currentlyActive = MAX(0, currentlyActive - 1),
updatedAt = ?
WHERE id = 1`
).run(new Date().toISOString());
// Decrement project's active count (don't go below 0)
this.db!.prepare(
`UPDATE projectHealth SET
inFlightAgentCount = MAX(0, inFlightAgentCount - 1),
updatedAt = ?
WHERE projectId = ?`
).run(new Date().toISOString(), projectId);
});
const state = await this.getGlobalConcurrencyState();
this.emit("concurrency:changed", state);
}
// ── Utility Methods ─────────────────────────────────────────────────────
/**
* Get the path to the central database file.
*
* @returns Absolute path to kb-central.db
*/
getDatabasePath(): string {
return this.db?.getPath() ?? join(this.globalDir, "kb-central.db");
}
/**
* Get the global directory path.
*
* @returns Absolute path to global kb directory
*/
getGlobalDir(): string {
return this.globalDir;
}
/**
* Get statistics about the central infrastructure.
*
* @returns Statistics including project count, task totals, and database size
*/
async getStats(): Promise<{ projectCount: number; totalTasksCompleted: number; dbSizeBytes: number }> {
this.ensureInitialized();
const projectCount = (
this.db!.prepare("SELECT COUNT(*) as count FROM projects").get() as { count: number }
).count;
const totalTasksCompleted = (
this.db!.prepare("SELECT SUM(totalTasksCompleted) as total FROM projectHealth").get() as {
total: number | null;
}
).total ?? 0;
const dbPath = this.db!.getPath();
let dbSizeBytes = 0;
try {
dbSizeBytes = statSync(dbPath).size;
} catch {
// File might not exist yet
}
return { projectCount, totalTasksCompleted, dbSizeBytes };
}
// ── Private Helpers ─────────────────────────────────────────────────────
private ensureInitialized(): void {
if (!this.initialized || !this.db) {
throw new Error("CentralCore not initialized. Call init() first.");
}
}
private rowToProject(row: {
id: string;
name: string;
path: string;
status: string;
isolationMode: string;
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
settings: string | null;
}): RegisteredProject {
return {
id: row.id,
name: row.name,
path: row.path,
status: row.status as ProjectStatus,
isolationMode: row.isolationMode as IsolationMode,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastActivityAt: row.lastActivityAt ?? undefined,
settings: fromJson<ProjectSettings>(row.settings),
};
}
private rowToHealth(row: {
projectId: string;
status: string;
activeTaskCount: number;
inFlightAgentCount: number;
lastActivityAt: string | null;
lastErrorAt: string | null;
lastErrorMessage: string | null;
totalTasksCompleted: number;
totalTasksFailed: number;
averageTaskDurationMs: number | null;
updatedAt: string;
}): ProjectHealth {
return {
projectId: row.projectId,
status: row.status as ProjectStatus,
activeTaskCount: row.activeTaskCount,
inFlightAgentCount: row.inFlightAgentCount,
lastActivityAt: row.lastActivityAt ?? undefined,
lastErrorAt: row.lastErrorAt ?? undefined,
lastErrorMessage: row.lastErrorMessage ?? undefined,
totalTasksCompleted: row.totalTasksCompleted,
totalTasksFailed: row.totalTasksFailed,
averageTaskDurationMs: row.averageTaskDurationMs ?? undefined,
updatedAt: row.updatedAt,
};
}
private rowToActivityEntry(row: {
id: string;
timestamp: string;
type: string;
projectId: string;
projectName: string;
taskId: string | null;
taskTitle: string | null;
details: string;
metadata: string | null;
}): CentralActivityLogEntry {
return {
id: row.id,
timestamp: row.timestamp,
type: row.type as ActivityEventType,
projectId: row.projectId,
projectName: row.projectName,
taskId: row.taskId ?? undefined,
taskTitle: row.taskTitle ?? undefined,
details: row.details,
metadata: fromJson<Record<string, unknown>>(row.metadata),
};
}
}

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralDatabase, createCentralDatabase, toJson, fromJson } from "./central-db.js";
describe("CentralDatabase", () => {
let tempDir: string;
let db: CentralDatabase;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-central-test-"));
db = createCentralDatabase(tempDir);
});
afterEach(() => {
db.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe("initialization", () => {
it("should create database at the specified path", () => {
db.init();
const dbPath = db.getPath();
expect(dbPath).toBe(join(tempDir, "kb-central.db"));
// Verify file exists
const stats = statSync(dbPath);
expect(stats.isFile()).toBe(true);
});
it("should create the global directory if it doesn't exist", () => {
const newTempDir = join(tmpdir(), `kb-central-test-${Date.now()}`);
const newDb = createCentralDatabase(newTempDir);
newDb.init();
expect(statSync(newTempDir).isDirectory()).toBe(true);
newDb.close();
rmSync(newTempDir, { recursive: true, force: true });
});
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(1);
});
it("should seed lastModified on init", () => {
db.init();
const lastModified = db.getLastModified();
expect(lastModified).toBeGreaterThan(0);
});
it("should seed globalConcurrency default row", () => {
db.init();
const row = db.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as {
id: number;
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
} | undefined;
expect(row).toBeDefined();
expect(row?.globalMaxConcurrent).toBe(4);
expect(row?.currentlyActive).toBe(0);
expect(row?.queuedCount).toBe(0);
});
it("should create all required tables", () => {
db.init();
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("projects");
expect(tableNames).toContain("projectHealth");
expect(tableNames).toContain("centralActivityLog");
expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("__meta");
});
it("should create required indexes", () => {
db.init();
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name")
.all() as Array<{ name: string }>;
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxProjectsPath");
expect(indexNames).toContain("idxProjectsStatus");
expect(indexNames).toContain("idxActivityLogTimestamp");
expect(indexNames).toContain("idxActivityLogType");
expect(indexNames).toContain("idxActivityLogProjectId");
});
});
describe("transactions", () => {
beforeEach(() => {
db.init();
});
it("should support basic transactions", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_1",
"Test Project",
"/test/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
});
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_1") as { id: string; name: string } | undefined;
expect(row).toBeDefined();
expect(row?.name).toBe("Test Project");
});
it("should rollback on error", () => {
expect(() => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_2",
"Test Project",
"/test/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
throw new Error("Intentional error");
});
}).toThrow("Intentional error");
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_2") as { id: string } | undefined;
expect(row).toBeUndefined();
});
it("should support nested transactions via savepoints", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_outer",
"Outer Project",
"/outer/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_inner",
"Inner Project",
"/inner/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
});
});
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer") as { id: string } | undefined;
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner") as { id: string } | undefined;
expect(outerRow).toBeDefined();
expect(innerRow).toBeDefined();
});
it("should rollback nested transaction without affecting outer", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_outer_2",
"Outer Project",
"/outer/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
// Inner transaction throws but is caught
try {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_inner_2",
"Inner Project",
"/inner/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
throw new Error("Inner error");
});
} catch {
// Ignore inner error
}
});
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer_2") as { id: string } | undefined;
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner_2") as { id: string } | undefined;
expect(outerRow).toBeDefined();
expect(innerRow).toBeUndefined();
});
});
describe("lastModified tracking", () => {
beforeEach(() => {
db.init();
});
it("should bump lastModified", () => {
const before = db.getLastModified();
// Small delay to ensure different timestamp
const start = Date.now();
while (Date.now() < start + 2) { /* spin */ }
db.bumpLastModified();
const after = db.getLastModified();
expect(after).toBeGreaterThan(before);
});
it("should guarantee monotonic increase", () => {
db.bumpLastModified();
const first = db.getLastModified();
db.bumpLastModified();
const second = db.getLastModified();
expect(second).toBeGreaterThan(first);
});
});
describe("foreign key constraints", () => {
beforeEach(() => {
db.init();
});
it("should enforce foreign key constraints", () => {
// Try to insert health record for non-existent project
expect(() => {
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
"nonexistent",
"active",
new Date().toISOString()
);
}).toThrow();
});
it("should cascade delete project health on project deletion", () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_cascade",
"Cascade Test",
"/cascade/path",
"active",
"in-process",
now,
now
);
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
"proj_cascade",
"active",
now
);
// Verify health record exists
const healthBefore = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
expect(healthBefore).toBeDefined();
// Delete project
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_cascade");
// Health record should be gone (cascade delete)
const healthAfter = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
expect(healthAfter).toBeUndefined();
});
it("should cascade delete activity log entries on project deletion", () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_activity",
"Activity Test",
"/activity/path",
"active",
"in-process",
now,
now
);
db.prepare("INSERT INTO centralActivityLog (id, timestamp, type, projectId, projectName, details) VALUES (?, ?, ?, ?, ?, ?)").run(
"log_1",
now,
"task:created",
"proj_activity",
"Activity Test",
"Test activity"
);
// Verify log entry exists
const logBefore = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
expect(logBefore).toBeDefined();
// Delete project
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_activity");
// Log entry should be gone (cascade delete)
const logAfter = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
expect(logAfter).toBeUndefined();
});
});
describe("JSON helpers", () => {
it("should stringify arrays for JSON columns", () => {
const arr = ["a", "b", "c"];
expect(toJson(arr)).toBe('["a","b","c"]');
});
it("should return '[]' for null/undefined", () => {
expect(toJson(null)).toBe("[]");
expect(toJson(undefined)).toBe("[]");
});
it("should parse JSON columns correctly", () => {
const json = '{"key": "value", "num": 42}';
const parsed = fromJson<{ key: string; num: number }>(json);
expect(parsed).toEqual({ key: "value", num: 42 });
});
it("should return undefined for null/empty JSON", () => {
expect(fromJson(null)).toBeUndefined();
expect(fromJson(undefined)).toBeUndefined();
expect(fromJson("")).toBeUndefined();
});
it("should return undefined for invalid JSON", () => {
expect(fromJson("not valid json")).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,264 @@
/**
* Central SQLite database module for kb's multi-project architecture.
*
* Uses Node.js built-in `node:sqlite` (DatabaseSync) for simplified
* synchronous transaction handling. The database runs in WAL mode
* for concurrent reader/writer access.
*
* This database is stored at `~/.pi/kb/kb-central.db` and serves as the
* coordination hub for all projects, storing the project registry,
* unified activity feed, global concurrency limits, and project health.
*/
import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import type { Statement } from "./db.js";
// ── JSON Helpers (reused from db.ts) ─────────────────────────────────────
import { toJson, toJsonNullable, fromJson } from "./db.js";
export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 1;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
settings TEXT -- JSON ProjectSettings snapshot
);
CREATE INDEX IF NOT EXISTS idxProjectsPath ON projects(path);
CREATE INDEX IF NOT EXISTS idxProjectsStatus ON projects(status);
-- Project health table (mutable state, updated frequently)
CREATE TABLE IF NOT EXISTS projectHealth (
projectId TEXT PRIMARY KEY,
status TEXT NOT NULL,
activeTaskCount INTEGER DEFAULT 0,
inFlightAgentCount INTEGER DEFAULT 0,
lastActivityAt TEXT,
lastErrorAt TEXT,
lastErrorMessage TEXT,
totalTasksCompleted INTEGER DEFAULT 0,
totalTasksFailed INTEGER DEFAULT 0,
averageTaskDurationMs INTEGER,
updatedAt TEXT NOT NULL,
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE
);
-- Central activity log (unified feed across all projects)
CREATE TABLE IF NOT EXISTS centralActivityLog (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
projectId TEXT NOT NULL,
projectName TEXT NOT NULL,
taskId TEXT,
taskTitle TEXT,
details TEXT NOT NULL,
metadata TEXT, -- JSON
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxActivityLogTimestamp ON centralActivityLog(timestamp);
CREATE INDEX IF NOT EXISTS idxActivityLogType ON centralActivityLog(type);
CREATE INDEX IF NOT EXISTS idxActivityLogProjectId ON centralActivityLog(projectId);
-- Global concurrency state (single row)
CREATE TABLE IF NOT EXISTS globalConcurrency (
id INTEGER PRIMARY KEY CHECK (id = 1),
globalMaxConcurrent INTEGER DEFAULT 4,
currentlyActive INTEGER DEFAULT 0,
queuedCount INTEGER DEFAULT 0,
updatedAt TEXT
);
-- Seed default row
INSERT OR IGNORE INTO globalConcurrency (id, globalMaxConcurrent, currentlyActive, queuedCount)
VALUES (1, 4, 0, 0);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`;
// ── Central Database Class ────────────────────────────────────────────────
/**
* Default directory for central kb data: `~/.pi/kb/`
*/
function defaultGlobalDir(): string {
return join(homedir(), ".pi", "kb");
}
export class CentralDatabase {
private db: DatabaseSync;
private readonly dbPath: string;
private readonly globalDir: string;
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
private transactionDepth = 0;
constructor(globalDir?: string) {
this.globalDir = globalDir ?? defaultGlobalDir();
this.dbPath = join(this.globalDir, "kb-central.db");
// Ensure directory exists
if (!existsSync(this.globalDir)) {
mkdirSync(this.globalDir, { recursive: true });
}
this.db = new DatabaseSync(this.dbPath);
// Enable WAL mode for concurrent reader/writer access
this.db.exec("PRAGMA journal_mode = WAL");
// Enable foreign key enforcement
this.db.exec("PRAGMA foreign_keys = ON");
}
/**
* Initialize the database: create tables if they don't exist
* and seed meta values.
*/
init(): void {
this.db.exec(CENTRAL_SCHEMA_SQL);
// Seed schemaVersion and lastModified idempotently
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
);
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
);
}
/**
* Close the database connection.
*/
close(): void {
this.db.close();
}
/**
* Execute a function inside a SQLite transaction.
* Supports nested calls via SAVEPOINTs.
* If the function throws, the transaction/savepoint is rolled back.
* If the function returns normally, the transaction/savepoint is committed.
*/
transaction<T>(fn: () => T): T {
const depth = this.transactionDepth++;
const isOutermost = depth === 0;
const savepointName = `sp_${depth}`;
if (isOutermost) {
this.db.exec("BEGIN");
} else {
this.db.exec(`SAVEPOINT ${savepointName}`);
}
try {
const result = fn();
if (isOutermost) {
this.db.exec("COMMIT");
} else {
this.db.exec(`RELEASE ${savepointName}`);
}
return result;
} catch (err) {
if (isOutermost) {
this.db.exec("ROLLBACK");
} else {
this.db.exec(`ROLLBACK TO ${savepointName}`);
this.db.exec(`RELEASE ${savepointName}`);
}
throw err;
} finally {
this.transactionDepth--;
}
}
/**
* Prepare a SQL statement. Returns a Statement object.
*/
prepare(sql: string): Statement {
return this.db.prepare(sql);
}
/**
* Execute a raw SQL string (no parameters).
*/
exec(sql: string): void {
this.db.exec(sql);
}
/**
* Get the last modification timestamp (epoch ms).
* Returns 0 if the value is not set.
*/
getLastModified(): number {
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'lastModified'").get() as
| { value: string }
| undefined;
if (!row) return 0;
return parseInt(row.value, 10) || 0;
}
/**
* Update the last modification timestamp to the current time.
* Guarantees monotonicity: the new value is always strictly greater than
* the previous value, even if called multiple times within the same millisecond.
* Call this after every write operation to enable change detection polling.
*/
bumpLastModified(): void {
const current = this.getLastModified();
const next = Math.max(Date.now(), current + 1);
this.db.prepare("UPDATE __meta SET value = ? WHERE key = 'lastModified'").run(String(next));
}
/**
* Get the schema version number.
*/
getSchemaVersion(): number {
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'").get() as
| { value: string }
| undefined;
if (!row) return 0;
return parseInt(row.value, 10) || 0;
}
/**
* Get the database file path.
*/
getPath(): string {
return this.dbPath;
}
/**
* Get the global directory path.
*/
getGlobalDir(): string {
return this.globalDir;
}
}
// ── Factory Function ──────────────────────────────────────────────────────
/**
* Create a new CentralDatabase instance (does NOT initialize schema).
* Callers must call `db.init()` separately.
* @param globalDir - Path to the global kb directory (e.g., `~/.pi/kb/`)
* @returns CentralDatabase instance (not yet initialized)
*/
export function createCentralDatabase(globalDir?: string): CentralDatabase {
return new CentralDatabase(globalDir);
}

View File

@@ -16,7 +16,7 @@ import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
/** Default directory for global kb settings: `~/.pi/kb/` */
function defaultGlobalDir(): string {
export function defaultGlobalDir(): string {
return join(homedir(), ".pi", "kb");
}

View File

@@ -109,3 +109,17 @@ export type {
} from "./mission-types.js";
export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents } from "./mission-store.js";
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────
export { CentralCore } from "./central-core.js";
export type { CentralCoreEvents } from "./central-core.js";
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
export type {
RegisteredProject,
IsolationMode,
ProjectStatus,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState
} from "./types.js";

View File

@@ -867,6 +867,94 @@ export interface ArchivedTaskEntry {
/** Type of planning question presented to the user */
export type PlanningQuestionType = "text" | "single_select" | "multi_select" | "confirm";
/** Isolation mode for project execution */
export type IsolationMode = "in-process" | "child-process";
/** Project status in the central registry */
export type ProjectStatus = "active" | "paused" | "errored" | "initializing";
/** A project registered in the central database */
export interface RegisteredProject {
/** Unique project ID (e.g., "proj_abc123") */
id: string;
/** Display name */
name: string;
/** Absolute path to project directory */
path: string;
/** Current project status */
status: ProjectStatus;
/** Execution isolation mode */
isolationMode: IsolationMode;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
/** ISO-8601 timestamp of last activity */
lastActivityAt?: string;
/** Cached project settings snapshot */
settings?: ProjectSettings;
}
/** Health metrics for a registered project */
export interface ProjectHealth {
/** Project ID reference */
projectId: string;
/** Current status */
status: ProjectStatus;
/** Number of tasks currently active */
activeTaskCount: number;
/** Number of agents currently running */
inFlightAgentCount: number;
/** ISO-8601 timestamp of last activity */
lastActivityAt?: string;
/** ISO-8601 timestamp of last error */
lastErrorAt?: string;
/** Last error message */
lastErrorMessage?: string;
/** Total completed tasks (cumulative) */
totalTasksCompleted: number;
/** Total failed tasks (cumulative) */
totalTasksFailed: number;
/** Rolling average task duration in milliseconds */
averageTaskDurationMs?: number;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/** Activity log entry in the central unified feed */
export interface CentralActivityLogEntry {
/** Unique entry ID */
id: string;
/** ISO-8601 timestamp */
timestamp: string;
/** Event type */
type: ActivityEventType;
/** Project ID this event belongs to */
projectId: string;
/** Project name (denormalized for display) */
projectName: string;
/** Task ID (optional) */
taskId?: string;
/** Task title (optional) */
taskTitle?: string;
/** Event details */
details: string;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/** Global concurrency state across all projects */
export interface GlobalConcurrencyState {
/** System-wide concurrent agent limit (default: 4) */
globalMaxConcurrent: number;
/** Active agents across all projects */
currentlyActive: number;
/** Tasks waiting for concurrency slots */
queuedCount: number;
/** Per-project active agent counts */
projectsActive: Record<string, number>;
}
/** A single question in the planning conversation flow */
export interface PlanningQuestion {
id: string;