feat(FN-2727): add node routing controls to CLI and dashboard

- Add CLI support for task node routing with create --node, task set-node, and task clear-node commands
- Extend settings command validation/output for defaultNodeId and unavailableNodePolicy and document new keys
- Surface task routing details in CLI task show plus dashboard Routing tab status/binding reason indicators
- Enhance Settings modal node routing UX with selected-node health status and add/refresh tests and changeset
This commit is contained in:
Fusion
2026-04-28 14:14:18 -07:00
committed by gsxdsm
parent 437d5a51c1
commit fdeba4cf89
14 changed files with 511 additions and 20 deletions

View File

@@ -32,6 +32,8 @@ const commandMocks = vi.hoisted(() => ({
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskSetNode: vi.fn(),
runTaskClearNode: vi.fn(),
runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(),
@@ -122,6 +124,8 @@ vi.mock("../commands/task.js", () => ({
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
runTaskSetNode: commandMocks.runTaskSetNode,
runTaskClearNode: commandMocks.runTaskClearNode,
runTaskPrCreate: commandMocks.runTaskPrCreate,
}));

View File

@@ -117,7 +117,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, runTaskPrCreate } = await import("./commands/task.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 { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -162,6 +162,8 @@ async function loadCommandHandlers() {
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskSetNode,
runTaskClearNode,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
@@ -233,7 +235,7 @@ Usage:
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn task create [desc] [opts] Create a new task (goes to triage)
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
fn task show <id> Show task details, steps, log
@@ -254,6 +256,8 @@ Usage:
fn task comment <id> [message] Add task comment (prompts if message omitted)
fn task comments <id> List task comments
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task set-node <id> <node-name-or-id> Set a per-task node override
fn task clear-node <id> Clear a per-task node override
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
@@ -281,6 +285,8 @@ Usage:
fn mesh status [--json] Show full mesh state
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
fn settings set defaultNodeId <node-id>
fn settings set unavailableNodePolicy <block|fallback-local>
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
@@ -435,6 +441,8 @@ async function main() {
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskSetNode,
runTaskClearNode,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
@@ -708,6 +716,7 @@ async function main() {
const createArgs = args.slice(2);
const attachFiles: string[] = [];
const dependsIds: string[] = [];
let nodeName: string | undefined;
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
@@ -716,12 +725,15 @@ async function main() {
} else if (createArgs[i] === "--depends" && i + 1 < createArgs.length) {
dependsIds.push(createArgs[i + 1]);
i++; // skip the value
} else if (createArgs[i] === "--node" && i + 1 < createArgs.length) {
nodeName = createArgs[i + 1];
i++; // skip the value
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName);
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName, nodeName);
break;
}
case "plan": {
@@ -889,6 +901,25 @@ async function main() {
await runTaskSteer(id, message || undefined, projectName);
break;
}
case "set-node": {
const id = args[2];
const nodeName = args[3];
if (!id || !nodeName) {
console.error("Usage: fn task set-node <id> <node-name-or-id>");
process.exit(1);
}
await runTaskSetNode(id, nodeName, projectName);
break;
}
case "clear-node": {
const id = args[2];
if (!id) {
console.error("Usage: fn task clear-node <id>");
process.exit(1);
}
await runTaskClearNode(id, projectName);
break;
}
case "retry": {
const id = args[2];
if (!id) {
@@ -971,7 +1002,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: task ${subcommand || ""}`);
console.log("Try: fn task create | list | move");
console.log("Try: fn task create | list | move | set-node | clear-node");
process.exit(1);
}
break;

View File

@@ -13,6 +13,8 @@ vi.mock("@fusion/core", () => {
githubTokenConfigured: false,
defaultProvider: undefined,
defaultModelId: undefined,
defaultNodeId: undefined,
unavailableNodePolicy: undefined,
};
return {
@@ -55,9 +57,15 @@ describe("settings commands", () => {
it("exposes expected valid settings and parser behavior", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(VALID_SETTINGS).toContain("defaultNodeId");
expect(VALID_SETTINGS).toContain("unavailableNodePolicy");
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
expect(parseValue("maxConcurrent", "4")).toBe(4);
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
expect(parseValue("defaultNodeId", "node-abc-123")).toBe("node-abc-123");
expect(parseValue("unavailableNodePolicy", "block")).toBe("block");
expect(parseValue("unavailableNodePolicy", "fallback-local")).toBe("fallback-local");
expect(() => parseValue("unavailableNodePolicy", "invalid")).toThrow(/block, fallback-local/);
});
it("runSettingsShow without project uses global settings even if a project could resolve", async () => {
@@ -172,6 +180,24 @@ describe("settings commands", () => {
expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 });
});
it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
await runSettingsSet("defaultNodeId", "my-node", "demo-project");
await runSettingsSet("unavailableNodePolicy", "fallback-local", "demo-project");
expect(updateSettings).toHaveBeenNthCalledWith(1, { defaultNodeId: "my-node" });
expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" });
});
it("rejects maxParallelSteps values outside range", async () => {
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
@@ -205,4 +231,25 @@ describe("settings commands", () => {
expect(output).toContain("Run Steps In New Sessions");
expect(output).toContain("Max Parallel Steps");
});
it("runSettingsShow includes Node Routing section", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({
defaultNodeId: "node-abc",
unavailableNodePolicy: "block",
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
await runSettingsShow("demo-project");
const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n");
expect(output).toContain("Node Routing");
expect(output).toContain("Default Node Id");
expect(output).toContain("Unavailable Node Policy");
});
});

View File

@@ -37,6 +37,8 @@ vi.mock("@fusion/core", () => {
getProject: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue(undefined),
registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue(undefined),
};
}),
};
@@ -79,9 +81,9 @@ vi.mock("../../project-context.js", () => ({
}));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { TaskStore, CentralCore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import {
getCurrentRepo,
isGhAuthenticated,
@@ -162,6 +164,151 @@ describe("runTaskShow", () => {
});
});
describe("task node overrides", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("runTaskSetNode resolves node name and updates task", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask,
}));
const getNodeByName = vi.fn().mockResolvedValue({ id: "node-123", name: "my-remote" });
const getNode = vi.fn().mockResolvedValue(undefined);
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode,
getNodeByName,
}));
await runTaskSetNode("FN-001", "my-remote");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: "node-123" });
});
it("runTaskSetNode accepts raw node id", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "12345678-1234-1234-1234-123456789012" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask,
}));
const getNode = vi.fn().mockResolvedValue({ id: "12345678-1234-1234-1234-123456789012", name: "raw" });
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode,
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await runTaskSetNode("FN-001", "12345678-1234-1234-1234-123456789012");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: "12345678-1234-1234-1234-123456789012" });
});
it("runTaskSetNode blocks in-progress tasks", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "in-progress" })),
}));
await expect(runTaskSetNode("FN-001", "my-remote")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Cannot change node override: task FN-001 is in progress");
});
it("runTaskSetNode errors when node is unknown", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask: vi.fn(),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await expect(runTaskSetNode("FN-001", "missing-node")).rejects.toThrow("process.exit:1");
});
it("runTaskClearNode clears override", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: undefined }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo", nodeId: "node-123" })),
updateTask,
}));
await runTaskClearNode("FN-001");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
});
it("runTaskClearNode blocks in-progress tasks", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "in-progress" })),
}));
await expect(runTaskClearNode("FN-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Cannot change node override: task FN-001 is in progress");
});
it("runTaskShow displays node routing info", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123", column: "todo" })),
getSettings: vi.fn().mockResolvedValue({ unavailableNodePolicy: "block" }),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue({ id: "node-123", name: "remote-a" }),
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await runTaskShow("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Node:");
expect(output).toContain("Unavailable Node Policy");
});
it("runTaskCreate with node resolves and applies override", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-900", column: "triage" })),
updateTask,
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue({ id: "node-123", name: "remote-a" }),
}));
await runTaskCreate("new task", undefined, undefined, undefined, "remote-a");
expect(updateTask).toHaveBeenCalledWith("FN-900", { nodeId: "node-123" });
});
});
// Mock fs/promises for runTaskCreate attach tests
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),

View File

@@ -15,6 +15,8 @@ export const VALID_SETTINGS = [
"defaultModel",
"runStepsInNewSessions",
"maxParallelSteps",
"defaultNodeId",
"unavailableNodePolicy",
] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
@@ -28,6 +30,8 @@ const PROJECT_ONLY_SETTINGS = [
"requirePlanApproval",
"runStepsInNewSessions",
"maxParallelSteps",
"defaultNodeId",
"unavailableNodePolicy",
] as const;
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
@@ -45,9 +49,10 @@ const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "ma
const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
};
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel"];
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel", "defaultNodeId"];
// Validation ranges for numeric settings
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
@@ -203,6 +208,10 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
title: "Tasks",
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
},
{
title: "Node Routing",
keys: ["defaultNodeId", "unavailableNodePolicy"],
},
{
title: "Notifications",
keys: ["ntfyEnabled", "ntfyTopic"],

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -14,6 +14,7 @@ import {
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -93,7 +94,31 @@ async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore();
await central.init();
try {
const looksLikeNodeId = nodeNameOrId.includes("-") && nodeNameOrId.length > 20;
let node = looksLikeNodeId
? await central.getNode(nodeNameOrId)
: await central.getNodeByName(nodeNameOrId);
if (!node) {
node = await findNodeByNameOrId(central, nodeNameOrId);
}
if (!node) {
throw new Error(`Node not found: ${nodeNameOrId}`);
}
return { id: node.id, name: node.name };
} finally {
await central.close();
}
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
@@ -111,6 +136,17 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
const store = projectContext?.store ?? await getStore(projectName);
const task = await store.createTask({ description: description.trim(), dependencies: depends });
let resolvedNode: { id: string; name?: string } | undefined;
if (nodeName) {
try {
resolvedNode = await resolveNodeByNameOrId(nodeName);
await store.updateTask(task.id, { nodeId: resolvedNode.id });
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
const label = task.description.length > 60
? task.description.slice(0, 60) + "…"
: task.description;
@@ -124,6 +160,9 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
if (resolvedNode) {
console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
@@ -411,9 +450,60 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
}
}
export async function runTaskSetNode(id: string, nodeNameOrId: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
let resolvedNode: { id: string; name?: string };
try {
resolvedNode = await resolveNodeByNameOrId(nodeNameOrId);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
await store.updateTask(id, { nodeId: resolvedNode.id });
console.log(`✓ Set node override for ${id}: ${resolvedNode.name || resolvedNode.id}`);
}
export async function runTaskClearNode(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
await store.updateTask(id, { nodeId: null });
console.log(`✓ Cleared node override for ${id}`);
}
export async function runTaskShow(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
const settings: Partial<Settings> = "getSettings" in store ? await store.getSettings() : {};
let nodeSummary = "(default local)";
if (task.nodeId) {
let nodeName: string | undefined;
const central = new CentralCore();
await central.init();
try {
nodeName = (await central.getNode(task.nodeId))?.name;
} finally {
await central.close();
}
nodeSummary = nodeName ? `${nodeName} (${task.nodeId})` : task.nodeId;
} else if (settings.defaultNodeId) {
nodeSummary = `project default: ${settings.defaultNodeId}`;
}
console.log();
console.log(` ${task.id}: ${task.title || task.description}`);
@@ -421,6 +511,10 @@ export async function runTaskShow(id: string, projectName?: string) {
if (task.dependencies.length) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Node: ${nodeSummary}`);
if (settings.unavailableNodePolicy) {
console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
}
console.log();
// Steps