Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
114
packages/cli/src/commands/__tests__/db.test.ts
Normal file
114
packages/cli/src/commands/__tests__/db.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
68
packages/cli/src/commands/db.ts
Normal file
68
packages/cli/src/commands/db.ts
Normal 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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user