feat(FN-972): auto-triage features when slice activated with autoAdvance
- When a slice is activated and has autoAdvance enabled, automatically triage all features in that slice - Add auto-triage logic to mission-store activateSlice method - Add comprehensive tests for auto-triage behavior covering edge cases - Fix module import paths across mission-related files (mission.ts, extension.ts, mission-integration.test.ts, mission-routes.ts, scheduler.ts)
This commit is contained in:
@@ -262,7 +262,7 @@ export async function runMissionActivateSlice(id: string, projectName?: string)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(id);
|
||||
const activated = await missionStore.activateSlice(id);
|
||||
console.log();
|
||||
console.log(` ✓ Activated ${activated.id}: "${activated.title}"`);
|
||||
console.log(` Status: ${SLICE_STATUS_LABELS[activated.status]}`);
|
||||
|
||||
@@ -1446,7 +1446,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(params.id);
|
||||
const activated = await missionStore.activateSlice(params.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -294,7 +294,7 @@ describe("MissionStore integration with TaskStore", () => {
|
||||
await taskStore.moveTask(task.id, "in-progress");
|
||||
|
||||
missionStore.linkFeatureToTask(feature.id, task.id);
|
||||
missionStore.activateSlice(slice.id);
|
||||
await missionStore.activateSlice(slice.id);
|
||||
|
||||
expect(events).toEqual(["mission:created", "feature:linked", "slice:activated"]);
|
||||
});
|
||||
|
||||
@@ -385,25 +385,25 @@ describe("MissionStore", () => {
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("activates a slice", () => {
|
||||
it("activates a slice", async () => {
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "To Activate" });
|
||||
|
||||
const activated = store.activateSlice(slice.id);
|
||||
const activated = await store.activateSlice(slice.id);
|
||||
|
||||
expect(activated.status).toBe("active");
|
||||
expect(activated.activatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits slice:activated event", () => {
|
||||
it("emits slice:activated event", async () => {
|
||||
const handler = vi.fn();
|
||||
store.on("slice:activated", handler);
|
||||
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Test" });
|
||||
const activated = store.activateSlice(slice.id);
|
||||
const activated = await store.activateSlice(slice.id);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(activated);
|
||||
@@ -863,7 +863,7 @@ describe("MissionStore", () => {
|
||||
expect(deleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits all slice lifecycle events", () => {
|
||||
it("emits all slice lifecycle events", async () => {
|
||||
const created = vi.fn();
|
||||
const updated = vi.fn();
|
||||
const deleted = vi.fn();
|
||||
@@ -877,7 +877,7 @@ describe("MissionStore", () => {
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Test" });
|
||||
store.activateSlice(slice.id);
|
||||
await store.activateSlice(slice.id);
|
||||
store.deleteSlice(slice.id);
|
||||
|
||||
expect(created).toHaveBeenCalledTimes(1);
|
||||
@@ -1147,6 +1147,155 @@ describe("MissionStore", () => {
|
||||
expect(triaged).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-Triage on Slice Activation Tests ─────────────────────────────
|
||||
|
||||
describe("activateSlice with autoAdvance", () => {
|
||||
/** Helper to create a MissionStore with a real TaskStore reference */
|
||||
async function createStoreWithTaskStore(): Promise<{
|
||||
ts: import("./store.js").TaskStore;
|
||||
ms: MissionStore;
|
||||
}> {
|
||||
const { TaskStore } = await import("./store.js");
|
||||
const ts = new TaskStore(kbDir);
|
||||
const ms = ts.getMissionStore();
|
||||
return { ts, ms };
|
||||
}
|
||||
|
||||
it("triages features when autoAdvance is true", async () => {
|
||||
const { ts, ms } = await createStoreWithTaskStore();
|
||||
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
ms.updateMission(mission.id, { autoAdvance: true });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
|
||||
|
||||
const activated = await ms.activateSlice(slice.id);
|
||||
|
||||
// Slice should be active
|
||||
expect(activated.status).toBe("active");
|
||||
expect(activated.activatedAt).toBeTruthy();
|
||||
|
||||
// Both features should be triaged with tasks
|
||||
const updatedF1 = ms.getFeature(f1.id)!;
|
||||
const updatedF2 = ms.getFeature(f2.id)!;
|
||||
expect(updatedF1.status).toBe("triaged");
|
||||
expect(updatedF1.taskId).toBeTruthy();
|
||||
expect(updatedF2.status).toBe("triaged");
|
||||
expect(updatedF2.taskId).toBeTruthy();
|
||||
|
||||
// Tasks should exist and be linked to the slice/mission
|
||||
const task1 = await ts.getTask(updatedF1.taskId!);
|
||||
const task2 = await ts.getTask(updatedF2.taskId!);
|
||||
expect(task1).toBeDefined();
|
||||
expect(task1!.sliceId).toBe(slice.id);
|
||||
expect(task1!.missionId).toBe(mission.id);
|
||||
expect(task2).toBeDefined();
|
||||
expect(task2!.sliceId).toBe(slice.id);
|
||||
expect(task2!.missionId).toBe(mission.id);
|
||||
});
|
||||
|
||||
it("does not triage features when autoAdvance is false", async () => {
|
||||
const { ms } = await createStoreWithTaskStore();
|
||||
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
// autoAdvance defaults to false
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||
|
||||
const activated = await ms.activateSlice(slice.id);
|
||||
|
||||
// Slice should be active
|
||||
expect(activated.status).toBe("active");
|
||||
|
||||
// Feature should still be "defined" — not triaged
|
||||
const updatedF1 = ms.getFeature(f1.id)!;
|
||||
expect(updatedF1.status).toBe("defined");
|
||||
expect(updatedF1.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not triage features when autoAdvance is unset", async () => {
|
||||
const { ms } = await createStoreWithTaskStore();
|
||||
|
||||
const mission = ms.createMission({ title: "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 activated = await ms.activateSlice(slice.id);
|
||||
|
||||
expect(activated.status).toBe("active");
|
||||
const updatedFeature = ms.getFeature(feature.id)!;
|
||||
expect(updatedFeature.status).toBe("defined");
|
||||
});
|
||||
|
||||
it("skips already-triaged features during auto-triage", async () => {
|
||||
const { ms } = await createStoreWithTaskStore();
|
||||
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
ms.updateMission(mission.id, { autoAdvance: true });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
|
||||
|
||||
// Manually triage f1 first
|
||||
await ms.triageFeature(f1.id);
|
||||
const f1TaskId = ms.getFeature(f1.id)!.taskId;
|
||||
expect(f1TaskId).toBeTruthy();
|
||||
|
||||
// Activate the slice — should only triage f2
|
||||
const activated = await ms.activateSlice(slice.id);
|
||||
|
||||
expect(activated.status).toBe("active");
|
||||
|
||||
// f1 should keep its existing taskId
|
||||
const updatedF1 = ms.getFeature(f1.id)!;
|
||||
expect(updatedF1.taskId).toBe(f1TaskId);
|
||||
|
||||
// f2 should now be triaged
|
||||
const updatedF2 = ms.getFeature(f2.id)!;
|
||||
expect(updatedF2.status).toBe("triaged");
|
||||
expect(updatedF2.taskId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still activates slice even if triage fails", async () => {
|
||||
const { ms } = await createStoreWithTaskStore();
|
||||
|
||||
const mission = ms.createMission({ title: "Mission" });
|
||||
ms.updateMission(mission.id, { autoAdvance: true });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = ms.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
// Sabotage the TaskStore by removing it to trigger a triage error
|
||||
// The MissionStore was created via TaskStore, so taskStore is available.
|
||||
// To make triage fail, we'll delete the task from the DB after it's created.
|
||||
// Instead, let's use a MissionStore WITHOUT a TaskStore but with autoAdvance.
|
||||
const storeNoTs = new MissionStore(kbDir, db);
|
||||
|
||||
const mission2 = storeNoTs.createMission({ title: "Mission 2" });
|
||||
storeNoTs.updateMission(mission2.id, { autoAdvance: true });
|
||||
const milestone2 = storeNoTs.addMilestone(mission2.id, { title: "Milestone 2" });
|
||||
const slice2 = storeNoTs.addSlice(milestone2.id, { title: "Slice 2" });
|
||||
storeNoTs.addFeature(slice2.id, { title: "Feature" });
|
||||
|
||||
// activateSlice should still succeed even though triageSlice will throw
|
||||
const activated = await storeNoTs.activateSlice(slice2.id);
|
||||
|
||||
expect(activated.status).toBe("active");
|
||||
expect(activated.activatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws meaningful error when slice not found", async () => {
|
||||
await expect(store.activateSlice("SL-NONEXISTENT")).rejects.toThrow(
|
||||
"Slice SL-NONEXISTENT not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// vi import for vitest mocking
|
||||
|
||||
@@ -716,18 +716,40 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
/**
|
||||
* Activate a slice for implementation.
|
||||
* Sets status to "active" and records activation time.
|
||||
* When the parent mission has `autoAdvance: true`, all "defined" features
|
||||
* in the slice are automatically triaged (converted to tasks and linked).
|
||||
*
|
||||
* @param id - Slice ID
|
||||
* @returns The activated slice
|
||||
* @throws Error if slice not found
|
||||
*/
|
||||
activateSlice(id: string): Slice {
|
||||
async activateSlice(id: string): Promise<Slice> {
|
||||
const slice = this.getSlice(id);
|
||||
if (!slice) {
|
||||
throw new Error(`Slice ${id} not found`);
|
||||
}
|
||||
|
||||
const milestone = this.getMilestone(slice.milestoneId);
|
||||
const mission = milestone ? this.getMission(milestone.missionId) : undefined;
|
||||
|
||||
const shouldAutoTriage = mission?.autoAdvance === true;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated = this.updateSlice(id, {
|
||||
status: "active",
|
||||
activatedAt: now,
|
||||
});
|
||||
|
||||
// Auto-triage features if autoAdvance is enabled
|
||||
if (shouldAutoTriage) {
|
||||
try {
|
||||
await this.triageSlice(id);
|
||||
} catch (err) {
|
||||
// Log but don't fail — triage failures shouldn't block slice activation
|
||||
console.error(`[MissionStore] Auto-triage failed for slice ${id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit("slice:activated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -879,7 +879,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
}
|
||||
|
||||
try {
|
||||
const slice = missionStore.activateSlice(sliceId);
|
||||
const slice = await missionStore.activateSlice(sliceId);
|
||||
res.json(slice);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
|
||||
@@ -743,7 +743,7 @@ export class Scheduler {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(pendingSlice.id);
|
||||
const activated = await missionStore.activateSlice(pendingSlice.id);
|
||||
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
|
||||
return activated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user