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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user