Merge upstream/main — v0.27.0 → v0.28.1 (303 commits)
Notable upstream changes: - FN-4128: bundled plugin entry resolution + load error surfacing - FN-4259: stop exhausted-task churn / deterministic exhausted contract - FN-4114: pre-session worktree liveness + fn_task_done invariants - FN-4080: thinking persistence toggles - FN-4148: GitHub tracking title auto-derive - 0.28.0 minor: mission interview drafts (Resume/Discard) Fork patches preserved: - Dockerfile (5 patches: ca-certs, codex symlink, 0.0.0.0 bind, /project cwd, single-stage) - cross-spawn dep in cli/package.json - registerPluginExemptPath in auth-middleware - mountPluginRoutes in dashboard routes - plugin autoload + state reset in dashboard.ts Local validation: - pnpm install: clean - pnpm build: exit 0 - pnpm typecheck: exit 0 (27 packages)
This commit is contained in:
@@ -29,6 +29,7 @@ const commandMocks = vi.hoisted(() => ({
|
||||
runTaskPlan: vi.fn(),
|
||||
runTaskDelete: vi.fn(),
|
||||
runTaskRetry: vi.fn(),
|
||||
runTaskBranchRecovery: vi.fn(),
|
||||
runTaskComment: vi.fn(),
|
||||
runTaskComments: vi.fn(),
|
||||
runTaskSteer: vi.fn(),
|
||||
@@ -133,6 +134,7 @@ vi.mock("../commands/task.js", () => ({
|
||||
runTaskPlan: commandMocks.runTaskPlan,
|
||||
runTaskDelete: commandMocks.runTaskDelete,
|
||||
runTaskRetry: commandMocks.runTaskRetry,
|
||||
runTaskBranchRecovery: commandMocks.runTaskBranchRecovery,
|
||||
runTaskComment: commandMocks.runTaskComment,
|
||||
runTaskComments: commandMocks.runTaskComments,
|
||||
runTaskSteer: commandMocks.runTaskSteer,
|
||||
@@ -389,6 +391,29 @@ describe("bin command routing and fallbacks", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task show <id>");
|
||||
});
|
||||
|
||||
it("routes task branch-recovery with reclaim/discard flags", async () => {
|
||||
await runBin(["task", "branch-recovery", "FN-123", "--reclaim", "fusion/fn-123-2", "-P", "demo"]);
|
||||
await runBin(["task", "branch-recovery", "FN-123", "--discard", "fusion/fn-123-2", "--yes", "-P", "demo"]);
|
||||
|
||||
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(1, "FN-123", {
|
||||
reclaim: "fusion/fn-123-2",
|
||||
discard: undefined,
|
||||
yes: false,
|
||||
}, "demo");
|
||||
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(2, "FN-123", {
|
||||
reclaim: undefined,
|
||||
discard: "fusion/fn-123-2",
|
||||
yes: true,
|
||||
}, "demo");
|
||||
});
|
||||
|
||||
it("errors for task branch-recovery missing id", async () => {
|
||||
await expect(runBin(["task", "branch-recovery"])).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes agent subcommands stop/start/import/mailbox", async () => {
|
||||
await runBin(["agent", "stop", "agent-1", "-P", "demo"]);
|
||||
await runBin(["agent", "start", "agent-1", "-P", "demo"]);
|
||||
@@ -501,7 +526,12 @@ describe("bin command routing and fallbacks", () => {
|
||||
|
||||
it("routes mission list alias", async () => {
|
||||
await runBin(["mission", "ls"]);
|
||||
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
|
||||
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: true });
|
||||
});
|
||||
|
||||
it("routes mission list with --no-drafts", async () => {
|
||||
await runBin(["mission", "list", "--no-drafts"]);
|
||||
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: false });
|
||||
});
|
||||
|
||||
it("routes mission show alias", async () => {
|
||||
@@ -529,6 +559,7 @@ describe("bin command routing and fallbacks", () => {
|
||||
host: "127.0.0.1",
|
||||
token: "fn_abc123",
|
||||
tokenOnly: true,
|
||||
noAutoRegister: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,6 +573,7 @@ describe("bin command routing and fallbacks", () => {
|
||||
host: undefined,
|
||||
token: undefined,
|
||||
tokenOnly: false,
|
||||
noAutoRegister: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { parse } from "yaml";
|
||||
|
||||
const workspaceRoot = join(import.meta.dirname!, "..", "..", "..", "..");
|
||||
|
||||
function loadWorkflow(name: string): any {
|
||||
const path = join(workspaceRoot, ".github", "workflows", name);
|
||||
function loadYamlFile(...pathParts: string[]): any {
|
||||
const path = join(workspaceRoot, ...pathParts);
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const parsed = parse(content) as Record<string, unknown>;
|
||||
|
||||
@@ -19,9 +19,18 @@ function loadWorkflow(name: string): any {
|
||||
return { content, parsed };
|
||||
}
|
||||
|
||||
function loadWorkflow(name: string): any {
|
||||
return loadYamlFile(".github", "workflows", name);
|
||||
}
|
||||
|
||||
function findCompositeSetupStep(steps: any[]) {
|
||||
return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm");
|
||||
}
|
||||
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
let compositeAction: any;
|
||||
let buildSteps: any[];
|
||||
let testShardJob: any;
|
||||
let contributingContent: string;
|
||||
@@ -35,6 +44,7 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
const result = loadWorkflow("ci.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed;
|
||||
buildSteps = workflow.jobs?.build?.steps ?? [];
|
||||
testShardJob = workflow.jobs?.["test-shards"];
|
||||
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
|
||||
@@ -72,9 +82,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
});
|
||||
|
||||
it("pins dependency bootstrap to frozen lockfile", () => {
|
||||
expect(content).toContain("run: pnpm install --frozen-lockfile");
|
||||
const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build];
|
||||
for (const job of jobs) {
|
||||
expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined();
|
||||
}
|
||||
expect(content).not.toContain("run: pnpm install\n");
|
||||
expect(content).not.toContain("--no-frozen-lockfile");
|
||||
expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile");
|
||||
});
|
||||
|
||||
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
|
||||
@@ -183,9 +197,7 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
expect(
|
||||
lintSteps.some(
|
||||
(step: any) =>
|
||||
step.name === "Install dependencies" &&
|
||||
typeof step.run === "string" &&
|
||||
step.run.includes("pnpm install --frozen-lockfile"),
|
||||
typeof step.uses === "string" && step.uses.includes("./.github/actions/setup-node-pnpm"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -288,8 +300,8 @@ describe("Version & Release workflow (.github/workflows/version.yml)", () => {
|
||||
|
||||
it("configures npm registry-url", () => {
|
||||
const steps = workflow.jobs.release.steps;
|
||||
const nodeStep = steps.find((s: any) => s.uses?.includes("actions/setup-node"));
|
||||
expect(nodeStep?.with?.["registry-url"]).toBe("https://registry.npmjs.org");
|
||||
const compositeStep = findCompositeSetupStep(steps);
|
||||
expect(compositeStep?.with?.["registry-url"]).toBe("https://registry.npmjs.org");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +352,33 @@ describe("Binary release workflow (.github/workflows/release.yml)", () => {
|
||||
expect(content).toContain("softprops/action-gh-release");
|
||||
});
|
||||
|
||||
it("uses frozen-lockfile install in every matrix job", () => {
|
||||
const steps = workflow.jobs["build-binaries"].steps ?? [];
|
||||
const setupSteps = steps.filter((step: any) => step.uses === "./.github/actions/setup-node-pnpm");
|
||||
|
||||
const hasValidCompositeSetup = setupSteps.some((step: any) => {
|
||||
const installArgs = step.with?.["install-args"];
|
||||
return installArgs === undefined || String(installArgs).trim() === "--frozen-lockfile";
|
||||
});
|
||||
|
||||
const hasInlineFrozenInstall = steps.some((step: any) =>
|
||||
typeof step.run === "string" && /\bpnpm install --frozen-lockfile\b/.test(step.run),
|
||||
);
|
||||
|
||||
expect(hasValidCompositeSetup || hasInlineFrozenInstall).toBe(true);
|
||||
|
||||
for (const step of setupSteps) {
|
||||
const installArgs = step.with?.["install-args"];
|
||||
if (installArgs !== undefined) {
|
||||
expect(String(installArgs).trim()).toBe("--frozen-lockfile");
|
||||
}
|
||||
}
|
||||
|
||||
expect(content).not.toMatch(/run:\s*pnpm install\s*(?:\r?\n)/);
|
||||
expect(content).not.toContain("--no-frozen-lockfile");
|
||||
expect(content).not.toMatch(/install-args:\s*["']?\s*["']?\s*(?:\r?\n)/);
|
||||
});
|
||||
|
||||
it("references signing scripts", () => {
|
||||
expect(content).toContain("scripts/sign-macos.sh");
|
||||
expect(content).toContain("scripts/sign-windows.ps1");
|
||||
@@ -399,8 +438,10 @@ describe("Test-release workflow (.github/workflows/test-release.yml)", () => {
|
||||
});
|
||||
|
||||
it("uses frozen-lockfile install in every matrix job", () => {
|
||||
const matches = content.match(/run:\s*pnpm install --frozen-lockfile/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
const steps = workflow.jobs["build-binaries"].steps ?? [];
|
||||
const compositeStep = findCompositeSetupStep(steps);
|
||||
expect(compositeStep).toBeDefined();
|
||||
expect(compositeStep.with?.["install-args"] ?? "--frozen-lockfile").toBe("--frozen-lockfile");
|
||||
expect(content).not.toContain("run: pnpm install\n");
|
||||
expect(content).not.toContain("--no-frozen-lockfile");
|
||||
});
|
||||
|
||||
@@ -176,6 +176,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
|
||||
expect(created.details.taskId).toMatch(/^[A-Z]+-\d+$/);
|
||||
expect(created.details.column).toBe("triage");
|
||||
expect(created.details.priority).toBe("normal");
|
||||
|
||||
const listTool = api.tools.get("fn_task_list")!;
|
||||
const listed = await listTool.execute("list-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
@@ -186,6 +187,17 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
await store.init();
|
||||
const persisted = await store.getTask(created.details.taskId);
|
||||
expect(persisted?.description).toBe("Ship the packed CLI contract");
|
||||
|
||||
const urgent = await createTool.execute(
|
||||
"create-2",
|
||||
{ description: "Needs urgency", priority: "high" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
expect(urgent.details.priority).toBe("high");
|
||||
const urgentPersisted = await store.getTask(urgent.details.taskId);
|
||||
expect(urgentPersisted?.priority).toBe("high");
|
||||
});
|
||||
|
||||
it("runs provisioning tools through the built extension", async () => {
|
||||
@@ -245,4 +257,23 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
|
||||
});
|
||||
|
||||
it("returns explicit error when fn_delegate_task hits task-id collision", async () => {
|
||||
const agent = await seedAgent(tmpDir, { name: "release-agent" });
|
||||
const delegateTool = api.tools.get("fn_delegate_task")!;
|
||||
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
|
||||
|
||||
const result = await delegateTool.execute(
|
||||
"delegate-collision",
|
||||
{ agent_id: agent.id, description: "collision task" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
|
||||
expect(result.details.error).toContain("Task ID already exists: FN-001");
|
||||
createSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,25 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
expect(result.content[0].text).toContain("Fix the login button");
|
||||
expect(result.content[0].text).toContain("triage");
|
||||
expect(result.details.column).toBe("triage");
|
||||
expect(result.details.priority).toBe("normal");
|
||||
});
|
||||
|
||||
it("creates a task with explicit priority", async () => {
|
||||
const tool = api.tools.get("fn_task_create")!;
|
||||
const result = await tool.execute(
|
||||
"call-priority",
|
||||
{ description: "Urgent task", priority: "urgent" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.priority).toBe("urgent");
|
||||
expect(result.content[0].text).toContain("Priority: urgent");
|
||||
|
||||
const showTool = api.tools.get("fn_task_show")!;
|
||||
const show = await showTool.execute("s-priority", { id: result.details.taskId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(show.details.task.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("creates a task with dependencies", async () => {
|
||||
@@ -958,7 +977,6 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
|
||||
describe("fn_mission_list", () => {
|
||||
it("returns formatted list of missions", async () => {
|
||||
// First create a mission
|
||||
const createTool = api.tools.get("fn_mission_create")!;
|
||||
await createTool.execute(
|
||||
"c1",
|
||||
@@ -981,6 +999,44 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
expect(result.content[0].text).toContain("Missions");
|
||||
expect(result.content[0].text).toContain("Summary:");
|
||||
});
|
||||
|
||||
it("includes mission interview drafts by default and exposes them in details", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
store.getDatabase().prepare(
|
||||
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
|
||||
VALUES (?, 'mission_interview', 'awaiting_input', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
|
||||
).run("draft-1", "Draft Mission", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
|
||||
|
||||
const listTool = api.tools.get("fn_mission_list")!;
|
||||
const result = await listTool.execute("call-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.content[0].text).toContain("Drafts (1)");
|
||||
expect(result.content[0].text).toContain("draft-1: Draft Mission (draft · interview awaiting_input)");
|
||||
expect(result.details.drafts).toEqual([
|
||||
{
|
||||
id: "draft-1",
|
||||
title: "Draft Mission",
|
||||
status: "awaiting_input",
|
||||
updatedAt: "2026-05-12T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses mission interview drafts when includeDrafts is false", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
store.getDatabase().prepare(
|
||||
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
|
||||
VALUES (?, 'mission_interview', 'error', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
|
||||
).run("draft-2", "Hidden Draft", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
|
||||
|
||||
const listTool = api.tools.get("fn_mission_list")!;
|
||||
const result = await listTool.execute("call-1", { includeDrafts: false }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.content[0].text).not.toContain("Drafts");
|
||||
expect(result.details.drafts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_mission_show", () => {
|
||||
@@ -1474,6 +1530,24 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expect(result.content[0].text).toContain(ephemeralId);
|
||||
});
|
||||
|
||||
it("returns explicit collision error when fn_task_create hits an existing task id", async () => {
|
||||
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
|
||||
const createTool = api.tools.get("fn_task_create")!;
|
||||
|
||||
const result = await createTool.execute(
|
||||
"create-collision",
|
||||
{ description: "collision task" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
|
||||
expect(result.details.error).toContain("Task ID already exists: FN-001");
|
||||
createSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
|
||||
await agentStore.init();
|
||||
@@ -1745,6 +1819,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expect(updated?.steps[1].status).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "zero-step execution-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step execution-failed task\n\nNo steps yet.\n");
|
||||
await store.updateTask(task.id, { steps: [] });
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.updateTask(task.id, { status: "failed", error: "executor crashed", mergeRetries: 0, steps: [] });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-zero-step-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details.newColumn).toBe("todo");
|
||||
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.column).toBe("todo");
|
||||
expect(updated?.status).toBeFalsy();
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.steps).toEqual([]);
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
@@ -1776,6 +1879,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "zero-step merge-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step merge-failed task\n\nNo steps yet.\n");
|
||||
await store.updateTask(task.id, { steps: [] });
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.updateTask(task.id, { status: "failed", error: "merge conflict", mergeRetries: 2, steps: [] });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-zero-step-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details.newColumn).toBe("in-review");
|
||||
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.column).toBe("in-review");
|
||||
expect(updated?.status).toBeFalsy();
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.steps).toEqual([]);
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_list_agents", () => {
|
||||
@@ -1839,6 +1971,20 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
});
|
||||
|
||||
describe("research tools", () => {
|
||||
it.each([
|
||||
"fn_research_run",
|
||||
"fn_research_list",
|
||||
"fn_research_get",
|
||||
"fn_research_cancel",
|
||||
"fn_research_retry",
|
||||
])("%s uses disambiguated cited-research wording", (toolName) => {
|
||||
const tool = api.tools.get(toolName)!;
|
||||
expect(tool.description).toMatch(/cited-research pipeline/i);
|
||||
if (/experiment loop/i.test(tool.description)) {
|
||||
expect(tool.description).toMatch(/not\s+.*experiment loop/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("fn_research_run treats builtin as configured when no provider is explicitly set", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveAffectedPackages,
|
||||
shouldForceFullSuite,
|
||||
} from "../../../../scripts/test-changed.mjs";
|
||||
import { parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
||||
import { computeSplitPlan, parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
||||
|
||||
describe("root test command changed-only planning", () => {
|
||||
it("uses changed mode when package-only changes are detected", () => {
|
||||
@@ -78,43 +78,102 @@ describe("CI shard test planner", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("deterministically balances weighted packages across shards", () => {
|
||||
it("deterministically balances weighted packages across shards with virtual dashboard slices", () => {
|
||||
const weightedPackages = [
|
||||
{ name: "@fusion/dashboard", testFileCount: 140 },
|
||||
{ name: "@fusion/engine", testFileCount: 120 },
|
||||
{ name: "@fusion/core", testFileCount: 60 },
|
||||
{ name: "@runfusion/fusion", testFileCount: 40 },
|
||||
{ name: "@fusion/plugin-sdk", testFileCount: 18 },
|
||||
{ name: "@fusion/mobile", testFileCount: 12 },
|
||||
{ name: "@fusion/desktop", testFileCount: 8 },
|
||||
{ name: "@fusion/dashboard-utils", testFileCount: 4 },
|
||||
{ name: "@fusion/no-tests-yet", testFileCount: 0 },
|
||||
{ name: "@fusion/dashboard", testFileCount: 505 },
|
||||
{ name: "@fusion/engine", testFileCount: 90 },
|
||||
{ name: "@fusion/core", testFileCount: 80 },
|
||||
{ name: "@runfusion/fusion", testFileCount: 50 },
|
||||
{ name: "@fusion/plugin-sdk", testFileCount: 30 },
|
||||
];
|
||||
|
||||
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
||||
expect(shardAssignments).toEqual([
|
||||
["@fusion/dashboard"],
|
||||
["@fusion/engine", "@fusion/desktop", "@fusion/dashboard-utils"],
|
||||
["@fusion/core", "@runfusion/fusion", "@fusion/plugin-sdk", "@fusion/mobile", "@fusion/no-tests-yet"],
|
||||
]);
|
||||
|
||||
expect(selectShardPackages(weightedPackages, 1, 3)).toEqual(shardAssignments[0]);
|
||||
expect(selectShardPackages(weightedPackages, 2, 3)).toEqual(shardAssignments[1]);
|
||||
expect(selectShardPackages(weightedPackages, 3, 3)).toEqual(shardAssignments[2]);
|
||||
|
||||
const weightsByName = new Map(weightedPackages.map((pkg) => [pkg.name, pkg.testFileCount]));
|
||||
const shardWeights = shardAssignments.map((shardPackages) =>
|
||||
shardPackages.reduce((sum, pkgName) => sum + (weightsByName.get(pkgName) ?? 0), 0),
|
||||
const dashboardSlices = shardAssignments
|
||||
.flat()
|
||||
.filter((entry) => entry.name === "@fusion/dashboard" && entry.shardCount === 3);
|
||||
expect(dashboardSlices).toHaveLength(3);
|
||||
expect(dashboardSlices.map((entry) => entry.shardIndex).sort()).toEqual([1, 2, 3]);
|
||||
|
||||
const shardsContainingDashboard = shardAssignments
|
||||
.map((entries, index) => ({ entries, index }))
|
||||
.filter(({ entries }) => entries.some((entry) => entry.name === "@fusion/dashboard"))
|
||||
.map(({ index }) => index);
|
||||
expect(shardsContainingDashboard).toEqual([0, 1, 2]);
|
||||
|
||||
const computedSplitPlan = computeSplitPlan(weightedPackages, 3);
|
||||
const byWeight = new Map(computedSplitPlan.map((entry) => [
|
||||
`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`,
|
||||
entry.weight,
|
||||
]));
|
||||
const shardWeights = shardAssignments.map((entries) =>
|
||||
entries.reduce(
|
||||
(sum, entry) =>
|
||||
sum +
|
||||
(byWeight.get(`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`) ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const totalWeight = weightedPackages.reduce((sum, pkg) => sum + pkg.testFileCount, 0);
|
||||
const mean = totalWeight / 3;
|
||||
|
||||
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.15);
|
||||
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.1);
|
||||
expect(Math.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
|
||||
});
|
||||
|
||||
const dashboardShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/dashboard"));
|
||||
const engineShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/engine"));
|
||||
expect(dashboardShard).not.toBe(engineShard);
|
||||
it("leaves packages whole when no single package exceeds the split threshold", () => {
|
||||
const weightedPackages = [
|
||||
{ name: "@fusion/engine", testFileCount: 40 },
|
||||
{ name: "@fusion/core", testFileCount: 40 },
|
||||
{ name: "@runfusion/fusion", testFileCount: 40 },
|
||||
];
|
||||
|
||||
const shardAssignments = planShardAssignments(weightedPackages, 3, { threshold: 2 });
|
||||
expect(shardAssignments.flat().every((entry) => entry.shardCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("splits never co-locate two slices of the same package on the same shard", () => {
|
||||
const weightedPackages = [
|
||||
{ name: "@fusion/dashboard", testFileCount: 505 },
|
||||
{ name: "@fusion/engine", testFileCount: 10 },
|
||||
{ name: "@fusion/core", testFileCount: 10 },
|
||||
];
|
||||
|
||||
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
||||
for (const entries of shardAssignments) {
|
||||
const dashboardEntries = entries.filter((entry) => entry.name === "@fusion/dashboard");
|
||||
expect(dashboardEntries).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSplitPlan", () => {
|
||||
it("splits oversized package into k slices where k is capped by total", () => {
|
||||
const result = computeSplitPlan([{ name: "big", testFileCount: 100 }], 3);
|
||||
expect(result).toEqual([
|
||||
{ name: "big", weight: 34, shardIndex: 1, shardCount: 3 },
|
||||
{ name: "big", weight: 34, shardIndex: 2, shardCount: 3 },
|
||||
{ name: "big", weight: 34, shardIndex: 3, shardCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns rewritten list with whole and virtual entries", () => {
|
||||
const result = computeSplitPlan(
|
||||
[
|
||||
{ name: "@fusion/dashboard", testFileCount: 505 },
|
||||
{ name: "@fusion/core", testFileCount: 60 },
|
||||
],
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: "@fusion/dashboard", weight: 169, shardIndex: 1, shardCount: 3 },
|
||||
{ name: "@fusion/dashboard", weight: 169, shardIndex: 2, shardCount: 3 },
|
||||
{ name: "@fusion/dashboard", weight: 169, shardIndex: 3, shardCount: 3 },
|
||||
{ name: "@fusion/core", weight: 60 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ async function loadCommandHandlers() {
|
||||
const { runServe } = await import("./commands/serve.js");
|
||||
const { runDaemon } = await import("./commands/daemon.js");
|
||||
const { runDesktop } = await import("./commands/desktop.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate, runTaskBranchRecovery } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
@@ -163,6 +163,7 @@ async function loadCommandHandlers() {
|
||||
runTaskPlan,
|
||||
runTaskDelete,
|
||||
runTaskRetry,
|
||||
runTaskBranchRecovery,
|
||||
runTaskComment,
|
||||
runTaskComments,
|
||||
runTaskSteer,
|
||||
@@ -246,10 +247,10 @@ Usage:
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
fn dashboard --interactive Start with interactive port selection
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
|
||||
Start Fusion as a headless node (API + engine, no UI)
|
||||
Use --daemon to enable bearer token authentication
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
|
||||
Auto-registers cwd project on first run (use --no-auto-register to disable)
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
|
||||
Start Fusion daemon (API + engine, auth required)
|
||||
fn desktop Launch the Fusion desktop app (Electron)
|
||||
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
||||
@@ -280,20 +281,22 @@ Usage:
|
||||
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 branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]
|
||||
Inspect, reclaim, or discard stranded task branches
|
||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||
Create a GitHub PR for an in-review task
|
||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
|
||||
Create and optionally wait for a research run
|
||||
Create and optionally wait for a cited-research run (search/fetch/synthesis)
|
||||
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
||||
List research runs
|
||||
fn research show <run-id> [--json] Show research run details
|
||||
List cited-research runs
|
||||
fn research show <run-id> [--json] Show cited-research run details
|
||||
fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]
|
||||
Export research run results
|
||||
Export cited-research run results
|
||||
fn research cancel <run-id> [--json]
|
||||
Cancel an active research run
|
||||
Cancel an active cited-research run
|
||||
fn research retry <run-id> [--json]
|
||||
Retry a failed/cancelled research run
|
||||
Retry a failed/cancelled cited-research run
|
||||
fn mission create [title] [desc] Create a new mission
|
||||
fn mission list | ls List missions
|
||||
fn mission show | info <id> Show mission details
|
||||
@@ -522,6 +525,7 @@ async function main() {
|
||||
runTaskPlan,
|
||||
runTaskDelete,
|
||||
runTaskRetry,
|
||||
runTaskBranchRecovery,
|
||||
runTaskComment,
|
||||
runTaskComments,
|
||||
runTaskSteer,
|
||||
@@ -642,7 +646,8 @@ async function main() {
|
||||
const hostIdx = args.indexOf("--host");
|
||||
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
|
||||
const daemon = args.includes("--daemon");
|
||||
await runServe(port, { paused, interactive, host, daemon });
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -658,7 +663,8 @@ async function main() {
|
||||
const tokenIdx = args.indexOf("--token");
|
||||
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
|
||||
const tokenOnly = args.includes("--token-only");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly });
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1117,6 +1123,20 @@ async function main() {
|
||||
await runTaskRetry(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "branch-recovery": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]");
|
||||
process.exit(1);
|
||||
}
|
||||
const reclaimIdx = args.indexOf("--reclaim");
|
||||
const discardIdx = args.indexOf("--discard");
|
||||
const reclaim = reclaimIdx !== -1 && reclaimIdx + 1 < args.length ? args[reclaimIdx + 1] : undefined;
|
||||
const discard = discardIdx !== -1 && discardIdx + 1 < args.length ? args[discardIdx + 1] : undefined;
|
||||
const yes = args.includes("--yes");
|
||||
await runTaskBranchRecovery(id, { reclaim, discard, yes }, projectName);
|
||||
break;
|
||||
}
|
||||
case "pr-create": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
@@ -1206,9 +1226,11 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
case "ls":
|
||||
await runMissionList(projectName);
|
||||
case "ls": {
|
||||
const includeDrafts = !args.includes("--no-drafts");
|
||||
await runMissionList(projectName, { includeDrafts });
|
||||
break;
|
||||
}
|
||||
case "show":
|
||||
case "info": {
|
||||
const id = args[2];
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockSyncStartupModels } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -123,16 +126,45 @@ const mocks = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
const centralCoreCtor = vi.fn().mockImplementation(() => {
|
||||
const now = new Date().toISOString();
|
||||
const projects = [
|
||||
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: now, updatedAt: now },
|
||||
];
|
||||
|
||||
const instance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
getProjectByPath: vi.fn().mockImplementation((path: string) =>
|
||||
Promise.resolve(projects.find((project) => project.path === path) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockResolvedValue([
|
||||
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]),
|
||||
registerProject: vi.fn().mockImplementation(({ name, path, isolationMode }: { name: string; path: string; isolationMode: "in-process" | "child-process" }) => {
|
||||
const project = {
|
||||
id: `project-${projects.length + 1}`,
|
||||
name,
|
||||
path,
|
||||
status: "inactive",
|
||||
isolationMode,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.push(project);
|
||||
return Promise.resolve(project);
|
||||
}),
|
||||
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
|
||||
const index = projects.findIndex((project) => project.id === id);
|
||||
if (index >= 0) {
|
||||
projects[index] = {
|
||||
...projects[index],
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -591,6 +623,10 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
|
||||
}));
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
|
||||
}));
|
||||
|
||||
const { runDaemon } = await import("../daemon.js");
|
||||
|
||||
describe("runDaemon", () => {
|
||||
@@ -785,6 +821,50 @@ describe("runDaemon", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("auto-registers cwd project when not previously registered", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
|
||||
try {
|
||||
await runDaemon({});
|
||||
|
||||
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
|
||||
instance.registerProject.mock.calls,
|
||||
);
|
||||
expect(registrationCalls).toContainEqual([
|
||||
expect.objectContaining({ path: freshCwd, isolationMode: "in-process" }),
|
||||
]);
|
||||
|
||||
const updateCalls = mocks.centralInstances.flatMap((instance) =>
|
||||
instance.updateProject.mock.calls,
|
||||
);
|
||||
expect(updateCalls).toContainEqual([expect.any(String), { status: "active" }]);
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
|
||||
try {
|
||||
await runDaemon({ noAutoRegister: true });
|
||||
|
||||
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
|
||||
instance.registerProject.mock.calls,
|
||||
);
|
||||
expect(registrationCalls).toHaveLength(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("stops engine services during shutdown", async () => {
|
||||
await runDaemon({});
|
||||
|
||||
|
||||
@@ -748,6 +748,12 @@ function disposeTrackedDashboards(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 5000, interval: 10 };
|
||||
|
||||
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
|
||||
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
|
||||
}
|
||||
|
||||
async function runDashboard(...args: Parameters<typeof runDashboardImpl>): ReturnType<typeof runDashboardImpl> {
|
||||
disposeTrackedDashboards();
|
||||
const result = await runDashboardImpl(...args);
|
||||
@@ -822,6 +828,12 @@ beforeEach(() => {
|
||||
mockExecSync.mockReset();
|
||||
mockExecSync.mockReturnValue("");
|
||||
mockExec.mockClear();
|
||||
mockListen.mockReset();
|
||||
mockListen.mockImplementation((port: number) => {
|
||||
const server = createMockServer(port);
|
||||
process.nextTick(() => server.emit("listening"));
|
||||
return server;
|
||||
});
|
||||
mockStuckCheckNow.mockReset();
|
||||
mockStuckCheckNow.mockResolvedValue(undefined);
|
||||
if (updateCacheDir) {
|
||||
@@ -1121,7 +1133,14 @@ describe("runDashboard — PR-first auto-merge queue", () => {
|
||||
const { aiMergeTask } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockCreatePr).toHaveBeenCalledWith({
|
||||
title: "FN-093: Task",
|
||||
body: "Automated PR for FN-093.\n\nDescription",
|
||||
head: "fusion/fn-093",
|
||||
base: "main",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockCreatePr).toHaveBeenCalledWith({
|
||||
title: "FN-093: Task",
|
||||
@@ -1270,8 +1289,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
to: "in-review",
|
||||
});
|
||||
|
||||
// Give async handlers time to process
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1296,8 +1314,9 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Give async handlers time to process
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(aiMergeTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Only the non-paused task should be enqueued
|
||||
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
@@ -1328,7 +1347,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1348,7 +1367,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1381,7 +1400,12 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockStore.logEntry).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockStore.logEntry).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
@@ -1415,7 +1439,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1475,9 +1499,9 @@ describe("runDashboard — immediate resume on unpause", () => {
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes executor recovery callbacks into SelfHealingManager", async () => {
|
||||
@@ -1527,7 +1551,9 @@ describe("runDashboard — immediate resume on unpause", () => {
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(aiMergeTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Both in-review tasks should be enqueued for merge
|
||||
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
@@ -1575,9 +1601,9 @@ describe("runDashboard — engine pause/unpause cycle", () => {
|
||||
previous: { enginePaused: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1614,9 +1640,9 @@ describe("runDashboard — stuck task timeout listener guards", () => {
|
||||
previous: { taskStuckTimeoutMs: 1_200_000 },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[stuck-detector] Error during immediate stuck-task check:",
|
||||
detectorError,
|
||||
@@ -1651,15 +1677,16 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
it("listens on the requested port when available", async () => {
|
||||
await runDashboard(4040, { open: false });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
|
||||
});
|
||||
|
||||
// mockListen should have been called with the requested port bound to localhost by default.
|
||||
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
|
||||
|
||||
// Banner should show the requested port
|
||||
// Banner should show the resolved localhost URL from the bound server.
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("http://localhost:4040"),
|
||||
expect.stringContaining("http://localhost:"),
|
||||
);
|
||||
|
||||
// No warning should be printed
|
||||
@@ -1697,8 +1724,9 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
|
||||
await runDashboard(4040, { open: false });
|
||||
|
||||
// Wait for async events to settle
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
|
||||
});
|
||||
|
||||
// Server should have retried with port 0, still bound to localhost.
|
||||
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
|
||||
@@ -1736,8 +1764,11 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
|
||||
await runDashboard(4040, { open: false });
|
||||
|
||||
// Wait for async events to settle
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
`[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
|
||||
);
|
||||
});
|
||||
|
||||
// Should print warning with both the requested and actual ports
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
@@ -1789,7 +1820,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
to: "in-review",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1815,9 +1846,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
previous: { enginePaused: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => {
|
||||
@@ -1852,7 +1883,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
previous: { enginePaused: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(aiMergeTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call: any[]) => call[2],
|
||||
@@ -2025,24 +2058,28 @@ describe("runDashboard — --dev mode", () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
await runDashboard(4040, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
|
||||
});
|
||||
|
||||
// Server should have been created and listen called (localhost default)
|
||||
expect(createServer).toHaveBeenCalled();
|
||||
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
|
||||
|
||||
// Banner should show the port
|
||||
// Banner should show the resolved localhost URL from the bound server.
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("http://localhost:4040"),
|
||||
expect.stringContaining("http://localhost:"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => {
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("✗ disabled (dev mode)"),
|
||||
);
|
||||
});
|
||||
|
||||
// Should show disabled message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
@@ -2053,8 +2090,7 @@ describe("runDashboard — --dev mode", () => {
|
||||
it("does NOT show triage/scheduler details in dev mode", async () => {
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
// Should NOT show triage/scheduler details
|
||||
const triageCall = consoleSpy.mock.calls.find(
|
||||
@@ -2079,8 +2115,11 @@ describe("runDashboard — --dev mode", () => {
|
||||
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("✓ active"),
|
||||
);
|
||||
});
|
||||
|
||||
// Should show active message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
@@ -2198,8 +2237,12 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Wait for retry scheduling
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-RETRY",
|
||||
expect.objectContaining({ mergeRetries: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
// Should have incremented mergeRetries
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
@@ -2245,7 +2288,7 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await Promise.resolve();
|
||||
|
||||
// Exhausted tasks are skipped before enqueue, so they should not be merged again.
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
@@ -2278,7 +2321,14 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
const disabledLog = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === "string" &&
|
||||
call[0].includes("autoResolveConflicts disabled"),
|
||||
);
|
||||
expect(disabledLog).toBeDefined();
|
||||
});
|
||||
|
||||
// Should log that auto-resolve is disabled
|
||||
const disabledLog = consoleSpy.mock.calls.find(
|
||||
@@ -2318,7 +2368,12 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-SUCCESS",
|
||||
expect.objectContaining({ mergeRetries: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
// Should clear mergeRetries on success
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
@@ -2356,7 +2411,16 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncExpectation(() => {
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-BUILD",
|
||||
expect.objectContaining({
|
||||
status: null,
|
||||
mergeRetries: 3,
|
||||
error: "Build verification failed for FN-BUILD: Dependency sync failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-BUILD",
|
||||
@@ -2466,7 +2530,7 @@ describe("runDashboard — lifecycle listener cleanup", () => {
|
||||
|
||||
it("engine cleans up its own listeners from the shared store on dispose", async () => {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await Promise.resolve();
|
||||
|
||||
dispose();
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { mkdtempSync, existsSync, rmSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { CentralCore } from "@fusion/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureCwdProjectRegistered } from "../ensure-project-registered.js";
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const path = mkdtempSync(join(tmpdir(), prefix));
|
||||
tempPaths.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ensureCwdProjectRegistered", () => {
|
||||
it("returns existing registered project without writing files", async () => {
|
||||
const globalDir = makeTempDir("fn-4266-global-");
|
||||
const cwd = makeTempDir("fn-4266-project-");
|
||||
|
||||
const central = new CentralCore(globalDir);
|
||||
await central.init();
|
||||
const existing = await central.registerProject({
|
||||
name: "existing-project",
|
||||
path: cwd,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
const registerSpy = vi.spyOn(central, "registerProject");
|
||||
const updateSpy = vi.spyOn(central, "updateProject");
|
||||
|
||||
const result = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central,
|
||||
logPrefix: "serve",
|
||||
autoRegister: true,
|
||||
});
|
||||
|
||||
expect(result?.id).toBe(existing.id);
|
||||
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
|
||||
expect(registerSpy).not.toHaveBeenCalled();
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
|
||||
await central.close();
|
||||
});
|
||||
|
||||
it("auto-registers unregistered project when enabled", async () => {
|
||||
const globalDir = makeTempDir("fn-4266-global-");
|
||||
const cwd = makeTempDir("fn-4266-project-");
|
||||
|
||||
const central = new CentralCore(globalDir);
|
||||
await central.init();
|
||||
|
||||
const registerSpy = vi.spyOn(central, "registerProject");
|
||||
const updateSpy = vi.spyOn(central, "updateProject");
|
||||
|
||||
const result = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central,
|
||||
logPrefix: "serve",
|
||||
autoRegister: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
|
||||
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(true);
|
||||
expect(statSync(join(cwd, ".fusion", "fusion.db")).size).toBe(0);
|
||||
expect(registerSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: cwd,
|
||||
isolationMode: "in-process",
|
||||
}),
|
||||
);
|
||||
expect(updateSpy).toHaveBeenCalledWith(expect.any(String), { status: "active" });
|
||||
|
||||
await central.close();
|
||||
});
|
||||
|
||||
it("returns null and does not write when autoRegister is false", async () => {
|
||||
const globalDir = makeTempDir("fn-4266-global-");
|
||||
const cwd = makeTempDir("fn-4266-project-");
|
||||
|
||||
const central = new CentralCore(globalDir);
|
||||
await central.init();
|
||||
|
||||
const registerSpy = vi.spyOn(central, "registerProject");
|
||||
|
||||
const result = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central,
|
||||
logPrefix: "daemon",
|
||||
autoRegister: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
|
||||
expect(registerSpy).not.toHaveBeenCalled();
|
||||
|
||||
await central.close();
|
||||
});
|
||||
|
||||
it("returns null and logs error when registration throws", async () => {
|
||||
const globalDir = makeTempDir("fn-4266-global-");
|
||||
const cwd = makeTempDir("fn-4266-project-");
|
||||
|
||||
const central = new CentralCore(globalDir);
|
||||
await central.init();
|
||||
|
||||
vi.spyOn(central, "registerProject").mockRejectedValueOnce(new Error("boom"));
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central,
|
||||
logPrefix: "serve",
|
||||
autoRegister: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] Failed to auto-register current project: boom"),
|
||||
);
|
||||
|
||||
await central.close();
|
||||
});
|
||||
});
|
||||
@@ -155,13 +155,22 @@ function createMockMissionStore(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockDatabase(drafts: Array<{ id: string; title: string; status: string; updatedAt: string }> = []) {
|
||||
return {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue(drafts),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockResolvedProjectStore(
|
||||
missionStore: ReturnType<typeof createMockMissionStore>,
|
||||
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
|
||||
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase> }> = {},
|
||||
) {
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => missionStore,
|
||||
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
|
||||
getDatabase: () => createMockDatabase(),
|
||||
...overrides,
|
||||
} as any);
|
||||
}
|
||||
@@ -281,9 +290,7 @@ describe("mission commands", () => {
|
||||
describe("runMissionList", () => {
|
||||
it("displays missions in formatted output", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
mockResolvedProjectStore(mockMissionStore);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
@@ -313,9 +320,7 @@ describe("mission commands", () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
mockResolvedProjectStore(mockMissionStore);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
@@ -337,6 +342,102 @@ describe("mission commands", () => {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows drafts before mission status sections when present", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
mockResolvedProjectStore(mockMissionStore, {
|
||||
getDatabase: () => createMockDatabase([
|
||||
{
|
||||
id: "draft-1",
|
||||
title: "Draft mission",
|
||||
status: "awaiting_input",
|
||||
updatedAt: "2026-05-12T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
await runMissionList();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
const joined = consoleCapture.logs.join("\n");
|
||||
expect(joined).toContain("◌ Drafts (1)");
|
||||
expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)");
|
||||
expect(joined.indexOf("◌ Drafts (1)")).toBeLessThan(joined.indexOf("● Active (1)"));
|
||||
|
||||
mockExit.mockRestore();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses drafts when includeDrafts is false", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
mockResolvedProjectStore(mockMissionStore, {
|
||||
getDatabase: () => createMockDatabase([
|
||||
{
|
||||
id: "draft-1",
|
||||
title: "Draft mission",
|
||||
status: "awaiting_input",
|
||||
updatedAt: "2026-05-12T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
await runMissionList(undefined, { includeDrafts: false });
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
|
||||
mockExit.mockRestore();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits drafts heading when no drafts exist", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
mockResolvedProjectStore(mockMissionStore, {
|
||||
getDatabase: () => createMockDatabase([]),
|
||||
});
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
await runMissionList();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
|
||||
mockExit.mockRestore();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMissionShow", () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("research commands", () => {
|
||||
it("creates a run", async () => {
|
||||
await runResearchCreate({ query: "hello" });
|
||||
expect(orchestratorMock.createRun).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created research run"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
|
||||
});
|
||||
|
||||
it("creates a run when provider is unset by defaulting to builtin", async () => {
|
||||
@@ -116,7 +116,7 @@ describe("research commands", () => {
|
||||
it("fails show on missing run", async () => {
|
||||
researchStoreMock.getRun.mockReturnValue(undefined);
|
||||
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Research run not found: RR-404");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Cited-research run not found: RR-404");
|
||||
});
|
||||
|
||||
it("exports with explicit output path", async () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockSyncStartupModels } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -146,23 +149,50 @@ const mocks = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
const centralCoreCtor = vi.fn().mockImplementation(() => {
|
||||
const now = new Date().toISOString();
|
||||
const projects = [
|
||||
{ ...PROJECT_FIXTURES.primary, createdAt: now, updatedAt: now },
|
||||
{ ...PROJECT_FIXTURES.secondary, createdAt: now, updatedAt: now },
|
||||
];
|
||||
|
||||
const instance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||
// Use per-test resolver when available; default to primary project
|
||||
// Use per-test resolver when available; default to lookup by path
|
||||
if (getProjectByPathResolver) {
|
||||
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||
}
|
||||
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||
return Promise.resolve(projects.find((project) => project.path === cwd) ?? null);
|
||||
}),
|
||||
registerProject: vi.fn().mockImplementation(({ name, path, isolationMode }: { name: string; path: string; isolationMode: "in-process" | "child-process" }) => {
|
||||
const project = {
|
||||
id: `project-${projects.length + 1}`,
|
||||
name,
|
||||
path,
|
||||
status: "inactive",
|
||||
isolationMode,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.push(project);
|
||||
return Promise.resolve(project);
|
||||
}),
|
||||
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
|
||||
const index = projects.findIndex((project) => project.id === id);
|
||||
if (index >= 0) {
|
||||
projects[index] = {
|
||||
...projects[index],
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockResolvedValue([
|
||||
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -656,7 +686,12 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
|
||||
}));
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
|
||||
}));
|
||||
|
||||
const { runServe } = await import("../serve.js");
|
||||
const ensureProjectRegisteredModule = await import("../ensure-project-registered.js");
|
||||
|
||||
describe("runServe", () => {
|
||||
it("invokes shared startup model sync", async () => {
|
||||
@@ -1906,20 +1941,53 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
||||
expect(serverOpts2.engine).toBe(originalEngine);
|
||||
});
|
||||
|
||||
it("exits process when cwd cannot be resolved to a registered project", async () => {
|
||||
// Configure cwd to return null (project not registered)
|
||||
setupProjectByPath((_cwd) => null);
|
||||
it("auto-registers cwd project when not previously registered", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "serve-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered")
|
||||
.mockResolvedValue({ ...PROJECT_FIXTURES.primary, path: freshCwd });
|
||||
|
||||
try {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cwd: freshCwd,
|
||||
logPrefix: "serve",
|
||||
autoRegister: true,
|
||||
}));
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered")
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await runServe(4040, {});
|
||||
try {
|
||||
await runServe(4040, { noAutoRegister: true });
|
||||
|
||||
// runServe should exit when no cwd engine can be started
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engine started for the current project")
|
||||
);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cwd: freshCwd,
|
||||
logPrefix: "serve",
|
||||
autoRegister: false,
|
||||
}));
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engine started for the current project")
|
||||
);
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("process.exit is NOT called when cwd project is resolved", async () => {
|
||||
|
||||
@@ -14,6 +14,19 @@ vi.mock("node:fs", () => ({
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify } = await import("node:util");
|
||||
const execFn = vi.fn((cmd: string, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
if (typeof callback === "function") callback(null, "", "");
|
||||
}) as any;
|
||||
execFn[promisify.custom] = (cmd: string, opts?: any) =>
|
||||
new Promise((resolve) => {
|
||||
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
|
||||
});
|
||||
return { exec: execFn };
|
||||
});
|
||||
|
||||
// Mock @fusion/core before importing the module under test
|
||||
vi.mock("@fusion/core", () => {
|
||||
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
||||
@@ -45,7 +58,7 @@ vi.mock("@fusion/core", () => {
|
||||
});
|
||||
|
||||
// Mock @fusion/engine
|
||||
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
|
||||
|
||||
// Mock @fusion/dashboard
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
@@ -83,7 +96,8 @@ vi.mock("../../project-context.js", () => ({
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore, CentralCore } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||
import { exec } from "node:child_process";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||
import {
|
||||
getCurrentRepo,
|
||||
isGhAuthenticated,
|
||||
@@ -93,7 +107,9 @@ import {
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
||||
import { resolveProject } from "../../project-context.js";
|
||||
import { aiMergeTask } from "@fusion/engine";
|
||||
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
|
||||
|
||||
const mockedExec = vi.mocked(exec);
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -2052,6 +2068,153 @@ describe("runTaskRetry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTaskBranchRecovery", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockGetTask: ReturnType<typeof vi.fn>;
|
||||
let mockUpdateTask: ReturnType<typeof vi.fn>;
|
||||
let mockLogEntry: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
});
|
||||
mockGetTask = vi.fn().mockResolvedValue(makeTask({
|
||||
id: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
worktree: "/tmp/fn-001",
|
||||
executionStartBranch: "main",
|
||||
status: "failed",
|
||||
column: "todo",
|
||||
}));
|
||||
mockUpdateTask = vi.fn().mockResolvedValue(undefined);
|
||||
mockLogEntry = vi.fn().mockResolvedValue(undefined);
|
||||
mockedExec.mockReset();
|
||||
vi.mocked(listBranchRecoveryCandidates).mockReset();
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getTask: mockGetTask,
|
||||
updateTask: mockUpdateTask,
|
||||
logEntry: mockLogEntry,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prints branch recovery candidates", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001",
|
||||
tipSha: "abc123def456",
|
||||
worktreePath: "/tmp/fn-001",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
|
||||
isCanonical: true,
|
||||
},
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Branch recovery candidates for FN-001");
|
||||
expect(output).toContain("fusion/fn-001 (canonical)");
|
||||
expect(output).toContain("abc123def456");
|
||||
expect(output).toContain("Sibling patch");
|
||||
});
|
||||
|
||||
it("reclaims the selected branch for the next run", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001",
|
||||
tipSha: "abc123def456",
|
||||
worktreePath: "/tmp/fn-001",
|
||||
strandedCommits: [],
|
||||
isCanonical: true,
|
||||
},
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001", { reclaim: "fusion/fn-001-2" });
|
||||
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
branch: "fusion/fn-001-2",
|
||||
worktree: "/tmp/fn-001-2",
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
expect(mockLogEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Branch recovery: reclaimed fusion/fn-001-2",
|
||||
"bbb222ccc333 @ /tmp/fn-001-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses discard without explicit confirmation", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Refusing to discard branch recovery state without --yes");
|
||||
expect(mockedExec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards the selected branch and worktree when confirmed", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2", yes: true });
|
||||
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
"git worktree remove '/tmp/fn-001-2' --force",
|
||||
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
"git branch -D 'fusion/fn-001-2'",
|
||||
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
||||
expect(mockLogEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Branch recovery: discarded fusion/fn-001-2",
|
||||
"bbb222ccc333 @ /tmp/fn-001-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Logs Tests ---
|
||||
|
||||
describe("runTaskLogs", () => {
|
||||
|
||||
@@ -64,6 +64,7 @@ import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, g
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
||||
import { syncStartupModels } from "./startup-model-sync.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
let daemonStartTime = 0;
|
||||
@@ -167,6 +168,8 @@ export interface DaemonOptions {
|
||||
interactive?: boolean;
|
||||
/** Just print/generate token without starting server */
|
||||
tokenOnly?: boolean;
|
||||
/** Disable cwd auto-registration and preserve legacy strict behavior */
|
||||
noAutoRegister?: boolean;
|
||||
}
|
||||
|
||||
export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
@@ -246,10 +249,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
try {
|
||||
sharedCentralCore = new CentralCore();
|
||||
await sharedCentralCore.init();
|
||||
const registered = await sharedCentralCore.getProjectByPath(cwd);
|
||||
if (registered) {
|
||||
ntfyProjectId = registered.id;
|
||||
}
|
||||
} catch {
|
||||
// Central DB unavailable or project not registered — backward compatible
|
||||
}
|
||||
@@ -307,6 +306,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedCentralCore) {
|
||||
const registered = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central: sharedCentralCore,
|
||||
logPrefix: "daemon",
|
||||
autoRegister: !opts.noAutoRegister,
|
||||
});
|
||||
ntfyProjectId = registered?.id;
|
||||
}
|
||||
|
||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
|
||||
90
packages/cli/src/commands/ensure-project-registered.ts
Normal file
90
packages/cli/src/commands/ensure-project-registered.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { CentralCore, RegisteredProject } from "@fusion/core";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface EnsureCwdProjectRegisteredOptions {
|
||||
cwd: string;
|
||||
central: CentralCore;
|
||||
logPrefix: string;
|
||||
autoRegister: boolean;
|
||||
}
|
||||
|
||||
export async function ensureCwdProjectRegistered(
|
||||
options: EnsureCwdProjectRegisteredOptions,
|
||||
): Promise<RegisteredProject | null> {
|
||||
const { cwd, central, logPrefix, autoRegister } = options;
|
||||
|
||||
const existing = await central.getProjectByPath(cwd);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!autoRegister) {
|
||||
logManualRegistrationHint(logPrefix, cwd);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fusionDir = join(cwd, ".fusion");
|
||||
const dbPath = join(fusionDir, "fusion.db");
|
||||
|
||||
if (!existsSync(fusionDir)) {
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
writeFileSync(dbPath, "");
|
||||
}
|
||||
|
||||
const projectName = await detectProjectName(cwd);
|
||||
const project = await central.registerProject({
|
||||
name: projectName,
|
||||
path: cwd,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
await central.updateProject(project.id, { status: "active" });
|
||||
console.log(`[${logPrefix}] Auto-registered project "${project.name}" at ${cwd}`);
|
||||
|
||||
return project;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${logPrefix}] Failed to auto-register current project: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
logManualRegistrationHint(logPrefix, cwd);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectProjectName(dir: string): Promise<string> {
|
||||
if (!existsSync(join(dir, ".git"))) {
|
||||
return basename(dir) || "my-project";
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: remoteUrl } = await execAsync("git remote get-url origin", {
|
||||
cwd: dir,
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (trimmed) {
|
||||
const match = trimmed.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
return match[2];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return basename(dir) || "my-project";
|
||||
}
|
||||
|
||||
function logManualRegistrationHint(logPrefix: string, cwd: string): void {
|
||||
console.error(`[${logPrefix}] Run 'fn init' to register this project, or 'fn project add <name> <path>' (${cwd})`);
|
||||
}
|
||||
@@ -93,22 +93,47 @@ export async function runMissionCreate(titleArg?: string, descriptionArg?: strin
|
||||
console.log();
|
||||
}
|
||||
|
||||
interface RunMissionListOptions {
|
||||
includeDrafts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all missions with status summary.
|
||||
*/
|
||||
export async function runMissionList(projectName?: string) {
|
||||
export async function runMissionList(projectName?: string, options: RunMissionListOptions = {}) {
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
const includeDrafts = options.includeDrafts ?? true;
|
||||
|
||||
const missions = missionStore.listMissions();
|
||||
const drafts = includeDrafts
|
||||
? (store.getDatabase()
|
||||
.prepare(
|
||||
`SELECT id, title, status, updatedAt
|
||||
FROM ai_sessions
|
||||
WHERE type = 'mission_interview'
|
||||
AND status IN ('generating', 'awaiting_input', 'error')
|
||||
AND COALESCE(archived, 0) = 0
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
|
||||
: [];
|
||||
|
||||
if (missions.length === 0) {
|
||||
if (missions.length === 0 && drafts.length === 0) {
|
||||
console.log("\n No missions yet. Create one with: fn mission create\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
if (drafts.length > 0) {
|
||||
console.log(` ◌ Drafts (${drafts.length})`);
|
||||
for (const draft of drafts) {
|
||||
console.log(` ◌ ${draft.id} ${draft.title} — (draft · interview ${draft.status})`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Group by status
|
||||
const byStatus: Record<string, typeof missions> = {};
|
||||
for (const mission of missions) {
|
||||
|
||||
@@ -43,7 +43,6 @@ async function getStore(projectName?: string): Promise<TaskStore> {
|
||||
|
||||
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
|
||||
if (!providerId || providerId === "builtin") return true;
|
||||
if (providerId === "none") return false;
|
||||
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
|
||||
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
|
||||
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx);
|
||||
@@ -59,7 +58,7 @@ async function getResearchRuntime(store: TaskStore) {
|
||||
}
|
||||
|
||||
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
|
||||
if (configuredProvider !== "builtin" && configuredProvider !== "none" && !hasProviderCredentials(settings, configuredProvider)) {
|
||||
if (configuredProvider !== "builtin" && !hasProviderCredentials(settings, configuredProvider)) {
|
||||
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
|
||||
}
|
||||
|
||||
@@ -128,7 +127,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
|
||||
if (options.json) {
|
||||
jsonOut(run);
|
||||
} else {
|
||||
console.log(`Created research run ${runId}.`);
|
||||
console.log(`Created cited-research run ${runId}.`);
|
||||
if (run) printRun(run);
|
||||
}
|
||||
return;
|
||||
@@ -180,7 +179,7 @@ export async function runResearchList(options: ResearchListOptions = {}): Promis
|
||||
}
|
||||
|
||||
if (!runs.length) {
|
||||
console.log("No research runs found.");
|
||||
console.log("No cited-research runs found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,7 +195,7 @@ export async function runResearchShow(runId: string, options: ResearchCommandOpt
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut(run);
|
||||
@@ -219,7 +218,7 @@ export async function runResearchExport(options: ResearchExportOptions): Promise
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(options.runId);
|
||||
if (!run) throw new Error(`Research run not found: ${options.runId}`);
|
||||
if (!run) throw new Error(`Cited-research run not found: ${options.runId}`);
|
||||
|
||||
const format = (options.format ?? "markdown") as ResearchExportFormat;
|
||||
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
|
||||
@@ -250,7 +249,7 @@ export async function runResearchCancel(runId: string, options: ResearchCommandO
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
|
||||
|
||||
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
|
||||
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
|
||||
@@ -275,7 +274,7 @@ export async function runResearchRetry(runId: string, options: ResearchCommandOp
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const existing = store.getResearchStore().getRun(runId);
|
||||
if (!existing) throw new Error(`Research run not found: ${runId}`);
|
||||
if (!existing) throw new Error(`Cited-research run not found: ${runId}`);
|
||||
|
||||
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
|
||||
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);
|
||||
|
||||
@@ -65,6 +65,7 @@ import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||
import { syncStartupModels } from "./startup-model-sync.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -223,7 +224,7 @@ function ensureProcessDiagnostics(): void {
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean } = {},
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
|
||||
) {
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
@@ -265,10 +266,6 @@ export async function runServe(
|
||||
try {
|
||||
sharedCentralCore = new CentralCore();
|
||||
await sharedCentralCore.init();
|
||||
const registered = await sharedCentralCore.getProjectByPath(cwd);
|
||||
if (registered) {
|
||||
ntfyProjectId = registered.id;
|
||||
}
|
||||
} catch {
|
||||
// Central DB unavailable or project not registered — backward compatible
|
||||
}
|
||||
@@ -337,6 +334,16 @@ export async function runServe(
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedCentralCore) {
|
||||
const registered = await ensureCwdProjectRegistered({
|
||||
cwd,
|
||||
central: sharedCentralCore,
|
||||
logPrefix: "serve",
|
||||
autoRegister: !opts.noAutoRegister,
|
||||
});
|
||||
ntfyProjectId = registered?.id;
|
||||
}
|
||||
|
||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
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, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { resolveProject, type ProjectContext } from "../project-context.js";
|
||||
import { findNodeByNameOrId } from "./node.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
|
||||
@@ -161,6 +164,70 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
||||
return (await getCommandContext(projectName)).projectPath;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function getCanonicalTaskBranch(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function formatRecoveryCandidate(candidate: BranchRecoveryCandidate): string[] {
|
||||
const lines = [
|
||||
` • ${candidate.branchName}${candidate.isCanonical ? " (canonical)" : ""}`,
|
||||
` tip: ${candidate.tipSha}`,
|
||||
` worktree: ${candidate.worktreePath ?? "(not attached to a worktree)"}`,
|
||||
];
|
||||
if (candidate.strandedCommits.length === 0) {
|
||||
lines.push(" stranded commits: none");
|
||||
} else {
|
||||
lines.push(" stranded commits:");
|
||||
for (const commit of candidate.strandedCommits) {
|
||||
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function runGit(projectPath: string, command: string): Promise<string> {
|
||||
const { stdout } = await execAsync(command, { cwd: projectPath, encoding: "utf-8" });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function resolveBranchRecoveryCandidates(id: string, projectName?: string): Promise<{
|
||||
store: TaskStore;
|
||||
projectPath: string;
|
||||
task: Awaited<ReturnType<TaskStore["getTask"]>>;
|
||||
canonicalBranch: string;
|
||||
candidates: BranchRecoveryCandidate[];
|
||||
}> {
|
||||
const context = await getCommandContext(projectName);
|
||||
const task = await context.store.getTask(id);
|
||||
const canonicalBranch = getCanonicalTaskBranch(task.id);
|
||||
const candidates = await listBranchRecoveryCandidates({
|
||||
repoDir: context.projectPath,
|
||||
branchName: canonicalBranch,
|
||||
startPoint: task.executionStartBranch ?? undefined,
|
||||
});
|
||||
return {
|
||||
store: context.store,
|
||||
projectPath: context.projectPath,
|
||||
task,
|
||||
canonicalBranch,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRecoveryCandidateOrExit(id: string, branch: string, projectName?: string) {
|
||||
const resolved = await resolveBranchRecoveryCandidates(id, projectName);
|
||||
const candidate = resolved.candidates.find((entry) => entry.branchName === branch);
|
||||
if (!candidate) {
|
||||
console.error(`Error: Branch recovery candidate not found for ${id}: ${branch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { ...resolved, candidate };
|
||||
}
|
||||
|
||||
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
@@ -830,6 +897,95 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskBranchRecovery(
|
||||
id: string,
|
||||
options: { reclaim?: string; discard?: string; yes?: boolean } = {},
|
||||
projectName?: string,
|
||||
) {
|
||||
if (options.reclaim && options.discard) {
|
||||
console.error("Error: --reclaim and --discard are mutually exclusive");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.reclaim) {
|
||||
const { store, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.reclaim, projectName);
|
||||
await store.updateTask(task.id, {
|
||||
branch: candidate.branchName,
|
||||
worktree: candidate.worktreePath,
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
`Branch recovery: reclaimed ${candidate.branchName}`,
|
||||
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Reclaimed ${candidate.branchName} for ${task.id}`);
|
||||
console.log(` Tip: ${candidate.tipSha}`);
|
||||
console.log(` Worktree: ${candidate.worktreePath ?? "(none)"}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.discard) {
|
||||
if (!options.yes) {
|
||||
console.error("Error: Refusing to discard branch recovery state without --yes");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { store, projectPath, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.discard, projectName);
|
||||
if (candidate.worktreePath) {
|
||||
await runGit(projectPath, `git worktree remove ${quoteShellArg(candidate.worktreePath)} --force`);
|
||||
}
|
||||
await runGit(projectPath, `git branch -D ${quoteShellArg(candidate.branchName)}`);
|
||||
|
||||
const patch: Record<string, unknown> = { status: null, error: null };
|
||||
if (task.branch === candidate.branchName) {
|
||||
patch.branch = null;
|
||||
}
|
||||
if (task.worktree && task.worktree === candidate.worktreePath) {
|
||||
patch.worktree = null;
|
||||
}
|
||||
await store.updateTask(task.id, patch);
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
`Branch recovery: discarded ${candidate.branchName}`,
|
||||
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Discarded ${candidate.branchName} for ${task.id}`);
|
||||
if (candidate.worktreePath) {
|
||||
console.log(` Removed worktree: ${candidate.worktreePath}`);
|
||||
}
|
||||
console.log(` Deleted branch tip: ${candidate.tipSha}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, candidates, canonicalBranch } = await resolveBranchRecoveryCandidates(id, projectName);
|
||||
|
||||
console.log();
|
||||
console.log(` Branch recovery candidates for ${task.id}`);
|
||||
console.log(` Canonical branch: ${canonicalBranch}`);
|
||||
console.log(` Current task branch: ${task.branch ?? "(none)"}`);
|
||||
console.log(` Current task worktree: ${task.worktree ?? "(none)"}`);
|
||||
if (candidates.length === 0) {
|
||||
console.log(" No matching canonical or sibling branches were found.");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const line of formatRecoveryCandidate(candidate)) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateNodeOverrideChange,
|
||||
type Task,
|
||||
type InsightCategory,
|
||||
type TaskPriority,
|
||||
type InsightStatus,
|
||||
type InsightRunStatus,
|
||||
type InsightRunTrigger,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
canAgentTakeImplementationTaskForExplicitRouting,
|
||||
formatRoleMismatchReason,
|
||||
resolveAgentProvisioningPolicy,
|
||||
TASK_PRIORITIES,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
@@ -238,9 +240,7 @@ async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean;
|
||||
const backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
|
||||
const configured = backend === "builtin"
|
||||
? true
|
||||
: backend === "none"
|
||||
? false
|
||||
: backend === "searxng"
|
||||
: backend === "searxng"
|
||||
? Boolean(settings.researchGlobalSearxngUrl)
|
||||
: backend === "brave"
|
||||
? Boolean(settings.researchGlobalBraveApiKey)
|
||||
@@ -406,6 +406,9 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
description: "Agent ID to assign this task to (e.g. 'agent-abc123')",
|
||||
}),
|
||||
),
|
||||
priority: Type.Optional(
|
||||
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
@@ -425,41 +428,55 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const task = await store.createTask({
|
||||
description: params.description.trim(),
|
||||
dependencies: params.depends,
|
||||
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
|
||||
source: { sourceType: "api" },
|
||||
});
|
||||
try {
|
||||
const task = await store.createTask({
|
||||
description: params.description.trim(),
|
||||
dependencies: params.depends,
|
||||
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
|
||||
priority: params.priority as TaskPriority | undefined,
|
||||
source: { sourceType: "api" },
|
||||
});
|
||||
|
||||
const label =
|
||||
task.description.length > 80
|
||||
? task.description.slice(0, 80) + "…"
|
||||
: task.description;
|
||||
const label =
|
||||
task.description.length > 80
|
||||
? task.description.slice(0, 80) + "…"
|
||||
: task.description;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Created ${task.id}: ${label}\n` +
|
||||
`Column: triage\n` +
|
||||
(task.dependencies.length
|
||||
? `Dependencies: ${task.dependencies.join(", ")}\n`
|
||||
: "") +
|
||||
(task.assignedAgentId
|
||||
? `Assigned to: ${task.assignedAgentId}\n`
|
||||
: "") +
|
||||
`Path: .fusion/tasks/${task.id}/`,
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Created ${task.id}: ${label}\n` +
|
||||
`Column: triage\n` +
|
||||
(task.dependencies.length
|
||||
? `Dependencies: ${task.dependencies.join(", ")}\n`
|
||||
: "") +
|
||||
(task.assignedAgentId
|
||||
? `Assigned to: ${task.assignedAgentId}\n`
|
||||
: "") +
|
||||
`Priority: ${task.priority}\n` +
|
||||
`Path: .fusion/tasks/${task.id}/`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
taskId: task.id,
|
||||
column: task.column,
|
||||
dependencies: task.dependencies,
|
||||
assignedAgentId: task.assignedAgentId,
|
||||
priority: task.priority,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
taskId: task.id,
|
||||
column: task.column,
|
||||
dependencies: task.dependencies,
|
||||
assignedAgentId: task.assignedAgentId,
|
||||
},
|
||||
};
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
|
||||
return {
|
||||
content: [{ type: "text", text: `ERROR: ${error.message}` }],
|
||||
isError: true,
|
||||
details: { error: error.message },
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -865,11 +882,15 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
// In-review retry: distinguish between execution failures and merge failures.
|
||||
if (task.column === 'in-review') {
|
||||
const hasIncompleteSteps =
|
||||
task.steps.length > 0 &&
|
||||
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
|
||||
const hasIncompleteSteps = task.steps.some(
|
||||
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
|
||||
);
|
||||
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
|
||||
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
|
||||
const isExecutionFailureInReview =
|
||||
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
|
||||
|
||||
if (hasIncompleteSteps) {
|
||||
if (isExecutionFailureInReview) {
|
||||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 });
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
|
||||
await store.moveTask(params.id, "todo", { preserveProgress: true });
|
||||
@@ -1453,7 +1474,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "fn_research_run",
|
||||
label: "fn: Run Research",
|
||||
description: "Start a bounded research run and optionally wait for findings.",
|
||||
description: "Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Research query or question" }),
|
||||
wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for the run to complete before returning (default: false)" })),
|
||||
@@ -1517,7 +1538,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "fn_research_list",
|
||||
label: "fn: List Research Runs",
|
||||
description: "List recent research runs.",
|
||||
description: "Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions).",
|
||||
parameters: Type.Object({
|
||||
status: Type.Optional(StringEnum([...RESEARCH_RUN_STATUSES], { description: "Filter by run status" }) as unknown as TSchema),
|
||||
limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })),
|
||||
@@ -1541,7 +1562,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "fn_research_get",
|
||||
label: "fn: Get Research Run",
|
||||
description: "Get one research run and structured findings.",
|
||||
description: "Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state).",
|
||||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
@@ -1583,7 +1604,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "fn_research_cancel",
|
||||
label: "fn: Cancel Research Run",
|
||||
description: "Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION.",
|
||||
description: "Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops).",
|
||||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
@@ -1645,7 +1666,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "fn_research_retry",
|
||||
label: "fn: Retry Research Run",
|
||||
description: "Retry a failed research run when lifecycle marks it retryable.",
|
||||
description: "Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry).",
|
||||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
@@ -1999,20 +2020,36 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
promptGuidelines: [
|
||||
"Use to see all missions and their current status",
|
||||
"Missions are grouped by status (active, planning, complete, etc.)",
|
||||
"Drafts represent unfinished mission interview sessions; fn_mission_show does not work on draft IDs because no mission row exists yet",
|
||||
"Use before fn_mission_show to find a specific mission ID",
|
||||
],
|
||||
parameters: Type.Object({}),
|
||||
parameters: Type.Object({
|
||||
includeDrafts: Type.Optional(Type.Boolean({ description: "Include in-flight mission interview drafts (default: true)" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
const includeDrafts = params.includeDrafts ?? true;
|
||||
|
||||
const missions = missionStore.listMissions();
|
||||
const drafts = includeDrafts
|
||||
? (store.getDatabase()
|
||||
.prepare(
|
||||
`SELECT id, title, status, updatedAt
|
||||
FROM ai_sessions
|
||||
WHERE type = 'mission_interview'
|
||||
AND status IN ('generating', 'awaiting_input', 'error')
|
||||
AND COALESCE(archived, 0) = 0
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
|
||||
: [];
|
||||
|
||||
if (missions.length === 0) {
|
||||
if (missions.length === 0 && drafts.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No missions yet." }],
|
||||
details: { count: 0 },
|
||||
details: { count: 0, drafts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2027,8 +2064,17 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Missions (${missions.length})`);
|
||||
lines.push(
|
||||
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
|
||||
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
if (drafts.length > 0) {
|
||||
lines.push(`Drafts (${drafts.length})`);
|
||||
for (const draft of drafts) {
|
||||
lines.push(` ◌ ${draft.id}: ${draft.title} (draft · interview ${draft.status})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const mission of missions) {
|
||||
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
|
||||
@@ -2038,7 +2084,11 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
|
||||
details: {
|
||||
count: missions.length,
|
||||
missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })),
|
||||
drafts: drafts.map((draft) => ({ id: draft.id, title: draft.title, status: draft.status, updatedAt: draft.updatedAt })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -2763,28 +2813,39 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.getAgent(params.agent_id);
|
||||
|
||||
// Create task assigned to the target agent
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.createTask({
|
||||
description: params.description,
|
||||
dependencies: params.dependencies,
|
||||
column: "todo",
|
||||
assignedAgentId: params.agent_id,
|
||||
source: {
|
||||
sourceType: "api",
|
||||
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Create task assigned to the target agent
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.createTask({
|
||||
description: params.description,
|
||||
dependencies: params.dependencies,
|
||||
column: "todo",
|
||||
assignedAgentId: params.agent_id,
|
||||
source: {
|
||||
sourceType: "api",
|
||||
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
|
||||
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
|
||||
}],
|
||||
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
|
||||
};
|
||||
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
|
||||
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
|
||||
}],
|
||||
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
|
||||
return {
|
||||
content: [{ type: "text", text: `ERROR: ${error.message}` }],
|
||||
isError: true,
|
||||
details: { error: error.message },
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,18 +3,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
|
||||
|
||||
const { mockExistsSync, mockReadFile, mockValidatePluginManifest } = vi.hoisted(() => ({
|
||||
const { mockExistsSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn<(path: string) => boolean>(),
|
||||
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean }>(),
|
||||
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
|
||||
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
|
||||
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
|
||||
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStatSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockFsStat,
|
||||
copyFile: mockCopyFile,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
@@ -194,6 +200,9 @@ async function getResolvedBundledPath(): Promise<string> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStatSync.mockImplementation(() => ({ isDirectory: () => false }));
|
||||
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
|
||||
mockCopyFile.mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("resolvePluginEntryPath", () => {
|
||||
@@ -216,6 +225,11 @@ describe("resolvePluginEntryPath", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||
});
|
||||
|
||||
it("returns null when no loadable entry file exists", () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
@@ -276,7 +290,7 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
|
||||
it("already installed with stale path → updates path to current bundled path", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph/bundled.js";
|
||||
|
||||
vi.clearAllMocks();
|
||||
const manifest = setupBundleExists();
|
||||
@@ -344,6 +358,51 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates an existing directory-backed install to the resolved entry file", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const staleDirectoryPath = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
|
||||
vi.clearAllMocks();
|
||||
setupBundleExists();
|
||||
mockStatSync.mockImplementation((path: string) => ({
|
||||
isDirectory: () => path === staleDirectoryPath,
|
||||
}));
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
store._inject(makePlugin({ path: staleDirectoryPath }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: bundledPath }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist"));
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
|
||||
setupBundleMissing();
|
||||
const store = makePluginStore();
|
||||
@@ -474,4 +533,65 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
|
||||
});
|
||||
|
||||
it("loads the real bundled dependency graph plugin and persists a started state", async () => {
|
||||
const { existsSync, mkdtempSync, statSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const { buildSync } = await import("esbuild");
|
||||
const { PluginLoader } = await import("../../../../core/src/plugin-loader.ts");
|
||||
const { PluginStore } = await import("../../../../core/src/plugin-store.ts");
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
|
||||
const sourceRoot = fileURLToPath(new URL("../../../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const stagedRoot = fileURLToPath(new URL("../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const pluginStateRoot = mkdtempSync(join(tmpdir(), "fn4128-bundled-plugin-"));
|
||||
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await mkdir(stagedRoot, { recursive: true });
|
||||
await cp(join(sourceRoot, "manifest.json"), join(stagedRoot, "manifest.json"));
|
||||
|
||||
buildSync({
|
||||
entryPoints: [join(sourceRoot, "src", "index.ts")],
|
||||
outfile: join(stagedRoot, "bundled.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
alias: {
|
||||
"@fusion/plugin-sdk": join(repoRoot, "packages", "plugin-sdk", "src", "index.ts"),
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((path: string) => existsSync(path));
|
||||
mockStatSync.mockImplementation((path: string) => statSync(path));
|
||||
mockReadFile.mockImplementation((path: string, encoding: string) => readFile(path, encoding as BufferEncoding));
|
||||
mockFsStat.mockImplementation((path: string) => stat(path));
|
||||
mockCopyFile.mockImplementation((src: string, dest: string) => copyFile(src, dest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
try {
|
||||
const pluginStore = new PluginStore(pluginStateRoot, { inMemoryDb: true, centralGlobalDir: pluginStateRoot });
|
||||
await pluginStore.init();
|
||||
const taskStore = {
|
||||
getRootDir: () => repoRoot,
|
||||
logActivity: vi.fn(),
|
||||
getPluginStore: () => pluginStore,
|
||||
} as any;
|
||||
const loader = new PluginLoader({ pluginStore, taskStore });
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(pluginStore, loader);
|
||||
const storedPlugin = await pluginStore.getPlugin(BUNDLED_PLUGIN_ID);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(storedPlugin.path.endsWith("/fusion-plugin-dependency-graph/bundled.js")).toBe(true);
|
||||
expect(storedPlugin.state).toBe("started");
|
||||
expect(storedPlugin.error ?? null).toBeNull();
|
||||
} finally {
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await rm(pluginStateRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -69,9 +69,12 @@ function resolveBundledPluginDir(pluginId: string): string | null {
|
||||
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
|
||||
* 2. ./dist/index.js (legacy prebuilt fallback)
|
||||
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
|
||||
* 4. fall back to the directory itself
|
||||
*
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing bundle rather than
|
||||
* persisting a directory path that Node cannot import.
|
||||
*/
|
||||
export function resolvePluginEntryPath(pluginDir: string): string {
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const candidates = [
|
||||
join(pluginDir, "bundled.js"),
|
||||
join(pluginDir, "dist", "index.js"),
|
||||
@@ -82,7 +85,15 @@ export function resolvePluginEntryPath(pluginDir: string): string {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return pluginDir;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBundledPluginInstalled(
|
||||
@@ -105,16 +116,22 @@ export async function ensureBundledPluginInstalled(
|
||||
const manifest = await loadManifest(bundledDir);
|
||||
const entryPath = resolvePluginEntryPath(bundledDir);
|
||||
|
||||
if (!entryPath) {
|
||||
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
if (existingPlugin) {
|
||||
const pathChanged = existingPlugin.path !== entryPath;
|
||||
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
|
||||
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
|
||||
const versionChanged = existingPlugin.version !== manifest.version;
|
||||
|
||||
if (!pathChanged && !versionChanged) {
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
return "already-installed";
|
||||
@@ -128,8 +145,8 @@ export async function ensureBundledPluginInstalled(
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +161,8 @@ export async function ensureBundledPluginInstalled(
|
||||
if (plugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(plugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user