feat(FN-1921): merge fusion/fn-1921
This commit is contained in:
5
.changeset/fix-dashboard-blocking-calls.md
Normal file
5
.changeset/fix-dashboard-blocking-calls.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" :
|
||||||
|
|||||||
Reference in New Issue
Block a user