FN-5899: add mission-goal linking commands and tools

Add mission↔goal linkage support across the API, CLI, and pi-extension surfaces.

- add mission goal list/link/unlink REST endpoints and dashboard route coverage
- add `fn mission goals`, `fn mission link-goal`, and `fn mission unlink-goal` CLI commands with tests
- add `fn_mission_list_goals`, `fn_mission_link_goal`, and `fn_mission_unlink_goal` extension tools plus skill/docs updates
- add a changeset for the published CLI package release

Files changed:
 .changeset/FN-5899-mission-goal-tooling.md         |   5 +
 docs/cli-reference.md                              |  11 +-
 docs/missions.md                                   |  27 ++++
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 packages/cli/skill/fusion/references/extension-tools.md |  26 ++++
 packages/cli/skill/fusion/references/fusion-capabilities.md |   3 +
 packages/cli/src/__tests__/bin.test.ts             |  21 +++
 packages/cli/src/__tests__/extension-mission-goal-tools.test.ts | 140 +++++++++++++++++
 packages/cli/src/bin.ts                            |  32 +++-
 packages/cli/src/commands/__tests__/mission.test.ts     |  99 +++++++++++-
 packages/cli/src/commands/mission.ts               | 102 +++++++++++++-
 packages/cli/src/extension.ts                      | 170 +++++++++++++++++++++
 packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts | 151 ++++++++++++++++++
 packages/dashboard/src/mission-routes.ts           | 153 ++++++++++++++++++-
 14 files changed, 930 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-5899

Fusion-Task-Lineage: c63b0f4b-c77e-4229-ade4-537a4dda0f5f
This commit is contained in:
gsxdsm
2026-06-02 14:47:51 -07:00
parent 30a09e3422
commit 93e8bd9940
14 changed files with 930 additions and 12 deletions

View File

