Merge remote-tracking branch 'upstream/main'

This commit is contained in:
semih
2026-05-12 12:43:19 +00:00
301 changed files with 26777 additions and 17600 deletions

View File

@@ -187,7 +187,7 @@ describe("CLI bundle output", () => {
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("roadmap-planner");
expect(manifest.id).toBe("fusion-plugin-roadmap");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);

View File

@@ -1474,7 +1474,25 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain(ephemeralId);
});
it("fn_task_create rejects non-executor assignment for implementation tasks", async () => {
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "engineer-create", role: "engineer" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-role-check-engineer",
{ description: "create with engineer", agentId: engineer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain(`Assigned to: ${engineer.id}`);
});
it("fn_task_create rejects reviewer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer-create", role: "reviewer" });
@@ -1492,7 +1510,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("fn_task_update rejects non-executor assignment for implementation tasks", async () => {
it("fn_task_update rejects reviewer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer", role: "reviewer" });
@@ -1973,7 +1991,25 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain("ephemeral/runtime agent");
});
it("rejects non-executor delegate target without override", async () => {
it("allows durable engineer delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "delegate-engineer", role: "engineer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-eng",
{ agent_id: engineer.id, description: "Engineer routing" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(engineer.id);
});
it("rejects reviewer delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "delegate-reviewer", role: "reviewer" });

View File

@@ -5,7 +5,7 @@ import {
resolveAffectedPackages,
shouldForceFullSuite,
} from "../../../../scripts/test-changed.mjs";
import { parseShardArgs, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
import { 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,10 +78,43 @@ describe("CI shard test planner", () => {
);
});
it("selects deterministic package partitions", () => {
const packages = ["a", "b", "c", "d", "e"];
expect(selectShardPackages(packages, 1, 3)).toEqual(["a", "d"]);
expect(selectShardPackages(packages, 2, 3)).toEqual(["b", "e"]);
expect(selectShardPackages(packages, 3, 3)).toEqual(["c"]);
it("deterministically balances weighted packages across shards", () => {
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 },
];
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 totalWeight = weightedPackages.reduce((sum, pkg) => sum + pkg.testFileCount, 0);
const mean = totalWeight / 3;
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.15);
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);
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Hoist mocks so they are evaluated before module imports
const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({
mockGetDatabase: vi.fn(),
mockVacuum: vi.fn(),
mockResolveProject: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn(),
getDatabase: mockGetDatabase,
})),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: mockResolveProject,
}));
import { runDbVacuum } from "../db.ts";
describe("runDbVacuum", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
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}`);
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("resolves project store and calls vacuum", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({
beforeSize: 10_485_760,
afterSize: 7_340_416,
durationMs: 123,
}),
getPath: () => "/projects/demo/.fusion/fusion.db",
});
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockVacuum).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("VACUUM"));
});
it("exits 1 on vacuum error", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockRejectedValue(new Error("database locked")),
getPath: () => "/projects/demo/.fusion/fusion.db",
});
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("database locked"));
});
it("falls back to cwd TaskStore when resolveProject fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
mockResolveProject.mockRejectedValue(new Error("no project"));
const mockStore = { init: vi.fn(), getDatabase: mockGetDatabase };
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
getPath: () => "/fallback/project/.fusion/fusion.db",
});
await expect(runDbVacuum("missing")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("missing");
cwdSpy.mockRestore();
});
it("skips vacuum on in-memory database (returns zero sizes)", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "mem-project",
projectPath: "/mem",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
getPath: () => ":memory:",
});
await expect(runDbVacuum("mem-project")).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("in-memory"));
});
});

View File

@@ -0,0 +1,68 @@
import { TaskStore } from "@fusion/core";
import { resolveProject } from "../project-context.js";
type VacuumResult = {
beforeSize: number;
afterSize: number;
durationMs: number;
};
type VacuumDatabase = {
vacuum?: () => Promise<VacuumResult> | VacuumResult;
exec?: (sql: string) => void;
getPath?: () => string;
};
async function resolveStore(projectName?: string): Promise<TaskStore> {
try {
return (await resolveProject(projectName)).store;
} catch {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
}
export async function runDbVacuum(projectName?: string): Promise<void> {
let db: VacuumDatabase;
let result: VacuumResult;
try {
const store = await resolveStore(projectName);
db = store.getDatabase() as unknown as VacuumDatabase;
if (typeof db.vacuum === "function") {
result = await db.vacuum();
} else {
const start = Date.now();
db.exec?.("VACUUM");
result = { beforeSize: 0, afterSize: 0, durationMs: Date.now() - start };
}
} catch (error) {
console.error(`Database VACUUM failed: ${(error as Error).message}`);
process.exit(1);
return;
}
const path = db.getPath?.() ?? "<unknown>";
if (path === ":memory:") {
console.log("VACUUM skipped for in-memory database.");
} else {
console.log(
`VACUUM completed in ${result.durationMs}ms (${formatBytes(result.beforeSize)} -> ${formatBytes(result.afterSize)}): ${path}`,
);
}
process.exit(0);
}

View File

@@ -16,7 +16,7 @@ import {
RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled,
resolveResearchSettings,
canAgentTakeImplementationTask,
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
resolveAgentProvisioningPolicy,
} from "@fusion/core";
@@ -106,7 +106,7 @@ async function validateAssignableAgentId(
if (isEphemeralAgent(agent)) {
return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
}
if (task && !override && !canAgentTakeImplementationTask(agent, task)) {
if (task && !override && !canAgentTakeImplementationTaskForExplicitRouting(agent, task)) {
return formatRoleMismatchReason(agent, task);
}
return null;
@@ -414,7 +414,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
if (normalizedAgentId !== undefined && normalizedAgentId !== null) {
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "triage" };
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, candidateTask);
if (error) {
return {
@@ -2732,7 +2732,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Use fn_list_agents first to find available agents and their capabilities",
"The task is created in 'todo' and assigned to the target agent",
"Cannot delegate to ephemeral/runtime agents",
"Implementation tasks require an executor-role agent unless override=true",
"Implementation tasks use executor by default; durable engineer supports explicit routing without override, other non-executor roles require override=true",
"Optionally specify dependencies on other tasks",
],
parameters: Type.Object({

View File

@@ -36,6 +36,7 @@ const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
@@ -225,6 +226,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
it("includes CLI printing press plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
});
it("includes reports plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
});
it("fresh install: registers and loads the plugin when not in DB", async () => {
setupBundleExists();
const store = makePluginStore();
@@ -417,6 +422,33 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
);
});
it("registers reports plugin via generic bundled installer", async () => {
const manifest = makeManifest({ id: REPORTS_PLUGIN_ID, name: "Reports" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(REPORTS_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(REPORTS_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
REPORTS_PLUGIN_ID,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: REPORTS_PLUGIN_ID }) }),
);
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(REPORTS_PLUGIN_ID);
});
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
mockExistsSync.mockImplementation((p: string) => {

View File

@@ -9,6 +9,7 @@ const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
"fusion-plugin-reports",
"fusion-plugin-whatsapp-chat",
"fusion-plugin-roadmap",
"fusion-plugin-hermes-runtime",