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:
10
.changeset/FN-5958-mission-goal-links.md
Normal file
10
.changeset/FN-5958-mission-goal-links.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user