feat(FN-2447): add dependency-aware task delete flow

- Extend TaskStore deleteTask with a safe default that blocks deleting tasks still referenced by live dependents
- Add an opt-in removeDependencyReferences path that rewrites dependent tasks atomically before deletion
- Update dashboard API/routes to surface TASK_HAS_DEPENDENTS as a 409 with structured details and a delete query flag
- Add TaskCard/TaskDetailModal confirmation-retry UX plus coverage in core, dashboard route/API, and component tests
- Document the new delete semantics and opt-in behavior in the dashboard API README
This commit is contained in:
Fusion
2026-04-24 07:27:15 -07:00
committed by gsxdsm
parent 82158cc570
commit 0f1da6e6af
11 changed files with 519 additions and 26 deletions

View File

@@ -4069,6 +4069,28 @@ Task with acceptance criteria
await expect(store.deleteTask(parent.id)).resolves.toMatchObject({ id: parent.id });
});
it("deleteTask removes incoming dependency references when explicitly requested", async () => {
const parent = await store.createTask({ description: "Parent to delete" });
const dependentOne = await store.createTask({ description: "Dependent one" });
const dependentTwo = await store.createTask({ description: "Dependent two" });
await store.updateTask(dependentOne.id, { dependencies: [parent.id, "FN-UNRELATED"] });
await store.updateTask(dependentTwo.id, { dependencies: [parent.id] });
await expect(
store.deleteTask(parent.id, { removeDependencyReferences: true }),
).resolves.toMatchObject({ id: parent.id });
const updatedOne = await store.getTask(dependentOne.id);
const updatedTwo = await store.getTask(dependentTwo.id);
expect(updatedOne.dependencies).toEqual(["FN-UNRELATED"]);
expect(updatedTwo.dependencies).toEqual([]);
expect(updatedOne.dependencies).not.toContain(parent.id);
expect(updatedTwo.dependencies).not.toContain(parent.id);
await expect(store.getTask(parent.id)).rejects.toThrow(`Task ${parent.id} not found`);
});
it("deleteTask allows deletion when a similarly-named id contains the target (substring false-positive guard)", async () => {
// The LIKE probe uses '%id%'; ensure we don't misidentify e.g. FN-1 as
// referencing FN-10 just because the id string appears inside a JSON

View File

@@ -3098,7 +3098,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return paths;
}
async deleteTask(id: string): Promise<Task> {
async deleteTask(id: string, options?: { removeDependencyReferences?: boolean }): Promise<Task> {
return this.withTaskLock(id, async () => {
const task = this.readTaskFromDb(id);
if (!task) {
@@ -3106,12 +3106,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
// Refuse to delete a task that is still referenced as a dependency
// by another live task. Scheduler treats missing-dep ids as unmet,
// so silently deleting a task with live dependents would permanently
// block them. Callers that want to split/replace a task must rewrite
// or drop the incoming references first.
// by another live task unless the caller explicitly opts into
// removing those incoming references as part of this delete.
const dependentIds = this.findLiveDependents(id);
if (dependentIds.length > 0) {
if (dependentIds.length > 0 && !options?.removeDependencyReferences) {
throw new TaskHasDependentsError(id, dependentIds);
}
@@ -3125,9 +3123,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
// Delete from SQLite
this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
this.db.bumpLastModified();
const rewrittenDependents = this.rewriteDependentsAndDeleteTask(id, dependentIds);
// Remove from cache if watcher is active
if (this.isWatching) this.taskCache.delete(id);
@@ -3139,11 +3135,52 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await rm(dir, { recursive: true });
}
for (const dependentTask of rewrittenDependents) {
this.emit("task:updated", dependentTask);
}
this.emit("task:deleted", task);
return task;
});
}
private rewriteDependentsAndDeleteTask(taskId: string, dependentIds: string[]): Task[] {
const rewrittenDependents: Task[] = [];
this.db.transaction(() => {
for (const dependentId of dependentIds) {
const dependentTask = this.readTaskFromDb(dependentId);
if (!dependentTask) continue;
const nextDependencies = dependentTask.dependencies.filter((dependencyId) => dependencyId !== taskId);
if (nextDependencies.length === dependentTask.dependencies.length) {
continue;
}
const updatedDependent = {
...dependentTask,
dependencies: nextDependencies,
updatedAt: new Date().toISOString(),
};
this.db.prepare("UPDATE tasks SET dependencies = ?, updatedAt = ? WHERE id = ?").run(
toJson(updatedDependent.dependencies),
updatedDependent.updatedAt,
updatedDependent.id,
);
if (this.isWatching) {
this.taskCache.set(updatedDependent.id, updatedDependent);
}
rewrittenDependents.push(updatedDependent);
}
this.db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
this.db.bumpLastModified();
});
return rewrittenDependents;
}
/**
* Clean up the git branch associated with a task.
*