@@ -0,0 +1,151 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, type Goal } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { get, request } from "../test-request.js";
async function createFixture() {
const rootDir = mkdtempSync(join(tmpdir(), "kb-mission-goal-links-"));
const globalDir = join(rootDir, ".fusion-global-settings");
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store));
return { app, store, rootDir };
}
describe("mission goal linkage routes", () => {
let rootDir: string;
let app: express.Express;
let store: TaskStore;
beforeEach(async () => {
({ app, store, rootDir } = await createFixture());
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
it("lists empty and populated linked goals", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const empty = await get(app, `/api/missions/${mission.id}/goals`);
expect(empty.status).toBe(200);
expect(empty.body).toEqual({ goals: [] });
store.getMissionStore().linkGoal(mission.id, goalA.id);
store.getMissionStore().linkGoal(mission.id, goalB.id);
const populated = await get(app, `/api/missions/${mission.id}/goals`);
expect(populated.status).toBe(200);
expect((populated.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalA.id, goalB.id]);
});
it("sets the full linked goal set", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const goalC = store.getGoalStore().createGoal({ title: "Goal C" });
store.getMissionStore().linkGoal(mission.id, goalA.id);
store.getMissionStore().linkGoal(mission.id, goalB.id);
const response = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goalB.id, goalC.id, goalC.id] }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalB.id, goalC.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
});
it("adds a linked goal idempotently", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const first = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(first.status).toBe(200);
expect((first.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]);
const second = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(second.status).toBe(200);
expect((second.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]);
});
it("removes a linked goal idempotently", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
store.getMissionStore().linkGoal(mission.id, goal.id);
const first = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(first.status).toBe(200);
expect(first.body).toEqual({ removed: true, goals: [] });
const second = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(second.status).toBe(200);
expect(second.body).toEqual({ removed: true, goals: [] });
});
it("returns 400 for malformed goal ids", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const listBad = await get(app, `/api/missions/not-a-mission/goals`);
expect(listBad.status).toBe(400);
const setBad = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: ["bad-goal-id"] }),
{ "content-type": "application/json" },
);
expect(setBad.status).toBe(400);
const addBad = await request(app, "POST", `/api/missions/${mission.id}/goals/not-a-goal`);
expect(addBad.status).toBe(400);
const deleteBad = await request(app, "DELETE", `/api/missions/${mission.id}/goals/not-a-goal`);
expect(deleteBad.status).toBe(400);
});
it("returns 404 for missing mission or goal", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const missingMissionList = await get(app, "/api/missions/M-404/goals");
expect(missingMissionList.status).toBe(404);
const missingMissionAdd = await request(app, "POST", `/api/missions/M-404/goals/${goal.id}`);
expect(missingMissionAdd.status).toBe(404);
const missingGoalAdd = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`);
expect(missingGoalAdd.status).toBe(404);
const missingGoalSet = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goal.id, "G-404"] }),
{ "content-type": "application/json" },
);
expect(missingGoalSet.status).toBe(404);
const missingGoalDelete = await request(app, "DELETE", `/api/missions/${mission.id}/goals/G-404`);
expect(missingGoalDelete.status).toBe(404);
});
});

View File

@@ -15,6 +15,7 @@
import { Router, type Request, type Response, type NextFunction } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core";
import type { Goal } from "@fusion/core";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import type {
Mission,
@@ -84,6 +85,10 @@ function validateAssertionId(id: string): boolean {
return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id);
}
function validateGoalId(id: string): boolean {
return /^G-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
}
function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || title.trim().length === 0) {
throw new Error("Title is required and must be a non-empty string");
@@ -181,6 +186,23 @@ function validateOrderedIds(body: unknown): string[] {
return orderedIds;
}
function validateGoalIdsBody(body: unknown): string[] {
if (!body || typeof body !== "object") {
throw badRequest("Request body must contain goalIds array");
}
const { goalIds } = body as Record<string, unknown>;
if (!Array.isArray(goalIds)) {
throw badRequest("goalIds must be an array");
}
if (!goalIds.every((goalId) => typeof goalId === "string")) {
throw badRequest("goalIds must be an array of strings");
}
if (!goalIds.every((goalId) => validateGoalId(goalId))) {
throw badRequest("goalIds must contain valid goal IDs");
}
return goalIds;
}
type TypedRequest = Request<Record<string, string>>;
function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
@@ -254,7 +276,7 @@ export function createMissionRouter(
engineManager?: import("@fusion/engine").ProjectEngineManager,
): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
const requestContext = new AsyncLocalStorage<TaskStore>();
function getProjectIdFromRequest(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
@@ -266,12 +288,74 @@ export function createMissionRouter(
return undefined;
}
function getScopedStore(): TaskStore {
return requestContext.getStore() ?? store;
}
function getScopedMissionStore() {
const missionStore = requestContext.getStore();
if (!missionStore) {
return store.getMissionStore();
return getScopedStore().getMissionStore();
}
function getScopedGoalStore() {
return getScopedStore().getGoalStore();
}
function requireMission(missionId: string) {
if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
}
return missionStore;
const mission = missionStore.getMission(missionId);
if (!mission) {
throw notFound("Mission not found");
}
return mission;
}
function requireGoal(goalId: string): Goal {
if (!validateGoalId(goalId)) {
throw badRequest("Invalid goal ID format");
}
const goal = getScopedGoalStore().getGoal(goalId);
if (!goal) {
throw notFound("Goal not found");
}
return goal;
}
function listLinkedGoalsForMission(missionId: string): Goal[] {
requireMission(missionId);
const goalStore = getScopedGoalStore();
return missionStore
.listGoalIdsForMission(missionId)
.map((goalId) => goalStore.getGoal(goalId))
.filter((goal): goal is Goal => Boolean(goal));
}
function setLinkedGoalsForMission(missionId: string, goalIds: string[]): Goal[] {
requireMission(missionId);
const uniqueGoalIds = Array.from(new Set(goalIds));
uniqueGoalIds.forEach((goalId) => requireGoal(goalId));
const existingGoalIds = new Set(missionStore.listGoalIdsForMission(missionId));
const nextGoalIds = new Set(uniqueGoalIds);
for (const goalId of existingGoalIds) {
if (!nextGoalIds.has(goalId)) {
missionStore.unlinkGoal(missionId, goalId);
}
}
for (const goalId of uniqueGoalIds) {
if (!existingGoalIds.has(goalId)) {
missionStore.linkGoal(missionId, goalId);
}
}
return listLinkedGoalsForMission(missionId);
}
const missionStore = new Proxy({} as ReturnType<TaskStore["getMissionStore"]>, {
@@ -286,7 +370,7 @@ export function createMissionRouter(
try {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
requestContext.run(scopedStore.getMissionStore(), next);
requestContext.run(scopedStore, next);
} catch (error) {
next(error);
}
@@ -899,6 +983,63 @@ export function createMissionRouter(
})
);
/**
* GET /api/missions/:missionId/goals
* List linked goals for a mission.
*/
router.get(
"/:missionId/goals",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const goals = listLinkedGoalsForMission(missionId);
res.json({ goals });
})
);
/**
* PUT /api/missions/:missionId/goals
* Replace the full linked-goal set for a mission.
*/
router.put(
"/:missionId/goals",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const goalIds = validateGoalIdsBody(req.body);
const goals = setLinkedGoalsForMission(missionId, goalIds);
res.json({ goals });
})
);
/**
* POST /api/missions/:missionId/goals/:goalId
* Link a single goal to a mission.
*/
router.post(
"/:missionId/goals/:goalId",
catchTypedHandler(async (req, res) => {
const { missionId, goalId } = req.params;
requireMission(missionId);
const goal = requireGoal(goalId);
missionStore.linkGoal(missionId, goalId);
res.json({ goal, goals: listLinkedGoalsForMission(missionId) });
})
);
/**
* DELETE /api/missions/:missionId/goals/:goalId
* Unlink a single goal from a mission.
*/
router.delete(
"/:missionId/goals/:goalId",
catchTypedHandler(async (req, res) => {
const { missionId, goalId } = req.params;
requireMission(missionId);
requireGoal(goalId);
missionStore.unlinkGoal(missionId, goalId);
res.json({ removed: true, goals: listLinkedGoalsForMission(missionId) });
})
);
/**
* POST /api/missions/:missionId/backfill-assertions
* Backfill store-managed assertions for mission features that have none.