From 26bc80a0adca68567bbee194c84902a95cbdc080 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 16:14:19 -0700 Subject: [PATCH] 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 --- .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 +++ .../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 + .../mission-goal-links-routes.test.ts | 201 +++++++++++++++++- packages/dashboard/src/mission-routes.ts | 66 ++++-- 10 files changed, 390 insertions(+), 40 deletions(-) create mode 100644 .changeset/FN-5958-mission-goal-links.md diff --git a/.changeset/FN-5958-mission-goal-links.md b/.changeset/FN-5958-mission-goal-links.md new file mode 100644 index 0000000000..c4e3b1e117 --- /dev/null +++ b/.changeset/FN-5958-mission-goal-links.md @@ -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 ` 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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index cad8358f5b..e74a444019 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ` 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 ` 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 ` flags to link active goals during creation. Mission ↔ goal linkage commands operate on the persisted `mission_goals` relation: - `fn mission goals ` lists the linked goals for a mission. -- `fn mission link-goal ` idempotently adds a goal link. -- `fn mission unlink-goal ` idempotently removes a goal link. +- `fn mission link-goal ` idempotently adds a goal link; archived goals reject with `GOAL_ARCHIVED` and missing goals still fail with `404`/not found. +- `fn mission unlink-goal ` idempotently removes a goal link, including archived goals. --- diff --git a/docs/missions.md b/docs/missions.md index 5ca0da17f7..d5b02f94d3 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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 ...]` — create a mission and batch-link active goals. - `fn mission goals ` — list linked goals for a mission. -- `fn mission link-goal ` — idempotently link a goal. -- `fn mission unlink-goal ` — idempotently unlink a goal. +- `fn mission link-goal ` — idempotently link a goal; archived goals reject with `GOAL_ARCHIVED`. +- `fn mission unlink-goal ` — 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. | diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 584f928a59..46a3c6b09b 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -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 }, diff --git a/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts b/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts index b49866f951..1b2a889b7b 100644 --- a/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts +++ b/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts @@ -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"); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 781e5b8275..7171ffd1d4 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -327,7 +327,8 @@ PR: Cancel an active cited-research run fn research retry [--json] Retry a failed/cancelled cited-research run - fn mission create [title] [desc] Create a new mission + fn mission create [title] [desc] [--goal ] [--base-branch ] + Create a new mission (repeat --goal to link goals) fn mission list | ls List missions fn mission show | info Show mission details fn mission goals 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": diff --git a/packages/cli/src/commands/mission.ts b/packages/cli/src/commands/mission.ts index 789d902f50..c83af45cf1 100644 --- a/packages/cli/src/commands/mission.ts +++ b/packages/cli/src/commands/mission.ts @@ -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>, 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); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7c801bd5ac..7383485347 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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 diff --git a/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts b/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts index c951af90d0..a703236ed4 100644 --- a/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts +++ b/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts @@ -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); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index fe8ebcd309..72e836440a 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -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; +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; + return validateOptionalGoalIds(goalIds); +} + type TypedRequest = Request>; function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise) { @@ -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 = {}; + 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")) {