feat(FN-5622): add goals REST API with store accessor and route handlers
Introduces a Goals REST API (`GET/POST/PUT /api/goals` and `GET/PUT /api/goals/:id`) backed by a new `@fusion/core` goal store and typed goal types, including comprehensive route and store test coverage. Documentation on architecture and storage is updated to reflect the new domain, and a changeset Fusion-Task-Id: FN-5622 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5622
This commit is contained in:
6
.changeset/FN-5622-goals-rest-api.md
Normal file
6
.changeset/FN-5622-goals-rest-api.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add Goals REST API (`/api/goals`) with list/create/update/archive/unarchive endpoints.
|
||||
Creating a 6th active goal or unarchiving when already at 5 active now returns HTTP 409 with `ACTIVE_GOAL_LIMIT_EXCEEDED` details.
|
||||
6
.changeset/fn-5620-goals-schema.md
Normal file
6
.changeset/fn-5620-goals-schema.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add a new project-level `goals` table to the core schema and fresh database DDL.
|
||||
Bump `SCHEMA_VERSION` from 91 to 92 with an idempotent migration that creates `goals` and `idxGoalsStatus`.
|
||||
@@ -174,7 +174,7 @@ Concrete references:
|
||||
- **Database adapter**: `packages/core/src/db.ts`
|
||||
- SQLite (`node:sqlite`) with WAL mode + foreign keys
|
||||
- JSON helpers: `toJson`, `toJsonNullable`, `fromJson`
|
||||
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
||||
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), goals table (`goals`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
||||
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
||||
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
||||
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`, plugin id `fusion-plugin-roadmap`) rather than dashboard/core ownership.
|
||||
@@ -184,6 +184,7 @@ Concrete references:
|
||||
- **Specialized stores**:
|
||||
- `AgentStore` (`agent-store.ts`) — filesystem-based agent metadata + heartbeat run history
|
||||
- `MissionStore` (`mission-store.ts`) — mission/milestone/slice/feature hierarchy
|
||||
- `GoalStore` (`goal-store.ts`) — strategic goal CRUD with server-enforced 5-active-goal cap
|
||||
- `AutomationStore` (`automation-store.ts`) — scheduled jobs with global/project scope isolation
|
||||
- `MessageStore` (`message-store.ts`) — SQLite-backed mailbox/inbox/outbox messaging
|
||||
- `ApprovalRequestStore` (`approval-request-store.ts`) — durable approval request lifecycle + append-only audit events
|
||||
|
||||
@@ -146,7 +146,7 @@ Important execution nuance:
|
||||
- **Backend settings keys defined in `@fusion/core`:** **78** total
|
||||
- **Global settings:** 17 (`GlobalSettings`)
|
||||
- **Project settings:** 61 (`ProjectSettings`)
|
||||
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **46** (including migration-created tables)
|
||||
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **47** (including migration-created tables)
|
||||
- **Issues identified:** **9**
|
||||
- High: 2
|
||||
- Medium: 5
|
||||
@@ -340,6 +340,7 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e
|
||||
| `task_documents` | Task-scoped document metadata/content keyed by `(taskId, key)` with current revision pointer. |
|
||||
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
|
||||
| `__meta` | Schema version + monotonic `lastModified` change detector, plus one-time bootstrap metadata such as `bootstrappedAt` and `projectIdentity`. |
|
||||
| `goals` | Strategic intent records (`title`, optional `description`, `status`, timestamps) that can outlive mission timelines. |
|
||||
| `missions` | Mission-level planning hierarchy root. |
|
||||
| `milestones` | Milestones under missions, including dependency lists and validation state. |
|
||||
| `slices` | Slices under milestones with plan-state/activation metadata. |
|
||||
|
||||
@@ -715,7 +715,7 @@ describe("schema migration", () => {
|
||||
|
||||
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -748,7 +748,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -798,7 +798,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -827,7 +827,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -862,7 +862,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -373,7 +373,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1443,7 +1443,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1468,11 +1468,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1507,7 +1507,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1548,7 +1548,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1620,7 +1620,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1860,7 +1860,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1934,7 +1934,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1958,7 +1958,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -2062,7 +2062,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2281,7 +2281,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(91);
|
||||
expect(localDb.getSchemaVersion()).toBe(92);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2592,7 +2592,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2746,7 +2746,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(91);
|
||||
expect(migrated.getSchemaVersion()).toBe(92);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2792,7 +2792,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(91);
|
||||
expect(migrated.getSchemaVersion()).toBe(92);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2819,7 +2819,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(91);
|
||||
expect(fresh.getSchemaVersion()).toBe(92);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
187
packages/core/src/__tests__/goal-store.test.ts
Normal file
187
packages/core/src/__tests__/goal-store.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "../db.js";
|
||||
import { GoalStore } from "../goal-store.js";
|
||||
import { ACTIVE_GOAL_LIMIT, ActiveGoalLimitExceededError } from "../goal-types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-goal-test-"));
|
||||
}
|
||||
|
||||
describe("GoalStore", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
let store: GoalStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new GoalStore(fusionDir, db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates goals with active status and generated ids", () => {
|
||||
const goal = store.createGoal({ title: "Ship v1", description: "Initial launch" });
|
||||
|
||||
expect(goal.id).toMatch(/^G-/);
|
||||
expect(goal.status).toBe("active");
|
||||
expect(goal.title).toBe("Ship v1");
|
||||
expect(goal.description).toBe("Initial launch");
|
||||
expect(goal.createdAt).toBeTruthy();
|
||||
expect(goal.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gets goals by id and returns null for unknown ids", () => {
|
||||
const created = store.createGoal({ title: "Find me" });
|
||||
|
||||
expect(store.getGoal(created.id)).toEqual(created);
|
||||
expect(store.getGoal("G-UNKNOWN")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates title/description and refreshed updatedAt", async () => {
|
||||
const created = store.createGoal({ title: "Before", description: "Old" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
const updated = store.updateGoal(created.id, { title: "After", description: "New" });
|
||||
|
||||
expect(updated.title).toBe("After");
|
||||
expect(updated.description).toBe("New");
|
||||
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(new Date(created.updatedAt).getTime());
|
||||
});
|
||||
|
||||
it("throws when updating unknown goal", () => {
|
||||
expect(() => store.updateGoal("G-UNKNOWN", { title: "Nope" })).toThrow("Goal G-UNKNOWN not found");
|
||||
});
|
||||
|
||||
it("archives goals and is idempotent for already archived goals", () => {
|
||||
const onUpdated = vi.fn();
|
||||
store.on("goal:updated", onUpdated);
|
||||
const created = store.createGoal({ title: "Archive me" });
|
||||
|
||||
const archived = store.archiveGoal(created.id);
|
||||
const archivedAgain = store.archiveGoal(created.id);
|
||||
|
||||
expect(archived.status).toBe("archived");
|
||||
expect(archivedAgain.status).toBe("archived");
|
||||
expect(archivedAgain.id).toBe(created.id);
|
||||
expect(onUpdated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when archiving unknown goal", () => {
|
||||
expect(() => store.archiveGoal("G-UNKNOWN")).toThrow("Goal G-UNKNOWN not found");
|
||||
});
|
||||
|
||||
it("lists goals and filters by status sorted by createdAt", () => {
|
||||
db.prepare("INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run("G-1", "First", null, "active", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
|
||||
db.prepare("INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run("G-2", "Second", null, "archived", "2026-01-02T00:00:00.000Z", "2026-01-02T00:00:00.000Z");
|
||||
db.prepare("INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run("G-3", "Third", null, "active", "2026-01-03T00:00:00.000Z", "2026-01-03T00:00:00.000Z");
|
||||
|
||||
const all = store.listGoals();
|
||||
const active = store.listGoals({ status: "active" });
|
||||
const archived = store.listGoals({ status: "archived" });
|
||||
|
||||
expect(all.map((goal) => goal.id)).toEqual(["G-1", "G-2", "G-3"]);
|
||||
expect(active.map((goal) => goal.id)).toEqual(["G-1", "G-3"]);
|
||||
expect(archived.map((goal) => goal.id)).toEqual(["G-2"]);
|
||||
});
|
||||
|
||||
it("enforces active goal cap on create and allows new create after archive", () => {
|
||||
for (let i = 0; i < ACTIVE_GOAL_LIMIT; i += 1) {
|
||||
store.createGoal({ title: `Goal ${i + 1}` });
|
||||
}
|
||||
|
||||
try {
|
||||
store.createGoal({ title: "Goal 6" });
|
||||
throw new Error("expected cap error");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ActiveGoalLimitExceededError);
|
||||
const capError = error as ActiveGoalLimitExceededError;
|
||||
expect(capError.code).toBe("ACTIVE_GOAL_LIMIT_EXCEEDED");
|
||||
expect(capError.limit).toBe(ACTIVE_GOAL_LIMIT);
|
||||
expect(capError.currentActive).toBe(ACTIVE_GOAL_LIMIT);
|
||||
}
|
||||
|
||||
const first = store.listGoals({ status: "active" })[0]!;
|
||||
store.archiveGoal(first.id);
|
||||
const replacement = store.createGoal({ title: "Replacement" });
|
||||
expect(replacement.status).toBe("active");
|
||||
});
|
||||
|
||||
it("enforces active cap on unarchive and allows unarchive at four active", () => {
|
||||
const archived = store.createGoal({ title: "Archived candidate" });
|
||||
store.archiveGoal(archived.id);
|
||||
for (let i = 0; i < ACTIVE_GOAL_LIMIT; i += 1) {
|
||||
store.createGoal({ title: `Active ${i + 1}` });
|
||||
}
|
||||
|
||||
expect(() => store.unarchiveGoal(archived.id)).toThrow(ActiveGoalLimitExceededError);
|
||||
|
||||
const oneActive = store.listGoals({ status: "active" })[0]!;
|
||||
store.archiveGoal(oneActive.id);
|
||||
const restored = store.unarchiveGoal(archived.id);
|
||||
expect(restored.status).toBe("active");
|
||||
});
|
||||
|
||||
it("unarchive is a no-op for already active goals", () => {
|
||||
const created = store.createGoal({ title: "Already active" });
|
||||
|
||||
const result = store.unarchiveGoal(created.id);
|
||||
|
||||
expect(result.status).toBe("active");
|
||||
expect(result.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("throws when unarchiving unknown goal", () => {
|
||||
expect(() => store.unarchiveGoal("G-UNKNOWN")).toThrow("Goal G-UNKNOWN not found");
|
||||
});
|
||||
|
||||
it("serializes concurrent creates to cap active goals at five", async () => {
|
||||
const attempts = Array.from({ length: 10 }, (_, i) => Promise.resolve().then(() => store.createGoal({ title: `Race ${i}` })));
|
||||
const settled = await Promise.allSettled(attempts);
|
||||
|
||||
const fulfilled = settled.filter((result) => result.status === "fulfilled");
|
||||
const rejected = settled.filter((result) => result.status === "rejected");
|
||||
|
||||
expect(fulfilled).toHaveLength(ACTIVE_GOAL_LIMIT);
|
||||
expect(rejected).toHaveLength(10 - ACTIVE_GOAL_LIMIT);
|
||||
for (const result of rejected) {
|
||||
expect(result.status).toBe("rejected");
|
||||
expect(result.reason).toBeInstanceOf(ActiveGoalLimitExceededError);
|
||||
}
|
||||
|
||||
const activeCount = (db.prepare("SELECT COUNT(*) as count FROM goals WHERE status = 'active'").get() as { count: number } | undefined)?.count ?? 0;
|
||||
expect(activeCount).toBe(ACTIVE_GOAL_LIMIT);
|
||||
});
|
||||
|
||||
it("emits created and updated events with goal payload", () => {
|
||||
const onCreated = vi.fn();
|
||||
const onUpdated = vi.fn();
|
||||
store.on("goal:created", onCreated);
|
||||
store.on("goal:updated", onUpdated);
|
||||
|
||||
const created = store.createGoal({ title: "Event goal" });
|
||||
const updated = store.updateGoal(created.id, { title: "Updated event goal" });
|
||||
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
expect(onCreated).toHaveBeenCalledWith(created);
|
||||
expect(onUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onUpdated).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
96
packages/core/src/__tests__/goals-schema.test.ts
Normal file
96
packages/core/src/__tests__/goals-schema.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { Database } from "../db.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-goals-schema-test-"));
|
||||
}
|
||||
|
||||
describe("goals schema", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates goals table with expected columns on fresh init", () => {
|
||||
const columns = db.prepare("PRAGMA table_info(goals)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toEqual([
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates idxGoalsStatus index", () => {
|
||||
const row = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idxGoalsStatus'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(row?.name).toBe("idxGoalsStatus");
|
||||
});
|
||||
|
||||
it("round-trips inserted goal rows", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
"G-001",
|
||||
"North Star",
|
||||
"Strategic markdown",
|
||||
"active",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
|
||||
.get("G-001") as {
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
expect(row).toEqual({
|
||||
title: "North Star",
|
||||
description: "Strategic markdown",
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates goals table when migrating from schema version 91", () => {
|
||||
db.exec("DROP INDEX IF EXISTS idxGoalsStatus");
|
||||
db.exec("DROP TABLE IF EXISTS goals");
|
||||
db.prepare("UPDATE __meta SET value = '91' WHERE key = 'schemaVersion'").run();
|
||||
|
||||
(db as unknown as { migrate: () => void }).migrate();
|
||||
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='goals'")
|
||||
.get() as { name: string } | undefined;
|
||||
expect(table?.name).toBe("goals");
|
||||
});
|
||||
|
||||
it("reports schema version 92", () => {
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
});
|
||||
@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(91);
|
||||
expect(db1.getSchemaVersion()).toBe(92);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(91);
|
||||
expect(db3.getSchemaVersion()).toBe(92);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(91);
|
||||
expect(db1.getSchemaVersion()).toBe(92);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(91);
|
||||
expect(db2.getSchemaVersion()).toBe(92);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(91);
|
||||
expect(db1.getSchemaVersion()).toBe(92);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2886,7 +2886,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("91");
|
||||
expect(version.value).toBe("92");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("91");
|
||||
expect(version.value).toBe("92");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
|
||||
expect(projectVersion.value).toBe("91");
|
||||
expect(projectVersion.value).toBe("92");
|
||||
expect(centralVersion.value).toBe("13");
|
||||
} finally {
|
||||
projectDb.close();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
|
||||
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
|
||||
);
|
||||
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(91);
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(92);
|
||||
});
|
||||
|
||||
it("migrates a legacy v88 database and preserves task rows", async () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 91;
|
||||
const SCHEMA_VERSION = 92;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -761,6 +761,17 @@ CREATE TABLE IF NOT EXISTS missions (
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Goals table (strategic intent across mission timelines)
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxGoalsStatus ON goals(status);
|
||||
|
||||
-- Milestones table (phases within a mission)
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -3572,6 +3583,25 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 92) {
|
||||
this.applyMigration(92, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxGoalsStatus
|
||||
ON goals(status)
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
204
packages/core/src/goal-store.ts
Normal file
204
packages/core/src/goal-store.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "./db.js";
|
||||
import {
|
||||
ACTIVE_GOAL_LIMIT,
|
||||
ActiveGoalLimitExceededError,
|
||||
type Goal,
|
||||
type GoalCreateInput,
|
||||
type GoalListFilter,
|
||||
type GoalStatus,
|
||||
type GoalUpdateInput,
|
||||
} from "./goal-types.js";
|
||||
|
||||
interface GoalRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: GoalStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalStoreEvents {
|
||||
"goal:created": [Goal];
|
||||
"goal:updated": [Goal];
|
||||
}
|
||||
|
||||
export class GoalStore extends EventEmitter<GoalStoreEvents> {
|
||||
private idSequence = 0;
|
||||
|
||||
public constructor(
|
||||
_fusionDir: string,
|
||||
private readonly db: Database,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public createGoal(input: GoalCreateInput): Goal {
|
||||
const now = new Date().toISOString();
|
||||
const goal = this.db.transactionImmediate(() => {
|
||||
const activeCountRow = this.db
|
||||
.prepare("SELECT COUNT(*) as count FROM goals WHERE status = 'active'")
|
||||
.get() as { count: number } | undefined;
|
||||
const currentActive = activeCountRow?.count ?? 0;
|
||||
|
||||
if (currentActive >= ACTIVE_GOAL_LIMIT) {
|
||||
throw new ActiveGoalLimitExceededError(ACTIVE_GOAL_LIMIT, currentActive);
|
||||
}
|
||||
|
||||
const created: Goal = {
|
||||
id: this.generateGoalId(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(created.id, created.title, created.description ?? null, created.status, created.createdAt, created.updatedAt);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("goal:created", goal);
|
||||
return goal;
|
||||
}
|
||||
|
||||
public updateGoal(id: string, input: GoalUpdateInput): Goal {
|
||||
const existing = this.getGoal(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Goal ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: Goal = {
|
||||
...existing,
|
||||
title: input.title ?? existing.title,
|
||||
description: input.description ?? existing.description,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare("UPDATE goals SET title = ?, description = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(updated.title, updated.description ?? null, updated.updatedAt, id);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("goal:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public archiveGoal(id: string): Goal {
|
||||
const existing = this.getGoal(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Goal ${id} not found`);
|
||||
}
|
||||
|
||||
if (existing.status === "archived") {
|
||||
this.emit("goal:updated", existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const updated: Goal = {
|
||||
...existing,
|
||||
status: "archived",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare("UPDATE goals SET status = 'archived', updatedAt = ? WHERE id = ?")
|
||||
.run(updated.updatedAt, id);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("goal:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public unarchiveGoal(id: string): Goal {
|
||||
const { goal, changed } = this.db.transactionImmediate(() => {
|
||||
const existing = this.getGoal(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Goal ${id} not found`);
|
||||
}
|
||||
|
||||
if (existing.status === "active") {
|
||||
return { goal: existing, changed: false };
|
||||
}
|
||||
|
||||
const activeCountRow = this.db
|
||||
.prepare("SELECT COUNT(*) as count FROM goals WHERE status = 'active'")
|
||||
.get() as { count: number } | undefined;
|
||||
const currentActive = activeCountRow?.count ?? 0;
|
||||
|
||||
if (currentActive >= ACTIVE_GOAL_LIMIT) {
|
||||
throw new ActiveGoalLimitExceededError(ACTIVE_GOAL_LIMIT, currentActive);
|
||||
}
|
||||
|
||||
const updated: Goal = {
|
||||
...existing,
|
||||
status: "active",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare("UPDATE goals SET status = 'active', updatedAt = ? WHERE id = ?")
|
||||
.run(updated.updatedAt, id);
|
||||
|
||||
return { goal: updated, changed: true };
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return goal;
|
||||
}
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("goal:updated", goal);
|
||||
return goal;
|
||||
}
|
||||
|
||||
public listGoals(filter?: GoalListFilter): Goal[] {
|
||||
const rows = filter?.status
|
||||
? this.db
|
||||
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE status = ? ORDER BY createdAt ASC")
|
||||
.all(filter.status) as GoalRow[]
|
||||
: this.db
|
||||
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals ORDER BY createdAt ASC")
|
||||
.all() as GoalRow[];
|
||||
|
||||
return rows.map((row) => this.toGoal(row));
|
||||
}
|
||||
|
||||
public getGoal(id: string): Goal | null {
|
||||
const row = this.db
|
||||
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
|
||||
.get(id) as GoalRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.toGoal(row);
|
||||
}
|
||||
|
||||
private toGoal(row: GoalRow): Goal {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? undefined,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private generateGoalId(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
this.idSequence += 1;
|
||||
const sequence = this.idSequence.toString(36).toUpperCase().padStart(4, "0");
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `G-${timestamp}-${sequence}-${random}`;
|
||||
}
|
||||
}
|
||||
38
packages/core/src/goal-types.ts
Normal file
38
packages/core/src/goal-types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type GoalStatus = "active" | "archived";
|
||||
|
||||
export const ACTIVE_GOAL_LIMIT = 5;
|
||||
|
||||
export interface Goal {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: GoalStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalCreateInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GoalUpdateInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GoalListFilter {
|
||||
status?: GoalStatus;
|
||||
}
|
||||
|
||||
export class ActiveGoalLimitExceededError extends Error {
|
||||
public readonly code = "ACTIVE_GOAL_LIMIT_EXCEEDED" as const;
|
||||
|
||||
public constructor(
|
||||
public readonly limit: number,
|
||||
public readonly currentActive: number,
|
||||
) {
|
||||
super(`Active goal limit exceeded: ${currentActive}/${limit}`);
|
||||
this.name = "ActiveGoalLimitExceededError";
|
||||
}
|
||||
}
|
||||
@@ -719,6 +719,10 @@ export type {
|
||||
} from "./mission-types.js";
|
||||
export { MissionStore } from "./mission-store.js";
|
||||
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||
export { ACTIVE_GOAL_LIMIT, ActiveGoalLimitExceededError } from "./goal-types.js";
|
||||
export type { Goal, GoalCreateInput, GoalListFilter, GoalStatus, GoalUpdateInput } from "./goal-types.js";
|
||||
export { GoalStore } from "./goal-store.js";
|
||||
export type { GoalStoreEvents } from "./goal-store.js";
|
||||
|
||||
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { InsightStore } from "./insight-store.js";
|
||||
import { ResearchStore } from "./research-store.js";
|
||||
import { ExperimentSessionStore } from "./experiment-session-store.js";
|
||||
import { TodoStore } from "./todo-store.js";
|
||||
import { GoalStore } from "./goal-store.js";
|
||||
import { EvalStore } from "./eval-store.js";
|
||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
@@ -1117,6 +1118,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private experimentSessionStore: ExperimentSessionStore | null = null;
|
||||
/** Cached TodoStore instance */
|
||||
private todoStore: TodoStore | null = null;
|
||||
/** Cached GoalStore instance */
|
||||
private goalStore: GoalStore | null = null;
|
||||
/** Cached EvalStore instance */
|
||||
private evalStore: EvalStore | null = null;
|
||||
/** Cached SecretsStore instance */
|
||||
@@ -10344,6 +10347,17 @@ ${notificationsSection}`;
|
||||
return this.todoStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GoalStore instance for project-scoped goals operations.
|
||||
* Lazily initializes the GoalStore on first access.
|
||||
*/
|
||||
getGoalStore(): GoalStore {
|
||||
if (!this.goalStore) {
|
||||
this.goalStore = new GoalStore(this.fusionDir, this.db);
|
||||
}
|
||||
return this.goalStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the EvalStore instance for eval run and task result operations.
|
||||
* Lazily initializes the EvalStore on first access.
|
||||
|
||||
193
packages/dashboard/src/__tests__/goals-routes.test.ts
Normal file
193
packages/dashboard/src/__tests__/goals-routes.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import type { Goal, GoalStatus, TaskStore } from "@fusion/core";
|
||||
import { createGoalsRouter } from "../goals-routes.js";
|
||||
import { get, request } from "../test-request.js";
|
||||
|
||||
function createMockGoalStore() {
|
||||
const goals = new Map<string, Goal>();
|
||||
let next = 1;
|
||||
|
||||
const listGoals = (filter?: { status?: GoalStatus }) => {
|
||||
const all = Array.from(goals.values());
|
||||
return filter?.status ? all.filter((g) => g.status === filter.status) : all;
|
||||
};
|
||||
|
||||
return {
|
||||
listGoals,
|
||||
createGoal: ({ title, description }: { title: string; description?: string }) => {
|
||||
const active = listGoals({ status: "active" }).length;
|
||||
if (active >= 5) {
|
||||
throw Object.assign(new Error("cap"), {
|
||||
code: "ACTIVE_GOAL_LIMIT_EXCEEDED",
|
||||
limit: 5,
|
||||
currentActive: active,
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const goal: Goal = { id: `G-MOCK-${next++}`, title, description, status: "active", createdAt: now, updatedAt: now };
|
||||
goals.set(goal.id, goal);
|
||||
return goal;
|
||||
},
|
||||
getGoal: (id: string) => goals.get(id) ?? null,
|
||||
updateGoal: (id: string, updates: { title?: string; description?: string }) => {
|
||||
const existing = goals.get(id);
|
||||
if (!existing) throw new Error(`Goal ${id} not found`);
|
||||
const updated: Goal = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
goals.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
archiveGoal: (id: string) => {
|
||||
const existing = goals.get(id);
|
||||
if (!existing) throw new Error(`Goal ${id} not found`);
|
||||
if (existing.status === "archived") return existing;
|
||||
const updated: Goal = { ...existing, status: "archived", updatedAt: new Date().toISOString() };
|
||||
goals.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
unarchiveGoal: (id: string) => {
|
||||
const existing = goals.get(id);
|
||||
if (!existing) throw new Error(`Goal ${id} not found`);
|
||||
if (existing.status === "active") return existing;
|
||||
const active = listGoals({ status: "active" }).length;
|
||||
if (active >= 5) {
|
||||
throw Object.assign(new Error("cap"), {
|
||||
code: "ACTIVE_GOAL_LIMIT_EXCEEDED",
|
||||
limit: 5,
|
||||
currentActive: active,
|
||||
});
|
||||
}
|
||||
const updated: Goal = { ...existing, status: "active", updatedAt: new Date().toISOString() };
|
||||
goals.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("goals-routes", () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
const goalStore = createMockGoalStore();
|
||||
const store = { getGoalStore: () => goalStore } as unknown as TaskStore;
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/goals", createGoalsRouter(store));
|
||||
});
|
||||
|
||||
it("GET / returns empty goals", async () => {
|
||||
const response = await get(app, "/api/goals");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ goals: [] });
|
||||
});
|
||||
|
||||
it("POST / creates and GET / lists", async () => {
|
||||
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Goal A" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({ title: "Goal A", status: "active" });
|
||||
|
||||
const listed = await get(app, "/api/goals");
|
||||
expect(listed.body).toEqual({ goals: [created.body] });
|
||||
});
|
||||
|
||||
it("POST / validates missing title", async () => {
|
||||
const response = await request(app, "POST", "/api/goals", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET / validates and filters status", async () => {
|
||||
await request(app, "POST", "/api/goals", JSON.stringify({ title: "A" }), { "content-type": "application/json" });
|
||||
const createdB = await request(app, "POST", "/api/goals", JSON.stringify({ title: "B" }), { "content-type": "application/json" });
|
||||
await request(app, "POST", `/api/goals/${(createdB.body as Goal).id}/archive`);
|
||||
|
||||
const active = await get(app, "/api/goals?status=active");
|
||||
expect((active.body as { goals: Goal[] }).goals).toHaveLength(1);
|
||||
|
||||
const invalid = await get(app, "/api/goals?status=bogus");
|
||||
expect(invalid.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PATCH /:id updates and validates", async () => {
|
||||
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Old" }), { "content-type": "application/json" });
|
||||
const id = (created.body as Goal).id;
|
||||
|
||||
const updated = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/goals/${id}`,
|
||||
JSON.stringify({ title: "New", description: "Desc" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toMatchObject({ title: "New", description: "Desc" });
|
||||
|
||||
const empty = await request(app, "PATCH", `/api/goals/${id}`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
expect(empty.status).toBe(400);
|
||||
|
||||
const unknown = await request(app, "PATCH", "/api/goals/G-UNKNOWN", JSON.stringify({ title: "X" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(unknown.status).toBe(404);
|
||||
});
|
||||
|
||||
it("archive is idempotent and unarchive works", async () => {
|
||||
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Archive me" }), { "content-type": "application/json" });
|
||||
const id = (created.body as Goal).id;
|
||||
|
||||
const archived = await request(app, "POST", `/api/goals/${id}/archive`);
|
||||
expect(archived.status).toBe(200);
|
||||
expect((archived.body as Goal).status).toBe("archived");
|
||||
|
||||
const archivedAgain = await request(app, "POST", `/api/goals/${id}/archive`);
|
||||
expect(archivedAgain.status).toBe(200);
|
||||
expect((archivedAgain.body as Goal).status).toBe("archived");
|
||||
|
||||
const unarchived = await request(app, "POST", `/api/goals/${id}/unarchive`);
|
||||
expect(unarchived.status).toBe(200);
|
||||
expect((unarchived.body as Goal).status).toBe("active");
|
||||
|
||||
const unknownArchive = await request(app, "POST", "/api/goals/G-UNKNOWN/archive");
|
||||
expect(unknownArchive.status).toBe(404);
|
||||
|
||||
const unknown = await request(app, "POST", "/api/goals/G-UNKNOWN/unarchive");
|
||||
expect(unknown.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 for cap violations on create and unarchive", async () => {
|
||||
const seededIds: string[] = [];
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const seeded = await request(app, "POST", "/api/goals", JSON.stringify({ title: `Goal ${i}` }), { "content-type": "application/json" });
|
||||
seededIds.push((seeded.body as Goal).id);
|
||||
}
|
||||
|
||||
const createOverflow = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Overflow" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(createOverflow.status).toBe(409);
|
||||
expect(createOverflow.body).toMatchObject({
|
||||
details: { code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 },
|
||||
});
|
||||
|
||||
const archived = await request(app, "POST", `/api/goals/${seededIds[0]}/archive`);
|
||||
expect(archived.status).toBe(200);
|
||||
|
||||
const createdArchived = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Will archive" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
const archivedId = (createdArchived.body as Goal).id;
|
||||
await request(app, "POST", `/api/goals/${archivedId}/archive`);
|
||||
await request(app, "POST", `/api/goals/${seededIds[0]}/unarchive`);
|
||||
|
||||
const unarchiveOverflow = await request(app, "POST", `/api/goals/${archivedId}/unarchive`);
|
||||
expect(unarchiveOverflow.status).toBe(409);
|
||||
expect(unarchiveOverflow.body).toMatchObject({
|
||||
details: { code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
209
packages/dashboard/src/goals-routes.ts
Normal file
209
packages/dashboard/src/goals-routes.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Goals REST API Routes
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET / -> list goals (`?status=active|archived` optional)
|
||||
* - POST / -> create goal
|
||||
* - PATCH /:id -> update goal title/description
|
||||
* - POST /:id/archive -> archive goal (idempotent)
|
||||
* - POST /:id/unarchive -> unarchive goal
|
||||
*
|
||||
* Cap violations from create/unarchive return HTTP 409 with details:
|
||||
* `{ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit, currentActive }`.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { Goal, GoalStatus, GoalUpdateInput, TaskStore } from "@fusion/core";
|
||||
import { ApiError, badRequest, catchHandler, conflict, internalError, notFound } from "./api-error.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
type GoalStoreLike = {
|
||||
listGoals(filter?: { status?: GoalStatus }): Goal[];
|
||||
createGoal(input: { title: string; description?: string }): Goal;
|
||||
getGoal(id: string): Goal | null;
|
||||
updateGoal(id: string, input: GoalUpdateInput): Goal;
|
||||
archiveGoal(id: string): Goal;
|
||||
unarchiveGoal(id: string): Goal;
|
||||
};
|
||||
|
||||
const GOAL_ID_RE = /^G-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i;
|
||||
const GOAL_STATUSES: GoalStatus[] = ["active", "archived"];
|
||||
|
||||
function getProjectIdFromRequest(req: Request): string | undefined {
|
||||
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
|
||||
return req.query.projectId;
|
||||
}
|
||||
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
|
||||
return req.body.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getGoalStore(store: TaskStore): GoalStoreLike {
|
||||
return store.getGoalStore();
|
||||
}
|
||||
|
||||
function validateGoalId(id: unknown): string {
|
||||
if (typeof id !== "string" || !GOAL_ID_RE.test(id)) {
|
||||
throw badRequest("Invalid goal id format");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
throw badRequest("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw badRequest("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
function validateDescription(description: unknown): string | undefined {
|
||||
if (description === undefined) return undefined;
|
||||
if (typeof description !== "string") {
|
||||
throw badRequest("description must be a string");
|
||||
}
|
||||
if (description.length > 5000) {
|
||||
throw badRequest("description must not exceed 5000 characters");
|
||||
}
|
||||
const trimmed = description.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function rethrowGoalCapError(error: unknown): never {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
const typed = error as Record<string, unknown>;
|
||||
if (typed.code === "ACTIVE_GOAL_LIMIT_EXCEEDED") {
|
||||
const limit = typeof typed.limit === "number" ? typed.limit : 5;
|
||||
const currentActive = typeof typed.currentActive === "number" ? typed.currentActive : limit;
|
||||
throw conflict("Active goal limit exceeded", {
|
||||
code: "ACTIVE_GOAL_LIMIT_EXCEEDED",
|
||||
limit,
|
||||
currentActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw internalError(error.message);
|
||||
}
|
||||
|
||||
throw internalError("Internal server error");
|
||||
}
|
||||
|
||||
export function createGoalsRouter(store: TaskStore): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<TaskStore>();
|
||||
|
||||
function getScopedStore(): TaskStore {
|
||||
return requestContext.getStore() ?? store;
|
||||
}
|
||||
|
||||
router.use(async (req: Request, _res: Response, next) => {
|
||||
try {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
|
||||
requestContext.run(scopedStore, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
catchHandler((req, res) => {
|
||||
const rawStatus = req.query.status;
|
||||
if (rawStatus !== undefined && rawStatus !== null) {
|
||||
if (typeof rawStatus !== "string" || !GOAL_STATUSES.includes(rawStatus as GoalStatus)) {
|
||||
throw badRequest("status must be one of: active, archived");
|
||||
}
|
||||
}
|
||||
|
||||
const goalStore = getGoalStore(getScopedStore());
|
||||
const goals = goalStore.listGoals(rawStatus ? { status: rawStatus as GoalStatus } : undefined);
|
||||
res.json({ goals });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
catchHandler((req, res) => {
|
||||
try {
|
||||
const input = req.body as { title?: unknown; description?: unknown };
|
||||
const goalStore = getGoalStore(getScopedStore());
|
||||
const goal = goalStore.createGoal({
|
||||
title: validateTitle(input.title),
|
||||
description: validateDescription(input.description),
|
||||
});
|
||||
res.status(201).json(goal);
|
||||
} catch (error) {
|
||||
rethrowGoalCapError(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id",
|
||||
catchHandler((req, res) => {
|
||||
const id = validateGoalId(req.params.id);
|
||||
const input = req.body as { title?: unknown; description?: unknown };
|
||||
const updates: GoalUpdateInput = {};
|
||||
if (input.title !== undefined) {
|
||||
updates.title = validateTitle(input.title);
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
updates.description = validateDescription(input.description);
|
||||
}
|
||||
if (updates.title === undefined && updates.description === undefined) {
|
||||
throw badRequest("At least one field must be provided");
|
||||
}
|
||||
|
||||
const goalStore = getGoalStore(getScopedStore());
|
||||
if (!goalStore.getGoal(id)) {
|
||||
throw notFound(`Goal ${id} not found`);
|
||||
}
|
||||
const updated = goalStore.updateGoal(id, updates);
|
||||
res.json(updated);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/archive",
|
||||
catchHandler((req, res) => {
|
||||
const id = validateGoalId(req.params.id);
|
||||
const goalStore = getGoalStore(getScopedStore());
|
||||
if (!goalStore.getGoal(id)) {
|
||||
throw notFound(`Goal ${id} not found`);
|
||||
}
|
||||
const archived = goalStore.archiveGoal(id);
|
||||
res.json(archived);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/unarchive",
|
||||
catchHandler((req, res) => {
|
||||
const id = validateGoalId(req.params.id);
|
||||
const goalStore = getGoalStore(getScopedStore());
|
||||
if (!goalStore.getGoal(id)) {
|
||||
throw notFound(`Goal ${id} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const unarchived = goalStore.unarchiveGoal(id);
|
||||
res.json(unarchived);
|
||||
} catch (error) {
|
||||
rethrowGoalCapError(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { createEvalsRouter } from "../evals-routes.js";
|
||||
import { createResearchRouter } from "../research-routes.js";
|
||||
import { createExperimentRouter } from "../experiment-routes.js";
|
||||
import { createTodoRouter } from "../todo-routes.js";
|
||||
import { createGoalsRouter } from "../goals-routes.js";
|
||||
import { createRoadmapCompatibilityRouter } from "../roadmap-routes.js";
|
||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
@@ -40,6 +41,7 @@ export function registerIntegratedRouters({
|
||||
router.use("/research", createResearchRouter(store));
|
||||
router.use("/experiments", createExperimentRouter(store));
|
||||
router.use("/todos", createTodoRouter(store));
|
||||
router.use("/goals", createGoalsRouter(store));
|
||||
router.use("/roadmaps", createRoadmapCompatibilityRouter(store));
|
||||
router.use("/stash-recovery", createStashRecoveryRouter(store));
|
||||
}
|
||||
|
||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
||||
});
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 91 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(91);
|
||||
it("schema version is 92 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(92);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user