fix(FN-1952): restore pi auth and extension loading
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -335,6 +335,7 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
|
||||
}
|
||||
}
|
||||
}, this.heartbeatIntervalMs);
|
||||
this.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
|
||||
Reference in New Issue
Block a user