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:
5
.changeset/cli-node-routing.md
Normal file
5
.changeset/cli-node-routing.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add CLI support for multi-node routing: configure project default node (`fn settings set defaultNodeId`), unavailable-node policy (`fn settings set unavailableNodePolicy`), per-task node overrides (`fn task set-node`, `fn task clear-node`), and `--node` flag for `fn task create`.
|
||||||
@@ -91,7 +91,13 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `maxWorktrees` | `number` | `4` | Max git worktrees. |
|
| `maxWorktrees` | `number` | `4` | Max git worktrees. |
|
||||||
| `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). |
|
| `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). |
|
||||||
| `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to all agent heartbeat intervals. Configured from the Agents screen (not Settings). |
|
| `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to all agent heartbeat intervals. Configured from the Agents screen (not Settings). |
|
||||||
|
| `defaultNodeId` | `string` | `undefined` | Optional project default node ID. When set, tasks without a per-task `nodeId` override are routed to this node. |
|
||||||
| `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Routing policy when a selected node is unavailable/unhealthy. `"block"` stops execution until the node is healthy; `"fallback-local"` runs the task on the local node instead. Applies to both project-default node routing and per-task node overrides. |
|
| `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Routing policy when a selected node is unavailable/unhealthy. `"block"` stops execution until the node is healthy; `"fallback-local"` runs the task on the local node instead. Applies to both project-default node routing and per-task node overrides. |
|
||||||
|
|
||||||
|
CLI usage:
|
||||||
|
- `fn settings set defaultNodeId <node-id>`
|
||||||
|
- `fn settings set unavailableNodePolicy <block|fallback-local>`
|
||||||
|
|
||||||
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
|
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
|
||||||
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
|
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
|
||||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. |
|
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. |
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const commandMocks = vi.hoisted(() => ({
|
|||||||
runTaskComment: vi.fn(),
|
runTaskComment: vi.fn(),
|
||||||
runTaskComments: vi.fn(),
|
runTaskComments: vi.fn(),
|
||||||
runTaskSteer: vi.fn(),
|
runTaskSteer: vi.fn(),
|
||||||
|
runTaskSetNode: vi.fn(),
|
||||||
|
runTaskClearNode: vi.fn(),
|
||||||
runTaskPrCreate: vi.fn(),
|
runTaskPrCreate: vi.fn(),
|
||||||
|
|
||||||
runSettingsShow: vi.fn(),
|
runSettingsShow: vi.fn(),
|
||||||
@@ -122,6 +124,8 @@ vi.mock("../commands/task.js", () => ({
|
|||||||
runTaskComment: commandMocks.runTaskComment,
|
runTaskComment: commandMocks.runTaskComment,
|
||||||
runTaskComments: commandMocks.runTaskComments,
|
runTaskComments: commandMocks.runTaskComments,
|
||||||
runTaskSteer: commandMocks.runTaskSteer,
|
runTaskSteer: commandMocks.runTaskSteer,
|
||||||
|
runTaskSetNode: commandMocks.runTaskSetNode,
|
||||||
|
runTaskClearNode: commandMocks.runTaskClearNode,
|
||||||
runTaskPrCreate: commandMocks.runTaskPrCreate,
|
runTaskPrCreate: commandMocks.runTaskPrCreate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async function loadCommandHandlers() {
|
|||||||
const { runServe } = await import("./commands/serve.js");
|
const { runServe } = await import("./commands/serve.js");
|
||||||
const { runDaemon } = await import("./commands/daemon.js");
|
const { runDaemon } = await import("./commands/daemon.js");
|
||||||
const { runDesktop } = await import("./commands/desktop.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 { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||||
@@ -162,6 +162,8 @@ async function loadCommandHandlers() {
|
|||||||
runTaskComment,
|
runTaskComment,
|
||||||
runTaskComments,
|
runTaskComments,
|
||||||
runTaskSteer,
|
runTaskSteer,
|
||||||
|
runTaskSetNode,
|
||||||
|
runTaskClearNode,
|
||||||
runTaskPrCreate,
|
runTaskPrCreate,
|
||||||
runSettingsShow,
|
runSettingsShow,
|
||||||
runSettingsSet,
|
runSettingsSet,
|
||||||
@@ -233,7 +235,7 @@ Usage:
|
|||||||
fn desktop Launch the Fusion desktop app (Electron)
|
fn desktop Launch the Fusion desktop app (Electron)
|
||||||
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
||||||
fn desktop --paused Launch with automation paused
|
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 plan [description] [opts] Create task via AI-guided planning
|
||||||
fn task list List all tasks
|
fn task list List all tasks
|
||||||
fn task show <id> Show task details, steps, log
|
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 comment <id> [message] Add task comment (prompts if message omitted)
|
||||||
fn task comments <id> List task comments
|
fn task comments <id> List task comments
|
||||||
fn task steer <id> [message] Add steering comment (prompts if message omitted)
|
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 retry <id> Retry a failed task (clears error, moves to todo)
|
||||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||||
Create a GitHub PR for an in-review task
|
Create a GitHub PR for an in-review task
|
||||||
@@ -281,6 +285,8 @@ Usage:
|
|||||||
fn mesh status [--json] Show full mesh state
|
fn mesh status [--json] Show full mesh state
|
||||||
fn settings Show current Fusion configuration
|
fn settings Show current Fusion configuration
|
||||||
fn settings set <key> <value> Update a configuration setting
|
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 export [opts] Export settings to a JSON file
|
||||||
fn settings import <file> [opts] Import settings from a JSON file
|
fn settings import <file> [opts] Import settings from a JSON file
|
||||||
|
|
||||||
@@ -435,6 +441,8 @@ async function main() {
|
|||||||
runTaskComment,
|
runTaskComment,
|
||||||
runTaskComments,
|
runTaskComments,
|
||||||
runTaskSteer,
|
runTaskSteer,
|
||||||
|
runTaskSetNode,
|
||||||
|
runTaskClearNode,
|
||||||
runTaskPrCreate,
|
runTaskPrCreate,
|
||||||
runSettingsShow,
|
runSettingsShow,
|
||||||
runSettingsSet,
|
runSettingsSet,
|
||||||
@@ -708,6 +716,7 @@ async function main() {
|
|||||||
const createArgs = args.slice(2);
|
const createArgs = args.slice(2);
|
||||||
const attachFiles: string[] = [];
|
const attachFiles: string[] = [];
|
||||||
const dependsIds: string[] = [];
|
const dependsIds: string[] = [];
|
||||||
|
let nodeName: string | undefined;
|
||||||
const descParts: string[] = [];
|
const descParts: string[] = [];
|
||||||
for (let i = 0; i < createArgs.length; i++) {
|
for (let i = 0; i < createArgs.length; i++) {
|
||||||
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
|
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
|
||||||
@@ -716,12 +725,15 @@ async function main() {
|
|||||||
} else if (createArgs[i] === "--depends" && i + 1 < createArgs.length) {
|
} else if (createArgs[i] === "--depends" && i + 1 < createArgs.length) {
|
||||||
dependsIds.push(createArgs[i + 1]);
|
dependsIds.push(createArgs[i + 1]);
|
||||||
i++; // skip the value
|
i++; // skip the value
|
||||||
|
} else if (createArgs[i] === "--node" && i + 1 < createArgs.length) {
|
||||||
|
nodeName = createArgs[i + 1];
|
||||||
|
i++; // skip the value
|
||||||
} else {
|
} else {
|
||||||
descParts.push(createArgs[i]);
|
descParts.push(createArgs[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const title = descParts.join(" ");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "plan": {
|
case "plan": {
|
||||||
@@ -889,6 +901,25 @@ async function main() {
|
|||||||
await runTaskSteer(id, message || undefined, projectName);
|
await runTaskSteer(id, message || undefined, projectName);
|
||||||
break;
|
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": {
|
case "retry": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -971,7 +1002,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ vi.mock("@fusion/core", () => {
|
|||||||
githubTokenConfigured: false,
|
githubTokenConfigured: false,
|
||||||
defaultProvider: undefined,
|
defaultProvider: undefined,
|
||||||
defaultModelId: undefined,
|
defaultModelId: undefined,
|
||||||
|
defaultNodeId: undefined,
|
||||||
|
unavailableNodePolicy: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -55,9 +57,15 @@ describe("settings commands", () => {
|
|||||||
|
|
||||||
it("exposes expected valid settings and parser behavior", () => {
|
it("exposes expected valid settings and parser behavior", () => {
|
||||||
expect(VALID_SETTINGS).toContain("maxConcurrent");
|
expect(VALID_SETTINGS).toContain("maxConcurrent");
|
||||||
|
expect(VALID_SETTINGS).toContain("defaultNodeId");
|
||||||
|
expect(VALID_SETTINGS).toContain("unavailableNodePolicy");
|
||||||
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
|
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
|
||||||
expect(parseValue("maxConcurrent", "4")).toBe(4);
|
expect(parseValue("maxConcurrent", "4")).toBe(4);
|
||||||
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
|
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 () => {
|
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 });
|
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 () => {
|
it("rejects maxParallelSteps values outside range", async () => {
|
||||||
vi.mocked(resolveProject).mockResolvedValue({
|
vi.mocked(resolveProject).mockResolvedValue({
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
@@ -205,4 +231,25 @@ describe("settings commands", () => {
|
|||||||
expect(output).toContain("Run Steps In New Sessions");
|
expect(output).toContain("Run Steps In New Sessions");
|
||||||
expect(output).toContain("Max Parallel Steps");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ vi.mock("@fusion/core", () => {
|
|||||||
getProject: vi.fn().mockResolvedValue(undefined),
|
getProject: vi.fn().mockResolvedValue(undefined),
|
||||||
getProjectByPath: vi.fn().mockResolvedValue(undefined),
|
getProjectByPath: vi.fn().mockResolvedValue(undefined),
|
||||||
registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }),
|
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 { 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 { 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 {
|
import {
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
isGhAuthenticated,
|
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
|
// Mock fs/promises for runTaskCreate attach tests
|
||||||
vi.mock("node:fs/promises", () => ({
|
vi.mock("node:fs/promises", () => ({
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const VALID_SETTINGS = [
|
|||||||
"defaultModel",
|
"defaultModel",
|
||||||
"runStepsInNewSessions",
|
"runStepsInNewSessions",
|
||||||
"maxParallelSteps",
|
"maxParallelSteps",
|
||||||
|
"defaultNodeId",
|
||||||
|
"unavailableNodePolicy",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
|
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
|
||||||
@@ -28,6 +30,8 @@ const PROJECT_ONLY_SETTINGS = [
|
|||||||
"requirePlanApproval",
|
"requirePlanApproval",
|
||||||
"runStepsInNewSessions",
|
"runStepsInNewSessions",
|
||||||
"maxParallelSteps",
|
"maxParallelSteps",
|
||||||
|
"defaultNodeId",
|
||||||
|
"unavailableNodePolicy",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
|
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[]> = {
|
const ENUM_SETTINGS: Record<string, readonly string[]> = {
|
||||||
worktreeNaming: ["random", "task-id", "task-title"],
|
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
|
// Validation ranges for numeric settings
|
||||||
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
||||||
@@ -203,6 +208,10 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
|||||||
title: "Tasks",
|
title: "Tasks",
|
||||||
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
|
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Node Routing",
|
||||||
|
keys: ["defaultNodeId", "unavailableNodePolicy"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
keys: ["ntfyEnabled", "ntfyTopic"],
|
keys: ["ntfyEnabled", "ntfyTopic"],
|
||||||
|
|||||||
@@ -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 { aiMergeTask } from "@fusion/engine";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
runGhJsonAsync,
|
runGhJsonAsync,
|
||||||
} from "@fusion/core/gh-cli";
|
} from "@fusion/core/gh-cli";
|
||||||
import { resolveProject, type ProjectContext } from "../project-context.js";
|
import { resolveProject, type ProjectContext } from "../project-context.js";
|
||||||
|
import { findNodeByNameOrId } from "./node.js";
|
||||||
|
|
||||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
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;
|
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;
|
let description = descriptionArg;
|
||||||
const projectContext = await getProjectContext(projectName);
|
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 store = projectContext?.store ?? await getStore(projectName);
|
||||||
const task = await store.createTask({ description: description.trim(), dependencies: depends });
|
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
|
const label = task.description.length > 60
|
||||||
? task.description.slice(0, 60) + "…"
|
? task.description.slice(0, 60) + "…"
|
||||||
: task.description;
|
: task.description;
|
||||||
@@ -124,6 +160,9 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
|||||||
if (task.dependencies.length > 0) {
|
if (task.dependencies.length > 0) {
|
||||||
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
if (resolvedNode) {
|
||||||
|
console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
|
||||||
|
}
|
||||||
console.log(` Path: .fusion/tasks/${task.id}/`);
|
console.log(` Path: .fusion/tasks/${task.id}/`);
|
||||||
|
|
||||||
if (attachFiles && attachFiles.length > 0) {
|
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) {
|
export async function runTaskShow(id: string, projectName?: string) {
|
||||||
const store = await getStore(projectName);
|
const store = await getStore(projectName);
|
||||||
const task = await store.getTask(id);
|
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();
|
||||||
console.log(` ${task.id}: ${task.title || task.description}`);
|
console.log(` ${task.id}: ${task.title || task.description}`);
|
||||||
@@ -421,6 +511,10 @@ export async function runTaskShow(id: string, projectName?: string) {
|
|||||||
if (task.dependencies.length) {
|
if (task.dependencies.length) {
|
||||||
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
console.log(` Node: ${nodeSummary}`);
|
||||||
|
if (settings.unavailableNodePolicy) {
|
||||||
|
console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
|
||||||
|
}
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
|
|||||||
@@ -50,10 +50,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.routing-summary-value {
|
.routing-summary-value {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routing-summary-warning {
|
.routing-summary-warning {
|
||||||
@@ -65,6 +68,49 @@
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.routing-node-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status__dot {
|
||||||
|
width: var(--space-sm);
|
||||||
|
height: var(--space-sm);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--online .routing-node-status__dot {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--offline .routing-node-status__dot,
|
||||||
|
.routing-node-status--error .routing-node-status__dot {
|
||||||
|
background: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--connecting .routing-node-status__dot {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--connecting {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--online {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-node-status--offline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.routing-tab__info-banner,
|
.routing-tab__info-banner,
|
||||||
.routing-tab__warning-banner,
|
.routing-tab__warning-banner,
|
||||||
.routing-tab__error {
|
.routing-tab__error {
|
||||||
|
|||||||
@@ -13,12 +13,19 @@ interface RoutingTabProps {
|
|||||||
onTaskUpdated?: (task: Task) => void;
|
onTaskUpdated?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DOT: Record<NodeInfo["status"], string> = {
|
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||||
online: "🟢",
|
if (status === "online") return "Online";
|
||||||
offline: "🔴",
|
if (status === "connecting") return "Connecting";
|
||||||
connecting: "🟡",
|
if (status === "error") return "Error";
|
||||||
error: "🔴",
|
return "Offline";
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function getNodeStatusClass(status: NodeInfo["status"]): string {
|
||||||
|
if (status === "online") return "routing-node-status--online";
|
||||||
|
if (status === "connecting") return "routing-node-status--connecting";
|
||||||
|
if (status === "error") return "routing-node-status--error";
|
||||||
|
return "routing-node-status--offline";
|
||||||
|
}
|
||||||
|
|
||||||
type RoutingSettings = Settings & {
|
type RoutingSettings = Settings & {
|
||||||
defaultNodeId?: string;
|
defaultNodeId?: string;
|
||||||
@@ -82,10 +89,14 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
|||||||
|
|
||||||
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
||||||
const effectiveNodeName = effectiveNode
|
const effectiveNodeName = effectiveNode
|
||||||
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
|
? `${effectiveNode.name} (${effectiveNode.type})`
|
||||||
: effectiveNodeId
|
: effectiveNodeId
|
||||||
? `${effectiveNodeId} (node unavailable or unknown)`
|
? `${effectiveNodeId} (node unavailable or unknown)`
|
||||||
: "Local (no routing configured)";
|
: "Local (no routing configured)";
|
||||||
|
const blockingReason =
|
||||||
|
(task as Task & { blockedReason?: string; statusReason?: string }).blockedReason
|
||||||
|
|| (task as Task & { statusReason?: string }).statusReason
|
||||||
|
|| "(not blocked)";
|
||||||
|
|
||||||
const taskInProgress = task.column === "in-progress";
|
const taskInProgress = task.column === "in-progress";
|
||||||
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
|
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
|
||||||
@@ -137,6 +148,12 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
|||||||
<span className="routing-summary-label">Effective node</span>
|
<span className="routing-summary-label">Effective node</span>
|
||||||
<span className="routing-summary-value">
|
<span className="routing-summary-value">
|
||||||
{effectiveNodeName}
|
{effectiveNodeName}
|
||||||
|
{effectiveNode ? (
|
||||||
|
<span className={`routing-node-status ${getNodeStatusClass(effectiveNode.status)}`}>
|
||||||
|
<span className="routing-node-status__dot" aria-hidden="true" />
|
||||||
|
{getNodeStatusLabel(effectiveNode.status)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{isUnhealthy(effectiveNode?.status) ? (
|
{isUnhealthy(effectiveNode?.status) ? (
|
||||||
<span className="routing-summary-warning">Unhealthy</span>
|
<span className="routing-summary-warning">Unhealthy</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -150,6 +167,10 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
|||||||
<span className="routing-summary-label">Unavailable-node policy</span>
|
<span className="routing-summary-label">Unavailable-node policy</span>
|
||||||
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
|
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="routing-summary-row" role="listitem">
|
||||||
|
<span className="routing-summary-label">Blocking reason</span>
|
||||||
|
<span className="routing-summary-value">{blockingReason}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{taskInProgress && effectiveNodeId ? (
|
{taskInProgress && effectiveNodeId ? (
|
||||||
<div className="routing-tab__info-banner">
|
<div className="routing-tab__info-banner">
|
||||||
@@ -181,7 +202,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
|||||||
<option value="">Use project default</option>
|
<option value="">Use project default</option>
|
||||||
{sortedNodes.map((node) => (
|
{sortedNodes.map((node) => (
|
||||||
<option key={node.id} value={node.id}>
|
<option key={node.id} value={node.id}>
|
||||||
{STATUS_DOT[node.status]} {node.name} ({node.type})
|
{node.name} ({node.type}) — {getNodeStatusLabel(node.status)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1522,3 +1522,32 @@
|
|||||||
.settings-node-status--connecting .settings-node-status__dot {
|
.settings-node-status--connecting .settings-node-status__dot {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-node-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-node-status__dot {
|
||||||
|
width: var(--space-sm);
|
||||||
|
height: var(--space-sm);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-node-status--online .settings-node-status__dot {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-node-status--offline .settings-node-status__dot,
|
||||||
|
.settings-node-status--error .settings-node-status__dot {
|
||||||
|
background: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-node-status--connecting .settings-node-status__dot {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
|
|||||||
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
|
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
|
||||||
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
|
||||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("RoutingTab", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders routing summary with project default", async () => {
|
it("renders routing summary with project default", async () => {
|
||||||
@@ -96,6 +97,7 @@ describe("RoutingTab", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
|
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
|
||||||
expect(screen.getByText("No routing")).toBeInTheDocument();
|
expect(screen.getByText("No routing")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(not blocked)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -1757,6 +1757,57 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("memory dream trigger", () => {
|
||||||
|
const openMemorySection = async () => {
|
||||||
|
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
||||||
|
await userEvent.click(memorySectionButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows Dream Now button when dreams are enabled", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
memoryDreamsSchedule: "0 4 * * *",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers dream processing from Dream Now button", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
});
|
||||||
|
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
|
||||||
|
|
||||||
|
renderModal({ addToast });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dream Now button when dreams are disabled", async () => {
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("memory dream trigger", () => {
|
describe("memory dream trigger", () => {
|
||||||
const openMemorySection = async () => {
|
const openMemorySection = async () => {
|
||||||
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
||||||
|
|||||||
Reference in New Issue
Block a user