feat(FN-2262): merge fusion/fn-2262 (auto-resolved)

- feat(FN-2262): document Paperclip runtime configuration and constraints
- docs(FN-2261): update README with implementation details
- feat(FN-2261): add paperclip runtime resolution compatibility tests
- feat(FN-2261): add runtime adapter and registration tests
- feat(FN-2261): integrate adapter into plugin entrypoint
- feat(FN-2261): implement PaperclipRuntimeAdapter
- fix(FN-2261): remove pi-coding-agent re-exports from types.ts
- feat(FN-2261): define runtime types for Paperclip plugin
- feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
Fusion
2026-04-22 14:33:02 -07:00
committed by gsxdsm
parent acceca7fe5
commit b0b492ed6d
16 changed files with 1060 additions and 251 deletions

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
import type { DetectedDevServerCommand, DevServerState, DevServerSession } from "../api";
import type { DetectedDevServerCommand } from "../api";
import { useDevServer } from "../hooks/useDevServer";
import { useDevServerConfig } from "../hooks/useDevServerConfig";
import { useDevServerLogs } from "../hooks/useDevServerLogs";
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
import type { ToastType } from "../hooks/useToast";
@@ -105,80 +104,21 @@ function truncateCommand(command: string): string {
}
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const devServer = useDevServer(projectId);
const {
config,
loading: configLoading,
error: configError,
selectScript,
clearSelection,
setPreviewUrlOverride,
refresh: refreshConfig,
} = useDevServerConfig(projectId);
const legacyServerState = (devServer.serverState as DevServerState | null | undefined) ?? null;
const session = devServer.session ?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
const detectedCommands = devServer.detectedCommands ?? devServer.candidates ?? [];
const isLoading = devServer.isLoading ?? devServer.loading ?? configLoading;
const error = devServer.error ?? configError ?? null;
const startServer = useCallback(async (command: string, cwd?: string) => {
if (typeof devServer.startServer === "function") {
await devServer.startServer(command, cwd);
return;
}
if (typeof devServer.start === "function") {
await devServer.start({ command, cwd });
}
}, [devServer]);
const stopServer = useCallback(async () => {
if (typeof devServer.stopServer === "function") {
await devServer.stopServer();
return;
}
if (typeof devServer.stop === "function") {
await devServer.stop();
}
}, [devServer]);
const restartServer = useCallback(async () => {
if (typeof devServer.restartServer === "function") {
await devServer.restartServer();
return;
}
if (typeof devServer.restart === "function") {
await devServer.restart();
}
}, [devServer]);
const detectCommands = useCallback(async () => {
if (typeof devServer.detectCommands === "function") {
await devServer.detectCommands();
return;
}
if (typeof devServer.detect === "function") {
await devServer.detect();
}
}, [devServer]);
const refresh = useCallback(async () => {
if (typeof devServer.refresh === "function") {
await devServer.refresh();
return;
}
if (typeof devServer.refreshStatus === "function") {
await devServer.refreshStatus();
return;
}
await refreshConfig();
}, [devServer, refreshConfig]);
session,
sessions,
logs,
detectedCommands,
previewUrl,
isLoading,
error,
startServer,
stopServer,
restartServer,
setPreviewUrl,
detectCommands,
refresh,
} = useDevServer(projectId);
const status = session?.status ?? "stopped";
const isRunning = status === "running" || status === "starting";
@@ -193,9 +133,8 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
loadMore: loadMoreLogs,
} = useDevServerLogs(projectId, Boolean(projectId));
const manualPreviewUrl = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
const effectivePreviewUrl = manualPreviewUrl ?? devServer.previewUrl ?? session?.previewUrl ?? null;
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
const effectivePreviewUrl = previewUrl;
const selectedSource = session?.config?.cwd ?? null;
const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState("");
@@ -206,15 +145,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
const previewEmbed = usePreviewEmbed(previewEmbedUrl);
const embedStatus = previewEmbed.embedStatus ?? "unknown";
const setEmbedStatus = previewEmbed.setEmbedStatus ?? (() => undefined);
const resetEmbedStatus = previewEmbed.resetEmbedStatus ?? (() => undefined);
const iframeRef = previewEmbed.iframeRef ?? useRef<HTMLIFrameElement | null>(null);
const isEmbedded = previewEmbed.isEmbedded ?? false;
const isBlocked = previewEmbed.isBlocked ?? false;
const blockReason = previewEmbed.blockReason ?? (previewEmbed as { embedContext?: string | null }).embedContext ?? null;
const retry = previewEmbed.retry ?? (() => undefined);
const {
embedStatus,
setEmbedStatus,
resetEmbedStatus,
iframeRef,
isEmbedded,
isBlocked,
blockReason,
retry,
} = usePreviewEmbed(previewEmbedUrl);
const [showFallback, setShowFallback] = useState(false);
const prevStatusRef = useRef(embedStatus);
@@ -273,12 +213,6 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
});
}, [addToast, detectCommands]);
useEffect(() => {
if (config?.selectedScript !== undefined) {
setSelectedScript(config?.selectedScript ?? null);
}
}, [config?.selectedScript]);
useEffect(() => {
if (selectedScript) {
setShowCandidates(false);
@@ -366,40 +300,17 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}, [addToast]);
const handleSelectCandidate = useCallback((candidate: DetectedDevServerCommand) => {
void (async () => {
try {
if (typeof selectScript === "function") {
await selectScript({
name: candidate.scriptName,
command: candidate.command,
source: normalizeCwdToSource(candidate.cwd),
});
}
setSelectedScript(candidate.scriptName);
setShowCandidates(false);
setCommandInput(candidate.command);
addToast(`Selected ${candidate.scriptName} script.`, "success");
} catch (selectionError) {
addToast(normalizeError(selectionError), "error");
}
})();
}, [addToast, selectScript]);
setSelectedScript(candidate.scriptName);
setShowCandidates(false);
setCommandInput(candidate.command);
addToast(`Selected ${candidate.scriptName} script.`, "success");
}, [addToast]);
const handleClearSelection = useCallback(() => {
void (async () => {
try {
if (typeof clearSelection === "function") {
await clearSelection();
}
setSelectedScript(null);
setShowCandidates(true);
addToast("Cleared selected dev server script.", "success");
} catch (clearError) {
addToast(normalizeError(clearError), "error");
}
})();
}, [addToast, clearSelection]);
setSelectedScript(null);
setShowCandidates(true);
addToast("Cleared selected dev server script.", "success");
}, [addToast]);
const handleStart = () => {
const trimmedCommand = commandInput.trim();
@@ -409,6 +320,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}
const fallbackCwd = normalizeSourceToCwd(selectedSource) ?? ".";
const scriptName = selectedCandidate?.scriptName ?? selectedScript ?? "custom";
const cwd = selectedCandidate?.cwd ?? fallbackCwd;
void runAction(
@@ -432,18 +344,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction(
"preview",
async () => {
if (typeof setPreviewUrlOverride === "function") {
await setPreviewUrlOverride(nextUrl);
}
if (typeof devServer.setPreviewUrl === "function") {
await devServer.setPreviewUrl(nextUrl);
return;
}
if (typeof devServer.setManualUrl === "function") {
await devServer.setManualUrl(nextUrl);
}
},
() => setPreviewUrl(nextUrl),
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
);
};
@@ -454,7 +355,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
}
}, [error, refresh]);
const isManualPreviewOverride = Boolean(manualPreviewUrl);
const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
const stopDisabled = status === "stopped" || actionInFlight !== null;

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
detectDevServerCommands,
fetchDevServer,
fetchDevServerLogs,
fetchDevServers,
getDevServerLogsStreamUrl,
getDevServerSessionLogsStreamUrl,
@@ -354,24 +355,17 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseJson<DevServerSession | { status?: DevServerSession["status"]; pid?: number }>(event.data);
// If payload is a full session, use it directly
if (payload && "config" in payload) {
setSession(payload as DevServerSession);
return;
}
// Otherwise, treat as partial update
const partial = payload as { status?: DevServerSession["status"]; pid?: number } | undefined;
const nextStatus = partial?.status;
const payload = parseJson<{ status?: DevServerSession["status"]; pid?: number }>(event.data);
const nextStatus = payload?.status;
if (nextStatus) {
setSession((prev) => (prev
? {
...prev,
status: nextStatus,
runtime: partial?.pid
runtime: payload.pid
? {
...(prev.runtime ?? { startedAt: new Date().toISOString() }),
pid: partial.pid,
pid: payload.pid,
}
: prev.runtime,
}
@@ -650,15 +644,6 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
await refresh();
}, [refresh]);
// Reset helper for tests: incrementing resetVersion signals hook to re-initialize
useEffect(() => {
const version = resetVersion;
return () => {
if (resetVersion !== version) {
contextVersionRef.current += 1;
}
};
}, []);
const previewUrl = extractPreviewUrl(session);
const serverState = session ? { ...session, pid: session.runtime?.pid } : null;

View File

@@ -1,17 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
export type EmbedDetectionMethod = "auto" | "manual" | null;
interface UsePreviewEmbedOptions {
loadTimeoutMs?: number;
detectionMethod?: EmbedDetectionMethod;
}
interface UsePreviewEmbedResult {
embedStatus: EmbedStatus;
setEmbedStatus: (status: EmbedStatus) => void;
resetEmbedStatus: () => void;
retry: () => void;
isEmbedded: boolean;
isBlocked: boolean;
blockReason: string | null;
detectionMethod: EmbedDetectionMethod;
iframeRef: RefObject<HTMLIFrameElement | null>;
resetEmbedStatus: () => void;
// Extended API for direct status control (backward compatibility)
setEmbedStatus: (status: EmbedStatus) => void;
retry: () => void;
// Legacy aliases for backward compatibility
embedContext: string | null;
handleIframeLoad: () => void;
handleIframeError: () => void;
isEmbedded: boolean;
@@ -40,13 +49,14 @@ function getContextForStatus(status: EmbedStatus): string | null {
}
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const timeoutRef = useRef<number | null>(null);
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
const [embedContext, setEmbedContext] = useState<string | null>(null);
const [blockReason, setBlockReason] = useState<string | null>(null);
const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
const clearLoadingTimeout = useCallback(() => {
if (timeoutRef.current !== null) {
@@ -57,20 +67,61 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
const setEmbedStatus = useCallback((status: EmbedStatus) => {
setEmbedStatusState(status);
setEmbedContext(getContextForStatus(status));
setBlockReason(defaultContextForStatus(status));
}, []);
const resetEmbedStatus = useCallback(() => {
clearLoadingTimeout();
setEmbedStatusState("unknown");
setEmbedContext(null);
}, [clearLoadingTimeout]);
const setBlockedByTimeout = useCallback(() => {
setEmbedStatusState("blocked");
setBlockReason(TIMEOUT_CONTEXT);
}, []);
const retry = useCallback(() => {
useEffect(() => {
clearLoadingTimeout();
if (!url) {
setEmbedStatusState("unknown");
setBlockReason(null);
return;
}
setEmbedStatusState("unknown");
setEmbedContext(null);
}, [clearLoadingTimeout]);
setBlockReason(null);
let canceled = false;
queueMicrotask(() => {
if (canceled) {
return;
}
setEmbedStatusState("loading");
setBlockReason(null);
});
return () => {
canceled = true;
clearLoadingTimeout();
};
}, [clearLoadingTimeout, url]);
useEffect(() => {
if (embedStatus !== "loading") {
clearLoadingTimeout();
return;
}
const timer = setTimeout(() => {
timeoutRef.current = null;
setBlockedByTimeout();
}, loadTimeoutMs);
timeoutRef.current = timer;
return () => {
clearTimeout(timer);
if (timeoutRef.current === timer) {
timeoutRef.current = null;
}
};
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, setBlockedByTimeout]);
const handleIframeLoad = useCallback(() => {
const iframeEl = iframeRef.current;
@@ -98,6 +149,9 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
useEffect(() => {
clearLoadingTimeout();
setEmbedStatusState("unknown");
setBlockReason(null);
}, [clearLoadingTimeout]);
if (!url) {
setEmbedStatusState("unknown");
@@ -111,6 +165,9 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
useEffect(() => {
clearLoadingTimeout();
setEmbedStatusState("unknown");
setBlockReason(null);
}, [clearLoadingTimeout]);
if (!url || embedStatus !== "loading") {
return;
@@ -134,8 +191,15 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
return {
embedStatus,
setEmbedStatus,
isEmbedded,
isBlocked,
blockReason,
detectionMethod,
iframeRef,
resetEmbedStatus,
// Legacy aliases
setEmbedStatus,
embedContext: blockReason, // Alias for backward compatibility
retry,
iframeRef,
handleIframeLoad,

View File

@@ -822,6 +822,98 @@ describe("PluginRunner", () => {
});
});
describe("Paperclip runtime compatibility", () => {
/**
* Verify that the paperclip runtime registration from
* plugins/fusion-plugin-paperclip-runtime is correctly resolvable
* through the engine's runtime resolution system.
*/
it("should resolve paperclip runtime when registered", () => {
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue({}),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const result = pluginRunner.getRuntimeById("paperclip");
expect(result).toBeDefined();
expect(result?.pluginId).toBe("fusion-plugin-paperclip-runtime");
expect(result?.runtime.metadata.runtimeId).toBe("paperclip");
expect(result?.runtime.metadata.name).toBe("Paperclip Runtime");
expect(result?.runtime.metadata.description).toContain("Paperclip");
expect(result?.runtime.metadata.version).toBe("1.0.0");
});
it("should expose paperclip runtime metadata correctly", () => {
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue({}),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const runtimes = pluginRunner.getPluginRuntimes();
const paperclip = runtimes.find(r => r.runtime.metadata.runtimeId === "paperclip");
expect(paperclip).toBeDefined();
expect(paperclip?.runtime.metadata).toEqual({
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
});
});
it("should allow factory invocation for paperclip runtime", async () => {
const mockAdapter = {
id: "paperclip",
name: "Paperclip Runtime",
createSession: vi.fn(),
promptWithFallback: vi.fn(),
describeModel: vi.fn(),
dispose: vi.fn(),
};
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue(mockAdapter),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const result = pluginRunner.getRuntimeById("paperclip");
expect(result).toBeDefined();
// Invoke the factory (simulating runtime instantiation)
const context = { pluginId: "fusion-plugin-paperclip-runtime" };
const runtime = await result!.runtime.factory(context as any);
expect(paperclipRuntime.factory).toHaveBeenCalledWith(context);
expect(runtime).toBe(mockAdapter);
expect(runtime.id).toBe("paperclip");
expect(runtime.name).toBe("Paperclip Runtime");
});
});
describe("getLoader() / getStore()", () => {
it("should return the plugin loader", () => {
const loader = pluginRunner.getLoader();