feat(FN-4919): merge fusion/fn-4919

This commit is contained in:
gsxdsm
2026-05-18 04:01:51 -07:00
parent b5e674059b
commit e33159e91b
9 changed files with 126 additions and 5 deletions

View File

@@ -23,7 +23,7 @@ import {
reconcileClaudeCliPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
import {
ProjectEngineManager,
PeerExchangeService,
@@ -324,6 +324,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
ntfyProjectId = registered?.id;
}
try {
registerGithubTrackingHook?.();
} catch {
// Some tests partially mock @fusion/dashboard and omit this export.
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -24,6 +24,7 @@ import {
getCliPackageVersion,
getProjectSettingsPath,
loadTlsCredentialsFromEnv,
registerGithubTrackingHook,
stopAllDevServers,
type RuntimeLogger,
} from "@fusion/dashboard";
@@ -1501,6 +1502,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Non-fatal — engine uses fallback concurrency defaults
}
try {
registerGithubTrackingHook?.();
} catch {
// Some tests partially mock @fusion/dashboard and omit this export.
}
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -23,7 +23,7 @@ import {
getEnabledPiExtensionPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
import {
ProjectEngineManager,
PeerExchangeService,
@@ -350,6 +350,12 @@ export async function runServe(
ntfyProjectId = registered?.id;
}
try {
registerGithubTrackingHook?.();
} catch {
// Some tests partially mock @fusion/dashboard and omit this export.
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -473,4 +473,27 @@ describe("registerGithubTrackingHook", () => {
}),
);
});
it("records a github-tracking-no-repo activity for agent-created tasks when defaults enable tracking", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubAuthMode: "token",
githubAuthToken: "tok",
});
const task = await store.createTask({
description: "agent-created task with missing repo",
source: { sourceType: "api" },
});
const activity = await store.getActivityLog({ type: "task:updated" });
const trackingNoRepoEntries = activity.filter((entry) =>
entry.taskId === task.id && (entry.metadata as { type?: string } | undefined)?.type === "github-tracking-no-repo",
);
expect(mockCreateIssue).not.toHaveBeenCalled();
expect(trackingNoRepoEntries).toHaveLength(1);
});
});

View File

@@ -26,8 +26,9 @@ export async function createTrackingIssueForTask(
rootDir: taskStore.getRootDir(),
logger,
});
} catch {
// Best-effort only.
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn?.(`[github-tracking-hook] ${task.id}: createTrackingIssueForTask failed: ${message}`);
}
}

View File

@@ -193,10 +193,12 @@ export async function maybeCreateTrackingIssue(
const inlineTracking = task.githubTracking;
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
if (!resolvedTracking.enabled) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (tracking_disabled)`);
return { created: false, reason: "tracking_disabled" };
}
if (inlineTracking?.issue) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
return { created: false, reason: "issue_already_linked" };
}
@@ -214,6 +216,7 @@ export async function maybeCreateTrackingIssue(
const tracking = latestTask.githubTracking ?? inlineTracking;
if (tracking?.issue) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
return { created: false, reason: "issue_already_linked" };
}

View File

@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore, setTaskCreatedHook, type Task } from "@fusion/core";
import { createTaskCreateTool, createDelegateTaskTool } from "../agent-tools.js";
function makeTmpDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
describe("agent task creation github-tracking hook integration", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
setTaskCreatedHook(undefined);
rootDir = makeTmpDir("kb-engine-agent-tools-gh-track-");
globalDir = makeTmpDir("kb-engine-agent-tools-gh-track-global-");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
setTaskCreatedHook(undefined);
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("calls the post-create hook for fn_task_create", async () => {
const hook = vi.fn(async (_task: Task) => {});
setTaskCreatedHook(hook);
const tool = createTaskCreateTool(store);
const result = await tool.execute("call-1", { description: "agent-created triage task" } as never, undefined, undefined, {} as never);
expect(result.details).toHaveProperty("taskId");
expect(hook).toHaveBeenCalledTimes(1);
expect(hook.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
description: "agent-created triage task",
column: "triage",
}));
});
it("calls the post-create hook for fn_delegate_task", async () => {
const hook = vi.fn(async (_task: Task) => {});
setTaskCreatedHook(hook);
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
};
const tool = createDelegateTaskTool(agentStore as never, store);
const result = await tool.execute("call-1", {
agent_id: "agent-1",
description: "delegated tracked task",
} as never, undefined, undefined, {} as never);
expect(result.details).toEqual(expect.objectContaining({ taskId: expect.any(String), agentId: "agent-1" }));
expect(hook).toHaveBeenCalledTimes(1);
expect(hook.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
description: "delegated tracked task",
assignedAgentId: "agent-1",
column: "todo",
}));
});
});