feat(FN-1010): add mission start endpoint and UI button
- Add POST /api/missions/:missionId/start backend endpoint to start planning missions - Add startMission() frontend API function in dashboard api.ts - Add Start button in MissionManager component for planning missions - Add integration tests for the startMission endpoint covering success, error, and edge cases
This commit is contained in:
@@ -2654,6 +2654,13 @@ export function stopMission(missionId: string, projectId?: string): Promise<Miss
|
||||
});
|
||||
}
|
||||
|
||||
/** Start a planning mission: sets status to "active" and activates the first pending slice */
|
||||
export function startMission(missionId: string, projectId?: string): Promise<MissionWithHierarchy> {
|
||||
return api<MissionWithHierarchy>(withProjectId(`/missions/${encodeURIComponent(missionId)}/start`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mission Autopilot API ────────────────────────────────────────────────
|
||||
|
||||
/** Fetch autopilot status for a mission */
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
stopMission,
|
||||
startMission,
|
||||
fetchMissionAutopilotStatus,
|
||||
updateMissionAutopilot,
|
||||
startMissionAutopilot,
|
||||
@@ -686,6 +687,18 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
// Start a planning mission — set status to "active" and activate first slice
|
||||
const handleStartMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
await startMission(missionId, projectId);
|
||||
addToast("Mission started — first slice activated", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to start mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
// ── Autopilot handlers ──
|
||||
|
||||
const loadAutopilotStatus = useCallback(async (missionId: string) => {
|
||||
@@ -980,6 +993,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
{selectedMission.status === "planning" && (
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--success"
|
||||
onClick={() => handleStartMission(selectedMission.id)}
|
||||
title="Start mission"
|
||||
aria-label="Start mission"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleEditMission(selectedMission)}
|
||||
@@ -1597,6 +1620,15 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
{m.status === "planning" && (
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--success"
|
||||
onClick={() => handleStartMission(m.id)}
|
||||
title="Start mission"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleEditMission(mission)}
|
||||
|
||||
@@ -159,6 +159,14 @@ function createMockMissionStore() {
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
),
|
||||
|
||||
updateSlice: vi.fn((id: string, updates: Partial<Slice>) => {
|
||||
const slice = slices.get(id);
|
||||
if (!slice) throw new Error("Slice " + id + " not found");
|
||||
const updated = { ...slice, ...updates, updatedAt: new Date().toISOString() };
|
||||
slices.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
addFeature: vi.fn((sliceId: string, input: { title: string; description?: string }) => {
|
||||
const feature: MissionFeature = {
|
||||
id: generateFeatureId(),
|
||||
@@ -185,6 +193,23 @@ function createMockMissionStore() {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
slices.set(id, updated);
|
||||
|
||||
// Simulate auto-triage: when mission.autoAdvance is true, triage "defined" features
|
||||
const milestone = milestones.get(slice.milestoneId);
|
||||
if (milestone) {
|
||||
const mission = missions.get(milestone.missionId);
|
||||
if (mission?.autoAdvance === true) {
|
||||
const sliceFeatures = Array.from(features.values()).filter(
|
||||
(f) => f.sliceId === id && f.status === "defined"
|
||||
);
|
||||
for (const f of sliceFeatures) {
|
||||
const taskId = "FN-" + String(features.size + 1).padStart(3, "0");
|
||||
const triaged = { ...f, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
|
||||
features.set(f.id, triaged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
@@ -245,6 +270,21 @@ function createMockMissionStore() {
|
||||
return triaged;
|
||||
}),
|
||||
|
||||
findNextPendingSlice: vi.fn((missionId: string) => {
|
||||
const missionMilestones = Array.from(milestones.values())
|
||||
.filter((m) => m.missionId === missionId)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
for (const milestone of missionMilestones) {
|
||||
const milestoneSlices = Array.from(slices.values())
|
||||
.filter((s) => s.milestoneId === milestone.id)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
for (const slice of milestoneSlices) {
|
||||
if (slice.status === "pending") return slice;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
|
||||
// Mission status helpers for pause/stop
|
||||
computeMissionStatus: vi.fn(() => "active"),
|
||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
||||
@@ -1050,4 +1090,117 @@ describe("Mission API", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mission Start Endpoint ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/missions/:missionId/start", () => {
|
||||
it("should start a planning mission and activate the first slice", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Create mission with milestone, slice, and defined features
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone 1" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice 1" });
|
||||
const feature1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||
const feature2 = ms.addFeature(slice.id, { title: "Feature 2" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Verify mission status is active
|
||||
expect(res.body.status).toBe("active");
|
||||
// Verify autoAdvance is true
|
||||
expect(res.body.autoAdvance).toBe(true);
|
||||
// Verify hierarchy is returned
|
||||
expect(res.body.milestones).toBeDefined();
|
||||
expect(res.body.milestones.length).toBe(1);
|
||||
|
||||
// Verify the slice was activated
|
||||
const activatedSlice = res.body.milestones[0].slices[0];
|
||||
expect(activatedSlice.status).toBe("active");
|
||||
expect(activatedSlice.activatedAt).toBeDefined();
|
||||
|
||||
// Verify features were triaged (auto-triage via activateSlice)
|
||||
const triagedFeatures = activatedSlice.features;
|
||||
expect(triagedFeatures.length).toBe(2);
|
||||
for (const f of triagedFeatures) {
|
||||
expect(f.status).toBe("triaged");
|
||||
expect(f.taskId).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 409 for already-active mission", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Active Mission" });
|
||||
ms.updateMission(mission.id, { status: "active" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("planning");
|
||||
});
|
||||
|
||||
it("should return 400 when no pending slices exist", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Empty Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Active Slice" });
|
||||
// Mark the slice as active (not pending)
|
||||
ms.updateSlice(slice.id, { status: "active" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("No pending slices");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent mission", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/M-NONEXISTENT-XXX/start",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid mission ID format", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/bad-id/start",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1332,6 +1332,52 @@ export function createMissionRouter(
|
||||
})
|
||||
);
|
||||
|
||||
// ── Mission Start Endpoint ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/missions/:missionId/start
|
||||
* Start a planning mission: set status to "active", activate the first
|
||||
* pending slice, and auto-triage all "defined" features in that slice.
|
||||
*/
|
||||
router.post(
|
||||
"/:missionId/start",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
return;
|
||||
}
|
||||
|
||||
const mission = missionStore.getMission(missionId);
|
||||
if (!mission) {
|
||||
res.status(404).json({ error: "Mission not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mission.status !== "planning") {
|
||||
res.status(409).json({ error: "Mission must be in 'planning' status to start" });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSlice = missionStore.findNextPendingSlice(missionId);
|
||||
if (!nextSlice) {
|
||||
res.status(400).json({ error: "No pending slices found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set autoAdvance: true so activateSlice() will auto-triage features
|
||||
missionStore.updateMission(missionId, { autoAdvance: true, status: "active" });
|
||||
|
||||
// Activate the first pending slice (triggers auto-triage via activateSlice)
|
||||
await missionStore.activateSlice(nextSlice.id);
|
||||
|
||||
// Return updated mission with hierarchy
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
|
||||
res.json(hierarchy);
|
||||
})
|
||||
);
|
||||
|
||||
// ── Autopilot Endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user