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:
206
packages/core/src/__tests__/central-integration.test.ts
Normal file
206
packages/core/src/__tests__/central-integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
974
packages/core/src/central-core.test.ts
Normal file
974
packages/core/src/central-core.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
948
packages/core/src/central-core.ts
Normal file
948
packages/core/src/central-core.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
338
packages/core/src/central-db.test.ts
Normal file
338
packages/core/src/central-db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
packages/core/src/central-db.ts
Normal file
264
packages/core/src/central-db.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user