FN-5762: add mission hierarchy delete tools with linked-task guards
Add mission delete tooling across core, dashboard routes, and CLI extension surfaces. - Add `fn_feature_delete`, `fn_slice_delete`, and `fn_milestone_delete` tools to the CLI extension with `force` override support. - Enforce live-task linkage guards for feature/slice/milestone deletes in mission store logic and expose conflict behavior through mission routes. - Extend coverage and docs for delete behavior, allowlist/gating metadata, and published Fusion skill/tool references. Files changed: .changeset/fn-5762-feature-delete.md | 7 + docs/missions.md | 15 +++ packages/cli/skill/fusion/SKILL.md | 2 +- .../cli/skill/fusion/references/extension-tools.md | 27 ++++ .../skill/fusion/references/fusion-capabilities.md | 3 + packages/cli/src/__tests__/extension.test.ts | 34 +++++ packages/cli/src/extension.ts | 106 +++++++++++++++ packages/core/src/__tests__/mission-store.test.ts | 146 ++++++++++++++++++++- packages/core/src/mission-store.ts | 98 ++++++++++++-- .../dashboard/src/__tests__/mission-e2e.test.ts | 62 +++++++-- packages/dashboard/src/mission-routes.ts | 42 +++++- .../workflow-step-readonly-allowlist.test.ts | 3 + packages/engine/src/gating-classifications.ts | 3 + 13 files changed, 514 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-5762 Fusion-Task-Lineage: 9c6eceff-dcb4-4a2b-90c6-59cb0228ca6d
This commit is contained in:
@@ -17,11 +17,12 @@ function createTaskInDb(
|
||||
taskId: string,
|
||||
description = "Test task",
|
||||
status?: string,
|
||||
options?: { column?: string; deletedAt?: string | null },
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database.prepare(
|
||||
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(taskId, description, "triage", status ?? null, now, now);
|
||||
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt, "deletedAt") VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null);
|
||||
}
|
||||
|
||||
describe("MissionStore", () => {
|
||||
@@ -1211,7 +1212,7 @@ describe("MissionStore", () => {
|
||||
expect(updated.title).toBe("Updated");
|
||||
});
|
||||
|
||||
it("deletes a feature", () => {
|
||||
it("deletes a feature when no task is linked", () => {
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
@@ -1222,6 +1223,79 @@ describe("MissionStore", () => {
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks delete when feature is linked to a live task", () => {
|
||||
createTaskInDb(db, "FN-001");
|
||||
|
||||
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: "Guarded" });
|
||||
store.linkFeatureToTask(feature.id, "FN-001");
|
||||
|
||||
expect(() => store.deleteFeature(feature.id)).toThrow(
|
||||
`Feature ${feature.id} is linked to task FN-001; pass force to delete anyway`,
|
||||
);
|
||||
expect(store.getFeature(feature.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("deletes linked feature with force and keeps task row", () => {
|
||||
createTaskInDb(db, "FN-001");
|
||||
|
||||
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: "Force Delete" });
|
||||
store.linkFeatureToTask(feature.id, "FN-001");
|
||||
|
||||
store.deleteFeature(feature.id, true);
|
||||
|
||||
expect(store.getFeature(feature.id)).toBeUndefined();
|
||||
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
|
||||
id: string;
|
||||
missionId: string | null;
|
||||
sliceId: string | null;
|
||||
};
|
||||
expect(taskRow.id).toBe("FN-001");
|
||||
expect(taskRow.missionId).toBeNull();
|
||||
expect(taskRow.sliceId).toBeNull();
|
||||
});
|
||||
|
||||
it("allows delete without force when linked task is archived", () => {
|
||||
createTaskInDb(db, "FN-ARCHIVE", "Archived", undefined, { column: "archived" });
|
||||
|
||||
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: "Archived Link" });
|
||||
store.updateFeature(feature.id, { taskId: "FN-ARCHIVE", status: "triaged" });
|
||||
|
||||
store.deleteFeature(feature.id);
|
||||
expect(store.getFeature(feature.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows delete without force when linked task is soft-deleted", () => {
|
||||
createTaskInDb(db, "FN-DELETED", "Deleted", undefined, { deletedAt: new Date().toISOString() });
|
||||
|
||||
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: "Deleted Link" });
|
||||
store.updateFeature(feature.id, { taskId: "FN-DELETED", status: "triaged" });
|
||||
|
||||
store.deleteFeature(feature.id);
|
||||
expect(store.getFeature(feature.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws not found on second delete", () => {
|
||||
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: "Idempotent" });
|
||||
|
||||
store.deleteFeature(feature.id);
|
||||
expect(() => store.deleteFeature(feature.id)).toThrow(`Feature ${feature.id} not found`);
|
||||
});
|
||||
|
||||
it("links a feature to a task and persists missionId/sliceId on the task row", () => {
|
||||
createTaskInDb(db, "FN-001");
|
||||
|
||||
@@ -1365,6 +1439,38 @@ describe("MissionStore", () => {
|
||||
expect(store.getFeature(feature.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks milestone delete when child feature links to live task", () => {
|
||||
createTaskInDb(db, "FN-LIVE");
|
||||
const mission = store.createMission({ title: "Parent" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Child" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
|
||||
const feature = store.addFeature(slice.id, { title: "Guarded" });
|
||||
store.linkFeatureToTask(feature.id, "FN-LIVE");
|
||||
|
||||
expect(() => store.deleteMilestone(milestone.id)).toThrow("pass force to delete anyway");
|
||||
expect(store.getMilestone(milestone.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("force deletes milestone with linked features", () => {
|
||||
createTaskInDb(db, "FN-LIVE");
|
||||
const mission = store.createMission({ title: "Parent" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Child" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
|
||||
const feature = store.addFeature(slice.id, { title: "Guarded" });
|
||||
store.linkFeatureToTask(feature.id, "FN-LIVE");
|
||||
|
||||
store.deleteMilestone(milestone.id, true);
|
||||
expect(store.getMilestone(milestone.id)).toBeUndefined();
|
||||
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-LIVE") as {
|
||||
id: string;
|
||||
missionId: string | null;
|
||||
sliceId: string | null;
|
||||
};
|
||||
expect(taskRow.id).toBe("FN-LIVE");
|
||||
expect(taskRow.missionId).toBeNull();
|
||||
expect(taskRow.sliceId).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes slice → features", () => {
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
@@ -1380,6 +1486,38 @@ describe("MissionStore", () => {
|
||||
expect(store.getSlice(slice.id)).toBeUndefined();
|
||||
expect(store.getFeature(feature.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks slice delete when child feature links to live task", () => {
|
||||
createTaskInDb(db, "FN-SLICE");
|
||||
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: "Guarded" });
|
||||
store.linkFeatureToTask(feature.id, "FN-SLICE");
|
||||
|
||||
expect(() => store.deleteSlice(slice.id)).toThrow("pass force to delete anyway");
|
||||
expect(store.getSlice(slice.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("force deletes slice with linked features", () => {
|
||||
createTaskInDb(db, "FN-SLICE");
|
||||
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: "Guarded" });
|
||||
store.linkFeatureToTask(feature.id, "FN-SLICE");
|
||||
|
||||
store.deleteSlice(slice.id, true);
|
||||
expect(store.getSlice(slice.id)).toBeUndefined();
|
||||
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-SLICE") as {
|
||||
id: string;
|
||||
missionId: string | null;
|
||||
sliceId: string | null;
|
||||
};
|
||||
expect(taskRow.id).toBe("FN-SLICE");
|
||||
expect(taskRow.missionId).toBeNull();
|
||||
expect(taskRow.sliceId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status Rollup Tests ───────────────────────────────────────────────
|
||||
@@ -1836,7 +1974,7 @@ describe("MissionStore", () => {
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Test" });
|
||||
store.linkFeatureToTask(feature.id, "FN-001");
|
||||
store.deleteFeature(feature.id);
|
||||
store.deleteFeature(feature.id, true);
|
||||
|
||||
expect(created).toHaveBeenCalledTimes(1);
|
||||
// Updated is called twice: once by linkFeatureToTask, once by delete triggering recompute
|
||||
|
||||
@@ -1391,17 +1391,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
* Cascades to delete all slices and features.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @param force - Override linked live-task guard for child features
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id: string): void {
|
||||
deleteMilestone(id: string, force = false): void {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
|
||||
const missionId = milestone.missionId;
|
||||
const features = this.listSlices(id).flatMap((slice) => this.listFeatures(slice.id));
|
||||
const blockingLinks = this.getLiveTaskLinkedFeatures(features);
|
||||
|
||||
this.db.prepare("DELETE FROM milestones WHERE id = ?").run(id);
|
||||
if (blockingLinks.length > 0 && !force) {
|
||||
throw new Error(
|
||||
`Milestone ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
this.db.transaction(() => {
|
||||
if (force) {
|
||||
for (const link of blockingLinks) {
|
||||
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), link.featureId);
|
||||
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(link.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM milestones WHERE id = ?").run(id);
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("milestone:deleted", id);
|
||||
@@ -1621,17 +1639,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
* Cascades to delete all features.
|
||||
*
|
||||
* @param id - Slice ID
|
||||
* @param force - Override linked live-task guard for child features
|
||||
* @throws Error if slice not found
|
||||
*/
|
||||
deleteSlice(id: string): void {
|
||||
deleteSlice(id: string, force = false): void {
|
||||
const slice = this.getSlice(id);
|
||||
if (!slice) {
|
||||
throw new Error(`Slice ${id} not found`);
|
||||
}
|
||||
|
||||
const milestoneId = slice.milestoneId;
|
||||
const features = this.listFeatures(id);
|
||||
const blockingLinks = this.getLiveTaskLinkedFeatures(features);
|
||||
|
||||
this.db.prepare("DELETE FROM slices WHERE id = ?").run(id);
|
||||
if (blockingLinks.length > 0 && !force) {
|
||||
throw new Error(
|
||||
`Slice ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
this.db.transaction(() => {
|
||||
if (force) {
|
||||
for (const link of blockingLinks) {
|
||||
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), link.featureId);
|
||||
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(link.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM slices WHERE id = ?").run(id);
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("slice:deleted", id);
|
||||
@@ -1906,26 +1942,46 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @param force - Override linked live-task guard
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id: string): void {
|
||||
deleteFeature(id: string, force = false): void {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
|
||||
const sliceId = feature.sliceId;
|
||||
const slice = this.getSlice(sliceId);
|
||||
const milestoneId = slice?.milestoneId;
|
||||
if (milestoneId) {
|
||||
const managedAssertion = this.listContractAssertions(milestoneId)
|
||||
.find((assertion) => assertion.sourceFeatureId === feature.id);
|
||||
if (managedAssertion) {
|
||||
this.deleteContractAssertion(managedAssertion.id);
|
||||
if (feature.taskId) {
|
||||
const linkedTask = this.db.prepare(
|
||||
`SELECT id, "column" FROM tasks WHERE id = ? AND "deletedAt" IS NULL`
|
||||
).get(feature.taskId) as { id: string; column: string } | undefined;
|
||||
const linkedToLiveTask = linkedTask && linkedTask.column !== "archived";
|
||||
|
||||
if (linkedToLiveTask && !force) {
|
||||
throw new Error(`Feature ${id} is linked to task ${feature.taskId}; pass force to delete anyway`);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM mission_features WHERE id = ?").run(id);
|
||||
const sliceId = feature.sliceId;
|
||||
const slice = this.getSlice(sliceId);
|
||||
const milestoneId = slice?.milestoneId;
|
||||
|
||||
this.db.transaction(() => {
|
||||
if (force && feature.taskId) {
|
||||
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), id);
|
||||
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(feature.taskId);
|
||||
}
|
||||
|
||||
if (milestoneId) {
|
||||
const managedAssertion = this.listContractAssertions(milestoneId)
|
||||
.find((assertion) => assertion.sourceFeatureId === feature.id);
|
||||
if (managedAssertion) {
|
||||
this.deleteContractAssertion(managedAssertion.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM mission_features WHERE id = ?").run(id);
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("feature:deleted", id);
|
||||
@@ -1934,6 +1990,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
this.recomputeSliceStatus(sliceId);
|
||||
}
|
||||
|
||||
private getLiveTaskLinkedFeatures(features: MissionFeature[]): Array<{ featureId: string; taskId: string }> {
|
||||
const links = features
|
||||
.filter((feature): feature is MissionFeature & { taskId: string } => Boolean(feature.taskId))
|
||||
.map((feature) => ({ featureId: feature.id, taskId: feature.taskId }));
|
||||
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = links.map(() => "?").join(", ");
|
||||
const liveRows = this.db.prepare(
|
||||
`SELECT id FROM tasks WHERE id IN (${placeholders}) AND "deletedAt" IS NULL AND "column" != 'archived'`
|
||||
).all(...links.map((link) => link.taskId)) as Array<{ id: string }>;
|
||||
const liveTaskIds = new Set(liveRows.map((row) => row.id));
|
||||
|
||||
return links.filter((link) => liveTaskIds.has(link.taskId));
|
||||
}
|
||||
|
||||
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
|
||||
const acceptanceCriteria = feature.acceptanceCriteria?.trim();
|
||||
if (acceptanceCriteria) {
|
||||
|
||||
Reference in New Issue
Block a user