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:
gsxdsm
2026-05-28 10:30:12 -07:00
parent 8c0a75f50e
commit 2a3535893b
23 changed files with 1030 additions and 39 deletions

View 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.

View 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`.

View File

@@ -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

View File

@@ -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. |

View File

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

View File

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

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

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

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(91);
expect(db.getSchemaVersion()).toBe(92);
});
});
});

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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)
`);
});
}
}
/**

View 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}`;
}
}

View 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";
}
}

View File

@@ -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) ───────────────────────────

View File

@@ -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.

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

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

View File

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

View File

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