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

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix blocking/synchronous filesystem calls in dashboard server route handlers. Converted `existsSync`, `statSync`, `readFileSync`, `mkdirSync`, `readdirSync`, and `rmSync` calls to async `fs/promises` equivalents in request handlers to prevent blocking the Node event loop. Added documentation comments for startup-time sync calls in terminal-service.ts and github-webhooks.ts.

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ declare module "express" {
} }
} }
import multer from "multer"; import multer from "multer";
import { createReadStream, createWriteStream, existsSync } from "node:fs"; import { createReadStream, createWriteStream } from "node:fs";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp, access, stat, mkdir, readdir, rm, readFile as fsReadFile } from "node:fs/promises";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { pipeline as streamPipeline } from "node:stream/promises"; import { pipeline as streamPipeline } from "node:stream/promises";
import { execFile } from "node:child_process"; 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 } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(); 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`; 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 }> = {}; let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try { try {
const { readFileSync } = await import("node:fs"); const authContent = await fsReadFile(authJsonPath, "utf-8");
const authContent = readFileSync(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent); allProviders = JSON.parse(authContent);
} catch { } catch {
// Auth file doesn't exist - export empty // Auth file doesn't exist - export empty
@@ -3672,10 +3671,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id); 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([]); res.json([]);
return; 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); const cached = sessionFilesCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
@@ -3687,30 +3699,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try { try {
const fileSet = new Set<string>(); const fileSet = new Set<string>();
const baseRef = await resolveDiffBase(task, task.worktree); const baseRef = await resolveDiffBase(task, worktree);
if (baseRef) { if (baseRef) {
// Committed changes since 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)) { for (const file of committedOutput.split("\n").filter(Boolean)) {
fileSet.add(file); fileSet.add(file);
} }
} }
// Staged changes (in git index) // 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)) { for (const file of stagedOutput.split("\n").filter(Boolean)) {
fileSet.add(file); fileSet.add(file);
} }
// Unstaged working tree changes // 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)) { for (const file of workingTreeOutput.split("\n").filter(Boolean)) {
fileSet.add(file); fileSet.add(file);
} }
// Untracked files (new files not yet staged) // 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)) { for (const file of untrackedOutput.split("\n").filter(Boolean)) {
fileSet.add(file); fileSet.add(file);
} }
@@ -7000,15 +7012,32 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false }); const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
res.json({
project: scopedStore.getRootDir(), // Filter to tasks with valid worktrees, checking existence asynchronously
tasks: tasks // to avoid blocking the event loop
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree)) const worktreeCheckPromises = tasks.map(async (task): Promise<{ id: string; title?: string; worktree: string } | null> => {
.map((task) => ({ if (typeof task.worktree !== "string" || task.worktree.length === 0) {
return null;
}
try {
await access(task.worktree);
return {
id: task.id, id: task.id,
title: task.title, 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) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -10678,7 +10707,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}; };
} else if (typeof source === "string" && source.trim()) { } else if (typeof source === "string" && source.trim()) {
const sourcePath = resolve(source); const sourcePath = resolve(source);
if (!existsSync(sourcePath)) { try {
await access(sourcePath);
} catch {
throw badRequest(`source does not exist: ${sourcePath}`); throw badRequest(`source does not exist: ${sourcePath}`);
} }
@@ -10688,10 +10719,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (isArchive) { if (isArchive) {
pkg = await parseCompanyArchive(sourcePath); pkg = await parseCompanyArchive(sourcePath);
} else if (nodeFs.statSync(sourcePath).isDirectory()) {
pkg = parseCompanyDirectory(sourcePath);
} else { } 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") { } else if (typeof manifest === "string") {
const { manifest: singleAgent } = parseSingleAgentManifest(manifest); const { manifest: singleAgent } = parseSingleAgentManifest(manifest);
@@ -10810,7 +10844,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Extract the archive // Extract the archive
// The archive extracts to a subdirectory named after the repo // The archive extracts to a subdirectory named after the repo
const extractDir = join(tempDir, "extracted"); const extractDir = join(tempDir, "extracted");
nodeFs.mkdirSync(extractDir, { recursive: true }); await mkdir(extractDir, { recursive: true });
// Use tar to extract (available on Linux/macOS) // Use tar to extract (available on Linux/macOS)
const execFileAsync = promisify(execFile); 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/) // 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) { if (extractedEntries.length === 0) {
throw badRequest("Archive extracted to empty directory"); throw badRequest("Archive extracted to empty directory");
} }
// The archive should have a single directory at the root // The archive should have a single directory at the root
const extractedDir = join(extractDir, extractedEntries[0]); 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"); 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 // subdirectories. If the extracted root doesn't contain COMPANY.md but has a
// subdirectory matching the requested slug, descend into it. // subdirectory matching the requested slug, descend into it.
let companyDir = extractedDir; 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); const slugDir = join(extractedDir, importCompanySlug);
if (existsSync(slugDir) && nodeFs.statSync(slugDir).isDirectory()) { try {
companyDir = slugDir; 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 // Clean up temp directory
if (tempDir) { if (tempDir) {
try { try {
nodeFs.rmSync(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
} catch { } catch {
// Best-effort cleanup // 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'"); throw badRequest("isolationMode must be 'in-process' or 'child-process'");
} }
// Check if path exists and has .fusion/ directory // Check if path exists and has .fusion/ directory (async to avoid blocking event loop)
const { existsSync } = await import("node:fs"); try {
const { join } = await import("node:path"); await access(path);
if (!existsSync(path)) { } catch {
throw badRequest("Project path does not exist"); 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 { CentralCore } = await import("@fusion/core");
const central = new CentralCore(); const central = new CentralCore();
@@ -13710,14 +13765,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/projects/detect", async (req, res) => { router.post("/projects/detect", async (req, res) => {
try { try {
const { basePath } = req.body; 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 // Default to home directory if no basePath provided
const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || "."; 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"); throw badRequest("Base path does not exist");
} }
@@ -13740,8 +13795,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dirPath = join(searchPath, entry.name); const dirPath = join(searchPath, entry.name);
const hasKbDb = existsSync(join(dirPath, ".fusion", "fusion.db")); // Check for .fusion/fusion.db or .fusion directory (async to avoid blocking event loop)
const hasFusionDir = existsSync(join(dirPath, ".fusion")); 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) { if (hasKbDb || hasFusionDir) {
detected.push({ detected.push({
@@ -14804,13 +14874,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const oauthProviders = authStorage.getOAuthProviders(); const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id)); 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`; 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 }> = {}; let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try { try {
const { readFileSync } = await import("node:fs"); const authContent = await fsReadFile(authJsonPath, "utf-8");
const authContent = readFileSync(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent); allProviders = JSON.parse(authContent);
} catch { } catch {
// Auth file doesn't exist or is unreadable - sync empty // 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 worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const resolvedWorktree = worktree || task.worktree; 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 } }); res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return; return;
} }
const cwd = resolvedWorktree; const cwd = resolvedWorktree;
// Use resolveDiffBase for consistent diff base across all endpoints // Use resolveDiffBase for consistent diff base across all endpoints
@@ -15707,10 +15787,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return; return;
} }
if (!task.worktree || !nodeFs.existsSync(task.worktree)) { // Check worktree existence asynchronously to avoid blocking event loop
if (!task.worktree) {
res.json([]); res.json([]);
return; 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); const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
@@ -15718,7 +15811,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return; return;
} }
const cwd = task.worktree; const cwd = worktree;
// Resolve a diff base using the shared strategy so both endpoints // Resolve a diff base using the shared strategy so both endpoints
// always agree on which files have changed. Prefer task-scoped // 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. * 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 { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, relative, dirname } from "node:path"; 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. * Minimal interface matching pi-coding-agent's PathMetadata.
* Duplicated here to avoid direct dependency on the pi-coding-agent package in the dashboard. * 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 // Load current settings to check enabled state
const settingsPath = options.getSettingsPath(rootDir); const settingsPath = options.getSettingsPath(rootDir);
let settings: { skills?: string[]; packages?: unknown[] } = {}; let settings: { skills?: string[]; packages?: unknown[] } = {};
if (existsSync(settingsPath)) { if (await pathExists(settingsPath)) {
try { try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as typeof settings; settings = JSON.parse(await readFile(settingsPath, "utf-8")) as typeof settings;
} catch { } catch {
@@ -272,12 +285,12 @@ export function createSkillsAdapter(options: {
// Load settings // Load settings
const settingsPath = options.getSettingsPath(rootDir); const settingsPath = options.getSettingsPath(rootDir);
const settingsDir = dirname(settingsPath); const settingsDir = dirname(settingsPath);
if (!existsSync(settingsDir)) { if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true }); await mkdir(settingsDir, { recursive: true });
} }
let settings: Record<string, unknown> = {}; let settings: Record<string, unknown> = {};
if (existsSync(settingsPath)) { if (await pathExists(settingsPath)) {
try { try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>; settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
} catch { } catch {
@@ -582,7 +595,7 @@ function normalizeEntry(entry: unknown): CatalogEntry {
export async function readProjectSettings(projectPath: string): Promise<Record<string, unknown>> { export async function readProjectSettings(projectPath: string): Promise<Record<string, unknown>> {
const fusionSettings = join(projectPath, ".fusion", "settings.json"); const fusionSettings = join(projectPath, ".fusion", "settings.json");
if (existsSync(fusionSettings)) { if (await pathExists(fusionSettings)) {
try { try {
return JSON.parse(await readFile(fusionSettings, "utf-8")) as Record<string, unknown>; return JSON.parse(await readFile(fusionSettings, "utf-8")) as Record<string, unknown>;
} catch { } catch {
@@ -600,7 +613,7 @@ export async function writeProjectSettings(projectPath: string, settings: Record
const settingsDir = join(projectPath, ".fusion"); const settingsDir = join(projectPath, ".fusion");
const settingsPath = join(settingsDir, "settings.json"); const settingsPath = join(settingsDir, "settings.json");
if (!existsSync(settingsDir)) { if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true }); 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. * Find the staged native assets directory for Bun-compiled binaries.
* Looks for runtime/<platform-arch>/ next to the binary. * 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 { function findStagedNativeDir(): string | null {
const platform = process.platform === "darwin" ? "darwin" : const platform = process.platform === "darwin" ? "darwin" :