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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user