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 ────────────────────────────────────────────────
|
// ── Mission Autopilot API ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fetch autopilot status for a mission */
|
/** Fetch autopilot status for a mission */
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user