FN-5958: add mission goal linking to create and update flows

Expand mission goal-link management across the REST API, CLI, and pi extension.

- accept optional goalIds during mission create and patch requests, and enforce archived-goal rejection while keeping unlink permissive
- add repeatable --goal support to fn mission create and reuse goal validation for CLI linking
- expose archived-goal errors in pi-extension mission goal tools and document the new behavior

Files changed:
 .changeset/FN-5958-mission-goal-links.md           |  10 +
 docs/cli-reference.md                              |   8 +-
 docs/missions.md                                   |  15 +-
 packages/cli/src/__tests__/bin.test.ts             |  34 ++++
 packages/cli/src/__tests__/extension-mission-goal-tools.test.ts |  36 ++++
 packages/cli/src/bin.ts                            |  24 ++-
 packages/cli/src/commands/mission.ts               |  29 ++-
 packages/cli/src/extension.ts                      |   7 +
 packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts | 201 ++++++++++++++++++++-
 packages/dashboard/src/mission-routes.ts           |  66 +++++--
 10 files changed, 390 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-5958
Fusion-Task-Lineage: cf9d6013-5484-439d-b66d-44cac843c6ef
This commit is contained in:
gsxdsm
2026-06-03 16:14:19 -07:00
parent c82cdb281c
commit 26bc80a0ad
10 changed files with 390 additions and 40 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": minor
---
Add mission↔goal batch linking support across REST, CLI, and pi-extension surfaces.
- `POST /api/missions` and `PATCH /api/missions/:missionId` now accept optional `goalIds: string[]` for mission goal linking on create and update.
- `fn mission create --goal <id>` supports repeatable goal flags to link goals during mission creation.
- Mission goal link surfaces now reject archived goals with `GOAL_ARCHIVED` while preserving `404` for missing goals.
- Unlink paths remain permissive so archived goals can still be removed from missions.

View File

@@ -653,7 +653,7 @@ Subcommands: `status`.
Mission hierarchy operations. Mission hierarchy operations.
```bash ```bash
fn mission create "Platform hardening" "Security and reliability initiative" --base-branch develop fn mission create "Platform hardening" "Security and reliability initiative" --base-branch develop --goal G-001 --goal G-002
fn mission list fn mission list
fn mission show mission_123 fn mission show mission_123
fn mission goals mission_123 fn mission goals mission_123
@@ -665,13 +665,13 @@ fn mission activate-slice slice_456
Subcommands: `create`, `list|ls`, `show|info`, `goals`, `link-goal`, `unlink-goal`, `delete`, `activate-slice`. Subcommands: `create`, `list|ls`, `show|info`, `goals`, `link-goal`, `unlink-goal`, `delete`, `activate-slice`.
`fn mission create` supports `--base-branch <branch>` to set a mission-level default integration branch used by mission feature/slice triage when no explicit branch override is provided. `fn mission create` supports `--base-branch <branch>` to set a mission-level default integration branch used by mission feature/slice triage when no explicit branch override is provided. It also supports repeatable `--goal <goal-id>` flags to link active goals during creation.
Mission ↔ goal linkage commands operate on the persisted `mission_goals` relation: Mission ↔ goal linkage commands operate on the persisted `mission_goals` relation:
- `fn mission goals <mission-id>` lists the linked goals for a mission. - `fn mission goals <mission-id>` lists the linked goals for a mission.
- `fn mission link-goal <mission-id> <goal-id>` idempotently adds a goal link. - `fn mission link-goal <mission-id> <goal-id>` idempotently adds a goal link; archived goals reject with `GOAL_ARCHIVED` and missing goals still fail with `404`/not found.
- `fn mission unlink-goal <mission-id> <goal-id>` idempotently removes a goal link. - `fn mission unlink-goal <mission-id> <goal-id>` idempotently removes a goal link, including archived goals.
--- ---

View File

