feat(FN-938): add feature triage, mission pause/stop/resume, and scheduler blocked check

- Add mission store methods for pausing, stopping, and resuming missions with proper state transitions
- Implement feature triage flow that evaluates and classifies mission features
- Add scheduler blocked-task check to prevent scheduling when dependencies are unmet
- Create dashboard mission management UI with pause/stop/resume controls
- Add mission API routes for triage, pause, stop, and resume operations
- Add e2e tests for mission routes and unit tests for mission store and scheduler
This commit is contained in:
gsxdsm
2026-04-04 18:03:03 -07:00
parent a67bd31f3c
commit 950c78c797
10 changed files with 1080 additions and 20 deletions

View File

@@ -211,6 +211,36 @@ function createMockMissionStore() {
reorderMilestones: vi.fn(),
reorderSlices: vi.fn(),
// Triage methods
triageFeature: vi.fn(async (featureId: string) => {
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
if (feature.status !== "defined") throw new Error("Feature " + featureId + " is already " + feature.status);
const taskId = "FN-" + String(features.size + 1).padStart(3, "0");
const updated = { ...feature, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return updated;
}),
triageSlice: vi.fn(async (sliceId: string) => {
const slice = slices.get(sliceId);
if (!slice) throw new Error("Slice " + sliceId + " not found");
const sliceFeatures = Array.from(features.values()).filter((f) => f.sliceId === sliceId && f.status === "defined");
const triaged: MissionFeature[] = [];
for (const f of sliceFeatures) {
const taskId = "FN-" + String(features.size + triaged.size + 1).padStart(3, "0");
const updated = { ...f, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(f.id, updated);
triaged.push(updated);
}
return triaged;
}),
// Mission status helpers for pause/stop
computeMissionStatus: vi.fn(() => "active"),
getMilestone: vi.fn((id: string) => milestones.get(id)),
getMission: vi.fn((id: string) => missions.get(id)),
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
@@ -220,6 +250,7 @@ function createMockMissionStore() {
function createMockStore(): TaskStore {
return {
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
pauseTask: vi.fn(),
} as unknown as TaskStore;
}
@@ -773,4 +804,241 @@ describe("Mission API", () => {
expect(res.status).toBe(404);
});
});
// ── Feature Triage Endpoints ────────────────────────────────────────────
describe("POST /api/missions/features/:featureId/triage", () => {
it("should triage a defined feature", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
// Create mission hierarchy
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const res = await request(
app,
"POST",
`/api/missions/features/${feature.id}/triage`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("triaged");
expect(res.body.taskId).toBeTruthy();
});
it("should return 404 for non-existent feature", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/features/F-NONEXISTENT-XXX/triage",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
it("should return 400 for already triaged feature", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
// Triage it first
await ms.triageFeature(feature.id);
// Try again — should fail
const res = await request(
app,
"POST",
`/api/missions/features/${feature.id}/triage`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/slices/:sliceId/triage-all", () => {
it("should triage all defined features in a slice", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
ms.addFeature(slice.id, { title: "Feature 1" });
ms.addFeature(slice.id, { title: "Feature 2" });
const res = await request(
app,
"POST",
`/api/missions/slices/${slice.id}/triage-all`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
expect(res.body.triaged).toHaveLength(2);
expect(res.body.triaged.every((f: MissionFeature) => f.status === "triaged")).toBe(true);
});
it("should return 404 for non-existent slice", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/slices/SL-NONEXISTENT-XXX/triage-all",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
});
// ── Mission Pause/Stop/Resume Endpoints ──────────────────────────────────
describe("POST /api/missions/:missionId/pause", () => {
it("should pause an active mission", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
// Set to active
ms.updateMission(mission.id, { status: "active" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/pause`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("blocked");
});
it("should return 404 for non-existent mission", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/M-NONEXISTENT-XXX/pause",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
it("should return 400 if mission is already blocked", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/pause`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/:missionId/resume", () => {
it("should resume a blocked mission", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("active");
});
it("should return 400 if mission is not blocked", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
// Mission starts as "planning"
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/:missionId/stop", () => {
it("should stop a mission and return paused task IDs", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "active" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
// Simulate a linked task
ms.linkFeatureToTask(feature.id, "FN-001");
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/stop`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("blocked");
expect(res.body.pausedTaskIds).toContain("FN-001");
});
it("should return 404 for non-existent mission", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/M-NONEXISTENT-XXX/stop",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
});
});

View File

@@ -1120,6 +1120,192 @@ export function createMissionRouter(store: TaskStore): Router {
})
);
// ── Feature Triage Endpoints ────────────────────────────────────────────────
/**
* POST /api/missions/features/:featureId/triage
* Triage a feature by creating a task and linking it.
* Body: { taskTitle?: string, taskDescription?: string }
*/
router.post(
"/features/:featureId/triage",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const { taskTitle, taskDescription } = req.body || {};
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
return;
}
const existing = missionStore.getFeature(featureId);
if (!existing) {
res.status(404).json({ error: "Feature not found" });
return;
}
try {
const feature = await missionStore.triageFeature(
featureId,
taskTitle || undefined,
taskDescription || undefined,
);
res.json(feature);
} catch (err: any) {
if (err.message?.includes("already")) {
res.status(400).json({ error: err.message });
return;
}
if (err.message?.includes("TaskStore")) {
res.status(503).json({ error: "TaskStore not available for triage operations" });
return;
}
throw err;
}
})
);
/**
* POST /api/missions/slices/:sliceId/triage-all
* Triage all "defined" features in a slice.
* Returns: { triaged: MissionFeature[], count: number }
*/
router.post(
"/slices/:sliceId/triage-all",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
return;
}
const slice = missionStore.getSlice(sliceId);
if (!slice) {
res.status(404).json({ error: "Slice not found" });
return;
}
try {
const triaged = await missionStore.triageSlice(sliceId);
res.json({ triaged, count: triaged.length });
} catch (err: any) {
if (err.message?.includes("TaskStore")) {
res.status(503).json({ error: "TaskStore not available for triage operations" });
return;
}
throw err;
}
})
);
// ── Mission Pause/Stop/Resume Endpoints ─────────────────────────────────────
/**
* POST /api/missions/:missionId/pause
* Pause a mission by setting status to "blocked".
* In-flight tasks continue running; no new tasks are scheduled.
*/
router.post(
"/:missionId/pause",
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 === "blocked") {
res.status(400).json({ error: "Mission is already paused (blocked)" });
return;
}
const updated = missionStore.updateMission(missionId, { status: "blocked" });
res.json(updated);
})
);
/**
* POST /api/missions/:missionId/resume
* Resume a paused mission by setting status to "active".
*/
router.post(
"/:missionId/resume",
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 !== "blocked") {
res.status(400).json({ error: "Mission is not paused (status must be 'blocked' to resume)" });
return;
}
const updated = missionStore.updateMission(missionId, { status: "active" });
res.json(updated);
})
);
/**
* POST /api/missions/:missionId/stop
* Stop a mission: set status to "blocked" and pause all linked tasks.
*/
router.post(
"/:missionId/stop",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
if (!hierarchy) {
res.status(404).json({ error: "Mission not found" });
return;
}
// Set mission status to blocked
const updated = missionStore.updateMission(missionId, { status: "blocked" });
// Pause all tasks linked to features in this mission
const pausedTaskIds: string[] = [];
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
if (feature.taskId) {
try {
await store.pauseTask(feature.taskId, true);
pausedTaskIds.push(feature.taskId);
} catch (err: any) {
// Log but don't fail — task may already be paused or not found
}
}
}
}
}
res.json({ ...updated, pausedTaskIds });
})
);
// ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router