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:
@@ -2468,6 +2468,42 @@ export function unlinkFeatureFromTask(featureId: string, projectId?: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
/** Triage a feature — create a task from the feature and link it */
|
||||
export function triageFeature(featureId: string, taskTitle?: string, taskDescription?: string, projectId?: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taskTitle, taskDescription }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Triage all "defined" features in a slice */
|
||||
export function triageAllSliceFeatures(sliceId: string, projectId?: string): Promise<{ triaged: MissionFeature[]; count: number }> {
|
||||
return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Pause a mission (sets status to "blocked", in-flight tasks continue) */
|
||||
export function pauseMission(missionId: string, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Resume a paused mission (sets status back to "active") */
|
||||
export function resumeMission(missionId: string, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/resume`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop a mission (sets status to "blocked" and pauses all linked tasks) */
|
||||
export function stopMission(missionId: string, projectId?: string): Promise<Mission & { pausedTaskIds: string[] }> {
|
||||
return api<Mission & { pausedTaskIds: string[] }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/stop`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mission Interview API ─────────────────────────────────────────────────
|
||||
|
||||
/** Mission plan types returned by the interview AI */
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
Link,
|
||||
Unlink,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||
@@ -51,6 +55,11 @@ import {
|
||||
deleteFeature,
|
||||
linkFeatureToTask,
|
||||
unlinkFeatureFromTask,
|
||||
triageFeature,
|
||||
triageAllSliceFeatures,
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
stopMission,
|
||||
} from "../api";
|
||||
|
||||
interface MissionManagerProps {
|
||||
@@ -572,6 +581,71 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
// Triage a single feature — creates a task and links it
|
||||
const handleTriageFeature = useCallback(async (featureId: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await triageFeature(featureId, undefined, undefined, projectId);
|
||||
addToast("Feature triaged — task created", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to triage feature", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
// Triage all defined features in a slice
|
||||
const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const result = await triageAllSliceFeatures(sliceId, projectId);
|
||||
addToast(`Triaged ${result.count} feature${result.count !== 1 ? "s" : ""}`, "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to triage slice features", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
// Pause mission — set status to "blocked"
|
||||
const handlePauseMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
await pauseMission(missionId, projectId);
|
||||
addToast("Mission paused", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to pause mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
// Resume a paused mission — set status back to "active"
|
||||
const handleResumeMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
await resumeMission(missionId, projectId);
|
||||
addToast("Mission resumed", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to resume mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
// Stop mission — set status to "blocked" and pause all linked tasks
|
||||
const handleStopMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
const result = await stopMission(missionId, projectId);
|
||||
const count = result.pausedTaskIds?.length ?? 0;
|
||||
addToast(`Mission stopped (${count} task${count !== 1 ? "s" : ""} paused)`, "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to stop mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
const handleSelectMission = useCallback((mission: Mission) => {
|
||||
loadMissionDetail(mission.id);
|
||||
}, [loadMissionDetail]);
|
||||
@@ -713,6 +787,36 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
</span>
|
||||
</div>
|
||||
<div className="mission-detail__actions">
|
||||
{selectedMission.status === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handlePauseMission(selectedMission.id)}
|
||||
title="Pause mission"
|
||||
aria-label="Pause mission"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(selectedMission.id)}
|
||||
title="Stop mission"
|
||||
aria-label="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selectedMission.status === "blocked" && (
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--success"
|
||||
onClick={() => handleResumeMission(selectedMission.id)}
|
||||
title="Resume mission"
|
||||
aria-label="Resume mission"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleEditMission(selectedMission)}
|
||||
@@ -886,6 +990,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
{slice.status === "active" && slice.features?.some((f) => f.status === "defined") && (
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleTriageAllSliceFeatures(slice.id)}
|
||||
title="Triage all features"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="spinner" /> : <Zap size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleCreateFeature(slice.id)}
|
||||
@@ -1004,6 +1118,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
</span>
|
||||
)}
|
||||
<div className="mission-feature__actions">
|
||||
{feature.status === "defined" && !feature.taskId && (
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleTriageFeature(feature.id)}
|
||||
title="Triage — create task"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="spinner" /> : <Zap size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{feature.taskId ? (
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
@@ -1012,7 +1136,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</button>
|
||||
) : (
|
||||
) : feature.status !== "defined" ? (
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => setLinkTaskFeatureId(feature.id)}
|
||||
@@ -1020,7 +1144,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
>
|
||||
<Link size={14} />
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleEditFeature(feature)}
|
||||
@@ -1254,6 +1378,33 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
)}
|
||||
</div>
|
||||
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{m.status === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handlePauseMission(m.id)}
|
||||
title="Pause mission"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(m.id)}
|
||||
title="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{m.status === "blocked" && (
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--success"
|
||||
onClick={() => handleResumeMission(m.id)}
|
||||
title="Resume mission"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handleEditMission(mission)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user