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:
gsxdsm
2026-04-06 00:08:39 -07:00
parent 1c76e25aa8
commit 2eae2834f4
4 changed files with 238 additions and 0 deletions

View File

@@ -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 ──────────────────────────────────────────────── // ── Mission Autopilot API ────────────────────────────────────────────────
/** Fetch autopilot status for a mission */ /** Fetch autopilot status for a mission */

View File

@@ -61,6 +61,7 @@ import {
pauseMission, pauseMission,
resumeMission, resumeMission,
stopMission, stopMission,
startMission,
fetchMissionAutopilotStatus, fetchMissionAutopilotStatus,
updateMissionAutopilot, updateMissionAutopilot,
startMissionAutopilot, startMissionAutopilot,
@@ -686,6 +687,18 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
} }
}, [addToast, loadMissionDetail, loadMissions, projectId]); }, [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 ── // ── Autopilot handlers ──
const loadAutopilotStatus = useCallback(async (missionId: string) => { const loadAutopilotStatus = useCallback(async (missionId: string) => {
@@ -980,6 +993,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </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 <button
className="mission-icon-btn" className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)} onClick={() => handleEditMission(selectedMission)}
@@ -1597,6 +1620,15 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </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 <button
className="mission-icon-btn" className="mission-icon-btn"
onClick={() => handleEditMission(mission)} onClick={() => handleEditMission(mission)}

View File

@@ -159,6 +159,14 @@ function createMockMissionStore() {
.sort((a, b) => a.orderIndex - b.orderIndex) .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 }) => { addFeature: vi.fn((sliceId: string, input: { title: string; description?: string }) => {
const feature: MissionFeature = { const feature: MissionFeature = {
id: generateFeatureId(), id: generateFeatureId(),
@@ -185,6 +193,23 @@ function createMockMissionStore() {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
slices.set(id, updated); 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; return updated;
}), }),
@@ -245,6 +270,21 @@ function createMockMissionStore() {
return triaged; 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 // Mission status helpers for pause/stop
computeMissionStatus: vi.fn(() => "active"), computeMissionStatus: vi.fn(() => "active"),
getMilestone: vi.fn((id: string) => milestones.get(id)), getMilestone: vi.fn((id: string) => milestones.get(id)),
@@ -1050,4 +1090,117 @@ describe("Mission API", () => {
expect(res.status).toBe(404); 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);
});
});
}); });

View File

@@ -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 ────────────────────────────────────────────────────── // ── Autopilot Endpoints ──────────────────────────────────────────────────────
/** /**