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