feat(FN-2854): ship node routing persistence and dashboard workflow updates

- Add effective node routing fields to task types/store, persist them in SQLite, and expand regression coverage for node override guard behavior
- Add dashboard and API support for manual memory dream processing/trigger actions plus expanded memory regression tests
- Improve task creation and task detail model/node UX, including quick chat default model selection and workflow/settings UI polish
- Apply mobile/dashboard UX fixes (form input zoom prevention, expand toggle styling, input layout tweaks) and update extension/docs/changelogs for the release
This commit is contained in:
Fusion
2026-04-28 07:19:37 -07:00
committed by gsxdsm
parent 6d5ca2cc91
commit 4672d11e3c
11 changed files with 123 additions and 26 deletions

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
db.close();
});
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -976,7 +976,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1000,7 +1000,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1104,7 +1104,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1473,7 +1473,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(49);
expect(db1.getSchemaVersion()).toBe(50);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(49);
expect(db3.getSchemaVersion()).toBe(50);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(49);
expect(db1.getSchemaVersion()).toBe(50);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(49);
expect(db2.getSchemaVersion()).toBe(50);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
});
it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
});
});
});

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-effective-node-fields-"));
}
describe("effective node routing fields persistence", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.stopWatching();
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("persists effective node fields through create/update/read and clear cycle", async () => {
const created = await store.createTask({ description: "task for effective node fields" });
await store.updateTask(created.id, {
effectiveNodeId: "node-abc",
effectiveNodeSource: "project-default",
});
const withRouting = await store.getTask(created.id);
expect(withRouting.effectiveNodeId).toBe("node-abc");
expect(withRouting.effectiveNodeSource).toBe("project-default");
await store.updateTask(created.id, {
effectiveNodeId: null,
effectiveNodeSource: null,
});
const cleared = await store.getTask(created.id);
expect(cleared.effectiveNodeId).toBeUndefined();
expect(cleared.effectiveNodeSource).toBeUndefined();
});
it("persists defaultNodeId in project settings through save/load", async () => {
await store.updateSettings({ defaultNodeId: "node-default-1" });
const settings = await store.getSettings();
expect(settings.defaultNodeId).toBe("node-default-1");
});
it("defaults defaultNodeId to undefined in fresh project settings", async () => {
const settings = await store.getSettings();
expect(settings.defaultNodeId).toBeUndefined();
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(49);
expect(db.getSchemaVersion()).toBe(50);
const index = db
.prepare(

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 49;
const SCHEMA_VERSION = 50;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -1852,6 +1852,15 @@ export class Database {
});
}
// Resolved effective node fields for task routing (FN-2854).
// effectiveNodeId is the scheduler-resolved target; effectiveNodeSource explains how it was chosen.
if (version < 50) {
this.applyMigration(50, () => {
this.addColumnIfMissing("tasks", "effectiveNodeId", "TEXT");
this.addColumnIfMissing("tasks", "effectiveNodeSource", "TEXT");
});
}
}
/**

View File

@@ -78,6 +78,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
pushAfterMerge: false,
pushRemote: "origin",
unavailableNodePolicy: "block",
defaultNodeId: undefined,
worktreeInitCommand: undefined,
testCommand: undefined,
buildCommand: undefined,

View File

@@ -91,6 +91,8 @@ interface TaskRow {
assignedAgentId: string | null;
assigneeUserId: string | null;
nodeId: string | null;
effectiveNodeId: string | null;
effectiveNodeSource: string | null;
checkedOutBy: string | null;
checkedOutAt: string | null;
}
@@ -615,6 +617,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
assignedAgentId: row.assignedAgentId || undefined,
assigneeUserId: row.assigneeUserId || undefined,
nodeId: row.nodeId || undefined,
effectiveNodeId: row.effectiveNodeId || undefined,
effectiveNodeSource: (row.effectiveNodeSource as Task["effectiveNodeSource"]) || undefined,
checkedOutBy: row.checkedOutBy || undefined,
checkedOutAt: row.checkedOutAt || undefined,
};
@@ -837,7 +841,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"checkedOutBy", "checkedOutAt",
// `log` is fetched in slim mode so the server can aggregate
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
@@ -885,7 +889,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"checkedOutBy", "checkedOutAt",
];
@@ -926,9 +930,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, checkedOutBy, checkedOutAt
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -996,6 +1000,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
assignedAgentId = excluded.assignedAgentId,
assigneeUserId = excluded.assigneeUserId,
nodeId = excluded.nodeId,
effectiveNodeId = excluded.effectiveNodeId,
effectiveNodeSource = excluded.effectiveNodeSource,
checkedOutBy = excluded.checkedOutBy,
checkedOutAt = excluded.checkedOutAt
`).run(
@@ -1065,6 +1071,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.assignedAgentId ?? null,
task.assigneeUserId ?? null,
task.nodeId ?? null,
task.effectiveNodeId ?? null,
task.effectiveNodeSource ?? null,
task.checkedOutBy ?? null,
task.checkedOutAt ?? null,
);
@@ -2619,7 +2627,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2704,6 +2712,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.nodeId !== undefined) {
task.nodeId = updates.nodeId;
}
if (updates.effectiveNodeId === null) {
task.effectiveNodeId = undefined;
} else if (updates.effectiveNodeId !== undefined) {
task.effectiveNodeId = updates.effectiveNodeId;
}
if (updates.effectiveNodeSource === null) {
task.effectiveNodeSource = undefined;
} else if (updates.effectiveNodeSource !== undefined) {
task.effectiveNodeSource = updates.effectiveNodeSource as Task["effectiveNodeSource"];
}
if (updates.checkedOutBy === null) {
task.checkedOutBy = undefined;
task.checkedOutAt = undefined;

View File

@@ -845,6 +845,10 @@ export interface Task {
assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */
nodeId?: string;
/** The node this task is actually routed to (resolved from nodeId override or project default). Set by the scheduler at dispatch time. */
effectiveNodeId?: string;
/** How the effectiveNodeId was determined. Set by the scheduler at dispatch time. */
effectiveNodeSource?: "task-override" | "project-default" | "local";
/** Explicitly assigned user ID for task-user linking. Used during review handoff to indicate
* which user should review the task. The sentinel value "requesting-user" indicates the
* user who created or steered the task. */
@@ -1332,6 +1336,8 @@ export interface ProjectSettings {
* - "block": prevent execution until the selected node is healthy/available (default)
* - "fallback-local": run on the local node when the selected node is unavailable */
unavailableNodePolicy?: UnavailableNodePolicy;
/** ID of the pinned default execution node. Tasks without a per-task override run on this node. */
defaultNodeId?: string;
/** Shell command to run inside each new worktree immediately after creation.
* Useful for project-specific setup (e.g. `pnpm install --frozen-lockfile`, `cp .env.local .env`). */
worktreeInitCommand?: string;