fix(FN-1952): restore pi auth and extension loading

This commit is contained in:
gsxdsm
2026-04-16 21:42:16 -07:00
parent a99af24297
commit 8df18fab2b
30 changed files with 644 additions and 72 deletions

View File

@@ -386,6 +386,31 @@ export function fetchSettingsByScope(projectId?: string): Promise<{ global: Glob
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>(withProjectId("/settings/scopes", projectId));
}
export interface PiExtensionEntry {
id: string;
name: string;
path: string;
source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project";
enabled: boolean;
}
export interface PiExtensionSettings {
extensions: PiExtensionEntry[];
disabledIds: string[];
settingsPath: string;
}
export function fetchPiExtensions(projectId?: string): Promise<PiExtensionSettings> {
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId));
}
export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise<PiExtensionSettings> {
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId), {
method: "PUT",
body: JSON.stringify({ disabledIds }),
});
}
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
method: "POST",
@@ -5553,4 +5578,3 @@ export function getInsightCreateTaskData(
method: "POST",
});
}

View File

@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory, fetchPiExtensions, updatePiExtensions } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, PiExtensionSettings } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -59,6 +59,7 @@ type SettingsSection = {
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
{ id: "pi-extensions", label: "Pi Extensions", scope: undefined },
{ id: "appearance", label: "Appearance", scope: "global" },
{ id: "notifications", label: "Notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", scope: "global" },
@@ -152,6 +153,11 @@ export function SettingsModal({
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Pi extension state (independent of the settings save flow)
const [piExtensions, setPiExtensions] = useState<PiExtensionSettings | null>(null);
const [piExtensionsLoading, setPiExtensionsLoading] = useState(false);
const [piExtensionsSaving, setPiExtensionsSaving] = useState(false);
// Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
@@ -296,6 +302,38 @@ export function SettingsModal({
};
}, [activeSection, loadAuthStatus]);
const loadPiExtensions = useCallback(() => {
setPiExtensionsLoading(true);
fetchPiExtensions(projectId)
.then(setPiExtensions)
.catch((err) => addToast(err.message, "error"))
.finally(() => setPiExtensionsLoading(false));
}, [addToast, projectId]);
useEffect(() => {
if (activeSection === "pi-extensions") {
loadPiExtensions();
}
}, [activeSection, loadPiExtensions]);
const togglePiExtension = async (extensionId: string, enabled: boolean) => {
if (!piExtensions) return;
const nextDisabledIds = enabled
? piExtensions.disabledIds.filter((id) => id !== extensionId)
: Array.from(new Set([...piExtensions.disabledIds, extensionId]));
setPiExtensionsSaving(true);
try {
const nextSettings = await updatePiExtensions(nextDisabledIds, projectId);
setPiExtensions(nextSettings);
addToast("Pi extension settings saved");
} catch (err) {
addToast(err instanceof Error ? err.message : "Failed to save Pi extension settings", "error");
} finally {
setPiExtensionsSaving(false);
}
};
const handleLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
@@ -2641,6 +2679,52 @@ export function SettingsModal({
<PluginSlot slotId="settings-section" projectId={projectId} />
</>
);
case "pi-extensions":
return (
<>
<h4 className="settings-section-heading">Pi Extensions</h4>
<div className="form-group">
<small>Choose which project and global Pi extensions Fusion loads. Changes are saved to your Fusion agent settings and apply after restarting the dashboard or headless node.</small>
</div>
<div className="modal-actions modal-actions-left">
<button
type="button"
className="btn btn-sm"
onClick={loadPiExtensions}
disabled={piExtensionsLoading || piExtensionsSaving}
>
Refresh
</button>
</div>
{piExtensionsLoading ? (
<div className="settings-empty-state">Loading Pi extensions…</div>
) : !piExtensions || piExtensions.extensions.length === 0 ? (
<div className="settings-empty-state settings-muted">
No Pi extensions found in this project, ~/.fusion/agent, or ~/.pi/agent.
</div>
) : (
<>
{piExtensions.extensions.map((extension) => (
<div key={extension.id} className="form-group">
<label htmlFor={`pi-extension-${extension.id}`} className="checkbox-label">
<input
id={`pi-extension-${extension.id}`}
type="checkbox"
checked={extension.enabled}
disabled={piExtensionsSaving}
onChange={(e) => togglePiExtension(extension.id, e.target.checked)}
/>
{extension.name}
</label>
<small>
{extension.source.replace("-", " ")} · {extension.path}
</small>
</div>
))}
</>
)}
</>
);
case "authentication":
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
const sortedProviders = [...authProviders].sort((a, b) => {

View File

@@ -57,8 +57,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -212,6 +215,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
@@ -461,7 +465,7 @@ export async function generateAgentSpec(
}
try {
await engineReady;
await ensureEngineReady();
await promptCatalogReadyPromise;
const spec = await generateSpecWithAI(session, rootDir, promptOverrides);
session.spec = spec;

View File

@@ -33,8 +33,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Types ───────────────────────────────────────────────────────────────────
@@ -194,6 +197,7 @@ function cleanupExpiredRateLimits(): void {
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
@@ -264,7 +268,7 @@ export async function refineText(
promptOverrides?: PromptOverrideMap,
): Promise<string> {
// Ensure engine is loaded before using createKbAgent
await engineReady;
await ensureEngineReady();
if (!createKbAgent) {
throw new AiServiceError("AI engine not available");

View File

@@ -47,8 +47,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -348,7 +351,7 @@ export class ChatManager {
try {
// Ensure engine is loaded
await engineReady;
await ensureEngineReady();
if (!createKbAgent) {
throw new Error("AI agent not available");

View File

@@ -118,6 +118,7 @@ export class GitHubPollingService extends EventEmitter<GitHubPollingServiceEvent
this.timer = setInterval(() => {
void this.pollOnce();
}, this.pollingIntervalMs);
this.timer.unref?.();
void this.pollOnce();
}
@@ -409,4 +410,3 @@ function hasIssueBadgeChanged(current: IssueInfo | undefined, next: IssueInfo):
current.title !== next.title ||
current.stateReason !== next.stateReason;
}

View File

@@ -101,7 +101,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -492,6 +496,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
@@ -671,7 +676,7 @@ async function createTargetInterviewAgent(
session: TargetInterviewSession,
rootDir: string,
): Promise<AgentResult> {
await engineReady;
await ensureEngineReady();
return createKbAgent({
cwd: rootDir,

View File

@@ -41,7 +41,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -392,6 +396,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
@@ -748,7 +753,7 @@ async function createMissionInterviewAgent(
rootDir: string,
promptOverrides?: PromptOverrideMap,
): Promise<AgentResult> {
await engineReady;
await ensureEngineReady();
const effectivePrompt = resolvePrompt("mission-interview-system", promptOverrides);

View File

@@ -50,8 +50,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -360,6 +363,7 @@ function cleanupExpiredSessions(): void {
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
@@ -576,7 +580,7 @@ export async function createSession(
// Create AI agent and get the first question
// Only await engineReady if createKbAgent hasn't been set externally (e.g., via __setCreateKbAgent)
if (!createKbAgent) {
await engineReady;
await ensureEngineReady();
}
const agentResult = await createKbAgent({
@@ -802,7 +806,7 @@ async function createPlanningAgent(
promptOverrides?: PromptOverrideMap,
): Promise<AgentResult> {
// Ensure engine is loaded before using createKbAgent
await engineReady;
await ensureEngineReady();
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2838,6 +2838,43 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/settings/pi-extensions
* List Pi/Fusion extension entry points and their Fusion-owned enabled state.
*/
router.get("/settings/pi-extensions", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
res.json(discoverPiExtensions(scopedStore.getRootDir()));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PUT /api/settings/pi-extensions
* Persist Fusion-owned disabled extension ids in ~/.fusion/agent/settings.json.
*/
router.put("/settings/pi-extensions", async (req, res) => {
try {
const disabledIds = (req.body as { disabledIds?: unknown }).disabledIds;
if (!Array.isArray(disabledIds) || disabledIds.some((entry) => typeof entry !== "string")) {
throw badRequest("disabledIds must be an array of extension ids");
}
const { store: scopedStore } = await getProjectContext(req);
res.json(updatePiExtensionDisabledIds(scopedStore.getRootDir(), disabledIds));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/settings/test-ntfy
* Send a test notification to verify ntfy configuration.
@@ -5655,6 +5692,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
}, batchImportWindowMs);
batchImportCleanupInterval.unref?.();
}
return (req: Request, res: Response, next: NextFunction): void => {

View File

@@ -20,7 +20,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
export interface SubtaskItem {
id: string;
@@ -263,6 +267,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
});
@@ -396,7 +401,7 @@ async function generateSubtasks(
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
await engineReady;
await ensureEngineReady();
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("subtask-breakdown-system", promptOverrides) || SUBTASK_BREAKDOWN_PROMPT;

View File

@@ -335,6 +335,7 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
}
}
}, this.heartbeatIntervalMs);
this.heartbeatTimer.unref?.();
}
private clearHeartbeat(): void {