Add safe task dependency mutation

This commit is contained in:
Phil Larson
2026-05-30 13:14:19 -07:00
parent cf95d3636f
commit b1c1a33d34
7 changed files with 363 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskMove: vi.fn(),
runTaskMerge: vi.fn(),
runTaskUpdate: vi.fn(),
runTaskDeps: vi.fn(),
runTaskLog: vi.fn(),
runTaskLogs: vi.fn(),
runTaskShow: vi.fn(),
@@ -122,6 +123,7 @@ vi.mock("../commands/task.js", () => ({
runTaskMove: commandMocks.runTaskMove,
runTaskMerge: commandMocks.runTaskMerge,
runTaskUpdate: commandMocks.runTaskUpdate,
runTaskDeps: commandMocks.runTaskDeps,
runTaskLog: commandMocks.runTaskLog,
runTaskLogs: commandMocks.runTaskLogs,
runTaskShow: commandMocks.runTaskShow,
@@ -401,6 +403,32 @@ describe("bin command routing and fallbacks", () => {
);
});
it("routes task dependency replacement", async () => {
await runBin(["task", "deps", "replace", "FN-155", "FN-191", "FN-195", "--project", "atlas-notes"]);
expect(commandMocks.runTaskDeps).toHaveBeenCalledWith(
"replace",
"FN-155",
["FN-191", "FN-195"],
"atlas-notes",
);
});
it("routes task dependency add", async () => {
await runBin(["task", "deps", "add", "FN-155", "FN-191", "--project", "atlas-notes"]);
expect(commandMocks.runTaskDeps).toHaveBeenCalledWith(
"add",
"FN-155",
["FN-191"],
"atlas-notes",
);
});
it("errors for task deps missing operation", async () => {
await expect(runBin(["task", "deps"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task deps add <id> <dependency>");
});
it("errors for task move missing arguments", async () => {
await expect(runBin(["task", "move"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task move <id> <column>");

View File

@@ -119,7 +119,7 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -153,6 +153,7 @@ async function loadCommandHandlers() {
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskDeps,
runTaskLog,
runTaskLogs,
runTaskShow,
@@ -274,6 +275,7 @@ Usage:
Show task agent execution logs
fn task move <id> <col> Move a task to a column
fn task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
fn task deps <op> <id> ... Add/remove/replace/set task dependencies
fn task log <id> <message> Add a log entry
fn task merge <id> Merge an in-review task and close it
fn task duplicate <id> Duplicate a task (creates copy in triage)
@@ -562,6 +564,7 @@ async function main() {
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskDeps,
runTaskLog,
runTaskLogs,
runTaskShow,
@@ -1088,6 +1091,20 @@ async function main() {
await runTaskUpdate(id, step, status, projectName);
break;
}
case "deps": {
const operation = args[2];
const id = args[3];
const dependencyArgs = args.slice(4);
if (!operation || !id || !["add", "remove", "replace", "set"].includes(operation)) {
console.error("Usage: fn task deps add <id> <dependency>");
console.error(" fn task deps remove <id> <dependency>");
console.error(" fn task deps replace <id> <old> <new>");
console.error(" fn task deps set <id> [dependency ...]");
process.exit(1);
}
await runTaskDeps(operation as "add" | "remove" | "replace" | "set", id, dependencyArgs, projectName);
break;
}
case "log": {
const id = args[2], message = args.slice(3).join(" ");
if (!id || !message) { console.error("Usage: fn task log <id> <message>"); process.exit(1); }

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -519,6 +519,43 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
console.log();
}
export async function runTaskDeps(
operation: "add" | "remove" | "replace" | "set",
id: string,
dependencyArgs: string[],
projectName?: string,
) {
const store = await getStore(projectName);
let mutation: TaskDependencyMutation;
switch (operation) {
case "add":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps add <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "remove":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps remove <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "replace":
if (dependencyArgs.length !== 2) throw new Error("Usage: fn task deps replace <task> <old> <new>");
mutation = { operation, from: dependencyArgs[0], to: dependencyArgs[1] };
break;
case "set":
mutation = { operation, dependencies: dependencyArgs };
break;
}
const task = await store.updateTaskDependencies(id, mutation);
console.log();
console.log(`${task.id}: dependencies → ${task.dependencies.length ? task.dependencies.join(", ") : "none"}`);
if (task.blockedBy) {
console.log(` Blocked by: ${task.blockedBy}`);
} else {
console.log(" Blocked by: none");
}
console.log();
}
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
const store = await getStore(projectName);
await store.logEntry(id, message, outcome);
@@ -601,6 +638,7 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
return result;
}
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);