@@ -118,7 +118,7 @@ Mission, milestone, slice, and feature read-only text surfaces in Mission Manage
### CLI ### CLI
```bash ```bash
fn mission create "Reliability initiative" "Reduce execution failures and improve recovery" fn mission create "Reliability initiative" "Reduce execution failures and improve recovery" --goal G-001 --goal G-002
fn mission list fn mission list
fn mission show mission_123 fn mission show mission_123
fn mission goals mission_123 fn mission goals mission_123
@@ -136,19 +136,22 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e
| Endpoint | Purpose | | Endpoint | Purpose |
|---|---| |---|---|
| `POST /api/missions` | Create a mission. Optional body field `goalIds: string[]` links goals during creation and returns `linkedGoals` in the response. |
| `PATCH /api/missions/:missionId` | Update mission fields. Optional `goalIds: string[]` replaces the full linked-goal set; `[]` clears links and `undefined` leaves links unchanged. |
| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. | | `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. |
| `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. | | `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. |
| `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. | | `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. |
| `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. | | `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. |
| `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. | | `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. |
The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All five endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows. The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All goal-link write endpoints preserve the same invariant: missing goals return `404`, archived goals reject with `400 { code: "GOAL_ARCHIVED" }`, duplicate/relinked ids are no-ops, and unlink remains allowed even after a goal is archived.
### CLI ### CLI
- `fn mission create ... --goal <goal-id> [--goal <goal-id> ...]` — create a mission and batch-link active goals.
- `fn mission goals <mission-id>` — list linked goals for a mission. - `fn mission goals <mission-id>` — list linked goals for a mission.
- `fn mission link-goal <mission-id> <goal-id>` — idempotently link a goal. - `fn mission link-goal <mission-id> <goal-id>` — idempotently link a goal; archived goals reject with `GOAL_ARCHIVED`.
- `fn mission unlink-goal <mission-id> <goal-id>` — idempotently unlink a goal. - `fn mission unlink-goal <mission-id> <goal-id>` — idempotently unlink a goal, including archived goals.
- Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card. - Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card.
## Mission Planning Tools (pi extension) ## Mission Planning Tools (pi extension)
@@ -161,8 +164,8 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r
| `fn_mission_list` | List missions and their current status. | | `fn_mission_list` | List missions and their current status. |
| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including a **Linked Goals** section plus milestone/feature acceptance criteria and slice verification when present. | | `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including a **Linked Goals** section plus milestone/feature acceptance criteria and slice verification when present. |
| `fn_mission_list_goals` | List the goals linked to a mission. | | `fn_mission_list_goals` | List the goals linked to a mission. |
| `fn_mission_link_goal` | Idempotently link a goal to a mission. | | `fn_mission_link_goal` | Idempotently link a goal to a mission; archived goals reject with `GOAL_ARCHIVED`. |
| `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission. | | `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission, including archived goals. |
| `fn_mission_delete` | Delete a mission and its hierarchy. | | `fn_mission_delete` | Delete a mission and its hierarchy. |
| `fn_mission_update` | Update mission title/description using partial patches. | | `fn_mission_update` | Update mission title/description using partial patches. |
| `fn_milestone_add` | Add a milestone to a mission. | | `fn_milestone_add` | Add a milestone to a mission. |

View File

