/** * Project Resolution Module * * Handles determination of which project to use for CLI commands based on: * - Explicit `--project ` flag * - Current working directory auto-detection (walking up to find `.fusion/`) * - Default project when only one is registered * - Interactive prompts when ambiguous */ import { existsSync, statSync } from "node:fs"; import { basename, dirname, join, normalize, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { CentralCore, createTaskStoreForBackend, hasProjectIdentity, isValidSqliteDatabaseFile, readProjectIdentity, writeProjectIdentity, detectWorkspaceRepos, saveWorkspaceConfig, suggestTaskPrefix, type TaskStore, type RegisteredProject, } from "@fusion/core"; import { ProjectManager } from "@fusion/engine"; // Singleton instances for reuse across commands let centralCoreInstance: CentralCore | null = null; let projectManagerInstance: ProjectManager | null = null; const resolvedProjectStores = new Map Promise>(); /** * Error thrown when project resolution fails with actionable context. */ export class ProjectResolutionError extends Error { constructor( message: string, public readonly code: | "NOT_FOUND" | "NOT_REGISTERED" | "MULTIPLE_MATCHES" | "NO_PROJECTS" | "PATH_MISMATCH" | "NOT_INITIALIZED" | "CANCELLED", public readonly context?: Record ) { super(message); this.name = "ProjectResolutionError"; } } /** * Resolved project with all necessary references for command execution. */ export interface ResolvedProject { /** Project ID from CentralCore */ projectId: string; /** Project display name */ name: string; /** Absolute path to project directory */ directory: string; /** Project status */ status: string; /** Isolation mode */ isolationMode: string; /** Reference to ProjectRuntime (if started) */ runtime?: import("@fusion/engine").ProjectRuntime; /** Initialized TaskStore for the project */ store: TaskStore; } /** * Options for project resolution. */ export interface ResolveOptions { /** Explicit project name from --project flag */ project?: string; /** Starting directory for cwd-based resolution (defaults to process.cwd()) */ cwd?: string; /** Allow interactive prompts (set to false for non-interactive environments) */ interactive?: boolean; } /** * Initialize and return CentralCore singleton. * Reuses the same instance across multiple calls for efficiency. */ export async function getCentralCore(): Promise { if (!centralCoreInstance) { centralCoreInstance = new CentralCore(); await centralCoreInstance.init(); } return centralCoreInstance; } /** * Initialize and return ProjectManager singleton. * Creates the instance on first call, reuses thereafter. */ export async function getProjectManager(): Promise { if (!projectManagerInstance) { const central = await getCentralCore(); projectManagerInstance = new ProjectManager(central); } return projectManagerInstance; } /** * Walk up from the given path to find a `.fusion/` directory. * * @param startPath - Directory to start searching from * @returns Absolute path to the directory containing `.fusion/`, or null if not found */ export function findKbDir(startPath: string): string | null { let current = resolve(startPath); // Safety limit to prevent infinite loops for (let i = 0; i < 100; i++) { const fusionDir = resolve(current, ".fusion"); // FNXC:ProjectIdentityMarker 2026-07-14-17:20: Project discovery uses the // PG-neutral marker, with SQLite recognition retained only for migration. if (hasProjectIdentity(fusionDir) || isValidSqliteDatabaseFile(resolve(fusionDir, "fusion.db"))) { return current; } const parent = dirname(current); if (parent === current) { // Reached root break; } current = parent; } return null; } /** * Prompt the user to select from a list of projects. */ async function promptProjectSelection( projects: RegisteredProject[], message = "Select a project:" ): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); console.log(`\n ${message}`); for (let i = 0; i < projects.length; i++) { console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`); } while (true) { const answer = await rl.question("\n Enter number: "); const num = parseInt(answer.trim(), 10); if (!isNaN(num) && num >= 1 && num <= projects.length) { rl.close(); return projects[num - 1]; } console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`); } } /** * Prompt for yes/no confirmation. */ async function promptConfirm(message: string, defaultYes = false): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); const prompt = defaultYes ? "[Y/n]" : "[y/N]"; const answer = await rl.question(` ${message} ${prompt}: `); rl.close(); const trimmed = answer.trim().toLowerCase(); if (trimmed === "" && defaultYes) return true; return trimmed === "y" || trimmed === "yes"; } /** * Resolve which project to use based on options and cwd. * * Resolution order: * 1. If --project flag given, look up by name in registry * 2. Walk up from cwd to find .fusion/ directory * 3. If found, match path against registered projects * 4. If not registered but has .fusion/, prompt to register or error * 5. If no .fusion/ found and exactly one project registered, use it * 6. If multiple projects and no match, error with list * * @param options - Resolution options * @returns Resolved project with store and runtime references * @throws ProjectResolutionError with specific error codes */ export async function resolveProject(options: ResolveOptions = {}): Promise { const central = await getCentralCore(); const interactive = options.interactive ?? true; // 1. Check explicit --project flag if (options.project) { const projects = await central.listProjects(); const match = projects.find((p) => p.name === options.project); if (!match) { // Suggest similar names if available const similar = projects .filter((p) => p.name.toLowerCase().includes(options.project!.toLowerCase())) .map((p) => p.name); let suggestion = ""; if (similar.length > 0) { suggestion = ` Did you mean: ${similar.join(", ")}?`; } throw new ProjectResolutionError( `Project "${options.project}" not found.${suggestion}`, "NOT_FOUND", { searchedName: options.project, availableProjects: projects.map((p) => p.name) } ); } // Check if path still exists if (!existsSync(match.path)) { throw new ProjectResolutionError( `Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` + "Run `fn project remove " + match.name + "` to clean up the registry entry.", "PATH_MISMATCH", { projectId: match.id, path: match.path } ); } return createResolvedProject(match); } // 2. Walk up from cwd to find .fusion/ const cwd = options.cwd ? resolve(options.cwd) : process.cwd(); const fusionDir = findKbDir(cwd); if (fusionDir) { // 3. Match path against registered projects const allProjects = await central.listProjects(); const normalizedKbDir = normalize(fusionDir); const match = allProjects.find((p) => normalize(p.path) === normalizedKbDir); if (match) { // Check if path still exists if (!existsSync(match.path)) { throw new ProjectResolutionError( `Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` + "Run `fn project remove " + match.name + "` to clean up the registry entry.", "PATH_MISMATCH", { projectId: match.id, path: match.path } ); } return createResolvedProject(match); } // 4. Has .fusion/ but not registered if (interactive) { console.log(`\n Found fn project at ${fusionDir} but it's not registered.`); /* * FNXC:CLIProjectIdentity 2026-06-27-00:00: * Project identity files live inside the project `.fusion` directory; cwd-based orphan restore must not read or stamp the project root. */ const projectFusionDir = join(fusionDir, ".fusion"); const identity = readProjectIdentity(projectFusionDir); if (identity) { const recover = await promptConfirm( `Found orphaned project data for this path under id ${identity.id} (createdAt=${identity.createdAt}). Restore it?`, true, ); if (recover) { try { const ensured = await central.ensureProjectForPath({ path: fusionDir, identity, name: basename(fusionDir) || "unnamed", }); const recoveredProject = ensured.project; await central.updateProject(recoveredProject.id, { status: "active" }); try { writeProjectIdentity(projectFusionDir, { id: recoveredProject.id, createdAt: recoveredProject.createdAt, }); } catch { // Best-effort stamp only. } console.log(`\n ✓ Restored project "${recoveredProject.name}" (${recoveredProject.id})`); return createResolvedProject(recoveredProject); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); throw new ProjectResolutionError( `Failed to restore project identity: ${errMsg}`, "NOT_REGISTERED", { directory: fusionDir, error: errMsg }, ); } } } const shouldRegister = await promptConfirm("Register this project now?", true); if (shouldRegister) { const rl = createInterface({ input: process.stdin, output: process.stdout }); const defaultName = basename(fusionDir) || "unnamed"; const name = await rl.question(` Project name [${defaultName}]: `); rl.close(); const finalName = name.trim() || defaultName; try { const ensured = await central.ensureProjectForPath({ path: fusionDir, identity: undefined, name: finalName, }); const newProject = ensured.project; // Activate the project (registration sets it to 'initializing') await central.updateProject(newProject.id, { status: "active" }); try { writeProjectIdentity(projectFusionDir, { id: newProject.id, createdAt: newProject.createdAt, }); } catch (error) { if (identity && error instanceof Error && error.name === "ProjectIdentityConflictError") { const overwrite = await promptConfirm( "Stored identity differs from newly registered id. Overwrite stored identity with new id?", false, ); if (!overwrite) { throw new ProjectResolutionError( "Registration cancelled to preserve existing stored identity.", "CANCELLED", { directory: fusionDir }, ); } writeProjectIdentity(projectFusionDir, { id: newProject.id, createdAt: newProject.createdAt, }); } } console.log(`\n ✓ Registered project "${newProject.name}"`); return createResolvedProject(newProject); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); throw new ProjectResolutionError( `Failed to register project: ${errMsg}`, "NOT_REGISTERED", { directory: fusionDir, error: errMsg } ); } } else { throw new ProjectResolutionError( "Project not registered. Run `fn project add ` to register.", "NOT_REGISTERED", { directory: fusionDir } ); } } else { throw new ProjectResolutionError( `Found fn project at ${fusionDir} but it's not registered.\n\n` + "Run `fn project add " + fusionDir + "` to register it, or use --project .", "NOT_REGISTERED", { directory: fusionDir } ); } } // 5. No .fusion/ found - check registered projects const allProjects = await central.listProjects(); if (allProjects.length === 0) { // 6a. No projects at all throw new ProjectResolutionError( "No projects registered.\n\n" + "To get started:\n" + " 1. Navigate to your project directory\n" + " 2. Run `fn init` to initialize fn\n" + " 3. Run `fn project add .` to register it\n" + "\nOr: `fn project add ` to register from anywhere.", "NO_PROJECTS" ); } if (allProjects.length === 1) { // 6b. Exactly one project - use as default const project = allProjects[0]; // Check if path still exists if (!existsSync(project.path)) { throw new ProjectResolutionError( `The only registered project "${project.name}" has a missing directory: ${project.path}\n\n` + "Run `fn project remove " + project.name + "` to clean up, then register a valid project.", "PATH_MISMATCH", { projectId: project.id, path: project.path } ); } return createResolvedProject(project); } // 6c. Multiple projects - need explicit selection if (interactive) { const selected = await promptProjectSelection( allProjects, "Multiple projects registered. Please select one:" ); return createResolvedProject(selected); } else { const projectList = allProjects.map((p) => ` - ${p.name}: ${p.path}`).join("\n"); throw new ProjectResolutionError( `Multiple projects registered. Use --project to specify one.\n\nAvailable projects:\n${projectList}`, "MULTIPLE_MATCHES", { availableProjects: allProjects.map((p) => ({ name: p.name, path: p.path })) } ); } } /** * FNXC:PostgresCutover 2026-07-04: boot a project TaskStore through the * PostgreSQL startup factory (embedded by default, external via DATABASE_URL) * instead of a legacy SQLite TaskStore whose runtime was removed under * VAL-REMOVAL-005. Shared by createResolvedProject and the onboarding init paths so * every `fn project`/`fn init` store construction stays backend-first. */ async function createProjectStore(rootDir: string): Promise { const boot = await createTaskStoreForBackend({ rootDir }); resolvedProjectStores.set(boot.taskStore, boot.shutdown); return boot.taskStore; } /** * Create a ResolvedProject from a RegisteredProject. * Initializes the TaskStore for the project. */ async function createResolvedProject(project: RegisteredProject): Promise { // Initialize TaskStore for this project // FNXC:PostgresCutover 2026-07-04: boot PostgreSQL via createTaskStoreForBackend // instead of a legacy SQLite TaskStore whose runtime was removed (VAL-REMOVAL-005). const store = await createProjectStore(project.path); // Try to get runtime from ProjectManager if available let runtime: import("@fusion/engine").ProjectRuntime | undefined; try { const pm = await getProjectManager(); runtime = pm.getRuntime(project.id); } catch { // ProjectManager not initialized or runtime not started - that's ok runtime = undefined; } return { projectId: project.id, name: project.name, directory: project.path, status: project.status, isolationMode: project.isolationMode, runtime, store, }; } /** * Clean up singleton instances. * Call this on CLI exit to close database connections. */ export async function cleanupProjectResolution(): Promise { /* FNXC:PostgresProjectResolverLifecycle 2026-07-14-18:08: ResolvedProject exposes only its TaskStore, so module cleanup must retain and release every startup-factory ownership handle on signals and normal explicit cleanup. */ for (const [store, shutdown] of resolvedProjectStores) { resolvedProjectStores.delete(store); await shutdown().catch(() => undefined); } if (projectManagerInstance) { // ProjectManager doesn't have a close method, but we should stop all runtimes try { await projectManagerInstance.stopAll(); } catch { // Ignore errors during cleanup } projectManagerInstance = null; } if (centralCoreInstance) { await centralCoreInstance.close(); centralCoreInstance = null; } } /** * Get the resolved project without needing to use it immediately. * Useful for commands that just need to verify the project exists. */ export async function getResolvedProject(options: ResolveOptions = {}): Promise { return resolveProject(options); } /** * Format project resolution error for CLI display. */ export function formatResolutionError(error: ProjectResolutionError): string { let output = `\n ✗ ${error.message}\n`; if (error.code === "NO_PROJECTS") { // Message already includes detailed instructions } else if (error.code === "MULTIPLE_MATCHES" && error.context?.availableProjects) { // List already included in message } else if (error.code === "NOT_FOUND" && error.context?.availableProjects) { output += `\n Available projects:\n`; for (const name of error.context.availableProjects as string[]) { output += ` - ${name}\n`; } } return output; } /** * Check if a project is registered at the given path. * Returns the project if found, undefined otherwise. */ export async function findProjectByPath( path: string, central?: CentralCore ): Promise { const core = central ?? (await getCentralCore()); const normalizedPath = normalize(resolve(path)); const projects = await core.listProjects(); return projects.find((p) => normalize(p.path) === normalizedPath); } /** * Check if a project name is already registered. */ export async function isProjectNameTaken( name: string, central?: CentralCore ): Promise { const core = central ?? (await getCentralCore()); const projects = await core.listProjects(); return projects.some((p) => p.name.toLowerCase() === name.toLowerCase()); } /** * Validate that a path contains an initialized fn project (.fusion/ directory exists). */ export function isKbProject(path: string): boolean { const fusionDir = resolve(path, ".fusion"); return hasProjectIdentity(fusionDir) || isValidSqliteDatabaseFile(resolve(fusionDir, "fusion.db")); } /** * Get suggested project name from directory path. */ export function suggestProjectName(path: string): string { const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); return parts[parts.length - 1] || "unnamed"; } /** * Resolve absolute path and validate it exists. */ export function resolveAbsolutePath(inputPath: string): string { const resolved = resolve(inputPath); if (!existsSync(resolved)) { throw new ProjectResolutionError( `Path does not exist: ${inputPath}`, "NOT_FOUND", { path: inputPath } ); } if (!statSync(resolved).isDirectory()) { throw new ProjectResolutionError( `Path is not a directory: ${inputPath}`, "NOT_FOUND", { path: inputPath } ); } return resolved; } /** * Get a quick summary of all registered projects. * Used for CLI hints and error messages. */ export async function getProjectSummary(): Promise< Array<{ name: string; path: string; status: string }> > { const central = await getCentralCore(); const projects = await central.listProjects(); return projects.map((p) => ({ name: p.name, path: p.path, status: p.status, })); } /** * Reset the singleton instances. Used primarily for testing. */ export function resetProjectResolution(): void { centralCoreInstance = null; projectManagerInstance = null; } /** * Check if CentralCore is initialized. */ export function isCentralCoreInitialized(): boolean { return centralCoreInstance?.isInitialized() ?? false; } /** * Get all registered projects from CentralCore. */ export async function listRegisteredProjects(): Promise { const central = await getCentralCore(); return central.listProjects(); } /** * Get a single project by name. */ export async function getProjectByName(name: string): Promise { const central = await getCentralCore(); const projects = await central.listProjects(); return projects.find((p) => p.name === name); } /** * Register a new project with interactive prompts for missing info. */ export async function registerProjectInteractive( dir: string, options: { name?: string; isolation?: "in-process" | "child-process"; interactive?: boolean; } = {} ): Promise { const central = await getCentralCore(); const interactive = options.interactive ?? true; // Validate directory const absPath = resolveAbsolutePath(dir); // Check for .fusion/ directory if (!isKbProject(absPath)) { // Check if this is a non-git directory containing sub-repos (workspace mode) const { spawnSync } = await import("node:child_process"); const gitCheck = spawnSync("git", ["-C", absPath, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); const isGitRepo = gitCheck.status === 0 && gitCheck.stdout.trim() === "true"; /* FNXC:Workspace 2026-06-22-00:00: Workspace detection only reports candidate sub-repos here; persistence is deferred until after the user confirms init AND TaskStore.init() succeeds. Writing .fusion/workspace.json before confirmation would leave a partial .fusion/ dir when the user declines or runs non-interactively, polluting a plain non-git directory with stray Fusion state. */ let detectedSubRepos: string[] | null = null; if (!isGitRepo) { const subRepos = await detectWorkspaceRepos(absPath); if (subRepos.length > 0) { console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); subRepos.forEach((r: string) => console.log(` • ${r}`)); if (interactive) { /* FNXC:Workspace 2026-06-24-16:00: Ask the user to confirm workspace mode instead of auto-applying it. A directory with nested git repos might be a monorepo with submodules or an existing project that happens to have git-tracked dependencies — the user must explicitly opt in. */ const useWorkspace = await promptConfirm( `\n Use workspace mode? (tasks run per sub-repo, no git at the root)`, true, ); if (useWorkspace) { detectedSubRepos = subRepos; } else { console.log(` ⚠ Skipping workspace mode. A git repo will be initialized at the root.`); } } else { // Non-interactive: auto-apply (dashboard registration or scripted flow) detectedSubRepos = subRepos; } // workspace.json is written below, only after a confirmed store.init() succeeds. } // else: fall through to existing error path } if (interactive) { console.log(`\n No .fusion/ directory found in ${absPath}`); const shouldInit = await promptConfirm("Initialize fn here first?", true); if (shouldInit) { // Initialize the project (create .fusion/) // FNXC:PostgresCutover 2026-07-04: initialize via the PostgreSQL backend // factory (createProjectStore) instead of a removed-SQLite-runtime TaskStore. const store = await createProjectStore(absPath); try { if (detectedSubRepos) { await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard await store.updateSettings({ workspaceMode: true }); } console.log(` ✓ Initialized fn at ${absPath}`); } finally { await store.close(); } } else { throw new ProjectResolutionError( "Cannot register project without .fusion/ directory. Run `fn init` first.", "NOT_INITIALIZED", { directory: absPath } ); } } else { throw new ProjectResolutionError( `No .fusion/ directory found in ${absPath}. Run \`fn init\` first.`, "NOT_INITIALIZED", { directory: absPath } ); } } // Determine project name let name = options.name; if (!name && interactive) { const rl = createInterface({ input: process.stdin, output: process.stdout }); const suggested = suggestProjectName(absPath); const input = await rl.question(` Project name [${suggested}]: `); rl.close(); name = input.trim() || suggested; } name = name || suggestProjectName(absPath); // Check for duplicate name const isTaken = await isProjectNameTaken(name, central); if (isTaken) { throw new ProjectResolutionError( `A project named "${name}" is already registered. Choose a different name.`, "NOT_REGISTERED", { name } ); } const identity = readProjectIdentity(join(absPath, ".fusion")); const ensured = await central.ensureProjectForPath({ path: absPath, identity: identity ?? undefined, name, }); const project = ensured.project; // Activate the project (registration sets it to 'initializing') await central.updateProject(project.id, { status: "active" }); try { writeProjectIdentity(join(absPath, ".fusion"), { id: project.id, createdAt: project.createdAt, }); } catch { // Best-effort stamp only. } /* FNXC:Onboarding 2026-06-24-18:00: After registration, set a task prefix and default workflow. The prefix defaults to the first 2-4 chars of the project name so each project gets recognizable task IDs (e.g., "MYPR" for "my-project"). The workflow defaults to coding. Both are persisted to config.json via the TaskStore. */ { // FNXC:PostgresCutover 2026-07-04: persist prefix/workflow via a PostgreSQL // backend store (createProjectStore), not the removed SQLite runtime. const store = await createProjectStore(absPath); try { let prefix = suggestTaskPrefix(name); if (interactive) { const rl = createInterface({ input: process.stdin, output: process.stdout }); const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `); rl.close(); const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, ""); if (rawPrefix.length >= 1 && rawPrefix.length <= 5) prefix = rawPrefix; } await store.updateSettings({ taskPrefix: prefix, defaultWorkflowId: "builtin:coding", }); console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`); } finally { await store.close(); } } return createResolvedProject(project); } /** * Unregister a project by name. */ export async function unregisterProject( name: string, options: { force?: boolean; interactive?: boolean } = {} ): Promise { const central = await getCentralCore(); const pm = await getProjectManager(); const interactive = options.interactive ?? true; // Find the project const project = await getProjectByName(name); if (!project) { throw new ProjectResolutionError( `Project "${name}" not found.`, "NOT_FOUND", { name } ); } // Check if runtime is active const runtime = pm.getRuntime(project.id); if (runtime) { // Stop the runtime first await pm.removeProject(project.id); } // Confirmation prompt if (!options.force && interactive) { const confirmed = await promptConfirm( `Unregister "${project.name}" from the registry? The project data will be preserved.`, false ); if (!confirmed) { throw new ProjectResolutionError("Cancelled by user.", "CANCELLED"); } } // Unregister from CentralCore await central.unregisterProject(project.id); } /** * Get detailed project info including runtime metrics and task counts. * * FNXC:CLIProjectHealth 2026-06-26-23:46: * This resolver returns raw central health for metadata compatibility, so `health.inFlightAgentCount` remains persisted bookkeeping. * Callers that display live running-agent counts must not render the raw health field; derive from slim task state with the FN-7097 predicate (`in-progress` plus `triage`/`planning`/not-paused) instead. */ export async function getProjectInfo(name?: string): Promise<{ project: ResolvedProject; health: import("@fusion/core").ProjectHealth | undefined; taskCounts: Record; }> { const central = await getCentralCore(); const pm = await getProjectManager(); let project: ResolvedProject; if (name) { const registered = await getProjectByName(name); if (!registered) { throw new ProjectResolutionError(`Project "${name}" not found.`, "NOT_FOUND", { name }); } project = await createResolvedProject(registered); } else { project = await resolveProject(); } // Get health metrics const health = await central.getProjectHealth(project.projectId); // Get task counts by column const tasks = await project.store.listTasks({ slim: true }); const taskCounts: Record = {}; for (const task of tasks) { taskCounts[task.column] = (taskCounts[task.column] || 0) + 1; } // Ensure runtime is tracked in ProjectManager const runtime = pm.getRuntime(project.projectId); if (runtime) { project.runtime = runtime; } return { project, health, taskCounts }; } /** * Start a project runtime if not already running. */ export async function startProjectRuntime(projectId: string): Promise { const central = await getCentralCore(); const pm = await getProjectManager(); // Check if already running const existing = pm.getRuntime(projectId); if (existing) { return existing; } // Get project config from registry const project = await central.getProject(projectId); if (!project) { throw new ProjectResolutionError(`Project "${projectId}" not found.`, "NOT_FOUND", { projectId, }); } // Add and start the runtime const runtime = await pm.addProject({ projectId: project.id, workingDirectory: project.path, isolationMode: project.isolationMode, maxConcurrent: project.settings?.maxConcurrent ?? 2, maxWorktrees: project.settings?.maxWorktrees ?? 4, }); return runtime; } /** * Stop a project runtime if running. */ export async function stopProjectRuntime(projectId: string): Promise { const pm = await getProjectManager(); await pm.removeProject(projectId); } /** * Get the current runtime status for a project. */ export async function getProjectRuntimeStatus( projectId: string ): Promise { const pm = await getProjectManager(); const runtime = pm.getRuntime(projectId); if (!runtime) { return "not_started"; } return runtime.getStatus(); } /** * Get the last activity timestamp for a project. */ export async function getProjectLastActivity(projectId: string): Promise { const central = await getCentralCore(); const health = await central.getProjectHealth(projectId); return health?.lastActivityAt; } /** * Get all projects with their runtime status. */ export async function getProjectsWithStatus(): Promise< Array<{ project: RegisteredProject; runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; taskCount: number; }> > { const central = await getCentralCore(); const pm = await getProjectManager(); const projects = await central.listProjects(); /* * FNXC:PostgresProjectStatus 2026-07-14-23:02: * Each status read owns a short-lived PostgreSQL pool. Bound fan-out so a large registry cannot open one pool per project simultaneously, while retaining input order and per-project soft failure semantics. */ const results = new Array<{ project: RegisteredProject; runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; taskCount: number; }>(projects.length); let nextProjectIndex = 0; const workerCount = Math.min(4, projects.length); await Promise.all(Array.from({ length: workerCount }, async () => { while (nextProjectIndex < projects.length) { const projectIndex = nextProjectIndex++; const project = projects[projectIndex]; const runtime = pm.getRuntime(project.id); const runtimeStatus = runtime?.getStatus() ?? "not_started"; // Get task count from store let taskCount = 0; let shutdown: (() => Promise) | undefined; try { /* FNXC:PostgresProjectStatus 2026-07-14-18:42: * Status aggregation must use the mandatory PostgreSQL startup factory. * A bare TaskStore entered the removed SQLite fallback and also leaked * its store because this read-only helper never retained ownership. */ const boot = await createTaskStoreForBackend({ rootDir: project.path, projectId: project.id }); shutdown = boot.shutdown; const tasks = await boot.taskStore.listTasks({ slim: true }); taskCount = tasks.length; } catch { // If we can't read tasks, just report 0 } finally { await shutdown?.().catch(() => undefined); } results[projectIndex] = { project, runtimeStatus, taskCount }; } })); return results; } /** * Format a timestamp for display (relative or absolute). */ export function formatLastActivity(timestamp?: string): string { if (!timestamp) return "never"; const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Get the count of tasks by column for a project. */ export async function getProjectTaskCounts( projectId: string, store?: TaskStore ): Promise> { let shutdown: (() => Promise) | undefined; let taskStore = store; if (!taskStore) { const central = await getCentralCore(); const project = await central.getProject(projectId); if (!project) return {}; /* FNXC:PostgresProjectStatus 2026-07-14-18:42: * One-shot task counts own a PostgreSQL backend for exactly this read. * Never construct the public TaskStore without an AsyncDataLayer. */ const boot = await createTaskStoreForBackend({ rootDir: project.path, projectId }); taskStore = boot.taskStore; shutdown = boot.shutdown; } try { const tasks = await taskStore.listTasks({ slim: true }); const counts: Record = {}; for (const task of tasks) { counts[task.column] = (counts[task.column] || 0) + 1; } return counts; } finally { await shutdown?.().catch(() => undefined); } } /** * Get summary information for a project. */ export async function getProjectSummaryInfo( project: ResolvedProject ): Promise<{ taskCounts: Record; lastActivity: string | undefined; runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; }> { await getCentralCore(); const pm = await getProjectManager(); const [taskCounts, lastActivity, runtime] = await Promise.all([ getProjectTaskCounts(project.projectId, project.store), getProjectLastActivity(project.projectId), Promise.resolve(pm.getRuntime(project.projectId)), ]); return { taskCounts, lastActivity, runtimeStatus: runtime?.getStatus() ?? "not_started", }; } // Cleanup on process exit (skip in test environment) if (process.env.NODE_ENV !== "test" && process.env.VITEST === undefined) { process.on("exit", () => { // Note: cleanupProjectResolution is async, but process.exit doesn't await // This is a best-effort cleanup - the OS will clean up resources anyway void cleanupProjectResolution(); }); process.on("SIGINT", () => { void cleanupProjectResolution().then(() => process.exit(0)); }); process.on("SIGTERM", () => { void cleanupProjectResolution().then(() => process.exit(0)); }); } // Add getStore export for backward compatibility with existing code export async function getStore(options?: { project?: string; cwd?: string }): Promise { const resolved = await resolveProject({ project: options?.project, cwd: options?.cwd, interactive: true, }); return resolved.store; } /** A factory-owned project store whose backend lifecycle has one awaited release path. */ export interface ResolvedProjectStoreOwner { readonly store: TaskStore; close(): Promise; } /** * Resolve a project store together with the startup-factory ownership handle. * Short-lived commands must prefer this over getStore() so success, errors, and * requested CLI exits can await backend shutdown before returning control. */ export async function resolveProjectStore( options?: { project?: string; cwd?: string }, ): Promise { /* FNXC:PostgresProjectResolverLifecycle 2026-07-14-22:20: One-shot CLI commands need an owner-aware handle for factory-created TaskStores. Releasing the handle removes it from module cleanup and awaits the exact startup-factory shutdown once, so commands do not depend on asynchronous process-exit hooks to flush PostgreSQL resources. */ const resolved = await resolveProject({ project: options?.project, cwd: options?.cwd, interactive: true, }); let closed = false; return { store: resolved.store, async close(): Promise { if (closed) return; closed = true; const shutdown = resolvedProjectStores.get(resolved.store); resolvedProjectStores.delete(resolved.store); await shutdown?.().catch(() => undefined); }, }; } // Export getStore as default for backward compatibility export { getStore as default }; // Re-export types from @fusion/core export type { RegisteredProject } from "@fusion/core"; // Export internal helpers for tests export { promptConfirm, promptProjectSelection, createResolvedProject };