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:
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user