feat(FN-3450): add mesh task create replication across nodes
Implements distributed mesh task creation by adding replicated create primitives to the core store, wiring new API routes (`register-mesh-routes.ts`) that replicate task creation across clustered nodes while preserving remote-targeting metadata, and updating the dashboard's task creation flow accord Fusion-Task-Id: FN-3450
This commit is contained in:
@@ -67,6 +67,7 @@ describe("fn insight extension tools", () => {
|
||||
provenance: { trigger: "manual" },
|
||||
content: "Ensure this appears in extension output",
|
||||
});
|
||||
store.close();
|
||||
|
||||
const listTool = api.tools.get("fn_insight_list")!;
|
||||
const listResult = await listTool.execute("call-1", { category: "quality" }, undefined, undefined, makeCtx(tmpDir));
|
||||
@@ -86,6 +87,7 @@ describe("fn insight extension tools", () => {
|
||||
|
||||
const run = insightStore.createRun("", { trigger: "manual" });
|
||||
insightStore.updateRun(run.id, { status: "completed", insightsCreated: 2, insightsUpdated: 1 });
|
||||
store.close();
|
||||
|
||||
const listTool = api.tools.get("fn_insight_run_list")!;
|
||||
const listResult = await listTool.execute("call-3", { status: "completed" }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
107
packages/core/src/__tests__/mesh-task-replication.test.ts
Normal file
107
packages/core/src/__tests__/mesh-task-replication.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBootstrapPrompt,
|
||||
buildMeshReplicatedTaskCreatePayload,
|
||||
taskMatchesReplicatedCreate,
|
||||
toReplicatedCreateInput,
|
||||
} from "../mesh-task-replication.js";
|
||||
|
||||
describe("mesh-task-replication", () => {
|
||||
it("buildBootstrapPrompt matches task bootstrap format", () => {
|
||||
expect(buildBootstrapPrompt("FN-1", undefined, "desc")).toBe("# FN-1\n\ndesc\n");
|
||||
expect(buildBootstrapPrompt("FN-1", "Title", "desc")).toBe("# FN-1: Title\n\ndesc\n");
|
||||
});
|
||||
|
||||
it("buildMeshReplicatedTaskCreatePayload includes canonical fields", () => {
|
||||
const payload = buildMeshReplicatedTaskCreatePayload({
|
||||
taskId: "FN-100",
|
||||
reservationId: "res-100",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-100\n\nhello\n",
|
||||
createInput: { description: "hello" },
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-100",
|
||||
taskId: "FN-100",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-100\n\nhello\n",
|
||||
input: { description: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
it("toReplicatedCreateInput preserves node targeting and source metadata", () => {
|
||||
const input = toReplicatedCreateInput({
|
||||
id: "FN-300",
|
||||
title: "Task",
|
||||
description: "hello",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
breakIntoSubtasks: false,
|
||||
enabledWorkflowSteps: [],
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
nodeId: "node-z",
|
||||
priority: "normal",
|
||||
sourceType: "agent",
|
||||
sourceAgentId: "agent-1",
|
||||
sourceRunId: "run-1",
|
||||
sourceSessionId: "session-1",
|
||||
sourceMessageId: "msg-1",
|
||||
sourceParentTaskId: "FN-100",
|
||||
sourceMetadata: { foo: "bar" },
|
||||
} as any);
|
||||
|
||||
expect(input.nodeId).toBe("node-z");
|
||||
expect(input.source?.sourceType).toBe("agent");
|
||||
expect(input.source?.sourceAgentId).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("taskMatchesReplicatedCreate validates equivalence", () => {
|
||||
const existing = {
|
||||
id: "FN-200",
|
||||
title: undefined,
|
||||
description: "hello",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
breakIntoSubtasks: false,
|
||||
enabledWorkflowSteps: [],
|
||||
priority: "normal",
|
||||
sourceType: "unknown",
|
||||
sourceAgentId: undefined,
|
||||
sourceRunId: undefined,
|
||||
sourceSessionId: undefined,
|
||||
sourceMessageId: undefined,
|
||||
sourceParentTaskId: undefined,
|
||||
sourceMetadata: undefined,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-200\n\nhello\n",
|
||||
} as const;
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-200",
|
||||
taskId: "FN-200",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-200\n\nhello\n",
|
||||
input: { description: "hello", column: "triage" as const },
|
||||
};
|
||||
|
||||
expect(taskMatchesReplicatedCreate(existing as any, payload)).toBe(true);
|
||||
expect(taskMatchesReplicatedCreate(existing as any, { ...payload, prompt: "# FN-200\n\nbye\n" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11356,6 +11356,91 @@ describe("RunMutationContext", () => {
|
||||
const second = store.getDistributedTaskIdAllocator();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("createTaskWithReservedId creates using provided id", async () => {
|
||||
const created = await store.createTaskWithReservedId(
|
||||
{ description: "replicated task", nodeId: "node-b" },
|
||||
{ taskId: "FN-9001" },
|
||||
);
|
||||
|
||||
expect(created.id).toBe("FN-9001");
|
||||
expect(created.nodeId).toBe("node-b");
|
||||
const detail = await store.getTask("FN-9001");
|
||||
expect(detail.prompt).toBe("# FN-9001\n\nreplicated task\n");
|
||||
});
|
||||
|
||||
it("createTaskWithReservedId rejects duplicates and self-dependencies", async () => {
|
||||
await store.createTaskWithReservedId({ description: "first" }, { taskId: "FN-9003" });
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId({ description: "duplicate" }, { taskId: "FN-9003" }),
|
||||
).rejects.toThrow("Task ID already exists: FN-9003");
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId(
|
||||
{ description: "self dep", dependencies: ["FN-9004"] },
|
||||
{ taskId: "FN-9004" },
|
||||
),
|
||||
).rejects.toThrow("Task FN-9004 cannot depend on itself");
|
||||
});
|
||||
|
||||
it("applyReplicatedTaskCreate does not auto-apply default workflow steps", async () => {
|
||||
const workflowStep = await store.createWorkflowStep({
|
||||
name: "Default step",
|
||||
description: "auto",
|
||||
enabled: true,
|
||||
defaultOn: true,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-default-step",
|
||||
taskId: "FN-9010",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-9010\n\ncluster create\n",
|
||||
input: {
|
||||
description: "cluster create",
|
||||
column: "triage" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.task.enabledWorkflowSteps).toBeUndefined();
|
||||
expect(workflowStep.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("applyReplicatedTaskCreate is idempotent and detects collisions", async () => {
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-9002",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-9002\n\ncluster create\n",
|
||||
input: {
|
||||
description: "cluster create",
|
||||
column: "triage" as const,
|
||||
nodeId: "node-c",
|
||||
},
|
||||
};
|
||||
|
||||
const first = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(first.applied).toBe(true);
|
||||
const second = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(second.applied).toBe(false);
|
||||
expect(second.task.id).toBe("FN-9002");
|
||||
|
||||
await expect(
|
||||
store.applyReplicatedTaskCreate({
|
||||
...payload,
|
||||
input: { ...payload.input, description: "different" },
|
||||
}),
|
||||
).rejects.toThrow("Replicated task payload collision");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FTS5 corruption recovery during upsert", () => {
|
||||
|
||||
@@ -938,10 +938,11 @@ export class Database {
|
||||
// and there's no other writer to coordinate with — so we skip WAL-only
|
||||
// tuning there.
|
||||
if (!inMemory) {
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY.
|
||||
// Set this before other PRAGMAs so they also benefit from lock waiting.
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// Enable WAL mode for concurrent reader/writer access
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// In WAL mode NORMAL is nearly as durable as FULL with much lower fsync cost.
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
// Checkpoint every 100 pages (~400 KB) to keep WAL small and reduce
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, PROJECT_AUTH_ROLES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export * from "./mesh-replication-protocol.js";
|
||||
export * from "./mesh-task-replication.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
resolveAgentPrompt,
|
||||
|
||||
169
packages/core/src/mesh-task-replication.ts
Normal file
169
packages/core/src/mesh-task-replication.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { MeshReplicatedTaskCreatePayload, Task, TaskCreateInput, TaskDetail, TaskSource } from "./types.js";
|
||||
|
||||
export function buildBootstrapPrompt(taskId: string, title: string | undefined, description: string): string {
|
||||
const heading = title ? `${taskId}: ${title}` : taskId;
|
||||
return `# ${heading}\n\n${description}\n`;
|
||||
}
|
||||
|
||||
export function buildMeshReplicatedTaskCreatePayload(input: {
|
||||
taskId: string;
|
||||
reservationId: string;
|
||||
sourceNodeId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
prompt: string;
|
||||
createInput: TaskCreateInput;
|
||||
}): MeshReplicatedTaskCreatePayload {
|
||||
return {
|
||||
replicationVersion: 1,
|
||||
reservationId: input.reservationId,
|
||||
taskId: input.taskId,
|
||||
sourceNodeId: input.sourceNodeId,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
prompt: input.prompt,
|
||||
input: input.createInput,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneUndefined<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => pruneUndefined(entry)) as T;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.map(([key, entry]) => [key, pruneUndefined(entry)]);
|
||||
return Object.fromEntries(entries) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCreateInput(input: TaskCreateInput): TaskCreateInput {
|
||||
const source = input.source;
|
||||
return pruneUndefined({
|
||||
...input,
|
||||
column: input.column ?? "triage",
|
||||
source: source
|
||||
? {
|
||||
...source,
|
||||
sourceType: source.sourceType ?? "unknown",
|
||||
}
|
||||
: { sourceType: "unknown" as const },
|
||||
dependencies: input.dependencies ?? [],
|
||||
enabledWorkflowSteps: input.enabledWorkflowSteps ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function toTaskSource(source: Omit<TaskSource, "sourceType"> & { sourceType?: TaskSource["sourceType"] }): TaskSource {
|
||||
return {
|
||||
...source,
|
||||
sourceType: source.sourceType ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function isSubsetEqual(expected: unknown, actual: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return Array.isArray(actual)
|
||||
&& expected.length === actual.length
|
||||
&& expected.every((entry, index) => isSubsetEqual(entry, actual[index]));
|
||||
}
|
||||
if (expected && typeof expected === "object") {
|
||||
if (!actual || typeof actual !== "object") return false;
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return Object.entries(expectedRecord).every(([key, value]) => isSubsetEqual(value, actualRecord[key]));
|
||||
}
|
||||
return Object.is(expected, actual);
|
||||
}
|
||||
|
||||
export function taskMatchesReplicatedCreate(existing: TaskDetail, payload: MeshReplicatedTaskCreatePayload): boolean {
|
||||
const existingPrompt = existing.prompt;
|
||||
const existingCreateInput: TaskCreateInput = {
|
||||
title: existing.title,
|
||||
description: existing.description,
|
||||
column: existing.column,
|
||||
dependencies: existing.dependencies,
|
||||
breakIntoSubtasks: existing.breakIntoSubtasks,
|
||||
enabledWorkflowSteps: existing.enabledWorkflowSteps,
|
||||
modelPresetId: existing.modelPresetId,
|
||||
modelProvider: existing.modelProvider,
|
||||
modelId: existing.modelId,
|
||||
validatorModelProvider: existing.validatorModelProvider,
|
||||
validatorModelId: existing.validatorModelId,
|
||||
planningModelProvider: existing.planningModelProvider,
|
||||
planningModelId: existing.planningModelId,
|
||||
thinkingLevel: existing.thinkingLevel,
|
||||
missionId: existing.missionId,
|
||||
sliceId: existing.sliceId,
|
||||
assignedAgentId: existing.assignedAgentId,
|
||||
nodeId: existing.nodeId,
|
||||
assigneeUserId: existing.assigneeUserId,
|
||||
reviewLevel: existing.reviewLevel,
|
||||
executionMode: existing.executionMode,
|
||||
priority: existing.priority,
|
||||
sourceIssue: existing.sourceIssue,
|
||||
source: toTaskSource({
|
||||
sourceType: existing.sourceType,
|
||||
sourceAgentId: existing.sourceAgentId,
|
||||
sourceRunId: existing.sourceRunId,
|
||||
sourceSessionId: existing.sourceSessionId,
|
||||
sourceMessageId: existing.sourceMessageId,
|
||||
sourceParentTaskId: existing.sourceParentTaskId,
|
||||
sourceMetadata: existing.sourceMetadata,
|
||||
}),
|
||||
baseBranch: existing.baseBranch,
|
||||
branch: existing.branch,
|
||||
};
|
||||
|
||||
return (
|
||||
existing.id === payload.taskId &&
|
||||
existing.createdAt === payload.createdAt &&
|
||||
existing.updatedAt === payload.updatedAt &&
|
||||
existingPrompt === payload.prompt &&
|
||||
isSubsetEqual(normalizeCreateInput(payload.input), normalizeCreateInput(existingCreateInput))
|
||||
);
|
||||
}
|
||||
|
||||
export function replicationCollisionError(taskId: string): Error {
|
||||
return new Error(`Replicated task payload collision for existing task ${taskId}`);
|
||||
}
|
||||
|
||||
export function toReplicatedCreateInput(task: Task): TaskCreateInput {
|
||||
return {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
column: task.column,
|
||||
dependencies: task.dependencies,
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
enabledWorkflowSteps: task.enabledWorkflowSteps,
|
||||
modelPresetId: task.modelPresetId,
|
||||
modelProvider: task.modelProvider,
|
||||
modelId: task.modelId,
|
||||
validatorModelProvider: task.validatorModelProvider,
|
||||
validatorModelId: task.validatorModelId,
|
||||
planningModelProvider: task.planningModelProvider,
|
||||
planningModelId: task.planningModelId,
|
||||
thinkingLevel: task.thinkingLevel,
|
||||
missionId: task.missionId,
|
||||
sliceId: task.sliceId,
|
||||
assignedAgentId: task.assignedAgentId,
|
||||
nodeId: task.nodeId,
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
reviewLevel: task.reviewLevel,
|
||||
executionMode: task.executionMode,
|
||||
priority: task.priority,
|
||||
sourceIssue: task.sourceIssue,
|
||||
source: toTaskSource({
|
||||
sourceType: task.sourceType,
|
||||
sourceAgentId: task.sourceAgentId,
|
||||
sourceRunId: task.sourceRunId,
|
||||
sourceSessionId: task.sourceSessionId,
|
||||
sourceMessageId: task.sourceMessageId,
|
||||
sourceParentTaskId: task.sourceParentTaskId,
|
||||
sourceMetadata: task.sourceMetadata,
|
||||
}),
|
||||
baseBranch: task.baseBranch,
|
||||
branch: task.branch,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,12 @@ import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
import { createDistributedTaskIdAllocator, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||
import {
|
||||
buildBootstrapPrompt,
|
||||
replicationCollisionError,
|
||||
taskMatchesReplicatedCreate,
|
||||
} from "./mesh-task-replication.js";
|
||||
import type { MeshReplicatedTaskApplyResult, MeshReplicatedTaskCreatePayload } from "./types.js";
|
||||
|
||||
/** Database row shape for the tasks table (all columns). */
|
||||
interface TaskRow {
|
||||
@@ -253,16 +259,6 @@ function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact PROMPT.md bytes that `createTask` writes for a triage task.
|
||||
* Single source of truth so the stub-detection comparison below stays in sync
|
||||
* with the bootstrap shape.
|
||||
*/
|
||||
function buildBootstrapPrompt(taskId: string, title: string | undefined, description: string): string {
|
||||
const heading = title ? `${taskId}: ${title}` : taskId;
|
||||
return `# ${heading}\n\n${description}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a PROMPT.md body is the auto-generated bootstrap stub
|
||||
* (`# heading\n\n<description>\n`) that `createTask` writes for triage tasks,
|
||||
@@ -2287,6 +2283,87 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return task;
|
||||
}
|
||||
|
||||
async createTaskWithReservedId(
|
||||
input: TaskCreateInput,
|
||||
options: {
|
||||
taskId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
prompt?: string;
|
||||
applyDefaultWorkflowSteps?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
if (!input.description?.trim()) {
|
||||
throw new Error("Description is required and cannot be empty");
|
||||
}
|
||||
|
||||
const id = options.taskId.trim();
|
||||
if (!id) {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
|
||||
if (input.dependencies?.includes(id)) {
|
||||
throw new Error(`Task ${id} cannot depend on itself`);
|
||||
}
|
||||
|
||||
if (this.readTaskFromDb(id)) {
|
||||
throw new Error(`Task ID already exists: ${id}`);
|
||||
}
|
||||
|
||||
const title = input.title?.trim() || undefined;
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
: undefined;
|
||||
|
||||
if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) {
|
||||
try {
|
||||
const allSteps = await this.listWorkflowSteps();
|
||||
const defaultOnSteps = allSteps
|
||||
.filter((ws) => ws.enabled && ws.defaultOn)
|
||||
.map((ws) => ws.id);
|
||||
if (defaultOnSteps.length > 0) {
|
||||
resolvedWorkflowSteps = defaultOnSteps;
|
||||
}
|
||||
} catch (err) {
|
||||
storeLog.warn("Failed to auto-apply default workflow steps during reserved task creation; auto-defaulting skipped", {
|
||||
phase: "createTaskWithReservedId:workflow-auto-default",
|
||||
skippedAutoDefaulting: true,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
descriptionLength: input.description.length,
|
||||
});
|
||||
}
|
||||
} else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) {
|
||||
resolvedWorkflowSteps = undefined;
|
||||
}
|
||||
|
||||
return this._createTaskInternal(input, title, resolvedWorkflowSteps, id, {
|
||||
createdAt: options.createdAt,
|
||||
updatedAt: options.updatedAt,
|
||||
promptOverride: options.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise<MeshReplicatedTaskApplyResult> {
|
||||
const existing = this.readTaskFromDb(payload.taskId);
|
||||
if (existing) {
|
||||
const existingDetail = await this.getTask(payload.taskId);
|
||||
if (taskMatchesReplicatedCreate(existingDetail, payload)) {
|
||||
return { task: existingDetail, applied: false };
|
||||
}
|
||||
throw replicationCollisionError(payload.taskId);
|
||||
}
|
||||
|
||||
const task = await this.createTaskWithReservedId(payload.input, {
|
||||
taskId: payload.taskId,
|
||||
createdAt: payload.createdAt,
|
||||
updatedAt: payload.updatedAt,
|
||||
prompt: payload.prompt,
|
||||
applyDefaultWorkflowSteps: false,
|
||||
});
|
||||
|
||||
return { task, applied: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper for task creation. Used by createTask() and potentially other
|
||||
* internal methods that need to create tasks without triggering summarization.
|
||||
@@ -2295,9 +2372,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
input: TaskCreateInput,
|
||||
title: string | undefined,
|
||||
resolvedWorkflowSteps: string[] | undefined,
|
||||
id: string
|
||||
id: string,
|
||||
options?: {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
promptOverride?: string;
|
||||
},
|
||||
): Promise<Task> {
|
||||
const now = new Date().toISOString();
|
||||
const now = options?.createdAt ?? new Date().toISOString();
|
||||
const task: Task = {
|
||||
id,
|
||||
title,
|
||||
@@ -2338,7 +2420,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
log: [{ timestamp: now, action: "Task created" }],
|
||||
columnMovedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
updatedAt: options?.updatedAt ?? now,
|
||||
};
|
||||
|
||||
const dir = this.taskDir(id);
|
||||
@@ -2348,9 +2430,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Update cache if watcher is active
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
|
||||
const prompt = task.column === "triage"
|
||||
? buildBootstrapPrompt(id, task.title, task.description)
|
||||
: this.generateSpecifiedPrompt(task);
|
||||
const prompt = options?.promptOverride
|
||||
?? (task.column === "triage"
|
||||
? buildBootstrapPrompt(id, task.title, task.description)
|
||||
: this.generateSpecifiedPrompt(task));
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||
|
||||
|
||||
@@ -1154,6 +1154,22 @@ export interface TaskCreateInput {
|
||||
|
||||
// ── Todo List Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface MeshReplicatedTaskCreatePayload {
|
||||
replicationVersion: 1;
|
||||
reservationId: string;
|
||||
taskId: string;
|
||||
sourceNodeId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
prompt: string;
|
||||
input: TaskCreateInput;
|
||||
}
|
||||
|
||||
export interface MeshReplicatedTaskApplyResult {
|
||||
task: Task;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -588,6 +588,37 @@ describe("createTask", () => {
|
||||
expect(body.baseBranch).toBe("main");
|
||||
});
|
||||
|
||||
it("serializes nodeId in create payload when execution target is specified", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
|
||||
...FAKE_CREATED_TASK,
|
||||
nodeId: "node-exec-1",
|
||||
}));
|
||||
|
||||
await createTask({
|
||||
description: "Task with remote execution target",
|
||||
nodeId: "node-exec-1",
|
||||
});
|
||||
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.nodeId).toBe("node-exec-1");
|
||||
});
|
||||
|
||||
it("routes createTask through node proxy when transportNodeId differs from local node", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
|
||||
|
||||
await createTask(
|
||||
{ description: "Proxy-routed task", nodeId: "node-exec-2" },
|
||||
"proj-1",
|
||||
{ transportNodeId: "node-remote", localNodeId: "node-local" },
|
||||
);
|
||||
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(call[0]).toBe("/api/proxy/node-remote/tasks?projectId=proj-1");
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.nodeId).toBe("node-exec-2");
|
||||
});
|
||||
|
||||
it("sends POST with multiple fields including executionMode", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
|
||||
...FAKE_CREATED_TASK,
|
||||
|
||||
@@ -247,7 +247,16 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
|
||||
export function createTask(input: TaskCreateInput, projectId?: string): Promise<Task> {
|
||||
export interface CreateTaskRequestOptions {
|
||||
transportNodeId?: string;
|
||||
localNodeId?: string;
|
||||
}
|
||||
|
||||
export function createTask(
|
||||
input: TaskCreateInput,
|
||||
projectId?: string,
|
||||
options?: CreateTaskRequestOptions,
|
||||
): Promise<Task> {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
@@ -269,12 +278,15 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
executionMode,
|
||||
priority,
|
||||
source,
|
||||
nodeId,
|
||||
branch,
|
||||
baseBranch,
|
||||
} = input;
|
||||
|
||||
return api<Task>(withProjectId("/tasks", projectId), {
|
||||
return proxyApi<Task>(withProjectId("/tasks", projectId), {
|
||||
method: "POST",
|
||||
nodeId: options?.transportNodeId,
|
||||
localNodeId: options?.localNodeId,
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
@@ -296,6 +308,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
executionMode,
|
||||
priority,
|
||||
source,
|
||||
nodeId,
|
||||
branch,
|
||||
baseBranch,
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,7 @@ const mockReserveDistributedTaskId = vi.fn();
|
||||
const mockCommitDistributedTaskIdReservation = vi.fn();
|
||||
const mockAbortDistributedTaskIdReservation = vi.fn();
|
||||
const mockGetDistributedTaskIdState = vi.fn();
|
||||
const mockApplyReplicatedTaskCreate = vi.fn();
|
||||
|
||||
// Mock GlobalSettingsStore
|
||||
const mockGetSettings = vi.fn().mockResolvedValue({});
|
||||
@@ -99,6 +100,10 @@ class MockStore extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
async applyReplicatedTaskCreate(payload: unknown): Promise<{ task: Task; applied: boolean }> {
|
||||
return mockApplyReplicatedTaskCreate(payload);
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
@@ -726,3 +731,97 @@ describe("/api/mesh/task-ids routes", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/mesh/tasks/create", () => {
|
||||
let app: ReturnType<typeof createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockClose.mockResolvedValue(undefined);
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockApplyReplicatedTaskCreate.mockResolvedValue({
|
||||
task: {
|
||||
id: "FN-001",
|
||||
description: "replicated",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
},
|
||||
applied: true,
|
||||
});
|
||||
app = createServer(new MockStore() as unknown as TaskStore);
|
||||
});
|
||||
|
||||
it("applies replicated task create payload", async () => {
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockApplyReplicatedTaskCreate).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("returns 200 when replicated task create is an idempotent replay", async () => {
|
||||
mockApplyReplicatedTaskCreate.mockResolvedValue({
|
||||
task: {
|
||||
id: "FN-001",
|
||||
description: "replicated",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthorized replicated create", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ id: "node_remote_1", apiKey: "secret" }));
|
||||
const payload = {
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-001",
|
||||
sourceNodeId: "node_remote_1",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-001\n\nreplicated\n",
|
||||
input: { description: "replicated" },
|
||||
};
|
||||
|
||||
const response = await request(app, "POST", "/api/mesh/tasks/create", JSON.stringify(payload), {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer wrong",
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralGetLocalNode = vi.fn().mockResolvedValue({ id: "node-local" });
|
||||
const mockCentralListNodes = vi.fn().mockResolvedValue([]);
|
||||
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
|
||||
mockPerformUpdateCheck: vi.fn(),
|
||||
mockClearUpdateCheckCache: vi.fn(),
|
||||
@@ -104,6 +106,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
close: mockCentralClose,
|
||||
listProjects: mockCentralListProjects,
|
||||
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
|
||||
getLocalNode: mockCentralGetLocalNode,
|
||||
listNodes: mockCentralListNodes,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -175,6 +179,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
createTaskWithReservedId: undefined,
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
@@ -205,6 +210,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||
reserveDistributedTaskId: vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7001" }),
|
||||
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
@@ -670,6 +680,98 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses distributed allocator flow when reserved-id create is available", async () => {
|
||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-7001",
|
||||
column: "triage",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
nodeId: "node-target",
|
||||
});
|
||||
const storeWithReservedCreate = createMockStore({
|
||||
createTaskWithReservedId,
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7001\n\nBig initiative\n" }),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Big initiative", nodeId: "node-target" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(createTaskWithReservedId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: "Big initiative", nodeId: "node-target" }),
|
||||
expect.objectContaining({ taskId: "FN-7001" }),
|
||||
);
|
||||
expect((storeWithReservedCreate.getDistributedTaskIdAllocator as ReturnType<typeof vi.fn>).mock.results[0]?.value.commitDistributedTaskIdReservation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when nodeId is not a string", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Task", nodeId: 123 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("nodeId must be a string");
|
||||
});
|
||||
|
||||
it("aborts reservation and deletes local task on replication failure", async () => {
|
||||
const reserveDistributedTaskId = vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7002" });
|
||||
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
||||
const abortDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-7002",
|
||||
column: "triage",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
});
|
||||
const deleteTask = vi.fn().mockResolvedValue(undefined);
|
||||
const storeWithReservedCreate = createMockStore({
|
||||
createTaskWithReservedId,
|
||||
deleteTask,
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7002\n\nBig initiative\n" }),
|
||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||
reserveDistributedTaskId,
|
||||
commitDistributedTaskIdReservation,
|
||||
abortDistributedTaskIdReservation,
|
||||
}),
|
||||
});
|
||||
mockCentralListNodes.mockResolvedValue([{ id: "node-remote", type: "remote", url: "https://remote.example.com", apiKey: "secret" }]);
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Big initiative" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
|
||||
expect(deleteTask).toHaveBeenCalledWith("FN-7002");
|
||||
expect(commitDistributedTaskIdReservation).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
mockCentralListNodes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("forwards branch and baseBranch on create", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
@@ -234,6 +234,35 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mesh/tasks/create", async (req, res) => {
|
||||
const payload = req.body;
|
||||
try {
|
||||
const senderNodeId = typeof payload?.sourceNodeId === "string" ? payload.sourceNodeId : undefined;
|
||||
if (!(await requireMeshAuth(req, res, senderNodeId))) return;
|
||||
if (payload?.replicationVersion !== 1) throw badRequest("replicationVersion must be 1");
|
||||
if (typeof payload?.reservationId !== "string" || payload.reservationId.trim().length === 0) throw badRequest("reservationId is required");
|
||||
if (typeof payload?.taskId !== "string" || payload.taskId.trim().length === 0) throw badRequest("taskId is required");
|
||||
if (typeof payload?.sourceNodeId !== "string" || payload.sourceNodeId.trim().length === 0) throw badRequest("sourceNodeId is required");
|
||||
if (typeof payload?.createdAt !== "string" || typeof payload?.updatedAt !== "string") throw badRequest("createdAt and updatedAt are required");
|
||||
if (typeof payload?.prompt !== "string") throw badRequest("prompt is required");
|
||||
if (!payload?.input || typeof payload.input !== "object") throw badRequest("input is required");
|
||||
|
||||
const result = await store.applyReplicatedTaskCreate(payload);
|
||||
res.status(result.applied ? 201 : 200).json(result);
|
||||
} catch (err: unknown) {
|
||||
emitRemoteRouteDiagnostic({
|
||||
route: "mesh-task-create",
|
||||
message: "Failed to apply replicated task create",
|
||||
nodeId: typeof payload?.sourceNodeId === "string" ? payload.sourceNodeId : undefined,
|
||||
upstreamPath: "/api/mesh/tasks/create",
|
||||
operationStage: "apply-replicated-create",
|
||||
error: err,
|
||||
});
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mesh/sync", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
COLUMNS,
|
||||
TASK_PRIORITIES,
|
||||
VALID_TRANSITIONS,
|
||||
buildMeshReplicatedTaskCreatePayload,
|
||||
isTaskPriority,
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
toReplicatedCreateInput,
|
||||
validateNodeOverrideChange,
|
||||
} from "@fusion/core";
|
||||
import { planTaskWorktreePath } from "@fusion/engine";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
interface TaskWorkflowRouteDeps {
|
||||
@@ -97,6 +100,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
source,
|
||||
branch,
|
||||
baseBranch,
|
||||
nodeId,
|
||||
} = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
throw badRequest("description is required");
|
||||
@@ -136,6 +140,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw badRequest(`priority must be one of: ${TASK_PRIORITIES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") {
|
||||
throw badRequest("nodeId must be a string");
|
||||
}
|
||||
|
||||
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
|
||||
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
|
||||
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
|
||||
@@ -198,33 +206,97 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
const normalizedBranch = validateOptionalBranchString(branch, "branch");
|
||||
const normalizedBaseBranch = validateOptionalBranchString(baseBranch, "baseBranch");
|
||||
|
||||
const task = await scopedStore.createTask(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||
modelProvider: executorModel.provider ?? undefined,
|
||||
modelId: executorModel.modelId ?? undefined,
|
||||
validatorModelProvider: validatorModel.provider ?? undefined,
|
||||
validatorModelId: validatorModel.modelId ?? undefined,
|
||||
planningModelProvider: planningModel.provider ?? undefined,
|
||||
planningModelId: planningModel.modelId ?? undefined,
|
||||
thinkingLevel: thinkingLevel || undefined,
|
||||
summarize,
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
executionMode: executionMode || undefined,
|
||||
priority: priority ?? undefined,
|
||||
source: normalizedSource,
|
||||
branch: normalizedBranch,
|
||||
baseBranch: normalizedBaseBranch,
|
||||
},
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
|
||||
);
|
||||
res.status(201).json(task);
|
||||
const createInput = {
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||
modelProvider: executorModel.provider ?? undefined,
|
||||
modelId: executorModel.modelId ?? undefined,
|
||||
validatorModelProvider: validatorModel.provider ?? undefined,
|
||||
validatorModelId: validatorModel.modelId ?? undefined,
|
||||
planningModelProvider: planningModel.provider ?? undefined,
|
||||
planningModelId: planningModel.modelId ?? undefined,
|
||||
thinkingLevel: thinkingLevel || undefined,
|
||||
summarize,
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
executionMode: executionMode || undefined,
|
||||
priority: priority ?? undefined,
|
||||
source: normalizedSource,
|
||||
branch: normalizedBranch,
|
||||
baseBranch: normalizedBaseBranch,
|
||||
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
|
||||
};
|
||||
|
||||
if (typeof scopedStore.createTaskWithReservedId !== "function") {
|
||||
const task = await scopedStore.createTask(
|
||||
createInput,
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
||||
);
|
||||
res.status(201).json(task);
|
||||
return;
|
||||
}
|
||||
|
||||
const allocator = scopedStore.getDistributedTaskIdAllocator();
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((node) => node.type === "local");
|
||||
const remoteNodes = nodes.filter((node) => node.type === "remote" && node.url && node.apiKey);
|
||||
await central.close();
|
||||
|
||||
const reservation = await allocator.reserveDistributedTaskId({
|
||||
prefix: "FN",
|
||||
nodeId: localNode?.id ?? "local",
|
||||
});
|
||||
|
||||
let createdTask: Task | null = null;
|
||||
try {
|
||||
createdTask = await scopedStore.createTaskWithReservedId(createInput, {
|
||||
taskId: reservation.taskId,
|
||||
});
|
||||
|
||||
const replicatedPayload = buildMeshReplicatedTaskCreatePayload({
|
||||
taskId: createdTask.id,
|
||||
reservationId: reservation.reservationId,
|
||||
sourceNodeId: localNode?.id ?? "local",
|
||||
createdAt: createdTask.createdAt,
|
||||
updatedAt: createdTask.updatedAt,
|
||||
prompt: (await scopedStore.getTask(createdTask.id)).prompt,
|
||||
createInput: toReplicatedCreateInput(createdTask),
|
||||
});
|
||||
|
||||
for (const peer of remoteNodes) {
|
||||
await fetchFromRemoteNode(peer, "/api/mesh/tasks/create", {
|
||||
method: "POST",
|
||||
body: replicatedPayload,
|
||||
});
|
||||
}
|
||||
|
||||
await allocator.commitDistributedTaskIdReservation({
|
||||
reservationId: reservation.reservationId,
|
||||
nodeId: localNode?.id ?? "local",
|
||||
});
|
||||
|
||||
res.status(201).json(createdTask);
|
||||
} catch (err: unknown) {
|
||||
await allocator.abortDistributedTaskIdReservation({
|
||||
reservationId: reservation.reservationId,
|
||||
nodeId: localNode?.id ?? "local",
|
||||
reason: "failed-create",
|
||||
}).catch(() => undefined);
|
||||
|
||||
if (createdTask) {
|
||||
await scopedStore.deleteTask(createdTask.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new ApiError(503, `Cluster task create failed: ${message}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("RemoteNodeClient", () => {
|
||||
await expect(client.getMetrics()).resolves.toEqual(metrics);
|
||||
});
|
||||
|
||||
it("createTask() sends POST with JSON body", async () => {
|
||||
it("createTask() sends POST with full JSON body including node targeting metadata", async () => {
|
||||
const createdTask = {
|
||||
id: "KB-001",
|
||||
description: "Create me",
|
||||
@@ -83,7 +83,12 @@ describe("RemoteNodeClient", () => {
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
await client.createTask({ description: "Create me" });
|
||||
await client.createTask({
|
||||
description: "Create me",
|
||||
title: "Task title",
|
||||
nodeId: "node-exec-1",
|
||||
dependencies: ["KB-010"],
|
||||
});
|
||||
|
||||
const options = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/tasks`, expect.any(Object));
|
||||
@@ -92,7 +97,12 @@ describe("RemoteNodeClient", () => {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
}));
|
||||
expect(options.body).toBe(JSON.stringify({ description: "Create me" }));
|
||||
expect(options.body).toBe(JSON.stringify({
|
||||
description: "Create me",
|
||||
title: "Task title",
|
||||
nodeId: "node-exec-1",
|
||||
dependencies: ["KB-010"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("listTasks() sends optional query params", async () => {
|
||||
|
||||
Reference in New Issue
Block a user