Expand plugin host surface (#5205)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports

## What Changed

- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.

## Risks

- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-05 07:42:57 -05:00 committed by GitHub
parent d6bee62f02
commit 3c73ed26b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 27516 additions and 914 deletions

View file

@ -125,6 +125,36 @@ export interface TimeseriesChartProps {
export interface MarkdownBlockProps {
/** Markdown content to render. */
content: string;
/** Optional CSS class name forwarded to the host renderer. */
className?: string;
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
enableWikiLinks?: boolean;
/** Base href used for wikilinks when no resolver is supplied. */
wikiLinkRoot?: string;
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
}
/** Props for `MarkdownEditor`. */
export interface MarkdownEditorProps {
/** Markdown source controlled by the plugin. */
value: string;
/** Called whenever the markdown source changes. */
onChange: (value: string) => void;
/** Placeholder text shown when the document is empty. */
placeholder?: string;
/** Optional wrapper CSS class name. */
className?: string;
/** Optional editable content CSS class name. */
contentClassName?: string;
/** Called when the editor loses focus. */
onBlur?: () => void;
/** Render the editor with a host border treatment. */
bordered?: boolean;
/** Render the rich editor without allowing edits. */
readOnly?: boolean;
/** Called on Cmd/Ctrl+Enter. */
onSubmit?: () => void;
}
/** A single key-value pair for `KeyValueList`. */
@ -217,6 +247,211 @@ export interface ErrorBoundaryProps {
onError?: (error: Error, info: React.ErrorInfo) => void;
}
/** File or directory node rendered by `FileTree`. */
export interface FileTreeNode {
/** Display name for this path segment. */
name: string;
/** Slash-separated path relative to the tree root. */
path: string;
/** Whether this node is a directory or file. */
kind: "dir" | "file";
/** Child nodes. Files should use an empty array. */
children: FileTreeNode[];
/** Optional stable action metadata for host/plugin workflows. */
action?: string | null;
}
/** Badge status variants supported by `FileTree`. */
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
/** Serializable badge metadata keyed by file path. */
export interface FileTreeBadge {
label: string;
status: FileTreeBadgeVariant;
tooltip?: string;
}
/** Row tone variants supported by `FileTree`. */
export type FileTreeTone = "default" | "warning" | "error" | "muted";
/** Empty-state content shown when a tree has no nodes. */
export interface FileTreeEmptyState {
title?: string;
description?: string;
}
/** Error-state content shown when a tree cannot be loaded. */
export interface FileTreeErrorState {
message: string;
retry?: () => void;
}
/** Accepted path collection shape for expanded and checked file tree state. */
export type FileTreePathCollection = ReadonlySet<string> | readonly string[];
/** Props for `FileTree`. */
export interface FileTreeProps {
/** Tree nodes to render. */
nodes: FileTreeNode[];
/** Currently selected file path. */
selectedFile?: string | null;
/** Expanded directory paths. */
expandedPaths?: FileTreePathCollection;
/** Checked file paths. */
checkedPaths?: FileTreePathCollection;
/** Called when a directory row is toggled. */
onToggleDir?: (path: string) => void;
/** Called when a file row is selected. */
onSelectFile?: (path: string) => void;
/** Called when a checkbox is toggled. */
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Badge metadata keyed by path. */
fileBadges?: Record<string, FileTreeBadge | undefined>;
/** Row tone metadata keyed by path. */
fileTones?: Record<string, FileTreeTone | undefined>;
/** Whether to render checkboxes. Defaults to false for plugin UIs. */
showCheckboxes?: boolean;
/** Allow long file and directory names to wrap. */
wrapLabels?: boolean;
/** Render a loading skeleton instead of nodes. */
loading?: boolean;
/** Render a structured error state instead of nodes. */
error?: FileTreeErrorState | null;
/** Empty state content. */
empty?: FileTreeEmptyState;
/** Accessible label for the tree. */
ariaLabel?: string;
}
export interface IssuesListFilters {
status?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
labelId?: string;
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
}
export interface IssuesListProps {
companyId: string | null;
projectId?: string | null;
filters?: IssuesListFilters;
viewStateKey?: string;
initialSearch?: string;
createIssueLabel?: string;
searchWithinLoadedIssues?: boolean;
}
export interface AssigneePickerSelection {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface AssigneePickerProps {
/** Company whose agents and users should be listed. Defaults to host context. */
companyId?: string | null;
/** Controlled value. Use `agent:<id>`, `user:<id>`, or an empty string. */
value: string;
/** Called with the encoded value plus parsed assignee IDs. */
onChange: (value: string, selection: AssigneePickerSelection) => void;
/** Button placeholder when no assignee is selected. */
placeholder?: string;
/** Label for the empty option. */
noneLabel?: string;
/** Search input placeholder. */
searchPlaceholder?: string;
/** Empty search result message. */
emptyMessage?: string;
/** Include active board users alongside agents. Defaults to true. */
includeUsers?: boolean;
/** Include terminated agents. Defaults to false. */
includeTerminatedAgents?: boolean;
/** CSS class forwarded to the trigger button. */
className?: string;
/** Called after the user confirms a selection with Enter, Tab, or click. */
onConfirm?: () => void;
}
export interface ProjectPickerProps {
/** Company whose projects should be listed. Defaults to host context. */
companyId?: string | null;
/** Controlled project id, or an empty string for no project. */
value: string;
/** Called with the selected project id. Empty string means no project. */
onChange: (projectId: string) => void;
/** Button placeholder when no project is selected. */
placeholder?: string;
/** Label for the empty option. */
noneLabel?: string;
/** Search input placeholder. */
searchPlaceholder?: string;
/** Empty search result message. */
emptyMessage?: string;
/** Include archived projects. Defaults to false. */
includeArchived?: boolean;
/** CSS class forwarded to the trigger button. */
className?: string;
/** Called after the user confirms a selection with Enter, Tab, or click. */
onConfirm?: () => void;
}
export interface ManagedRoutinesListAgent {
id: string;
name: string;
icon?: string | null;
}
export interface ManagedRoutinesListProject {
id: string;
name: string;
color?: string | null;
}
export interface ManagedRoutineMissingRef {
resourceKind: string;
resourceKey: string;
}
export interface ManagedRoutinesListItem {
key: string;
title: string;
status: string;
routineId?: string | null;
href?: string | null;
resourceKey?: string | null;
projectId?: string | null;
assigneeAgentId?: string | null;
cronExpression?: string | null;
lastRunAt?: Date | string | null;
lastRunStatus?: string | null;
managedByPluginDisplayName?: string | null;
missingRefs?: ManagedRoutineMissingRef[];
}
export interface ManagedRoutinesListProps {
routines: ManagedRoutinesListItem[];
agents?: ManagedRoutinesListAgent[];
projects?: ManagedRoutinesListProject[];
pluginDisplayName?: string | null;
emptyMessage?: string;
runningRoutineKey?: string | null;
statusMutationRoutineKey?: string | null;
reconcilingRoutineKey?: string | null;
resettingRoutineKey?: string | null;
onRunNow?: (routine: ManagedRoutinesListItem) => void;
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
onReconcile?: (routine: ManagedRoutinesListItem) => void;
onReset?: (routine: ManagedRoutinesListItem) => void;
}
// ---------------------------------------------------------------------------
// Component declarations (provided by host at runtime)
// ---------------------------------------------------------------------------
@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("Times
*/
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
/**
* Renders Paperclip's shared Markdown editor.
*
* @see PLUGIN_SPEC.md §19.6 Shared Components
*/
export const MarkdownEditor = createSdkUiComponent<MarkdownEditorProps>("MarkdownEditor");
/**
* Renders a definition-list of label/value pairs.
*
@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
/**
* Renders the host file tree component with a stable plugin-safe prop surface.
*
* @example
* ```tsx
* import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
*
* const nodes: FileTreeNode[] = [
* { name: "README.md", path: "README.md", kind: "file", children: [] },
* ];
*
* <FileTree nodes={nodes} onSelectFile={(path) => console.log(path)} />;
* ```
*/
export const FileTree = createSdkUiComponent<FileTreeProps>("FileTree");
/**
* Renders Paperclip's native issue list component for company-scoped plugin
* pages that need a standard board issue view.
*/
export const IssuesList = createSdkUiComponent<IssuesListProps>("IssuesList");
/**
* Renders the same host assignee picker used by the new issue pane.
*/
export const AssigneePicker = createSdkUiComponent<AssigneePickerProps>("AssigneePicker");
/**
* Renders the same host project picker used by the new issue pane.
*/
export const ProjectPicker = createSdkUiComponent<ProjectPickerProps>("ProjectPicker");
/**
* Renders Paperclip's native managed routines list for plugin settings pages.
*/
export const ManagedRoutinesList = createSdkUiComponent<ManagedRoutinesListProps>("ManagedRoutinesList");