Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: Drop lockfile from watcher change Tighten plugin dev file watching Fix plugin smoke example typecheck Fix plugin dev watcher and migration snapshot Clarify plugin authoring and external dev workflow Expand kitchen sink plugin demos fix: set AGENT_HOME env var for agent processes Add kitchen sink plugin example Simplify plugin runtime and cleanup lifecycle Add plugin framework and settings UI # Conflicts: # packages/db/src/migrations/meta/0029_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
160
packages/plugins/sdk/src/bundlers.ts
Normal file
160
packages/plugins/sdk/src/bundlers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bundling presets for Paperclip plugins.
|
||||
*
|
||||
* These helpers return plain config objects so plugin authors can use them
|
||||
* with esbuild or rollup without re-implementing host contract defaults.
|
||||
*/
|
||||
|
||||
export interface PluginBundlerPresetInput {
|
||||
pluginRoot?: string;
|
||||
manifestEntry?: string;
|
||||
workerEntry?: string;
|
||||
uiEntry?: string;
|
||||
outdir?: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
}
|
||||
|
||||
export interface EsbuildLikeOptions {
|
||||
entryPoints: string[];
|
||||
outdir: string;
|
||||
bundle: boolean;
|
||||
format: "esm";
|
||||
platform: "node" | "browser";
|
||||
target: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
external?: string[];
|
||||
}
|
||||
|
||||
export interface RollupLikeConfig {
|
||||
input: string;
|
||||
output: {
|
||||
dir: string;
|
||||
format: "es";
|
||||
sourcemap?: boolean;
|
||||
entryFileNames?: string;
|
||||
};
|
||||
external?: string[];
|
||||
plugins?: unknown[];
|
||||
}
|
||||
|
||||
export interface PluginBundlerPresets {
|
||||
esbuild: {
|
||||
worker: EsbuildLikeOptions;
|
||||
ui?: EsbuildLikeOptions;
|
||||
manifest: EsbuildLikeOptions;
|
||||
};
|
||||
rollup: {
|
||||
worker: RollupLikeConfig;
|
||||
ui?: RollupLikeConfig;
|
||||
manifest: RollupLikeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
|
||||
*
|
||||
* The presets intentionally externalize host/runtime deps (`react`, SDK packages)
|
||||
* to match the Paperclip plugin loader contract.
|
||||
*/
|
||||
export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
|
||||
const uiExternal = [
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
"@paperclipai/plugin-sdk/ui/hooks",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
];
|
||||
|
||||
const outdir = input.outdir ?? "dist";
|
||||
const workerEntry = input.workerEntry ?? "src/worker.ts";
|
||||
const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
|
||||
const uiEntry = input.uiEntry;
|
||||
const sourcemap = input.sourcemap ?? true;
|
||||
const minify = input.minify ?? false;
|
||||
|
||||
const esbuildWorker: EsbuildLikeOptions = {
|
||||
entryPoints: [workerEntry],
|
||||
outdir,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const esbuildManifest: EsbuildLikeOptions = {
|
||||
entryPoints: [manifestEntry],
|
||||
outdir,
|
||||
bundle: false,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
};
|
||||
|
||||
const esbuildUi = uiEntry
|
||||
? {
|
||||
entryPoints: [uiEntry],
|
||||
outdir: `${outdir}/ui`,
|
||||
bundle: true,
|
||||
format: "esm" as const,
|
||||
platform: "browser" as const,
|
||||
target: "es2022",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const rollupWorker: RollupLikeConfig = {
|
||||
input: workerEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "worker.js",
|
||||
},
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const rollupManifest: RollupLikeConfig = {
|
||||
input: manifestEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "manifest.js",
|
||||
},
|
||||
external: ["@paperclipai/plugin-sdk"],
|
||||
};
|
||||
|
||||
const rollupUi = uiEntry
|
||||
? {
|
||||
input: uiEntry,
|
||||
output: {
|
||||
dir: `${outdir}/ui`,
|
||||
format: "es" as const,
|
||||
sourcemap,
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
esbuild: {
|
||||
worker: esbuildWorker,
|
||||
manifest: esbuildManifest,
|
||||
...(esbuildUi ? { ui: esbuildUi } : {}),
|
||||
},
|
||||
rollup: {
|
||||
worker: rollupWorker,
|
||||
manifest: rollupManifest,
|
||||
...(rollupUi ? { ui: rollupUi } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
|
||||
*
|
||||
* Plugin authors call `definePlugin()` and export the result as the default
|
||||
* export from their worker entrypoint. The host imports the worker module,
|
||||
* calls `setup()` with a `PluginContext`, and from that point the plugin
|
||||
* responds to events, jobs, webhooks, and UI requests through the context.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // dist/worker.ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Linear sync plugin starting");
|
||||
*
|
||||
* // Subscribe to events
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* const config = await ctx.config.get();
|
||||
* await ctx.http.fetch(`https://api.linear.app/...`, {
|
||||
* method: "POST",
|
||||
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
|
||||
* body: JSON.stringify({ title: event.payload.title }),
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* // Register a job handler
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Running full-sync job", { runId: job.runId });
|
||||
* // ... sync logic
|
||||
* });
|
||||
*
|
||||
* // Register data for the UI
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { PluginContext } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
export interface PluginHealthDiagnostics {
|
||||
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
|
||||
status: "ok" | "degraded" | "error";
|
||||
/** Human-readable description of the current health state. */
|
||||
message?: string;
|
||||
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config validation result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result returned from the `validateConfig()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
export interface PluginConfigValidationResult {
|
||||
/** Whether the config is valid. */
|
||||
ok: boolean;
|
||||
/** Non-fatal warnings about the config. */
|
||||
warnings?: string[];
|
||||
/** Validation errors (populated when `ok` is `false`). */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook handler input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input received by the plugin worker's `handleWebhook` handler.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
export interface PluginWebhookInput {
|
||||
/** Endpoint key matching the manifest declaration. */
|
||||
endpointKey: string;
|
||||
/** Inbound request headers. */
|
||||
headers: Record<string, string | string[]>;
|
||||
/** Raw request body as a UTF-8 string. */
|
||||
rawBody: string;
|
||||
/** Parsed JSON body (if applicable and parseable). */
|
||||
parsedBody?: unknown;
|
||||
/** Unique request identifier for idempotency checks. */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The plugin definition shape passed to `definePlugin()`.
|
||||
*
|
||||
* The only required field is `setup`, which receives the `PluginContext` and
|
||||
* is where the plugin registers its handlers (events, jobs, data, actions,
|
||||
* tools, etc.).
|
||||
*
|
||||
* All other lifecycle hooks are optional. If a hook is not implemented the
|
||||
* host applies default behaviour (e.g. restarting the worker on config change
|
||||
* instead of calling `onConfigChanged`).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
*/
|
||||
export interface PluginDefinition {
|
||||
/**
|
||||
* Called once when the plugin worker starts up, after `initialize` completes.
|
||||
*
|
||||
* This is where the plugin registers all its handlers: event subscriptions,
|
||||
* job handlers, data/action handlers, and tool registrations. Registration
|
||||
* must be synchronous after `setup` resolves — do not register handlers
|
||||
* inside async callbacks that may resolve after `setup` returns.
|
||||
*
|
||||
* @param ctx - The full plugin context provided by the host
|
||||
*/
|
||||
setup(ctx: PluginContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host wants to know if the plugin is healthy.
|
||||
*
|
||||
* The host polls this on a regular interval and surfaces the result in the
|
||||
* plugin health dashboard. If not implemented, the host infers health from
|
||||
* worker process liveness.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
onHealth?(): Promise<PluginHealthDiagnostics>;
|
||||
|
||||
/**
|
||||
* Called when the operator updates the plugin's instance configuration at
|
||||
* runtime, without restarting the worker.
|
||||
*
|
||||
* If not implemented, the host restarts the worker to apply the new config.
|
||||
*
|
||||
* @param newConfig - The newly resolved configuration
|
||||
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||||
*/
|
||||
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host is about to shut down the plugin worker.
|
||||
*
|
||||
* The worker has at most 10 seconds (configurable via plugin config) to
|
||||
* finish in-flight work and resolve this promise. After the deadline the
|
||||
* host sends SIGTERM, then SIGKILL.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||
*/
|
||||
onShutdown?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called to validate the current plugin configuration.
|
||||
*
|
||||
* The host calls this:
|
||||
* - after the plugin starts (to surface config errors immediately)
|
||||
* - after the operator saves a new config (to validate before persisting)
|
||||
* - via the "Test Connection" button in the settings UI
|
||||
*
|
||||
* @param config - The configuration to validate
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
|
||||
|
||||
/**
|
||||
* Called to handle an inbound webhook delivery.
|
||||
*
|
||||
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
|
||||
* this handler. The plugin is responsible for signature verification using
|
||||
* a resolved secret ref.
|
||||
*
|
||||
* If not implemented but webhooks are declared in the manifest, the host
|
||||
* returns HTTP 501 for webhook deliveries.
|
||||
*
|
||||
* @param input - Webhook delivery metadata and payload
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
onWebhook?(input: PluginWebhookInput): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaperclipPlugin — the sealed object returned by definePlugin()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The sealed plugin object returned by `definePlugin()`.
|
||||
*
|
||||
* Plugin authors export this as the default export from their worker
|
||||
* entrypoint. The host imports it and calls the lifecycle methods.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
*/
|
||||
export interface PaperclipPlugin {
|
||||
/** The original plugin definition passed to `definePlugin()`. */
|
||||
readonly definition: PluginDefinition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// definePlugin — top-level factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Define a Paperclip plugin.
|
||||
*
|
||||
* Call this function in your worker entrypoint and export the result as the
|
||||
* default export. The host will import the module and call lifecycle methods
|
||||
* on the returned object.
|
||||
*
|
||||
* @param definition - Plugin lifecycle handlers
|
||||
* @returns A sealed `PaperclipPlugin` object for the host to consume
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin started");
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* // handle event
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*/
|
||||
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
|
||||
return Object.freeze({ definition });
|
||||
}
|
||||
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { startPluginDevServer } from "./dev-server.js";
|
||||
|
||||
function parseArg(flag: string): string | undefined {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index < 0) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI entrypoint for the local plugin UI preview server.
|
||||
*
|
||||
* This is intentionally minimal and delegates all serving behavior to
|
||||
* `startPluginDevServer` so tests and programmatic usage share one path.
|
||||
*/
|
||||
async function main() {
|
||||
const rootDir = parseArg("--root") ?? process.cwd();
|
||||
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
|
||||
const host = parseArg("--host") ?? "127.0.0.1";
|
||||
const rawPort = parseArg("--port") ?? "4177";
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid --port value: ${rawPort}`);
|
||||
}
|
||||
|
||||
const server = await startPluginDevServer({
|
||||
rootDir: path.resolve(rootDir),
|
||||
uiDir,
|
||||
host,
|
||||
port,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Paperclip plugin dev server listening at ${server.url}`);
|
||||
|
||||
const shutdown = async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
228
packages/plugins/sdk/src/dev-server.ts
Normal file
228
packages/plugins/sdk/src/dev-server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { createReadStream, existsSync, statSync, watch } from "node:fs";
|
||||
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PluginDevServerOptions {
|
||||
/** Plugin project root. Defaults to `process.cwd()`. */
|
||||
rootDir?: string;
|
||||
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
|
||||
uiDir?: string;
|
||||
/** Bind port for local preview server. Defaults to `4177`. */
|
||||
port?: number;
|
||||
/** Bind host. Defaults to `127.0.0.1`. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PluginDevServer {
|
||||
url: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface Closeable {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (filePath.endsWith(".svg")) return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeFilePath(baseDir: string, reqPath: string): string {
|
||||
const pathname = reqPath.split("?")[0] || "/";
|
||||
const resolved = pathname === "/" ? "/index.js" : pathname;
|
||||
const absolute = path.resolve(baseDir, `.${resolved}`);
|
||||
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
|
||||
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
|
||||
throw new Error("path traversal blocked");
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function send404(res: ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, value: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function ensureUiDir(uiDir: string): Promise<void> {
|
||||
if (existsSync(uiDir)) return;
|
||||
await mkdir(uiDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function listFilesRecursive(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await listFilesRecursive(abs));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(abs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
|
||||
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
|
||||
}
|
||||
|
||||
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
|
||||
try {
|
||||
// macOS/Windows support recursive native watching.
|
||||
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
|
||||
if (!filename) return;
|
||||
onReload(path.join(uiDir, filename));
|
||||
});
|
||||
return watcher;
|
||||
} catch {
|
||||
// Linux may reject recursive watch. Fall back to polling snapshots.
|
||||
let previous = snapshotSignature(
|
||||
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
|
||||
file: row.file,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
);
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
|
||||
const next = snapshotSignature(nextRows);
|
||||
if (next === previous) return;
|
||||
previous = next;
|
||||
onReload("__snapshot__");
|
||||
} catch {
|
||||
// Ignore transient read errors while bundlers are writing files.
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
close() {
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local static server for plugin UI assets with SSE reload events.
|
||||
*
|
||||
* Endpoint summary:
|
||||
* - `GET /__paperclip__/health` for diagnostics
|
||||
* - `GET /__paperclip__/events` for hot-reload stream
|
||||
* - Any other path serves files from the configured UI build directory
|
||||
*/
|
||||
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
|
||||
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const port = options.port ?? 4177;
|
||||
|
||||
await ensureUiDir(uiDir);
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url ?? "/";
|
||||
|
||||
if (url === "/__paperclip__/health") {
|
||||
sendJson(res, { ok: true, rootDir, uiDir });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === "/__paperclip__/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(`event: connected\ndata: {"ok":true}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on("close", () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = normalizeFilePath(uiDir, url);
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
send404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType(filePath));
|
||||
createReadStream(filePath).pipe(res);
|
||||
} catch {
|
||||
send404(res);
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
|
||||
const notifyReload = (filePath: string) => {
|
||||
const rel = path.relative(uiDir, filePath);
|
||||
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: reload\ndata: ${payload}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const watcher = await startUiWatcher(uiDir, notifyReload);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
|
||||
|
||||
return {
|
||||
url: `http://${host}:${actualPort}`,
|
||||
async close() {
|
||||
watcher.close();
|
||||
for (const client of sseClients) {
|
||||
client.end();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable file+mtime snapshot for a built plugin UI directory.
|
||||
*
|
||||
* Used by the polling watcher fallback and useful for tests that need to assert
|
||||
* whether a UI build has changed between runs.
|
||||
*/
|
||||
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
|
||||
const baseDir = path.resolve(rootDir, uiDir);
|
||||
if (!existsSync(baseDir)) return [];
|
||||
const files = await listFilesRecursive(baseDir);
|
||||
const rows = await Promise.all(files.map(async (filePath) => {
|
||||
const fileStat = await stat(filePath);
|
||||
return {
|
||||
file: path.relative(baseDir, filePath),
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
};
|
||||
}));
|
||||
return rows.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Host-side client factory — creates capability-gated handler maps for
|
||||
* servicing worker→host JSON-RPC calls.
|
||||
*
|
||||
* When a plugin worker calls `ctx.state.get(...)` inside its process, the
|
||||
* SDK serializes the call as a JSON-RPC request over stdio. On the host side,
|
||||
* the `PluginWorkerManager` receives the request and dispatches it to the
|
||||
* handler registered for that method. This module provides a factory that
|
||||
* creates those handlers for all `WorkerToHostMethods`, with automatic
|
||||
* capability enforcement.
|
||||
*
|
||||
* ## Design
|
||||
*
|
||||
* 1. **Capability gating**: Each handler checks the plugin's declared
|
||||
* capabilities before executing. If the plugin lacks a required capability,
|
||||
* the handler throws a `CapabilityDeniedError` (which the worker manager
|
||||
* translates into a JSON-RPC error response with code
|
||||
* `CAPABILITY_DENIED`).
|
||||
*
|
||||
* 2. **Service adapters**: The caller provides a `HostServices` object with
|
||||
* concrete implementations of each platform service. The factory wires
|
||||
* each handler to the appropriate service method.
|
||||
*
|
||||
* 3. **Type safety**: The returned handler map is typed as
|
||||
* `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
|
||||
* directly into `WorkerStartOptions.hostHandlers`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const handlers = createHostClientHandlers({
|
||||
* pluginId: "acme.linear",
|
||||
* capabilities: manifest.capabilities,
|
||||
* services: {
|
||||
* config: { get: () => registry.getConfig(pluginId) },
|
||||
* state: { get: ..., set: ..., delete: ... },
|
||||
* entities: { upsert: ..., list: ... },
|
||||
* // ... all services
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* await workerManager.startWorker("acme.linear", {
|
||||
* // ...
|
||||
* hostHandlers: handlers,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown when a plugin calls a host method it does not have the capability for.
|
||||
*
|
||||
* The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
|
||||
* the worker manager can propagate it as the correct JSON-RPC error code.
|
||||
*/
|
||||
export class CapabilityDeniedError extends Error {
|
||||
override readonly name = "CapabilityDeniedError";
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, capability: PluginCapability) {
|
||||
super(
|
||||
`Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Service adapters that the host must provide. Each property maps to a group
|
||||
* of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
|
||||
* function signatures.
|
||||
*
|
||||
* All methods return promises to support async I/O (database, HTTP, etc.).
|
||||
*/
|
||||
export interface HostServices {
|
||||
/** Provides `config.get`. */
|
||||
config: {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||
state: {
|
||||
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
|
||||
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `entities.upsert`, `entities.list`. */
|
||||
entities: {
|
||||
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
|
||||
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `events.emit`. */
|
||||
events: {
|
||||
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `http.fetch`. */
|
||||
http: {
|
||||
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `secrets.resolve`. */
|
||||
secrets: {
|
||||
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
||||
};
|
||||
|
||||
/** Provides `activity.log`. */
|
||||
activity: {
|
||||
log(params: {
|
||||
companyId: string;
|
||||
message: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `metrics.write`. */
|
||||
metrics: {
|
||||
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `log`. */
|
||||
logger: {
|
||||
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `companies.list`, `companies.get`. */
|
||||
companies: {
|
||||
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
|
||||
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
|
||||
projects: {
|
||||
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
|
||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
|
||||
issues: {
|
||||
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
||||
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
||||
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
||||
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
|
||||
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
|
||||
agents: {
|
||||
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
|
||||
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
|
||||
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||
agentSessions: {
|
||||
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
|
||||
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
|
||||
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
|
||||
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
|
||||
goals: {
|
||||
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
|
||||
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
|
||||
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
|
||||
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for `createHostClientHandlers`.
|
||||
*/
|
||||
export interface HostClientFactoryOptions {
|
||||
/** The plugin ID. Used for error messages and logging. */
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The capabilities declared by the plugin in its manifest. The factory
|
||||
* enforces these at runtime before delegating to the service adapter.
|
||||
*/
|
||||
capabilities: readonly PluginCapability[];
|
||||
|
||||
/**
|
||||
* Concrete implementations of host platform services. Each handler in the
|
||||
* returned map delegates to the corresponding service method.
|
||||
*/
|
||||
services: HostServices;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A handler function for a specific worker→host method.
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
* A complete map of all worker→host method handlers.
|
||||
*
|
||||
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
|
||||
* but makes every handler required (the factory always provides all handlers).
|
||||
*/
|
||||
export type HostClientHandlers = {
|
||||
[M in WorkerToHostMethodName]: HostHandler<M>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability → method mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps each worker→host RPC method to the capability required to invoke it.
|
||||
* Methods without a capability requirement (e.g. `config.get`, `log`) are
|
||||
* mapped to `null`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
|
||||
// Config — always allowed
|
||||
"config.get": null,
|
||||
|
||||
// State
|
||||
"state.get": "plugin.state.read",
|
||||
"state.set": "plugin.state.write",
|
||||
"state.delete": "plugin.state.write",
|
||||
|
||||
// Entities — no specific capability required (plugin-scoped by design)
|
||||
"entities.upsert": null,
|
||||
"entities.list": null,
|
||||
|
||||
// Events
|
||||
"events.emit": "events.emit",
|
||||
|
||||
// HTTP
|
||||
"http.fetch": "http.outbound",
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": "secrets.read-ref",
|
||||
|
||||
// Activity
|
||||
"activity.log": "activity.log.write",
|
||||
|
||||
// Metrics
|
||||
"metrics.write": "metrics.write",
|
||||
|
||||
// Logger — always allowed
|
||||
"log": null,
|
||||
|
||||
// Companies
|
||||
"companies.list": "companies.read",
|
||||
"companies.get": "companies.read",
|
||||
|
||||
// Projects
|
||||
"projects.list": "projects.read",
|
||||
"projects.get": "projects.read",
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
|
||||
// Issues
|
||||
"issues.list": "issues.read",
|
||||
"issues.get": "issues.read",
|
||||
"issues.create": "issues.create",
|
||||
"issues.update": "issues.update",
|
||||
"issues.listComments": "issue.comments.read",
|
||||
"issues.createComment": "issue.comments.create",
|
||||
|
||||
// Agents
|
||||
"agents.list": "agents.read",
|
||||
"agents.get": "agents.read",
|
||||
"agents.pause": "agents.pause",
|
||||
"agents.resume": "agents.resume",
|
||||
"agents.invoke": "agents.invoke",
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": "agent.sessions.create",
|
||||
"agents.sessions.list": "agent.sessions.list",
|
||||
"agents.sessions.sendMessage": "agent.sessions.send",
|
||||
"agents.sessions.close": "agent.sessions.close",
|
||||
|
||||
// Goals
|
||||
"goals.list": "goals.read",
|
||||
"goals.get": "goals.read",
|
||||
"goals.create": "goals.create",
|
||||
"goals.update": "goals.update",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a complete handler map for all worker→host JSON-RPC methods.
|
||||
*
|
||||
* Each handler:
|
||||
* 1. Checks the plugin's declared capabilities against the required capability
|
||||
* for the method (if any).
|
||||
* 2. Delegates to the corresponding service adapter method.
|
||||
* 3. Returns the service result, which is serialized as the JSON-RPC response
|
||||
* by the worker manager.
|
||||
*
|
||||
* If a capability check fails, the handler throws a `CapabilityDeniedError`
|
||||
* with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
|
||||
* JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
|
||||
* in the plugin's SDK client.
|
||||
*
|
||||
* @param options - Plugin ID, capabilities, and service adapters
|
||||
* @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
|
||||
*/
|
||||
export function createHostClientHandlers(
|
||||
options: HostClientFactoryOptions,
|
||||
): HostClientHandlers {
|
||||
const { pluginId, services } = options;
|
||||
const capabilitySet = new Set<PluginCapability>(options.capabilities);
|
||||
|
||||
/**
|
||||
* Assert that the plugin has the required capability for a method.
|
||||
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||
*/
|
||||
function requireCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): void {
|
||||
const required = METHOD_CAPABILITY_MAP[method];
|
||||
if (required === null) return; // No capability required
|
||||
if (capabilitySet.has(required)) return;
|
||||
throw new CapabilityDeniedError(pluginId, method, required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability-gated proxy handler for a method.
|
||||
*
|
||||
* @param method - The RPC method name (used for capability lookup)
|
||||
* @param handler - The actual handler implementation
|
||||
* @returns A wrapper that checks capabilities before delegating
|
||||
*/
|
||||
function gated<M extends WorkerToHostMethodName>(
|
||||
method: M,
|
||||
handler: HostHandler<M>,
|
||||
): HostHandler<M> {
|
||||
return async (params: WorkerToHostMethods[M][0]) => {
|
||||
requireCapability(method);
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Build the complete handler map
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// Config
|
||||
"config.get": gated("config.get", async () => {
|
||||
return services.config.get();
|
||||
}),
|
||||
|
||||
// State
|
||||
"state.get": gated("state.get", async (params) => {
|
||||
return services.state.get(params);
|
||||
}),
|
||||
"state.set": gated("state.set", async (params) => {
|
||||
return services.state.set(params);
|
||||
}),
|
||||
"state.delete": gated("state.delete", async (params) => {
|
||||
return services.state.delete(params);
|
||||
}),
|
||||
|
||||
// Entities
|
||||
"entities.upsert": gated("entities.upsert", async (params) => {
|
||||
return services.entities.upsert(params);
|
||||
}),
|
||||
"entities.list": gated("entities.list", async (params) => {
|
||||
return services.entities.list(params);
|
||||
}),
|
||||
|
||||
// Events
|
||||
"events.emit": gated("events.emit", async (params) => {
|
||||
return services.events.emit(params);
|
||||
}),
|
||||
|
||||
// HTTP
|
||||
"http.fetch": gated("http.fetch", async (params) => {
|
||||
return services.http.fetch(params);
|
||||
}),
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": gated("secrets.resolve", async (params) => {
|
||||
return services.secrets.resolve(params);
|
||||
}),
|
||||
|
||||
// Activity
|
||||
"activity.log": gated("activity.log", async (params) => {
|
||||
return services.activity.log(params);
|
||||
}),
|
||||
|
||||
// Metrics
|
||||
"metrics.write": gated("metrics.write", async (params) => {
|
||||
return services.metrics.write(params);
|
||||
}),
|
||||
|
||||
// Logger
|
||||
"log": gated("log", async (params) => {
|
||||
return services.logger.log(params);
|
||||
}),
|
||||
|
||||
// Companies
|
||||
"companies.list": gated("companies.list", async (params) => {
|
||||
return services.companies.list(params);
|
||||
}),
|
||||
"companies.get": gated("companies.get", async (params) => {
|
||||
return services.companies.get(params);
|
||||
}),
|
||||
|
||||
// Projects
|
||||
"projects.list": gated("projects.list", async (params) => {
|
||||
return services.projects.list(params);
|
||||
}),
|
||||
"projects.get": gated("projects.get", async (params) => {
|
||||
return services.projects.get(params);
|
||||
}),
|
||||
"projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
|
||||
return services.projects.listWorkspaces(params);
|
||||
}),
|
||||
"projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
|
||||
return services.projects.getPrimaryWorkspace(params);
|
||||
}),
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
|
||||
// Issues
|
||||
"issues.list": gated("issues.list", async (params) => {
|
||||
return services.issues.list(params);
|
||||
}),
|
||||
"issues.get": gated("issues.get", async (params) => {
|
||||
return services.issues.get(params);
|
||||
}),
|
||||
"issues.create": gated("issues.create", async (params) => {
|
||||
return services.issues.create(params);
|
||||
}),
|
||||
"issues.update": gated("issues.update", async (params) => {
|
||||
return services.issues.update(params);
|
||||
}),
|
||||
"issues.listComments": gated("issues.listComments", async (params) => {
|
||||
return services.issues.listComments(params);
|
||||
}),
|
||||
"issues.createComment": gated("issues.createComment", async (params) => {
|
||||
return services.issues.createComment(params);
|
||||
}),
|
||||
|
||||
// Agents
|
||||
"agents.list": gated("agents.list", async (params) => {
|
||||
return services.agents.list(params);
|
||||
}),
|
||||
"agents.get": gated("agents.get", async (params) => {
|
||||
return services.agents.get(params);
|
||||
}),
|
||||
"agents.pause": gated("agents.pause", async (params) => {
|
||||
return services.agents.pause(params);
|
||||
}),
|
||||
"agents.resume": gated("agents.resume", async (params) => {
|
||||
return services.agents.resume(params);
|
||||
}),
|
||||
"agents.invoke": gated("agents.invoke", async (params) => {
|
||||
return services.agents.invoke(params);
|
||||
}),
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
||||
return services.agentSessions.create(params);
|
||||
}),
|
||||
"agents.sessions.list": gated("agents.sessions.list", async (params) => {
|
||||
return services.agentSessions.list(params);
|
||||
}),
|
||||
"agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
|
||||
return services.agentSessions.sendMessage(params);
|
||||
}),
|
||||
"agents.sessions.close": gated("agents.sessions.close", async (params) => {
|
||||
return services.agentSessions.close(params);
|
||||
}),
|
||||
|
||||
// Goals
|
||||
"goals.list": gated("goals.list", async (params) => {
|
||||
return services.goals.list(params);
|
||||
}),
|
||||
"goals.get": gated("goals.get", async (params) => {
|
||||
return services.goals.get(params);
|
||||
}),
|
||||
"goals.create": gated("goals.create", async (params) => {
|
||||
return services.goals.create(params);
|
||||
}),
|
||||
"goals.update": gated("goals.update", async (params) => {
|
||||
return services.goals.update(params);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: getRequiredCapability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the capability required for a given worker→host method, or `null` if
|
||||
* no capability is required.
|
||||
*
|
||||
* Useful for inspecting capability requirements without calling the factory.
|
||||
*
|
||||
* @param method - The worker→host method name
|
||||
* @returns The required capability, or `null`
|
||||
*/
|
||||
export function getRequiredCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): PluginCapability | null {
|
||||
return METHOD_CAPABILITY_MAP[method];
|
||||
}
|
||||
286
packages/plugins/sdk/src/index.ts
Normal file
286
packages/plugins/sdk/src/index.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
|
||||
*
|
||||
* This is the main entrypoint for plugin worker code. For plugin UI bundles,
|
||||
* import from `@paperclipai/plugin-sdk/ui` instead.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Plugin worker entrypoint (dist/worker.ts)
|
||||
* import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const plugin = definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin starting up");
|
||||
*
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* ctx.logger.info("Issue created", { issueId: event.entityId });
|
||||
* });
|
||||
*
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Starting full sync", { runId: job.runId });
|
||||
* // ... sync implementation
|
||||
* });
|
||||
*
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync-at",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* export default plugin;
|
||||
* runWorker(plugin, import.meta.url);
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { definePlugin } from "./define-plugin.js";
|
||||
export { createTestHarness } from "./testing.js";
|
||||
export { createPluginBundlerPresets } from "./bundlers.js";
|
||||
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
|
||||
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
|
||||
export {
|
||||
createHostClientHandlers,
|
||||
getRequiredCapability,
|
||||
CapabilityDeniedError,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol helpers and constants
|
||||
export {
|
||||
JSONRPC_VERSION,
|
||||
JSONRPC_ERROR_CODES,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
HOST_TO_WORKER_REQUIRED_METHODS,
|
||||
HOST_TO_WORKER_OPTIONAL_METHODS,
|
||||
MESSAGE_DELIMITER,
|
||||
createRequest,
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
createNotification,
|
||||
isJsonRpcRequest,
|
||||
isJsonRpcNotification,
|
||||
isJsonRpcResponse,
|
||||
isJsonRpcSuccessResponse,
|
||||
isJsonRpcErrorResponse,
|
||||
serializeMessage,
|
||||
parseMessage,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
_resetIdCounter,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Plugin definition and lifecycle types
|
||||
export type {
|
||||
PluginDefinition,
|
||||
PaperclipPlugin,
|
||||
PluginHealthDiagnostics,
|
||||
PluginConfigValidationResult,
|
||||
PluginWebhookInput,
|
||||
} from "./define-plugin.js";
|
||||
export type {
|
||||
TestHarness,
|
||||
TestHarnessOptions,
|
||||
TestHarnessLogEntry,
|
||||
} from "./testing.js";
|
||||
export type {
|
||||
PluginBundlerPresetInput,
|
||||
PluginBundlerPresets,
|
||||
EsbuildLikeOptions,
|
||||
RollupLikeConfig,
|
||||
} from "./bundlers.js";
|
||||
export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
|
||||
export type {
|
||||
WorkerRpcHostOptions,
|
||||
WorkerRpcHost,
|
||||
RunWorkerOptions,
|
||||
} from "./worker-rpc-host.js";
|
||||
export type {
|
||||
HostServices,
|
||||
HostClientFactoryOptions,
|
||||
HostClientHandlers,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
JsonRpcErrorResponse,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
ValidateConfigParams,
|
||||
OnEventParams,
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
ExecuteToolParams,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
HostToWorkerMethods,
|
||||
HostToWorkerMethodName,
|
||||
WorkerToHostMethods,
|
||||
WorkerToHostMethodName,
|
||||
HostToWorkerRequest,
|
||||
HostToWorkerResponse,
|
||||
WorkerToHostRequest,
|
||||
WorkerToHostResponse,
|
||||
WorkerToHostNotifications,
|
||||
WorkerToHostNotificationName,
|
||||
} from "./protocol.js";
|
||||
|
||||
// Plugin context and all client interfaces
|
||||
export type {
|
||||
PluginContext,
|
||||
PluginConfigClient,
|
||||
PluginEventsClient,
|
||||
PluginJobsClient,
|
||||
PluginLaunchersClient,
|
||||
PluginHttpClient,
|
||||
PluginSecretsClient,
|
||||
PluginActivityClient,
|
||||
PluginActivityLogEntry,
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
AgentSessionSendResult,
|
||||
PluginGoalsClient,
|
||||
PluginDataClient,
|
||||
PluginActionsClient,
|
||||
PluginStreamsClient,
|
||||
PluginToolsClient,
|
||||
PluginMetricsClient,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// Supporting types for context clients
|
||||
export type {
|
||||
ScopeKey,
|
||||
EventFilter,
|
||||
PluginEvent,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginEntityUpsert,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
// Plugin authors import manifest types from here so they have a single
|
||||
// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
|
||||
export type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginRecord,
|
||||
PluginConfig,
|
||||
JsonSchema,
|
||||
PluginStatus,
|
||||
PluginCategory,
|
||||
PluginCapability,
|
||||
PluginUiSlotType,
|
||||
PluginUiSlotEntityType,
|
||||
PluginLauncherPlacementZone,
|
||||
PluginLauncherAction,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginStateScopeKind,
|
||||
PluginJobStatus,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginWebhookDeliveryStatus,
|
||||
PluginEventType,
|
||||
PluginBridgeErrorCode,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod re-export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod is re-exported for plugin authors to use when defining their
|
||||
* `instanceConfigSchema` and tool `parametersSchema`.
|
||||
*
|
||||
* Plugin authors do not need to add a separate `zod` dependency.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const configSchema = z.object({
|
||||
* apiKey: z.string().describe("Your API key"),
|
||||
* workspace: z.string().optional(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants re-exports (for plugin code that needs to check values at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
PLUGIN_JOB_RUN_TRIGGERS,
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
} from "@paperclipai/shared";
|
||||
1028
packages/plugins/sdk/src/protocol.ts
Normal file
1028
packages/plugins/sdk/src/protocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
708
packages/plugins/sdk/src/testing.ts
Normal file
708
packages/plugins/sdk/src/testing.ts
Normal file
@@ -0,0 +1,708 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginCapability,
|
||||
PluginEventType,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
EventFilter,
|
||||
PluginContext,
|
||||
PluginEntityRecord,
|
||||
PluginEntityUpsert,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
PluginEvent,
|
||||
ScopeKey,
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
||||
capabilities?: PluginCapability[];
|
||||
/** Initial config returned by `ctx.config.get()`. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessLogEntry {
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
issues?: Issue[];
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
||||
/** Execute a previously-registered scheduled job handler. */
|
||||
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
getState(input: ScopeKey): unknown;
|
||||
/** Simulate a streaming event arriving for an active session. */
|
||||
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
||||
logs: TestHarnessLogEntry[];
|
||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
name: PluginEventType | `plugin.${string}`;
|
||||
filter?: EventFilter;
|
||||
fn: (event: PluginEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
||||
return {
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId,
|
||||
namespace: input.namespace ?? "default",
|
||||
stateKey: input.stateKey,
|
||||
};
|
||||
}
|
||||
|
||||
function stateMapKey(input: ScopeKey): string {
|
||||
const normalized = normalizeScope(input);
|
||||
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
||||
}
|
||||
|
||||
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
||||
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
||||
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
||||
if (allowed.has(capability)) return;
|
||||
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
||||
}
|
||||
|
||||
function requireCompanyId(companyId?: string): string {
|
||||
if (!companyId) throw new Error("companyId is required for this operation");
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function isInCompany<T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
companyId: string,
|
||||
): record is T {
|
||||
return Boolean(record && record.companyId === companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory host harness for plugin worker tests.
|
||||
*
|
||||
* The harness enforces declared capabilities and simulates host APIs, so tests
|
||||
* can validate plugin behavior without spinning up the Paperclip server runtime.
|
||||
*/
|
||||
export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const manifest = options.manifest;
|
||||
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
||||
let currentConfig = { ...(options.config ?? {}) };
|
||||
|
||||
const logs: TestHarnessLogEntry[] = [];
|
||||
const activity: TestHarness["activity"] = [];
|
||||
const metrics: TestHarness["metrics"] = [];
|
||||
|
||||
const state = new Map<string, unknown>();
|
||||
const entities = new Map<string, PluginEntityRecord>();
|
||||
const entityExternalIndex = new Map<string, string>();
|
||||
const companies = new Map<string, Company>();
|
||||
const projects = new Map<string, Project>();
|
||||
const issues = new Map<string, Issue>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||
|
||||
const events: EventRegistration[] = [];
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
manifest,
|
||||
config: {
|
||||
async get() {
|
||||
return { ...currentConfig };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
let registration: EventRegistration;
|
||||
if (typeof filterOrFn === "function") {
|
||||
registration = { name, fn: filterOrFn };
|
||||
} else {
|
||||
if (!maybeFn) throw new Error("event handler is required");
|
||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||
}
|
||||
events.push(registration);
|
||||
return () => {
|
||||
const idx = events.indexOf(registration);
|
||||
if (idx !== -1) events.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
async emit(name, companyId, payload) {
|
||||
requireCapability(manifest, capabilitySet, "events.emit");
|
||||
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
register(key, fn) {
|
||||
requireCapability(manifest, capabilitySet, "jobs.schedule");
|
||||
jobs.set(key, fn);
|
||||
},
|
||||
},
|
||||
launchers: {
|
||||
register(launcher) {
|
||||
launchers.set(launcher.id, launcher);
|
||||
},
|
||||
},
|
||||
http: {
|
||||
async fetch(url, init) {
|
||||
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||
return fetch(url, init);
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
async resolve(secretRef) {
|
||||
requireCapability(manifest, capabilitySet, "secrets.read-ref");
|
||||
return `resolved:${secretRef}`;
|
||||
},
|
||||
},
|
||||
activity: {
|
||||
async log(entry) {
|
||||
requireCapability(manifest, capabilitySet, "activity.log.write");
|
||||
activity.push(entry);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
async get(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.read");
|
||||
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
|
||||
},
|
||||
async set(input, value) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.set(stateMapKey(input), value);
|
||||
},
|
||||
async delete(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.delete(stateMapKey(input));
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
async upsert(input: PluginEntityUpsert) {
|
||||
const externalKey = input.externalId
|
||||
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
|
||||
: null;
|
||||
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
|
||||
const existing = existingId ? entities.get(existingId) : undefined;
|
||||
const now = new Date().toISOString();
|
||||
const previousExternalKey = existing?.externalId
|
||||
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
|
||||
: null;
|
||||
const record: PluginEntityRecord = existing
|
||||
? {
|
||||
...existing,
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
id: randomUUID(),
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
if (previousExternalKey && previousExternalKey !== externalKey) {
|
||||
entityExternalIndex.delete(previousExternalKey);
|
||||
}
|
||||
if (externalKey) entityExternalIndex.set(externalKey, record.id);
|
||||
return record;
|
||||
},
|
||||
async list(query) {
|
||||
let out = [...entities.values()];
|
||||
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
|
||||
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
|
||||
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
|
||||
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
|
||||
if (query.offset) out = out.slice(query.offset);
|
||||
if (query.limit) out = out.slice(0, query.limit);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...projects.values()];
|
||||
out = out.filter((project) => project.companyId === companyId);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const project = projects.get(projectId);
|
||||
return isInCompany(project, companyId) ? project : null;
|
||||
},
|
||||
async listWorkspaces(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return [];
|
||||
return projectWorkspaces.get(projectId) ?? [];
|
||||
},
|
||||
async getPrimaryWorkspace(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
async getWorkspaceForIssue(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
const issue = issues.get(issueId);
|
||||
if (!isInCompany(issue, companyId)) return null;
|
||||
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
|
||||
if (!projectId) return null;
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
},
|
||||
companies: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
let out = [...companies.values()];
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
return companies.get(companyId) ?? null;
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...issues.values()];
|
||||
out = out.filter((issue) => issue.companyId === companyId);
|
||||
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const issue = issues.get(issueId);
|
||||
return isInCompany(issue, companyId) ? issue : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.create");
|
||||
const now = new Date();
|
||||
const record: Issue = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: "todo",
|
||||
priority: input.priority ?? "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
issues.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(issueId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.update");
|
||||
const record = issues.get(issueId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
||||
const updated: Issue = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
issues.set(issueId, updated);
|
||||
return updated;
|
||||
},
|
||||
async listComments(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return issueComments.get(issueId) ?? [];
|
||||
},
|
||||
async createComment(issueId, body, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
throw new Error(`Issue not found: ${issueId}`);
|
||||
}
|
||||
const now = new Date();
|
||||
const comment: IssueComment = {
|
||||
id: randomUUID(),
|
||||
companyId: parentIssue.companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const current = issueComments.get(issueId) ?? [];
|
||||
current.push(comment);
|
||||
issueComments.set(issueId, current);
|
||||
return comment;
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...agents.values()];
|
||||
out = out.filter((agent) => agent.companyId === companyId);
|
||||
if (input?.status) out = out.filter((agent) => agent.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const agent = agents.get(agentId);
|
||||
return isInCompany(agent, companyId) ? agent : null;
|
||||
},
|
||||
async pause(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.pause");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
|
||||
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async resume(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.resume");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
|
||||
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
|
||||
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async invoke(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agents.invoke");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (
|
||||
agent!.status === "paused" ||
|
||||
agent!.status === "terminated" ||
|
||||
agent!.status === "pending_approval"
|
||||
) {
|
||||
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
sessions: {
|
||||
async create(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
const session: AgentSession = {
|
||||
sessionId: randomUUID(),
|
||||
agentId,
|
||||
companyId: cid,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(session.sessionId, session);
|
||||
return session;
|
||||
},
|
||||
async list(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.list");
|
||||
const cid = requireCompanyId(companyId);
|
||||
return [...sessions.values()].filter(
|
||||
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
|
||||
);
|
||||
},
|
||||
async sendMessage(sessionId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.send");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (opts.onEvent) {
|
||||
sessionEventCallbacks.set(sessionId, opts.onEvent);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
async close(sessionId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.close");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
session.status = "closed";
|
||||
sessionEventCallbacks.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...goals.values()];
|
||||
out = out.filter((goal) => goal.companyId === companyId);
|
||||
if (input?.level) out = out.filter((goal) => goal.level === input.level);
|
||||
if (input?.status) out = out.filter((goal) => goal.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(goalId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const goal = goals.get(goalId);
|
||||
return isInCompany(goal, companyId) ? goal : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.create");
|
||||
const now = new Date();
|
||||
const record: Goal = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
level: input.level ?? "task",
|
||||
status: input.status ?? "planned",
|
||||
parentId: input.parentId ?? null,
|
||||
ownerAgentId: input.ownerAgentId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
goals.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(goalId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.update");
|
||||
const record = goals.get(goalId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
|
||||
const updated: Goal = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
goals.set(goalId, updated);
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
data: {
|
||||
register(key, handler) {
|
||||
dataHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
register(key, handler) {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
streams: (() => {
|
||||
const channelCompanyMap = new Map<string, string>();
|
||||
return {
|
||||
open(channel: string, companyId: string) {
|
||||
channelCompanyMap.set(channel, companyId);
|
||||
},
|
||||
emit(_channel: string, _event: unknown) {
|
||||
// No-op in test harness — events are not forwarded
|
||||
},
|
||||
close(channel: string) {
|
||||
channelCompanyMap.delete(channel);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
tools: {
|
||||
register(name, _decl, fn) {
|
||||
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||
toolHandlers.set(name, fn);
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
async write(name, value, tags) {
|
||||
requireCapability(manifest, capabilitySet, "metrics.write");
|
||||
metrics.push({ name, value, tags });
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info(message, meta) {
|
||||
logs.push({ level: "info", message, meta });
|
||||
},
|
||||
warn(message, meta) {
|
||||
logs.push({ level: "warn", message, meta });
|
||||
},
|
||||
error(message, meta) {
|
||||
logs.push({ level: "error", message, meta });
|
||||
},
|
||||
debug(message, meta) {
|
||||
logs.push({ level: "debug", message, meta });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const harness: TestHarness = {
|
||||
ctx,
|
||||
seed(input) {
|
||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||
for (const row of input.issues ?? []) issues.set(row.id, row);
|
||||
for (const row of input.issueComments ?? []) {
|
||||
const list = issueComments.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
issueComments.set(row.issueId, list);
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
},
|
||||
async emit(eventType, payload, base) {
|
||||
const event: PluginEvent = {
|
||||
eventId: base?.eventId ?? randomUUID(),
|
||||
eventType,
|
||||
companyId: base?.companyId ?? "test-company",
|
||||
occurredAt: base?.occurredAt ?? new Date().toISOString(),
|
||||
actorId: base?.actorId,
|
||||
actorType: base?.actorType,
|
||||
entityId: base?.entityId,
|
||||
entityType: base?.entityType,
|
||||
payload,
|
||||
};
|
||||
|
||||
for (const handler of events) {
|
||||
const exactMatch = handler.name === event.eventType;
|
||||
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
|
||||
const wildcardPluginOne = String(handler.name).endsWith(".*")
|
||||
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
|
||||
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
|
||||
if (!allowsEvent(handler.filter, event)) continue;
|
||||
await handler.fn(event);
|
||||
}
|
||||
},
|
||||
async runJob(jobKey, partial = {}) {
|
||||
const handler = jobs.get(jobKey);
|
||||
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
|
||||
await handler({
|
||||
jobKey,
|
||||
runId: partial.runId ?? randomUUID(),
|
||||
trigger: partial.trigger ?? "manual",
|
||||
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = dataHandlers.get(key);
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
||||
const ctxToPass: ToolRunContext = {
|
||||
agentId: runCtx.agentId ?? "agent-test",
|
||||
runId: runCtx.runId ?? randomUUID(),
|
||||
companyId: runCtx.companyId ?? "company-test",
|
||||
projectId: runCtx.projectId ?? "project-test",
|
||||
};
|
||||
return await handler(params, ctxToPass) as T;
|
||||
},
|
||||
getState(input) {
|
||||
return state.get(stateMapKey(input));
|
||||
},
|
||||
simulateSessionEvent(sessionId, event) {
|
||||
const cb = sessionEventCallbacks.get(sessionId);
|
||||
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
|
||||
cb({ ...event, sessionId });
|
||||
},
|
||||
logs,
|
||||
activity,
|
||||
metrics,
|
||||
};
|
||||
|
||||
return harness;
|
||||
}
|
||||
1085
packages/plugins/sdk/src/types.ts
Normal file
1085
packages/plugins/sdk/src/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
310
packages/plugins/sdk/src/ui/components.ts
Normal file
310
packages/plugins/sdk/src/ui/components.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Shared UI component declarations for plugin frontends.
|
||||
*
|
||||
* These components are exported from `@paperclipai/plugin-sdk/ui` and are
|
||||
* provided by the host at runtime. They match the host's design tokens and
|
||||
* visual language, reducing the boilerplate needed to build consistent plugin UIs.
|
||||
*
|
||||
* **Plugins are not required to use these components.** They exist to reduce
|
||||
* boilerplate and keep visual consistency. A plugin may render entirely custom
|
||||
* UI using any React component library.
|
||||
*
|
||||
* Component implementations are provided by the host — plugin bundles contain
|
||||
* only the type declarations; the runtime implementations are injected via the
|
||||
* host module registry.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { renderSdkUiComponent } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A trend value that can accompany a metric.
|
||||
* Positive values indicate upward trends; negative values indicate downward trends.
|
||||
*/
|
||||
export interface MetricTrend {
|
||||
/** Direction of the trend. */
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Percentage change value (e.g. `12.5` for 12.5%). */
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
/** Props for `MetricCard`. */
|
||||
export interface MetricCardProps {
|
||||
/** Short label describing the metric (e.g. `"Synced Issues"`). */
|
||||
label: string;
|
||||
/** The metric value to display. */
|
||||
value: number | string;
|
||||
/** Optional trend indicator. */
|
||||
trend?: MetricTrend;
|
||||
/** Optional sparkline data (array of numbers, latest last). */
|
||||
sparkline?: number[];
|
||||
/** Optional unit suffix (e.g. `"%"`, `"ms"`). */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** Status variants for `StatusBadge`. */
|
||||
export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
/** Props for `StatusBadge`. */
|
||||
export interface StatusBadgeProps {
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Visual variant determining colour. */
|
||||
status: StatusBadgeVariant;
|
||||
}
|
||||
|
||||
/** A single column definition for `DataTable`. */
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
/** Column key, matching a field on the row object. */
|
||||
key: keyof T & string;
|
||||
/** Column header label. */
|
||||
header: string;
|
||||
/** Optional custom cell renderer. */
|
||||
render?: (value: unknown, row: T) => React.ReactNode;
|
||||
/** Whether this column is sortable. */
|
||||
sortable?: boolean;
|
||||
/** CSS width (e.g. `"120px"`, `"20%"`). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/** Props for `DataTable`. */
|
||||
export interface DataTableProps<T = Record<string, unknown>> {
|
||||
/** Column definitions. */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Row data. Each row should have a stable `id` field. */
|
||||
rows: T[];
|
||||
/** Whether the table is currently loading. */
|
||||
loading?: boolean;
|
||||
/** Message shown when `rows` is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Total row count for pagination (if different from `rows.length`). */
|
||||
totalCount?: number;
|
||||
/** Current page (0-based, for pagination). */
|
||||
page?: number;
|
||||
/** Rows per page (for pagination). */
|
||||
pageSize?: number;
|
||||
/** Callback when page changes. */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** Callback when a column header is clicked to sort. */
|
||||
onSort?: (key: string, direction: "asc" | "desc") => void;
|
||||
}
|
||||
|
||||
/** A single data point for `TimeseriesChart`. */
|
||||
export interface TimeseriesDataPoint {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Numeric value. */
|
||||
value: number;
|
||||
/** Optional label for the point. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `TimeseriesChart`. */
|
||||
export interface TimeseriesChartProps {
|
||||
/** Series data. */
|
||||
data: TimeseriesDataPoint[];
|
||||
/** Chart title. */
|
||||
title?: string;
|
||||
/** Y-axis label. */
|
||||
yLabel?: string;
|
||||
/** Chart type. Defaults to `"line"`. */
|
||||
type?: "line" | "bar";
|
||||
/** Height of the chart in pixels. Defaults to `200`. */
|
||||
height?: number;
|
||||
/** Whether the chart is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `MarkdownBlock`. */
|
||||
export interface MarkdownBlockProps {
|
||||
/** Markdown content to render. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A single key-value pair for `KeyValueList`. */
|
||||
export interface KeyValuePair {
|
||||
/** Label for the key. */
|
||||
label: string;
|
||||
/** Value to display. May be a string, number, or a React node. */
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Props for `KeyValueList`. */
|
||||
export interface KeyValueListProps {
|
||||
/** Pairs to render in the list. */
|
||||
pairs: KeyValuePair[];
|
||||
}
|
||||
|
||||
/** A single action button for `ActionBar`. */
|
||||
export interface ActionBarItem {
|
||||
/** Button label. */
|
||||
label: string;
|
||||
/** Action key to call via the plugin bridge. */
|
||||
actionKey: string;
|
||||
/** Optional parameters to pass to the action handler. */
|
||||
params?: Record<string, unknown>;
|
||||
/** Button variant. Defaults to `"default"`. */
|
||||
variant?: "default" | "primary" | "destructive";
|
||||
/** Whether to show a confirmation dialog before executing. */
|
||||
confirm?: boolean;
|
||||
/** Text for the confirmation dialog (used when `confirm` is true). */
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
/** Props for `ActionBar`. */
|
||||
export interface ActionBarProps {
|
||||
/** Action definitions. */
|
||||
actions: ActionBarItem[];
|
||||
/** Called after an action succeeds. Use to trigger data refresh. */
|
||||
onSuccess?: (actionKey: string, result: unknown) => void;
|
||||
/** Called when an action fails. */
|
||||
onError?: (actionKey: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
/** A single log line for `LogView`. */
|
||||
export interface LogViewEntry {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Log level. */
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
/** Log message. */
|
||||
message: string;
|
||||
/** Optional structured metadata. */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Props for `LogView`. */
|
||||
export interface LogViewProps {
|
||||
/** Log entries to display. */
|
||||
entries: LogViewEntry[];
|
||||
/** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
|
||||
maxHeight?: string;
|
||||
/** Whether to auto-scroll to the latest entry. */
|
||||
autoScroll?: boolean;
|
||||
/** Whether the log is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `JsonTree`. */
|
||||
export interface JsonTreeProps {
|
||||
/** The data to render as a collapsible JSON tree. */
|
||||
data: unknown;
|
||||
/** Initial depth to expand. Defaults to `2`. */
|
||||
defaultExpandDepth?: number;
|
||||
}
|
||||
|
||||
/** Props for `Spinner`. */
|
||||
export interface SpinnerProps {
|
||||
/** Size of the spinner. Defaults to `"md"`. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Accessible label for the spinner (used as `aria-label`). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `ErrorBoundary`. */
|
||||
export interface ErrorBoundaryProps {
|
||||
/** Content to render inside the error boundary. */
|
||||
children: React.ReactNode;
|
||||
/** Optional custom fallback to render when an error is caught. */
|
||||
fallback?: React.ReactNode;
|
||||
/** Called when an error is caught, for logging or reporting. */
|
||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component declarations (provided by host at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// These are declared as ambient values so plugin TypeScript code can import
|
||||
// and use them with full type-checking. The host's module registry provides
|
||||
// the concrete React component implementations at bundle load time.
|
||||
|
||||
/**
|
||||
* Displays a single metric with an optional trend indicator and sparkline.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
function createSdkUiComponent<TProps>(name: string): React.ComponentType<TProps> {
|
||||
return function PaperclipSdkUiComponent(props: TProps) {
|
||||
return renderSdkUiComponent(name, props) as React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const MetricCard = createSdkUiComponent<MetricCardProps>("MetricCard");
|
||||
|
||||
/**
|
||||
* Displays an inline status badge (ok / warning / error / info / pending).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const StatusBadge = createSdkUiComponent<StatusBadgeProps>("StatusBadge");
|
||||
|
||||
/**
|
||||
* Sortable, paginated data table.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const DataTable = createSdkUiComponent<DataTableProps>("DataTable");
|
||||
|
||||
/**
|
||||
* Line or bar chart for time-series data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("TimeseriesChart");
|
||||
|
||||
/**
|
||||
* Renders Markdown text as HTML.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||
|
||||
/**
|
||||
* Renders a definition-list of label/value pairs.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const KeyValueList = createSdkUiComponent<KeyValueListProps>("KeyValueList");
|
||||
|
||||
/**
|
||||
* Row of action buttons wired to the plugin bridge's `performAction` handlers.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const ActionBar = createSdkUiComponent<ActionBarProps>("ActionBar");
|
||||
|
||||
/**
|
||||
* Scrollable, timestamped log output viewer.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const LogView = createSdkUiComponent<LogViewProps>("LogView");
|
||||
|
||||
/**
|
||||
* Collapsible JSON tree for debugging or raw data inspection.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const JsonTree = createSdkUiComponent<JsonTreeProps>("JsonTree");
|
||||
|
||||
/**
|
||||
* Loading indicator.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||
|
||||
/**
|
||||
* React error boundary that prevents plugin rendering errors from crashing
|
||||
* the host page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginHostContext,
|
||||
PluginStreamResult,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
import { getSdkUiRuntimeValue } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch data from the plugin worker's registered `getData` handler.
|
||||
*
|
||||
* Calls `ctx.data.register(key, handler)` in the worker and returns the
|
||||
* result as reactive state. Re-fetches when `params` changes.
|
||||
*
|
||||
* @template T The expected shape of the returned data
|
||||
* @param key - The data key matching the handler registered with `ctx.data.register()`
|
||||
* @param params - Optional parameters forwarded to the handler
|
||||
* @returns `PluginDataResult<T>` with `data`, `loading`, `error`, and `refresh`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SyncWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData<SyncHealth>("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
* return <div>Synced Issues: {data!.syncedCount}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.8 — `getData`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginData<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
): PluginDataResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextKey: string, nextParams?: Record<string, unknown>) => PluginDataResult<T>
|
||||
>("usePluginData");
|
||||
return impl(key, params);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a callable function that invokes the plugin worker's registered
|
||||
* `performAction` handler.
|
||||
*
|
||||
* The returned function is async and throws a `PluginBridgeError` on failure.
|
||||
*
|
||||
* @param key - The action key matching the handler registered with `ctx.actions.register()`
|
||||
* @returns An async function that sends the action to the worker and resolves with the result
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ResyncButton({ context }: PluginWidgetProps) {
|
||||
* const resync = usePluginAction("resync");
|
||||
* const [error, setError] = useState<string | null>(null);
|
||||
*
|
||||
* async function handleClick() {
|
||||
* try {
|
||||
* await resync({ companyId: context.companyId });
|
||||
* } catch (err) {
|
||||
* setError((err as PluginBridgeError).message);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return <button onClick={handleClick}>Resync Now</button>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginAction(key: string): PluginActionFn {
|
||||
const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction");
|
||||
return impl(key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the current host context (active company, project, entity, user).
|
||||
*
|
||||
* Use this to know which context the plugin component is being rendered in
|
||||
* so you can scope data requests and actions accordingly.
|
||||
*
|
||||
* @returns The current `PluginHostContext`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function IssueTab() {
|
||||
* const { companyId, entityId } = useHostContext();
|
||||
* const { data } = usePluginData("linear-link", { issueId: entityId });
|
||||
* return <div>{data?.linearIssueUrl}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export function useHostContext(): PluginHostContext {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext");
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscribe to a real-time event stream pushed from the plugin worker.
|
||||
*
|
||||
* Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel`
|
||||
* and accumulates events as they arrive. The worker pushes events using
|
||||
* `ctx.streams.emit(channel, event)`.
|
||||
*
|
||||
* @template T The expected shape of each streamed event
|
||||
* @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`)
|
||||
* @param options - Optional configuration for the stream
|
||||
* @returns `PluginStreamResult<T>` with `events`, `lastEvent`, connection status, and `close()`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ChatMessages() {
|
||||
* const { events, connected, close } = usePluginStream<ChatToken>("chat-stream");
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
* {connected && <span className="pulse" />}
|
||||
* <button onClick={close}>Stop</button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export function usePluginStream<T = unknown>(
|
||||
channel: string,
|
||||
options?: { companyId?: string },
|
||||
): PluginStreamResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult<T>
|
||||
>("usePluginStream");
|
||||
return impl(channel, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger a host toast notification from plugin UI.
|
||||
*
|
||||
* This lets plugin pages and widgets surface user-facing feedback through the
|
||||
* same toast system as the host app without reaching into host internals.
|
||||
*/
|
||||
export function usePluginToast(): PluginToastFn {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginToastFn>("usePluginToast");
|
||||
return impl();
|
||||
}
|
||||
87
packages/plugins/sdk/src/ui/index.ts
Normal file
87
packages/plugins/sdk/src/ui/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk/ui` — Paperclip plugin UI SDK.
|
||||
*
|
||||
* Import this subpath from plugin UI bundles (React components that run in
|
||||
* the host frontend). Do **not** import this from plugin worker code.
|
||||
*
|
||||
* The worker-side SDK is available from `@paperclipai/plugin-sdk` (root).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
||||
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
*
|
||||
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
* const resync = usePluginAction("resync");
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* return (
|
||||
* <div style={{ display: "grid", gap: 8 }}>
|
||||
* <strong>Synced Issues</strong>
|
||||
* <div>{data!.syncedCount}</div>
|
||||
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bridge hooks for plugin UI components to communicate with the plugin worker.
|
||||
*
|
||||
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||
*/
|
||||
export {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
// Bridge error and host context types
|
||||
export type {
|
||||
PluginBridgeError,
|
||||
PluginBridgeErrorCode,
|
||||
PluginHostContext,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginRenderCloseHandler,
|
||||
PluginRenderCloseLifecycle,
|
||||
PluginRenderEnvironmentContext,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginStreamResult,
|
||||
PluginToastTone,
|
||||
PluginToastAction,
|
||||
PluginToastInput,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
PluginSettingsPageProps,
|
||||
} from "./types.js";
|
||||
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type PluginBridgeRegistry = {
|
||||
react?: {
|
||||
createElement?: (type: unknown, props?: Record<string, unknown> | null) => unknown;
|
||||
} | null;
|
||||
sdkUi?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type GlobalBridge = typeof globalThis & {
|
||||
__paperclipPluginBridge__?: PluginBridgeRegistry;
|
||||
};
|
||||
|
||||
function getBridgeRegistry(): PluginBridgeRegistry | undefined {
|
||||
return (globalThis as GlobalBridge).__paperclipPluginBridge__;
|
||||
}
|
||||
|
||||
function missingBridgeValueError(name: string): Error {
|
||||
return new Error(
|
||||
`Paperclip plugin UI runtime is not initialized for "${name}". ` +
|
||||
'Ensure the host loaded the plugin bridge before rendering this UI module.',
|
||||
);
|
||||
}
|
||||
|
||||
export function getSdkUiRuntimeValue<T>(name: string): T {
|
||||
const value = getBridgeRegistry()?.sdkUi?.[name];
|
||||
if (value === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function renderSdkUiComponent<TProps>(
|
||||
name: string,
|
||||
props: TProps,
|
||||
): unknown {
|
||||
const registry = getBridgeRegistry();
|
||||
const component = registry?.sdkUi?.[name];
|
||||
if (component === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
|
||||
const createElement = registry?.react?.createElement;
|
||||
if (typeof createElement === "function") {
|
||||
return createElement(component, props as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (typeof component === "function") {
|
||||
return component(props);
|
||||
}
|
||||
|
||||
throw new Error(`Paperclip plugin UI component "${name}" is not callable`);
|
||||
}
|
||||
381
packages/plugins/sdk/src/ui/types.ts
Normal file
381
packages/plugins/sdk/src/ui/types.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Paperclip plugin UI SDK — types for plugin frontend components.
|
||||
*
|
||||
* Plugin UI bundles import from `@paperclipai/plugin-sdk/ui`. This subpath
|
||||
* provides the bridge hooks, component prop interfaces, and error types that
|
||||
* plugin React components use to communicate with the host.
|
||||
*
|
||||
* Plugin UI bundles are loaded as ES modules into designated extension slots.
|
||||
* All communication with the plugin worker goes through the host bridge — plugin
|
||||
* components must NOT access host internals or call host APIs directly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// Re-export PluginBridgeErrorCode for plugin UI authors
|
||||
export type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
export type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structured error returned by the bridge when a UI → worker call fails.
|
||||
*
|
||||
* Plugin components receive this in `usePluginData()` as the `error` field
|
||||
* and may encounter it as a thrown value from `usePluginAction()`.
|
||||
*
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginBridgeError {
|
||||
/** Machine-readable error code. */
|
||||
code: PluginBridgeErrorCode;
|
||||
/** Human-readable error message. */
|
||||
message: string;
|
||||
/**
|
||||
* Original error details from the worker, if available.
|
||||
* Only present when `code === "WORKER_ERROR"`.
|
||||
*/
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host context available to all plugin components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read-only host context passed to every plugin component via `useHostContext()`.
|
||||
*
|
||||
* Plugin components use this to know which company, project, or entity is
|
||||
* currently active so they can scope their data requests accordingly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface PluginHostContext {
|
||||
/** UUID of the currently active company, if any. */
|
||||
companyId: string | null;
|
||||
/** URL prefix for the current company (e.g. `"my-company"`). */
|
||||
companyPrefix: string | null;
|
||||
/** UUID of the currently active project, if any. */
|
||||
projectId: string | null;
|
||||
/** UUID of the current entity (for detail tab contexts), if any. */
|
||||
entityId: string | null;
|
||||
/** Type of the current entity (e.g. `"issue"`, `"agent"`). */
|
||||
entityType: string | null;
|
||||
/**
|
||||
* UUID of the parent entity when rendering nested slots.
|
||||
* For `commentAnnotation` slots this is the issue ID containing the comment.
|
||||
*/
|
||||
parentEntityId?: string | null;
|
||||
/** UUID of the current authenticated user. */
|
||||
userId: string | null;
|
||||
/** Runtime metadata for the host container currently rendering this plugin UI. */
|
||||
renderEnvironment?: PluginRenderEnvironmentContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async-capable callback invoked during a host-managed close lifecycle.
|
||||
*/
|
||||
export type PluginRenderCloseHandler = (
|
||||
event: PluginRenderCloseEvent,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Close lifecycle hooks available when the plugin UI is rendered inside a
|
||||
* host-managed launcher environment.
|
||||
*/
|
||||
export interface PluginRenderCloseLifecycle {
|
||||
/** Register a callback before the host closes the current environment. */
|
||||
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
/** Register a callback after the host closes the current environment. */
|
||||
onClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime information about the host container currently rendering a plugin UI.
|
||||
*/
|
||||
export interface PluginRenderEnvironmentContext
|
||||
extends PluginLauncherRenderContextSnapshot {
|
||||
/** Optional host callback for requesting new bounds while a modal is open. */
|
||||
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
|
||||
/** Optional close lifecycle callbacks for host-managed overlays. */
|
||||
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Props passed to a plugin page component.
|
||||
*
|
||||
* A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.1 — Global Operator Routes
|
||||
* @see PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||
*/
|
||||
export interface PluginPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin dashboard widget component.
|
||||
*
|
||||
* A dashboard widget is rendered as a card or section on the main dashboard.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets
|
||||
*/
|
||||
export interface PluginWidgetProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin detail tab component.
|
||||
*
|
||||
* A detail tab is rendered as an additional tab on a project, issue, agent,
|
||||
* goal, or run detail page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export interface PluginDetailTabProps {
|
||||
/** The current host context, always including `entityId` and `entityType`. */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin sidebar component.
|
||||
*
|
||||
* A sidebar entry adds a link or section to the application sidebar.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||
*/
|
||||
export interface PluginSidebarProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin project sidebar item component.
|
||||
*
|
||||
* A project sidebar item is rendered **once per project** under that project's
|
||||
* row in the sidebar Projects list. The host passes the current project's id
|
||||
* in `context.entityId` and `context.entityType` is `"project"`.
|
||||
*
|
||||
* Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to
|
||||
* the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items
|
||||
*/
|
||||
export interface PluginProjectSidebarItemProps {
|
||||
/** Host context plus entityId (project id) and entityType "project". */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: "project";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment annotation component.
|
||||
*
|
||||
* A comment annotation is rendered below each individual comment in the
|
||||
* issue detail timeline. The host passes the comment ID as `entityId`
|
||||
* and `"comment"` as `entityType`, plus the parent issue ID as
|
||||
* `parentEntityId` so the plugin can scope data fetches to both.
|
||||
*
|
||||
* Use this slot to augment comments with parsed file links, sentiment
|
||||
* badges, inline actions, or any per-comment metadata.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Comment Annotations
|
||||
*/
|
||||
export interface PluginCommentAnnotationProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment being annotated. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment annotation slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment context menu item component.
|
||||
*
|
||||
* A comment context menu item is rendered in a "more" dropdown menu on
|
||||
* each comment in the issue detail timeline. The host passes the comment
|
||||
* ID as `entityId` and `"comment"` as `entityType`, plus the parent
|
||||
* issue ID as `parentEntityId`.
|
||||
*
|
||||
* Use this slot to add per-comment actions such as "Create sub-issue from
|
||||
* comment", "Translate", "Flag for review", or any custom plugin action.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items
|
||||
*/
|
||||
export interface PluginCommentContextMenuItemProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment this menu item acts on. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment context menu item slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin settings page component.
|
||||
*
|
||||
* Overrides the auto-generated JSON Schema form when the plugin declares
|
||||
* a `settingsPage` UI slot. The component is responsible for reading and
|
||||
* writing config through the bridge.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI
|
||||
*/
|
||||
export interface PluginSettingsPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginData(key, params)`.
|
||||
*
|
||||
* Mirrors a standard async data-fetching hook pattern:
|
||||
* exactly one of `data` or `error` is non-null at any time (unless `loading`).
|
||||
*
|
||||
* @template T The type of the data returned by the worker handler
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginDataResult<T = unknown> {
|
||||
/** The data returned by the worker's `getData` handler. `null` while loading or on error. */
|
||||
data: T | null;
|
||||
/** `true` while the initial request or a refresh is in flight. */
|
||||
loading: boolean;
|
||||
/** Bridge error if the request failed. `null` on success or while loading. */
|
||||
error: PluginBridgeError | null;
|
||||
/**
|
||||
* Manually trigger a data refresh.
|
||||
* Useful for poll-based updates or post-action refreshes.
|
||||
*/
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast hook types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PluginToastTone = "info" | "success" | "warn" | "error";
|
||||
|
||||
export interface PluginToastAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface PluginToastInput {
|
||||
id?: string;
|
||||
dedupeKey?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
tone?: PluginToastTone;
|
||||
ttlMs?: number;
|
||||
action?: PluginToastAction;
|
||||
}
|
||||
|
||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginStream<T>(channel)`.
|
||||
*
|
||||
* Provides a growing array of events pushed from the plugin worker via SSE,
|
||||
* plus connection status metadata.
|
||||
*
|
||||
* @template T The type of each event emitted by the worker
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export interface PluginStreamResult<T = unknown> {
|
||||
/** All events received so far, in arrival order. */
|
||||
events: T[];
|
||||
/** The most recently received event, or `null` if none yet. */
|
||||
lastEvent: T | null;
|
||||
/** `true` while the SSE connection is being established. */
|
||||
connecting: boolean;
|
||||
/** `true` once the SSE connection is open and receiving events. */
|
||||
connected: boolean;
|
||||
/** Error if the SSE connection failed or was interrupted. `null` otherwise. */
|
||||
error: Error | null;
|
||||
/** Close the SSE connection and stop receiving events. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginAction(key)`.
|
||||
*
|
||||
* Returns an async function that, when called, sends an action request
|
||||
* to the worker's `performAction` handler and returns the result.
|
||||
*
|
||||
* On failure, the async function throws a `PluginBridgeError`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const resync = usePluginAction("resync");
|
||||
* <button onClick={() => resync({ companyId }).catch(err => console.error(err))}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;
|
||||
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user