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.
```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 show 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`.
`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:
- `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 unlink-goal <mission-id> <goal-id>` idempotently removes 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, including archived goals.
---

View File

@@ -118,7 +118,7 @@ Mission, milestone, slice, and feature read-only text surfaces in Mission Manage
### CLI
```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 show 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 |
|---|---|
| `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/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. |
| `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. |
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
- `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 link-goal <mission-id> <goal-id>` — idempotently link a goal.
- `fn mission unlink-goal <mission-id> <goal-id>` — idempotently unlink 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, 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 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_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_link_goal` | Idempotently link a goal to a mission. |
| `fn_mission_unlink_goal` | Idempotently unlink a goal from 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, including archived goals. |
| `fn_mission_delete` | Delete a mission and its hierarchy. |
| `fn_mission_update` | Update mission title/description using partial patches. |
| `fn_milestone_add` | Add a milestone to a mission. |

View File

@@ -615,9 +615,43 @@ describe("bin command routing and fallbacks", () => {
"Detailed mission description",
"demo",
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([
{ args: ["mission", "ls"], includeDrafts: true },
{ 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]);
});
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 () => {
const missionCreate = api.tools.get("fn_mission_create");
const goalCreate = api.tools.get("fn_goal_create");

View File

@@ -327,7 +327,8 @@ PR:
Cancel an active cited-research run
fn research retry <run-id> [--json]
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 show | info <id> Show mission details
fn mission goals <id> List linked goals for a mission
@@ -498,6 +499,22 @@ function getFlagValue(args: string[], flag: string): string | undefined {
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 {
const value = getFlagValue(args, flag);
if (value === undefined) {
@@ -1388,18 +1405,21 @@ async function main() {
case "create": {
const createArgs = args.slice(2);
let baseBranch: string | undefined;
const goalIds = getRepeatedFlagValues(createArgs, "--goal");
const positional: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--base-branch" && i + 1 < createArgs.length) {
baseBranch = createArgs[i + 1];
i++;
} else if (createArgs[i] === "--goal" && i + 1 < createArgs.length) {
i++;
} else {
positional.push(createArgs[i]);
}
}
const title = positional[0];
const description = positional.length > 1 ? positional.slice(1).join(" ") : undefined;
await runMissionCreate(title, description, projectName, baseBranch);
await runMissionCreate(title, description, projectName, baseBranch, goalIds);
break;
}
case "list":

View File

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

View File

@@ -22,6 +22,11 @@ async function createFixture() {
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", () => {
let rootDir: string;
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]);
});
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 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);
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);
const response = await request(
@@ -71,11 +200,35 @@ describe("mission goal linkage routes", () => {
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]);
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 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}`);
expect(first.status).toBe(200);
@@ -85,18 +238,29 @@ describe("mission goal linkage routes", () => {
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]);
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 goal = store.getGoalStore().createGoal({ title: "Goal A" });
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.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.body).toEqual({ removed: true, goals: [] });
});
@@ -116,6 +280,24 @@ describe("mission goal linkage routes", () => {
);
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`);
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}`);
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`);
expect(missingGoalAdd.status).toBe(404);

View File

@@ -187,11 +187,7 @@ 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>;
function validateOptionalGoalIds(goalIds: unknown): string[] {
if (!Array.isArray(goalIds)) {
throw badRequest("goalIds must be an array");
}
@@ -204,6 +200,14 @@ function validateGoalIdsBody(body: unknown): string[] {
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>>;
function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
@@ -327,6 +331,14 @@ export function createMissionRouter(
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[] {
requireMission(missionId);
const goalStore = getScopedGoalStore();
@@ -339,7 +351,7 @@ export function createMissionRouter(
function setLinkedGoalsForMission(missionId: string, goalIds: string[]): Goal[] {
requireMission(missionId);
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 nextGoalIds = new Set(uniqueGoalIds);
@@ -417,10 +429,11 @@ export function createMissionRouter(
router.post(
"/",
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 validatedDescription = validateDescription(description);
const validatedGoalIds = goalIds === undefined ? undefined : validateOptionalGoalIds(goalIds);
const input: MissionCreateInput = {
title: validatedTitle,
@@ -435,13 +448,20 @@ export function createMissionRouter(
if (autoAdvance !== undefined) {
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
}
if (Object.keys(updates).length > 0) {
const updatedMission = missionStore.updateMission(mission.id, updates);
res.status(201).json(updatedMission);
return;
}
const updatedMission = Object.keys(updates).length > 0
? missionStore.updateMission(mission.id, updates)
: mission;
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) => {
const { missionId, goalId } = req.params;
requireMission(missionId);
const goal = requireGoal(goalId);
const goal = requireLinkableGoal(goalId);
missionStore.linkGoal(missionId, goalId);
res.json({ goal, goals: listLinkedGoalsForMission(missionId) });
})
@@ -1081,13 +1101,15 @@ export function createMissionRouter(
"/:missionId",
catchTypedHandler(async (req, res) => {
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)) {
throw badRequest("Invalid mission ID format");
}
const updates: Partial<Mission> = {};
const validatedGoalIds = goalIds === undefined ? undefined : validateOptionalGoalIds(goalIds);
validatedGoalIds?.forEach((goalId) => requireLinkableGoal(goalId));
if (title !== undefined) {
updates.title = validateTitle(title);
@@ -1111,17 +1133,25 @@ export function createMissionRouter(
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");
}
try {
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) {
missionAutopilot.watchMission(missionId);
}
res.json(mission);
const linkedGoals = validatedGoalIds === undefined
? listLinkedGoalsForMission(missionId)
: setLinkedGoalsForMission(missionId, validatedGoalIds);
res.json({
...mission,
linkedGoals,
});
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("not found")) {