feat(KB-635): add mission management CLI commands and engine integration
- Add comprehensive mission CLI commands: create, list, show, start, abort, delete, and next - Add Pi extension tools for mission operations: list_missions, show_mission, start_mission, abort_mission - Implement mission-aware scheduler with task start handling and autoAdvance support - Add sliceId to Task type and autoAdvance flag to Mission type for slice sequencing - Add MissionStore helper methods for mission lifecycle operations - Include comprehensive tests for CLI commands and Pi extension mission tools
This commit is contained in:
@@ -455,4 +455,197 @@ describe("Scheduler", () => {
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mission integration", () => {
|
||||
// Helper to create mock MissionStore
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
return {
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
getSlice: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
computeSliceStatus: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
findNextPendingSlice: vi.fn(),
|
||||
activateSlice: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("activateNextPendingSlice returns null when no missionStore", async () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store);
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event by calling the registered handler
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
expect(movedHandler).toBeDefined();
|
||||
|
||||
// Simulate task moving to in-progress with sliceId
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "in-progress" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
|
||||
});
|
||||
|
||||
it("does not update feature status when already past triaged", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "in-progress" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers feature done update when task with sliceId moves to done", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("active"),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event by calling the registered handler
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
expect(movedHandler).toBeDefined();
|
||||
|
||||
// Simulate task moving to done with sliceId
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("auto-advances when slice completes and autoAdvance is enabled", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("complete"),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: true }),
|
||||
findNextPendingSlice: vi.fn().mockReturnValue({ id: "SL-002" }),
|
||||
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.computeSliceStatus).toHaveBeenCalledWith("SL-001");
|
||||
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
});
|
||||
|
||||
it("does not auto-advance when autoAdvance is disabled", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("complete"),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: false }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.findNextPendingSlice).not.toHaveBeenCalled();
|
||||
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles task with sliceId but no linked feature gracefully", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activateNextPendingSlice finds and activates correct slice", async () => {
|
||||
const nextSlice = { id: "SL-002", status: "pending" };
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
findNextPendingSlice: vi.fn().mockReturnValue(nextSlice),
|
||||
activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
expect(result).toEqual({ id: "SL-002", status: "active" });
|
||||
});
|
||||
|
||||
it("activateNextPendingSlice returns null when no pending slices", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
findNextPendingSlice: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user