@@ -615,9 +615,43 @@ describe("bin command routing and fallbacks", () => {
"Detailed mission description", "Detailed mission description",
"demo", "demo",
undefined, undefined,
[],
); );
}); });
it("routes mission create with repeated --goal flags", async () => {
await runBin([
"mission",
"create",
"Test Mission",
"Detailed",
"mission",
"description",
"--goal",
"G-001",
"--goal",
"G-002",
"--base-branch",
"feature/mission",
]);
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
"Test Mission",
"Detailed mission description",
undefined,
"feature/mission",
["G-001", "G-002"],
);
});
it("surfaces non-zero exit from mission create goal validation failures", async () => {
commandMocks.runMissionCreate.mockImplementationOnce(() => {
throw new Error("process.exit:1");
});
await expect(runBin(["mission", "create", "Test Mission", "--goal", "G-ARCHIVED"])).rejects.toThrow("process.exit:1");
});
it.each([ it.each([
{ args: ["mission", "ls"], includeDrafts: true }, { args: ["mission", "ls"], includeDrafts: true },
{ args: ["mission", "list", "--no-drafts"], includeDrafts: false }, { args: ["mission", "list", "--no-drafts"], includeDrafts: false },

View File

@@ -108,6 +108,42 @@ describe("extension mission goal tools", () => {
expect(unlinkAgain.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalBId]); expect(unlinkAgain.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalBId]);
}); });
it("rejects archived goals on link and still unlinks archived links", async () => {
const missionCreate = api.tools.get("fn_mission_create");
const goalCreate = api.tools.get("fn_goal_create");
const goalArchive = api.tools.get("fn_goal_archive");
const linkGoal = api.tools.get("fn_mission_link_goal");
const unlinkGoal = api.tools.get("fn_mission_unlink_goal");
expect(missionCreate && goalCreate && goalArchive && linkGoal && unlinkGoal).toBeTruthy();
const missionResult = await missionCreate!.execute("mission-create", { title: "Mission Alpha" }, undefined, undefined, makeCtx(tmpDir));
const activeGoalResult = await goalCreate!.execute("goal-active", { title: "Goal Active" }, undefined, undefined, makeCtx(tmpDir));
const archivedGoalResult = await goalCreate!.execute("goal-archived", { title: "Goal Archived" }, undefined, undefined, makeCtx(tmpDir));
const missionId = missionResult.details.missionId as string;
const activeGoalId = activeGoalResult.details.goalId as string;
const archivedGoalId = archivedGoalResult.details.goalId as string;
await goalArchive!.execute("archive-goal", { id: archivedGoalId }, undefined, undefined, makeCtx(tmpDir));
await linkGoal!.execute("link-active", { missionId, goalId: activeGoalId }, undefined, undefined, makeCtx(tmpDir));
const relink = await linkGoal!.execute("relink-active", { missionId, goalId: activeGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(relink.isError).toBeUndefined();
expect(relink.details.goals.map((goal: { id: string }) => goal.id)).toEqual([activeGoalId]);
const archivedLink = await linkGoal!.execute("link-archived", { missionId, goalId: archivedGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(archivedLink.isError).toBe(true);
expect(archivedLink.details).toEqual({ code: "GOAL_ARCHIVED", goalId: archivedGoalId });
const linkedArchivedResult = await goalCreate!.execute("goal-linked-then-archived", { title: "Goal Linked Then Archived" }, undefined, undefined, makeCtx(tmpDir));
const linkedArchivedGoalId = linkedArchivedResult.details.goalId as string;
await linkGoal!.execute("link-before-archive", { missionId, goalId: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
await goalArchive!.execute("archive-linked-goal", { id: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
const unlinked = await unlinkGoal!.execute("unlink-archived", { missionId, goalId: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(unlinked.isError).toBeUndefined();
expect(unlinked.details.goals.map((goal: { id: string }) => goal.id)).toEqual([activeGoalId]);
});
it("returns stable missing mission and goal errors", async () => { it("returns stable missing mission and goal errors", async () => {
const missionCreate = api.tools.get("fn_mission_create"); const missionCreate = api.tools.get("fn_mission_create");
const goalCreate = api.tools.get("fn_goal_create"); const goalCreate = api.tools.get("fn_goal_create");

View File

@@ -327,7 +327,8 @@ PR:
Cancel an active cited-research run Cancel an active cited-research run
fn research retry <run-id> [--json] fn research retry <run-id> [--json]
Retry a failed/cancelled cited-research run Retry a failed/cancelled cited-research run
fn mission create [title] [desc] Create a new mission fn mission create [title] [desc] [--goal <id>] [--base-branch <branch>]
Create a new mission (repeat --goal to link goals)
fn mission list | ls List missions fn mission list | ls List missions
fn mission show | info <id> Show mission details fn mission show | info <id> Show mission details
fn mission goals <id> List linked goals for a mission fn mission goals <id> List linked goals for a mission
@@ -498,6 +499,22 @@ function getFlagValue(args: string[], flag: string): string | undefined {
return value; return value;
} }
function getRepeatedFlagValues(args: string[], flag: string): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index++) {
if (args[index] !== flag) {
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
continue;
}
values.push(value);
index += 1;
}
return values;
}
function getFlagValueNumber(args: string[], flag: string): number | undefined { function getFlagValueNumber(args: string[], flag: string): number | undefined {
const value = getFlagValue(args, flag); const value = getFlagValue(args, flag);
if (value === undefined) { if (value === undefined) {
@@ -1388,18 +1405,21 @@ async function main() {
case "create": { case "create": {
const createArgs = args.slice(2); const createArgs = args.slice(2);
let baseBranch: string | undefined; let baseBranch: string | undefined;
const goalIds = getRepeatedFlagValues(createArgs, "--goal");
const positional: string[] = []; const positional: string[] = [];
for (let i = 0; i < createArgs.length; i++) { for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--base-branch" && i + 1 < createArgs.length) { if (createArgs[i] === "--base-branch" && i + 1 < createArgs.length) {
baseBranch = createArgs[i + 1]; baseBranch = createArgs[i + 1];
i++; i++;
} else if (createArgs[i] === "--goal" && i + 1 < createArgs.length) {
i++;
} else { } else {
positional.push(createArgs[i]); positional.push(createArgs[i]);
} }
} }
const title = positional[0]; const title = positional[0];
const description = positional.length > 1 ? positional.slice(1).join(" ") : undefined; const description = positional.length > 1 ? positional.slice(1).join(" ") : undefined;
await runMissionCreate(title, description, projectName, baseBranch); await runMissionCreate(title, description, projectName, baseBranch, goalIds);
break; break;
} }
case "list": case "list":

View File

@@ -75,14 +75,30 @@ async function promptForTitleAndDescription(
* Create a new mission with optional title and description. * Create a new mission with optional title and description.
* If arguments are omitted, prompts interactively. * If arguments are omitted, prompts interactively.
*/ */
function requireCliLinkableGoal(store: Awaited<ReturnType<typeof getStore>>, goalId: string): Goal {
const goal = store.getGoalStore().getGoal(goalId);
if (!goal) {
console.error(`✗ Goal ${goalId} not found`);
process.exit(1);
}
if (goal.status === "archived") {
console.error(`✗ Goal ${goalId} is archived and cannot be linked`);
process.exit(1);
}
return goal;
}
export async function runMissionCreate( export async function runMissionCreate(
titleArg?: string, titleArg?: string,
descriptionArg?: string, descriptionArg?: string,
projectName?: string, projectName?: string,
baseBranch?: string, baseBranch?: string,
goalIds?: string[],
) { ) {
const store = await getStore({ project: projectName }); const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore(); const missionStore = store.getMissionStore();
const uniqueGoalIds = Array.from(new Set(goalIds ?? []));
const linkableGoals = uniqueGoalIds.map((goalId) => requireCliLinkableGoal(store, goalId));
const { title, description } = titleArg const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined } ? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
@@ -98,12 +114,19 @@ export async function runMissionCreate(
baseBranch: baseBranch?.trim() || undefined, baseBranch: baseBranch?.trim() || undefined,
}); });
for (const goal of linkableGoals) {
missionStore.linkGoal(mission.id, goal.id);
}
console.log(); console.log();
console.log(` ✓ Created ${mission.id}: ${mission.title}`); console.log(` ✓ Created ${mission.id}: ${mission.title}`);
console.log(` Status: ${MISSION_STATUS_LABELS[mission.status]}`); console.log(` Status: ${MISSION_STATUS_LABELS[mission.status]}`);
if (mission.description) { if (mission.description) {
console.log(` Description: ${mission.description.slice(0, 80)}${mission.description.length > 80 ? "…" : ""}`); console.log(` Description: ${mission.description.slice(0, 80)}${mission.description.length > 80 ? "…" : ""}`);
} }
if (linkableGoals.length > 0) {
console.log(` Linked goals: ${linkableGoals.length}`);
}
console.log(); console.log();
} }
@@ -464,11 +487,7 @@ export async function runMissionLinkGoal(missionId: string, goalId: string, proj
process.exit(1); process.exit(1);
} }
const goal = store.getGoalStore().getGoal(goalId); const goal = requireCliLinkableGoal(store, goalId);
if (!goal) {
console.error(`✗ Goal ${goalId} not found`);
process.exit(1);
}
missionStore.linkGoal(missionId, goalId); missionStore.linkGoal(missionId, goalId);

View File

@@ -2787,6 +2787,13 @@ export default function kbExtension(pi: ExtensionAPI) {
details: { code: "GOAL_NOT_FOUND", goalId: params.goalId }, details: { code: "GOAL_NOT_FOUND", goalId: params.goalId },
}; };
} }
if (goal.status === "archived") {
return {
content: [{ type: "text", text: `Goal ${params.goalId} is archived and cannot be linked` }],
isError: true,
details: { code: "GOAL_ARCHIVED", goalId: params.goalId },
};
}
missionStore.linkGoal(params.missionId, params.goalId); missionStore.linkGoal(params.missionId, params.goalId);
const goals = missionStore const goals = missionStore

View File

@@ -22,6 +22,11 @@ async function createFixture() {
return { app, store, rootDir }; return { app, store, rootDir };
} }
async function createArchivedGoal(store: TaskStore, title = "Archived Goal") {
const created = store.getGoalStore().createGoal({ title });
return store.getGoalStore().archiveGoal(created.id);
}
describe("mission goal linkage routes", () => { describe("mission goal linkage routes", () => {
let rootDir: string; let rootDir: string;
let app: express.Express; let app: express.Express;
@@ -52,12 +57,136 @@ describe("mission goal linkage routes", () => {
expect((populated.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalA.id, goalB.id]); expect((populated.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalA.id, goalB.id]);
}); });
it("sets the full linked goal set", async () => { it("creates a mission with linked goalIds and dedupes duplicates", async () => {
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const response = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Ship mission", goalIds: [goalA.id, goalB.id, goalB.id] }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect((response.body as { linkedGoals: Goal[] }).linkedGoals.map((goal) => goal.id)).toEqual([goalA.id, goalB.id]);
const missionId = (response.body as { id: string }).id;
expect(store.getMissionStore().listGoalIdsForMission(missionId)).toEqual([goalA.id, goalB.id]);
});
it("rejects archived and unknown goalIds on mission create", async () => {
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const archivedGoal = await createArchivedGoal(store);
const archivedResponse = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Ship mission", goalIds: [goal.id, archivedGoal.id] }),
{ "content-type": "application/json" },
);
expect(archivedResponse.status).toBe(400);
expect(archivedResponse.body).toMatchObject({
details: { code: "GOAL_ARCHIVED", goalId: archivedGoal.id },
});
const archivedMissionId = (archivedResponse.body as { id?: string }).id;
if (archivedMissionId) {
expect(store.getMissionStore().listGoalIdsForMission(archivedMissionId)).toEqual([]);
}
const missingResponse = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Ship another mission", goalIds: [goal.id, "G-404"] }),
{ "content-type": "application/json" },
);
expect(missingResponse.status).toBe(404);
});
it("replaces linked goals via patch, clears with [], and ignores undefined goalIds", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" }); const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goalA = store.getGoalStore().createGoal({ title: "Goal A" }); const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" }); const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const goalC = store.getGoalStore().createGoal({ title: "Goal C" }); const goalC = store.getGoalStore().createGoal({ title: "Goal C" });
store.getMissionStore().linkGoal(mission.id, goalA.id); store.getMissionStore().linkGoal(mission.id, goalA.id);
const replaceResponse = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ goalIds: [goalB.id, goalC.id, goalC.id] }),
{ "content-type": "application/json" },
);
expect(replaceResponse.status).toBe(200);
expect((replaceResponse.body as { linkedGoals: Goal[] }).linkedGoals.map((goal) => goal.id)).toEqual([goalB.id, goalC.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
const unchangedResponse = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ title: "Renamed mission" }),
{ "content-type": "application/json" },
);
expect(unchangedResponse.status).toBe(200);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
const clearResponse = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ goalIds: [] }),
{ "content-type": "application/json" },
);
expect(clearResponse.status).toBe(200);
expect(clearResponse.body).toMatchObject({ linkedGoals: [] });
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([]);
});
it("rejects archived and unknown goalIds on patch before mutating links", 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 archivedGoal = await createArchivedGoal(store);
store.getMissionStore().linkGoal(mission.id, goalA.id);
const archivedResponse = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ title: "Updated title", goalIds: [goalB.id, archivedGoal.id] }),
{ "content-type": "application/json" },
);
expect(archivedResponse.status).toBe(400);
expect(archivedResponse.body).toMatchObject({
details: { code: "GOAL_ARCHIVED", goalId: archivedGoal.id },
});
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalA.id]);
expect(store.getMissionStore().getMission(mission.id)?.title).toBe("Ship mission");
const missingResponse = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ goalIds: [goalB.id, "G-404"] }),
{ "content-type": "application/json" },
);
expect(missingResponse.status).toBe(404);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalA.id]);
});
it("sets the full linked goal set via put and rejects invalid states atomically", 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" });
const archivedGoal = await createArchivedGoal(store);
store.getMissionStore().linkGoal(mission.id, goalA.id);
store.getMissionStore().linkGoal(mission.id, goalB.id); store.getMissionStore().linkGoal(mission.id, goalB.id);
const response = await request( const response = await request(
@@ -71,11 +200,35 @@ describe("mission goal linkage routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect((response.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalB.id, goalC.id]); 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]); expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
const archivedResponse = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goalA.id, archivedGoal.id] }),
{ "content-type": "application/json" },
);
expect(archivedResponse.status).toBe(400);
expect(archivedResponse.body).toMatchObject({
details: { code: "GOAL_ARCHIVED", goalId: archivedGoal.id },
});
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
const missingResponse = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goalA.id, "G-404"] }),
{ "content-type": "application/json" },
);
expect(missingResponse.status).toBe(404);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
}); });
it("adds a linked goal idempotently", async () => { it("adds a linked goal idempotently and rejects archived or missing goals", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" }); const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" }); const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const archivedGoal = await createArchivedGoal(store);
const first = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`); const first = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(first.status).toBe(200); expect(first.status).toBe(200);
@@ -85,18 +238,29 @@ describe("mission goal linkage routes", () => {
expect(second.status).toBe(200); expect(second.status).toBe(200);
expect((second.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]); expect((second.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]); expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]);
const archivedResponse = await request(app, "POST", `/api/missions/${mission.id}/goals/${archivedGoal.id}`);
expect(archivedResponse.status).toBe(400);
expect(archivedResponse.body).toMatchObject({
details: { code: "GOAL_ARCHIVED", goalId: archivedGoal.id },
});
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]);
const missingResponse = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`);
expect(missingResponse.status).toBe(404);
}); });
it("removes a linked goal idempotently", async () => { it("removes a linked archived goal idempotently", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" }); const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" }); const goal = store.getGoalStore().createGoal({ title: "Goal A" });
store.getMissionStore().linkGoal(mission.id, goal.id); store.getMissionStore().linkGoal(mission.id, goal.id);
const archivedGoal = store.getGoalStore().archiveGoal(goal.id);
const first = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`); const first = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${archivedGoal.id}`);
expect(first.status).toBe(200); expect(first.status).toBe(200);
expect(first.body).toEqual({ removed: true, goals: [] }); expect(first.body).toEqual({ removed: true, goals: [] });
const second = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`); const second = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${archivedGoal.id}`);
expect(second.status).toBe(200); expect(second.status).toBe(200);
expect(second.body).toEqual({ removed: true, goals: [] }); expect(second.body).toEqual({ removed: true, goals: [] });
}); });
@@ -116,6 +280,24 @@ describe("mission goal linkage routes", () => {
); );
expect(setBad.status).toBe(400); expect(setBad.status).toBe(400);
const patchBad = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ goalIds: ["bad-goal-id"] }),
{ "content-type": "application/json" },
);
expect(patchBad.status).toBe(400);
const createBad = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Ship mission", goalIds: ["bad-goal-id"] }),
{ "content-type": "application/json" },
);
expect(createBad.status).toBe(400);
const addBad = await request(app, "POST", `/api/missions/${mission.id}/goals/not-a-goal`); const addBad = await request(app, "POST", `/api/missions/${mission.id}/goals/not-a-goal`);
expect(addBad.status).toBe(400); expect(addBad.status).toBe(400);
@@ -133,6 +315,15 @@ describe("mission goal linkage routes", () => {
const missingMissionAdd = await request(app, "POST", `/api/missions/M-404/goals/${goal.id}`); const missingMissionAdd = await request(app, "POST", `/api/missions/M-404/goals/${goal.id}`);
expect(missingMissionAdd.status).toBe(404); expect(missingMissionAdd.status).toBe(404);
const missingMissionPatch = await request(
app,
"PATCH",
"/api/missions/M-404",
JSON.stringify({ goalIds: [goal.id] }),
{ "content-type": "application/json" },
);
expect(missingMissionPatch.status).toBe(404);
const missingGoalAdd = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`); const missingGoalAdd = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`);
expect(missingGoalAdd.status).toBe(404); expect(missingGoalAdd.status).toBe(404);

View File

@@ -187,11 +187,7 @@ function validateOrderedIds(body: unknown): string[] {
return orderedIds; return orderedIds;
} }
function validateGoalIdsBody(body: unknown): string[] { function validateOptionalGoalIds(goalIds: 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)) { if (!Array.isArray(goalIds)) {
throw badRequest("goalIds must be an array"); throw badRequest("goalIds must be an array");
} }
@@ -204,6 +200,14 @@ function validateGoalIdsBody(body: unknown): string[] {
return goalIds; return goalIds;
} }
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>;
return validateOptionalGoalIds(goalIds);
}
type TypedRequest = Request<Record<string, string>>; type TypedRequest = Request<Record<string, string>>;
function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) { function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
@@ -327,6 +331,14 @@ export function createMissionRouter(
return goal; return goal;
} }
function requireLinkableGoal(goalId: string): Goal {
const goal = requireGoal(goalId);
if (goal.status === "archived") {
throw badRequest("Cannot link an archived goal", { code: "GOAL_ARCHIVED", goalId });
}
return goal;
}
function listLinkedGoalsForMission(missionId: string): Goal[] { function listLinkedGoalsForMission(missionId: string): Goal[] {
requireMission(missionId); requireMission(missionId);
const goalStore = getScopedGoalStore(); const goalStore = getScopedGoalStore();
@@ -339,7 +351,7 @@ export function createMissionRouter(
function setLinkedGoalsForMission(missionId: string, goalIds: string[]): Goal[] { function setLinkedGoalsForMission(missionId: string, goalIds: string[]): Goal[] {
requireMission(missionId); requireMission(missionId);
const uniqueGoalIds = Array.from(new Set(goalIds)); const uniqueGoalIds = Array.from(new Set(goalIds));
uniqueGoalIds.forEach((goalId) => requireGoal(goalId)); uniqueGoalIds.forEach((goalId) => requireLinkableGoal(goalId));
const existingGoalIds = new Set(missionStore.listGoalIdsForMission(missionId)); const existingGoalIds = new Set(missionStore.listGoalIdsForMission(missionId));
const nextGoalIds = new Set(uniqueGoalIds); const nextGoalIds = new Set(uniqueGoalIds);
@@ -417,10 +429,11 @@ export function createMissionRouter(
router.post( router.post(
"/", "/",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { title, description, autoAdvance, baseBranch, branchStrategy } = req.body; const { title, description, autoAdvance, baseBranch, branchStrategy, goalIds } = req.body;
const validatedTitle = validateTitle(title); const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description); const validatedDescription = validateDescription(description);
const validatedGoalIds = goalIds === undefined ? undefined : validateOptionalGoalIds(goalIds);
const input: MissionCreateInput = { const input: MissionCreateInput = {
title: validatedTitle, title: validatedTitle,
@@ -435,13 +448,20 @@ export function createMissionRouter(
if (autoAdvance !== undefined) { if (autoAdvance !== undefined) {
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance"); updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
} }
if (Object.keys(updates).length > 0) { const updatedMission = Object.keys(updates).length > 0
const updatedMission = missionStore.updateMission(mission.id, updates); ? missionStore.updateMission(mission.id, updates)
res.status(201).json(updatedMission); : mission;
return;
}
res.status(201).json(mission); // Mission creation and mission↔goal linking are separate store operations today,
// so creation may succeed even when a later goal validation/linking step fails.
const linkedGoals = validatedGoalIds === undefined
? listLinkedGoalsForMission(mission.id)
: setLinkedGoalsForMission(mission.id, validatedGoalIds);
res.status(201).json({
...updatedMission,
linkedGoals,
});
}) })
); );
@@ -1023,7 +1043,7 @@ export function createMissionRouter(
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { missionId, goalId } = req.params; const { missionId, goalId } = req.params;
requireMission(missionId); requireMission(missionId);
const goal = requireGoal(goalId); const goal = requireLinkableGoal(goalId);
missionStore.linkGoal(missionId, goalId); missionStore.linkGoal(missionId, goalId);
res.json({ goal, goals: listLinkedGoalsForMission(missionId) }); res.json({ goal, goals: listLinkedGoalsForMission(missionId) });
}) })
@@ -1081,13 +1101,15 @@ export function createMissionRouter(
"/:missionId", "/:missionId",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { missionId } = req.params; const { missionId } = req.params;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy } = req.body; const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
if (!validateMissionId(missionId)) { if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format"); throw badRequest("Invalid mission ID format");
} }
const updates: Partial<Mission> = {}; const updates: Partial<Mission> = {};
const validatedGoalIds = goalIds === undefined ? undefined : validateOptionalGoalIds(goalIds);
validatedGoalIds?.forEach((goalId) => requireLinkableGoal(goalId));
if (title !== undefined) { if (title !== undefined) {
updates.title = validateTitle(title); updates.title = validateTitle(title);
@@ -1111,17 +1133,25 @@ export function createMissionRouter(
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy); updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
} }
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
throw badRequest("No valid fields to update"); throw badRequest("No valid fields to update");
} }
try { try {
const existingMission = missionStore.getMission(missionId); const existingMission = missionStore.getMission(missionId);
const mission = missionStore.updateMission(missionId, updates); const mission = Object.keys(updates).length > 0
? missionStore.updateMission(missionId, updates)
: requireMission(missionId);
if (missionAutopilot && updates.autopilotEnabled === true && existingMission?.autopilotEnabled !== true) { if (missionAutopilot && updates.autopilotEnabled === true && existingMission?.autopilotEnabled !== true) {
missionAutopilot.watchMission(missionId); missionAutopilot.watchMission(missionId);
} }
res.json(mission); const linkedGoals = validatedGoalIds === undefined
? listLinkedGoalsForMission(missionId)
: setLinkedGoalsForMission(missionId, validatedGoalIds);
res.json({
...mission,
linkedGoals,
});
} catch (err: unknown) { } catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("not found")) { if (errMsg.includes("not found")) {