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 f847dfc52d
commit bc980926e0
10 changed files with 1080 additions and 20 deletions

View File

@@ -945,6 +945,208 @@ describe("MissionStore", () => {
);
});
});
describe("triageFeature", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
await expect(store.triageFeature(feature.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
"Feature F-NONEXISTENT not found",
);
});
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
// Triaging once should work
await msWithTs.triageFeature(feature.id);
// Triaging again should fail
const updated = msWithTs.getFeature(feature.id)!;
await expect(msWithTs.triageFeature(updated.id)).rejects.toThrow(
`already triaged`,
);
});
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, {
title: "Login Page",
description: "Build a login page",
acceptanceCriteria: "User can log in",
});
const triaged = await msWithTs.triageFeature(feature.id);
// Feature should be triaged with a taskId
expect(triaged.status).toBe("triaged");
expect(triaged.taskId).toBeTruthy();
// Task should exist with correct properties
const task = await ts.getTask(triaged.taskId!);
expect(task).toBeDefined();
expect(task!.title).toBe("Login Page");
expect(task!.description).toContain("Build a login page");
expect(task!.description).toContain("Acceptance Criteria");
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
});
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(
feature.id,
"Custom Title",
"Custom description for the task",
);
const task = await ts.getTask(triaged.taskId!);
expect(task!.title).toBe("Custom Title");
expect(task!.description).toBe("Custom description for the task");
});
it("emits feature:linked event", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
msWithTs.on("feature:linked", linkedHandler);
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
const triaged = await msWithTs.triageFeature(feature.id);
expect(linkedHandler).toHaveBeenCalledWith(
expect.objectContaining({
feature: expect.objectContaining({ id: feature.id }),
taskId: triaged.taskId,
}),
);
});
});
describe("triageSlice", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
await expect(store.triageSlice(slice.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if slice not found", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
"Slice SL-NONEXISTENT not found",
);
});
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
const f3 = msWithTs.addFeature(slice.id, { title: "Feature 3" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(3);
expect(triaged.every((f) => f.status === "triaged")).toBe(true);
expect(triaged.every((f) => f.taskId)).toBe(true);
// All tasks should exist and be linked to the slice/mission
for (const feature of triaged) {
const task = await ts.getTask(feature.taskId!);
expect(task).toBeDefined();
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
}
});
it("skips already triaged features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
// Triage f1 first
await msWithTs.triageFeature(f1.id);
// Now triage the whole slice — should only triage f2
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(1);
expect(triaged[0].id).toBe(f2.id);
expect(triaged[0].status).toBe("triaged");
});
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toEqual([]);
});
});
});
// vi import for vitest mocking

View File

@@ -72,10 +72,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
*
* @param kbDir - Path to the .fusion directory (e.g., /path/to/project/.fusion)
* @param db - Shared Database instance (same instance used by TaskStore)
* @param taskStore - Optional TaskStore reference for triage operations that create tasks
*/
constructor(
private kbDir: string,
private db: Database,
private taskStore?: import("./store.js").TaskStore,
) {
super();
this.setMaxListeners(100);
@@ -1048,6 +1050,92 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return this.rowToFeature(row);
}
// ── Triage Operations ────────────────────────────────────────────────
/**
* Triage a feature by creating a new task and linking it.
*
* Creates a kb task from the feature's title and description, then links
* the feature to the newly created task using `linkFeatureToTask()`.
* The feature status transitions from "defined" to "triaged".
*
* Requires MissionStore to have been constructed with a TaskStore reference.
*
* @param featureId - Feature ID to triage
* @param taskTitle - Optional title override (defaults to feature title)
* @param taskDescription - Optional description override (defaults to feature description + acceptance criteria)
* @returns The updated feature with taskId set
* @throws Error if feature not found, already triaged, or TaskStore not available
*/
async triageFeature(
featureId: string,
taskTitle?: string,
taskDescription?: string,
): Promise<MissionFeature> {
if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations");
}
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (feature.status !== "defined") {
throw new Error(`Feature ${featureId} is already ${feature.status} (status must be "defined" to triage)`);
}
// Build description from feature + acceptance criteria
const description = taskDescription || [
feature.description,
feature.acceptanceCriteria ? `\n**Acceptance Criteria:**\n${feature.acceptanceCriteria}` : "",
].filter(Boolean).join("\n\n") || feature.title;
// Create the task
const task = await this.taskStore.createTask({
title: taskTitle || feature.title,
description,
});
// Link the feature to the new task (this also updates feature status to "triaged")
const updated = this.linkFeatureToTask(featureId, task.id);
return updated;
}
/**
* Triage all "defined" features in a slice.
*
* Convenience method that iterates over all features in a slice with
* status "defined" and triages each one, creating a task and linking it.
* Features that are already triaged or in-progress are skipped.
*
* @param sliceId - Slice ID whose features should be triaged
* @returns Array of updated features that were triaged
* @throws Error if slice not found or TaskStore not available
*/
async triageSlice(sliceId: string): Promise<MissionFeature[]> {
if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations");
}
const slice = this.getSlice(sliceId);
if (!slice) {
throw new Error(`Slice ${sliceId} not found`);
}
const features = this.listFeatures(sliceId);
const definedFeatures = features.filter((f) => f.status === "defined");
const triaged: MissionFeature[] = [];
for (const feature of definedFeatures) {
const updated = await this.triageFeature(feature.id);
triaged.push(updated);
}
return triaged;
}
// ── Status Rollup Logic ───────────────────────────────────────────
/**

View File

@@ -2972,7 +2972,7 @@ ${notificationsSection}`;
*/
getMissionStore(): MissionStore {
if (!this.missionStore) {
this.missionStore = new MissionStore(this.kbDir, this.db);
this.missionStore = new MissionStore(this.kbDir, this.db, this);
}
return this.missionStore;
}