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

This commit is contained in:
gsxdsm
2026-04-16 11:08:06 -07:00
parent 74b4d20c45
commit 4e602d8d90
7 changed files with 198 additions and 71 deletions

View File

@@ -1,6 +1,5 @@
import { join, resolve, relative, dirname, basename } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir, access } from "node:fs/promises";
import type { TaskStore } from "@fusion/core";
/**
@@ -74,9 +73,14 @@ export type WorkspaceId = "project" | string;
async function getTaskBasePath(store: TaskStore, taskId: string): Promise<string> {
try {
const task = await store.getTask(taskId);
// Use worktree if available and exists
if (task.worktree && existsSync(task.worktree)) {
return resolve(task.worktree);
// Use worktree if available and exists (check async to avoid blocking event loop)
if (task.worktree) {
try {
await access(task.worktree);
return resolve(task.worktree);
} catch {
// Worktree doesn't exist, fall back to task directory
}
}
// Fall back to task directory
const rootDir = store.getRootDir();

View File

@@ -78,7 +78,10 @@ export function getGitHubAppConfig(): GitHubAppConfig | null {
if (process.env.FUSION_GITHUB_APP_PRIVATE_KEY) {
privateKey = process.env.FUSION_GITHUB_APP_PRIVATE_KEY;
} else if (process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH) {
// Check cache before reading from disk
// Check cache before reading from disk.
// Intentionally synchronous: this is a one-time cold-load at server startup or
// on first webhook request. The result is cached in cachedPrivateKey, so the
// readFileSync only executes once per process lifetime.
if (cachedPrivateKey === undefined) {
try {
cachedPrivateKey = readFileSync(process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH, "utf-8");

View File

@@ -16,7 +16,7 @@
*/
import { Router, type Request, type Response } from "express";
import { existsSync, statSync } from "node:fs";
import { access, stat, readFile } from "node:fs/promises";
import { join, isAbsolute, dirname, basename } from "node:path";
import type {
PluginLoader,
@@ -97,26 +97,29 @@ export async function resolvePluginManifest(
sourcePath: string,
): Promise<{ manifestDir: string; manifest: import("@fusion/core").PluginManifest }> {
// Validate the path exists and is a directory
if (!existsSync(sourcePath)) {
try {
await access(sourcePath);
} catch {
throw notFound(`Path does not exist: ${sourcePath}`);
}
let stat;
let sourceStat;
try {
stat = statSync(sourcePath);
sourceStat = await stat(sourcePath);
} catch {
throw badRequest(`Cannot access path: ${sourcePath}`);
}
if (!stat.isDirectory()) {
if (!sourceStat.isDirectory()) {
throw badRequest(`Path is not a directory: ${sourcePath}`);
}
const { readFile } = await import("node:fs/promises");
// 1. Try manifest.json directly in the provided path
const directManifestPath = join(sourcePath, "manifest.json");
if (existsSync(directManifestPath)) {
const manifest = await readAndValidateManifest(readFile, directManifestPath);
try {
await access(directManifestPath);
const manifest = await readAndValidateManifest(directManifestPath);
return { manifestDir: sourcePath, manifest };
} catch {
// Not found at direct path
}
// 2. If the selected dir is a well-known dist folder, check the parent
@@ -124,10 +127,13 @@ export async function resolvePluginManifest(
if (DIST_DIR_NAMES.has(dirName)) {
const parentDir = dirname(sourcePath);
const parentManifestPath = join(parentDir, "manifest.json");
if (existsSync(parentManifestPath)) {
const manifest = await readAndValidateManifest(readFile, parentManifestPath);
try {
await access(parentManifestPath);
const manifest = await readAndValidateManifest(parentManifestPath);
// Return the parent (package root) as the canonical install dir
return { manifestDir: parentDir, manifest };
} catch {
// Not found at parent path
}
}
@@ -142,7 +148,6 @@ export async function resolvePluginManifest(
* Read and validate a manifest.json file.
*/
async function readAndValidateManifest(
readFile: (path: string, encoding: BufferEncoding) => Promise<string>,
manifestPath: string,
): Promise<import("@fusion/core").PluginManifest> {
let content: string;

View File

@@ -7,8 +7,8 @@ declare module "express" {
}
}
import multer from "multer";
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { mkdtemp, access, stat, mkdir, readdir, rm, readFile as fsReadFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline as streamPipeline } from "node:stream/promises";
import { execFile } from "node:child_process";
@@ -2710,13 +2710,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create();
// Read auth.json directly
// Read auth.json directly (async to avoid blocking event loop)
const authJsonPath = `${process.env.HOME || process.env.USERPROFILE}/.pi/agent/auth.json`;
let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try {
const { readFileSync } = await import("node:fs");
const authContent = readFileSync(authJsonPath, "utf-8");
const authContent = await fsReadFile(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent);
} catch {
// Auth file doesn't exist - export empty
@@ -3672,10 +3671,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task.worktree || !nodeFs.existsSync(task.worktree)) {
// Check worktree existence asynchronously to avoid blocking event loop
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree; // Capture after check
const cached = sessionFilesCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
@@ -3687,30 +3699,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const fileSet = new Set<string>();
const baseRef = await resolveDiffBase(task, task.worktree);
const baseRef = await resolveDiffBase(task, worktree);
if (baseRef) {
// Committed changes since baseRef
const committedOutput = (await runGitCommand(["diff", "--name-only", `${baseRef}..HEAD`], task.worktree, 5000)).trim();
const committedOutput = (await runGitCommand(["diff", "--name-only", `${baseRef}..HEAD`], worktree, 5000)).trim();
for (const file of committedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
}
// Staged changes (in git index)
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-only"], task.worktree, 5000)).trim();
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-only"], worktree, 5000)).trim();
for (const file of stagedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
// Unstaged working tree changes
const workingTreeOutput = (await runGitCommand(["diff", "--name-only"], task.worktree, 5000)).trim();
const workingTreeOutput = (await runGitCommand(["diff", "--name-only"], worktree, 5000)).trim();
for (const file of workingTreeOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
// Untracked files (new files not yet staged)
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], task.worktree, 5000)).trim();
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], worktree, 5000)).trim();
for (const file of untrackedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
@@ -7000,15 +7012,32 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const { store: scopedStore } = await getProjectContext(req);
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
res.json({
project: scopedStore.getRootDir(),
tasks: tasks
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree))
.map((task) => ({
// Filter to tasks with valid worktrees, checking existence asynchronously
// to avoid blocking the event loop
const worktreeCheckPromises = tasks.map(async (task): Promise<{ id: string; title?: string; worktree: string } | null> => {
if (typeof task.worktree !== "string" || task.worktree.length === 0) {
return null;
}
try {
await access(task.worktree);
return {
id: task.id,
title: task.title,
worktree: task.worktree!,
})),
worktree: task.worktree,
};
} catch {
return null;
}
});
const workspaceTasks = (await Promise.all(worktreeCheckPromises)).filter(
(t): t is { id: string; title?: string; worktree: string } => t !== null
);
res.json({
project: scopedStore.getRootDir(),
tasks: workspaceTasks,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -10678,7 +10707,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
};
} else if (typeof source === "string" && source.trim()) {
const sourcePath = resolve(source);
if (!existsSync(sourcePath)) {
try {
await access(sourcePath);
} catch {
throw badRequest(`source does not exist: ${sourcePath}`);
}
@@ -10688,10 +10719,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (isArchive) {
pkg = await parseCompanyArchive(sourcePath);
} else if (nodeFs.statSync(sourcePath).isDirectory()) {
pkg = parseCompanyDirectory(sourcePath);
} else {
throw badRequest("Source must be a server-side directory or archive path");
const sourceStat = await stat(sourcePath);
if (sourceStat.isDirectory()) {
pkg = parseCompanyDirectory(sourcePath);
} else {
throw badRequest("Source must be a server-side directory or archive path");
}
}
} else if (typeof manifest === "string") {
const { manifest: singleAgent } = parseSingleAgentManifest(manifest);
@@ -10810,7 +10844,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Extract the archive
// The archive extracts to a subdirectory named after the repo
const extractDir = join(tempDir, "extracted");
nodeFs.mkdirSync(extractDir, { recursive: true });
await mkdir(extractDir, { recursive: true });
// Use tar to extract (available on Linux/macOS)
const execFileAsync = promisify(execFile);
@@ -10826,14 +10860,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Find the extracted directory (GitHub archives extract to owner-repo-hash/)
const extractedEntries = nodeFs.readdirSync(extractDir);
const extractedEntries = await readdir(extractDir);
if (extractedEntries.length === 0) {
throw badRequest("Archive extracted to empty directory");
}
// The archive should have a single directory at the root
const extractedDir = join(extractDir, extractedEntries[0]);
if (!nodeFs.statSync(extractedDir).isDirectory()) {
const extractedDirStat = await stat(extractedDir);
if (!extractedDirStat.isDirectory()) {
throw badRequest("Archive did not extract to a directory");
}
@@ -10842,10 +10877,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// subdirectories. If the extracted root doesn't contain COMPANY.md but has a
// subdirectory matching the requested slug, descend into it.
let companyDir = extractedDir;
if (!existsSync(join(extractedDir, "COMPANY.md"))) {
const companyMdPath = join(extractedDir, "COMPANY.md");
let companyMdExists = false;
try {
await access(companyMdPath);
companyMdExists = true;
} catch {
companyMdExists = false;
}
if (!companyMdExists) {
const slugDir = join(extractedDir, importCompanySlug);
if (existsSync(slugDir) && nodeFs.statSync(slugDir).isDirectory()) {
companyDir = slugDir;
try {
const slugDirStat = await stat(slugDir);
if (slugDirStat.isDirectory()) {
companyDir = slugDir;
}
} catch {
// slugDir doesn't exist or isn't a directory
}
}
@@ -10862,7 +10910,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Clean up temp directory
if (tempDir) {
try {
nodeFs.rmSync(tempDir, { recursive: true, force: true });
await rm(tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
@@ -13665,13 +13713,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("isolationMode must be 'in-process' or 'child-process'");
}
// Check if path exists and has .fusion/ directory
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
if (!existsSync(path)) {
// Check if path exists and has .fusion/ directory (async to avoid blocking event loop)
try {
await access(path);
} catch {
throw badRequest("Project path does not exist");
}
const hasFusionDir = existsSync(join(path, ".fusion"));
let hasFusionDir = false;
const fusionDirPath = join(path, ".fusion");
try {
await access(fusionDirPath);
hasFusionDir = true;
} catch {
hasFusionDir = false;
}
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
@@ -13710,14 +13765,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/projects/detect", async (req, res) => {
try {
const { basePath } = req.body;
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const { readdir } = await import("node:fs/promises");
// Default to home directory if no basePath provided
const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || ".";
if (!existsSync(searchPath)) {
// Check search path exists (async to avoid blocking event loop)
try {
await access(searchPath);
} catch {
throw badRequest("Base path does not exist");
}
@@ -13740,8 +13795,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!entry.isDirectory()) continue;
const dirPath = join(searchPath, entry.name);
const hasKbDb = existsSync(join(dirPath, ".fusion", "fusion.db"));
const hasFusionDir = existsSync(join(dirPath, ".fusion"));
// Check for .fusion/fusion.db or .fusion directory (async to avoid blocking event loop)
let hasKbDb = false;
let hasFusionDir = false;
try {
await access(join(dirPath, ".fusion", "fusion.db"));
hasKbDb = true;
} catch {
hasKbDb = false;
}
if (!hasKbDb) {
try {
await access(join(dirPath, ".fusion"));
hasFusionDir = true;
} catch {
hasFusionDir = false;
}
}
if (hasKbDb || hasFusionDir) {
detected.push({
@@ -14804,13 +14874,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id));
// Read auth.json directly to get all providers
// Read auth.json directly to get all providers (async to avoid blocking event loop)
const authJsonPath = `${process.env.HOME || process.env.USERPROFILE}/.pi/agent/auth.json`;
let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try {
const { readFileSync } = await import("node:fs");
const authContent = readFileSync(authJsonPath, "utf-8");
const authContent = await fsReadFile(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent);
} catch {
// Auth file doesn't exist or is unreadable - sync empty
@@ -15530,11 +15599,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const resolvedWorktree = worktree || task.worktree;
if (!resolvedWorktree || !nodeFs.existsSync(resolvedWorktree)) {
// Check worktree existence asynchronously to avoid blocking event loop
if (!resolvedWorktree) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
let worktreeExists = false;
try {
await access(resolvedWorktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const cwd = resolvedWorktree;
// Use resolveDiffBase for consistent diff base across all endpoints
@@ -15707,10 +15787,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
if (!task.worktree || !nodeFs.existsSync(task.worktree)) {
// Check worktree existence asynchronously to avoid blocking event loop
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree; // Capture after check
const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
@@ -15718,7 +15811,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const cwd = task.worktree;
const cwd = worktree;
// Resolve a diff base using the shared strategy so both endpoints
// always agree on which files have changed. Prefer task-scoped

View File

@@ -5,10 +5,23 @@
* by integrating with the pi-coding-agent package manager and skills.sh API.
*/
import { existsSync } from "node:fs";
import { access } from "node:fs/promises";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
/**
* Check if a path exists asynchronously using access().
* Preferred over existsSync() to avoid blocking the Node event loop.
*/
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Minimal interface matching pi-coding-agent's PathMetadata.
* Duplicated here to avoid direct dependency on the pi-coding-agent package in the dashboard.
@@ -215,7 +228,7 @@ export function createSkillsAdapter(options: {
// Load current settings to check enabled state
const settingsPath = options.getSettingsPath(rootDir);
let settings: { skills?: string[]; packages?: unknown[] } = {};
if (existsSync(settingsPath)) {
if (await pathExists(settingsPath)) {
try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as typeof settings;
} catch {
@@ -272,12 +285,12 @@ export function createSkillsAdapter(options: {
// Load settings
const settingsPath = options.getSettingsPath(rootDir);
const settingsDir = dirname(settingsPath);
if (!existsSync(settingsDir)) {
if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true });
}
let settings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
if (await pathExists(settingsPath)) {
try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
} catch {
@@ -582,7 +595,7 @@ function normalizeEntry(entry: unknown): CatalogEntry {
export async function readProjectSettings(projectPath: string): Promise<Record<string, unknown>> {
const fusionSettings = join(projectPath, ".fusion", "settings.json");
if (existsSync(fusionSettings)) {
if (await pathExists(fusionSettings)) {
try {
return JSON.parse(await readFile(fusionSettings, "utf-8")) as Record<string, unknown>;
} catch {
@@ -600,7 +613,7 @@ export async function writeProjectSettings(projectPath: string, settings: Record
const settingsDir = join(projectPath, ".fusion");
const settingsPath = join(settingsDir, "settings.json");
if (!existsSync(settingsDir)) {
if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true });
}

View File

@@ -24,6 +24,10 @@ let ptyLoadError: Error | null = null;
/**
* Find the staged native assets directory for Bun-compiled binaries.
* Looks for runtime/<platform-arch>/ next to the binary.
*
* NOTE: The existsSync() calls in this function run during service initialization
* (when terminal is first used). This is acceptable as it only executes once per
* service lifetime, not per-request.
*/
function findStagedNativeDir(): string | null {
const platform = process.platform === "darwin" ? "darwin" :