mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
This commit is contained in:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
177
server/src/services/adapter-plugin-store.ts
Normal file
177
server/src/services/adapter-plugin-store.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* JSON-file-backed store for external adapter registrations.
|
||||
*
|
||||
* Stores metadata about externally installed adapter packages at
|
||||
* ~/.paperclip/adapter-plugins.json. This is the source of truth for which
|
||||
* external adapters should be loaded at startup.
|
||||
*
|
||||
* Both the plugin store and the settings store are cached in memory after
|
||||
* the first read. Writes invalidate the cache so the next read picks up
|
||||
* the new state without a redundant disk round-trip.
|
||||
*
|
||||
* @module server/services/adapter-plugin-store
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdapterPluginRecord {
|
||||
/** npm package name (e.g., "droid-paperclip-adapter") */
|
||||
packageName: string;
|
||||
/** Absolute local filesystem path (for locally linked adapters) */
|
||||
localPath?: string;
|
||||
/** Installed version string (for npm packages) */
|
||||
version?: string;
|
||||
/** Adapter type identifier (matches ServerAdapterModule.type) */
|
||||
type: string;
|
||||
/** ISO 8601 timestamp of when the adapter was installed */
|
||||
installedAt: string;
|
||||
/** Whether this adapter is disabled (hidden from menus but still functional) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface AdapterSettings {
|
||||
disabledTypes: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip");
|
||||
const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins");
|
||||
const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json");
|
||||
const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory caches (invalidated on write)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let storeCache: AdapterPluginRecord[] | null = null;
|
||||
let settingsCache: AdapterSettings | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDirs(): void {
|
||||
fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true });
|
||||
const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json");
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify({
|
||||
name: "paperclip-adapter-plugins",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.",
|
||||
}, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function readStore(): AdapterPluginRecord[] {
|
||||
if (storeCache) return storeCache;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [];
|
||||
} catch {
|
||||
storeCache = [];
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
function writeStore(records: AdapterPluginRecord[]): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
|
||||
storeCache = records;
|
||||
}
|
||||
|
||||
function readSettings(): AdapterSettings {
|
||||
if (settingsCache) return settingsCache;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
settingsCache = parsed && Array.isArray(parsed.disabledTypes)
|
||||
? (parsed as AdapterSettings)
|
||||
: { disabledTypes: [] };
|
||||
} catch {
|
||||
settingsCache = { disabledTypes: [] };
|
||||
}
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
function writeSettings(settings: AdapterSettings): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
||||
settingsCache = settings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listAdapterPlugins(): AdapterPluginRecord[] {
|
||||
return readStore();
|
||||
}
|
||||
|
||||
export function addAdapterPlugin(record: AdapterPluginRecord): void {
|
||||
const store = [...readStore()];
|
||||
const idx = store.findIndex((r) => r.type === record.type);
|
||||
if (idx >= 0) {
|
||||
store[idx] = record;
|
||||
} else {
|
||||
store.push(record);
|
||||
}
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function removeAdapterPlugin(type: string): boolean {
|
||||
const store = [...readStore()];
|
||||
const idx = store.findIndex((r) => r.type === type);
|
||||
if (idx < 0) return false;
|
||||
store.splice(idx, 1);
|
||||
writeStore(store);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAdapterPluginByType(type: string): AdapterPluginRecord | undefined {
|
||||
return readStore().find((r) => r.type === type);
|
||||
}
|
||||
|
||||
export function getAdapterPluginsDir(): string {
|
||||
ensureDirs();
|
||||
return ADAPTER_PLUGINS_DIR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter enable/disable (settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDisabledAdapterTypes(): string[] {
|
||||
return readSettings().disabledTypes;
|
||||
}
|
||||
|
||||
export function isAdapterDisabled(type: string): boolean {
|
||||
return readSettings().disabledTypes.includes(type);
|
||||
}
|
||||
|
||||
export function setAdapterDisabled(type: string, disabled: boolean): boolean {
|
||||
const settings = { ...readSettings(), disabledTypes: [...readSettings().disabledTypes] };
|
||||
const idx = settings.disabledTypes.indexOf(type);
|
||||
|
||||
if (disabled && idx < 0) {
|
||||
settings.disabledTypes.push(type);
|
||||
writeSettings(settings);
|
||||
return true;
|
||||
}
|
||||
if (!disabled && idx >= 0) {
|
||||
settings.disabledTypes.splice(idx, 1);
|
||||
writeSettings(settings);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -7,6 +7,12 @@ function readNumericField(record: Record<string, unknown>, key: string) {
|
|||
return key in record ? record[key] ?? null : undefined;
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
|
|
@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson(
|
|||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
export function buildHeartbeatRunIssueComment(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): string | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readCommentText(resultJson.summary)
|
||||
?? readCommentText(resultJson.result)
|
||||
?? readCommentText(resultJson.message)
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { companySkillService } from "./company-skills.js";
|
|||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
|
|
@ -2838,6 +2838,19 @@ export function heartbeatService(db: Db) {
|
|||
exitCode: adapterResult.exitCode,
|
||||
},
|
||||
});
|
||||
if (issueId && outcome === "succeeded") {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ export function buildHostServices(
|
|||
return (await issues.addComment(
|
||||
params.issueId,
|
||||
params.body,
|
||||
{},
|
||||
{ agentId: params.authorAgentId },
|
||||
)) as IssueComment;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue